From ec55ba65469649aa2e0ddf67b07028675a17d993 Mon Sep 17 00:00:00 2001 From: alexrich700 Date: Fri, 20 Mar 2026 19:59:03 -0500 Subject: [PATCH 01/36] Add preflight.sh for GAds-MCP setup checks This script performs a series of pre-flight checks for the GAds-MCP setup, including OS detection, Homebrew installation, Python version check, Git installation, and network connectivity. --- scripts/preflight.sh | 226 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 scripts/preflight.sh diff --git a/scripts/preflight.sh b/scripts/preflight.sh new file mode 100644 index 0000000..0465635 --- /dev/null +++ b/scripts/preflight.sh @@ -0,0 +1,226 @@ +#!/bin/bash +# ============================================================================== +# GAds-MCP Pre-Flight Check +# Rossman Media - Google Ads MCP Setup +# +# Run this first. It checks your machine and tells you what (if anything) +# needs to be fixed before running the installer. +# +# Usage: curl -sSL https://raw.githubusercontent.com/alexrich700/GAds-MCP/main/scripts/preflight.sh | bash +# ============================================================================== + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +PASS=0 +FAIL=0 +WARN=0 + +echo "" +echo "===========================================" +echo " GAds-MCP Pre-Flight Check" +echo " Rossman Media" +echo "===========================================" +echo "" + +check_pass() { + echo -e " ${GREEN}[PASS]${NC} $1" + PASS=$((PASS + 1)) +} + +check_fail() { + echo -e " ${RED}[FAIL]${NC} $1" + FAIL=$((FAIL + 1)) +} + +check_warn() { + echo -e " ${YELLOW}[WARN]${NC} $1" + WARN=$((WARN + 1)) +} + +# ------------------------------------------- +# 1. Operating System +# ------------------------------------------- +echo "Checking operating system..." +if [[ "$OSTYPE" == "darwin"* ]]; then + check_pass "macOS detected ($(sw_vers -productVersion))" +elif [[ "$OSTYPE" == "linux-gnu"* ]]; then + check_pass "Linux detected" +else + check_warn "Detected OS: $OSTYPE - this setup is tested on macOS and Linux" +fi +echo "" + +# ------------------------------------------- +# 2. Homebrew (macOS only) +# ------------------------------------------- +if [[ "$OSTYPE" == "darwin"* ]]; then + echo "Checking Homebrew..." + if command -v brew &> /dev/null; then + check_pass "Homebrew installed ($(brew --version | head -1))" + else + check_fail "Homebrew not installed" + echo "" + echo -e " ${BLUE}Fix: Run this command, then re-run the pre-flight check:${NC}" + echo "" + echo ' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' + echo "" + fi + echo "" +fi + +# ------------------------------------------- +# 3. Python 3.11+ +# ------------------------------------------- +echo "Checking Python..." +if command -v python3 &> /dev/null; then + PY_VERSION=$(python3 --version 2>&1 | awk '{print $2}') + PY_MAJOR=$(echo "$PY_VERSION" | cut -d. -f1) + PY_MINOR=$(echo "$PY_VERSION" | cut -d. -f2) + + if [[ "$PY_MAJOR" -ge 3 ]] && [[ "$PY_MINOR" -ge 11 ]]; then + check_pass "Python $PY_VERSION (3.11+ required)" + else + check_fail "Python $PY_VERSION found, but 3.11+ is required" + echo "" + if [[ "$OSTYPE" == "darwin"* ]]; then + echo -e " ${BLUE}Fix: Run this command, then re-run the pre-flight check:${NC}" + echo "" + echo " brew install python@3.12" + echo "" + else + echo -e " ${BLUE}Fix: Install Python 3.12 from https://www.python.org/downloads/${NC}" + echo "" + fi + fi +else + check_fail "Python 3 not found" + echo "" + if [[ "$OSTYPE" == "darwin"* ]]; then + echo -e " ${BLUE}Fix: Run this command, then re-run the pre-flight check:${NC}" + echo "" + echo " brew install python@3.12" + echo "" + else + echo -e " ${BLUE}Fix: Install Python 3.12 from https://www.python.org/downloads/${NC}" + echo "" + fi +fi +echo "" + +# ------------------------------------------- +# 4. Git +# ------------------------------------------- +echo "Checking Git..." +if command -v git &> /dev/null; then + check_pass "Git installed ($(git --version))" +else + check_fail "Git not found" + echo "" + if [[ "$OSTYPE" == "darwin"* ]]; then + echo -e " ${BLUE}Fix: Run: xcode-select --install${NC}" + else + echo -e " ${BLUE}Fix: Install Git from https://git-scm.com/${NC}" + fi +fi +echo "" + +# ------------------------------------------- +# 5. uv (Python package manager) +# ------------------------------------------- +echo "Checking uv..." +if command -v uv &> /dev/null; then + check_pass "uv installed ($(uv --version))" +else + check_warn "uv not installed (the installer will handle this automatically)" +fi +echo "" + +# ------------------------------------------- +# 6. Claude Desktop / Cowork +# ------------------------------------------- +echo "Checking Claude Desktop..." +CLAUDE_CONFIG_DIR="$HOME/Library/Application Support/Claude" +CLAUDE_CONFIG_FILE="$CLAUDE_CONFIG_DIR/claude_desktop_config.json" + +if [[ "$OSTYPE" == "darwin"* ]]; then + if [[ -d "$CLAUDE_CONFIG_DIR" ]]; then + check_pass "Claude Desktop config directory found" + if [[ -f "$CLAUDE_CONFIG_FILE" ]]; then + check_pass "Claude Desktop config file exists" + else + check_warn "Claude Desktop config file not found (installer will create it)" + fi + else + check_warn "Claude Desktop config directory not found" + echo -e " This is fine if you only use Claude Code (not Claude Desktop/Cowork)" + echo -e " If you want Claude Desktop support, install it from https://claude.ai/download" + fi +else + check_warn "Claude Desktop config check skipped (non-macOS)" +fi +echo "" + +# ------------------------------------------- +# 7. Disk space +# ------------------------------------------- +echo "Checking disk space..." +if [[ "$OSTYPE" == "darwin"* ]]; then + AVAILABLE_GB=$(df -g "$HOME" | tail -1 | awk '{print $4}') +else + AVAILABLE_GB=$(df -BG "$HOME" | tail -1 | awk '{print $4}' | tr -d 'G') +fi + +if [[ "$AVAILABLE_GB" -ge 2 ]]; then + check_pass "${AVAILABLE_GB}GB available (need ~500MB)" +else + check_warn "Only ${AVAILABLE_GB}GB available, need at least 500MB" +fi +echo "" + +# ------------------------------------------- +# 8. Network connectivity +# ------------------------------------------- +echo "Checking network..." +if curl -sSf https://github.com > /dev/null 2>&1; then + check_pass "Can reach GitHub" +else + check_fail "Cannot reach GitHub - check your internet connection or VPN" +fi + +if curl -sSf https://pypi.org > /dev/null 2>&1; then + check_pass "Can reach PyPI (Python packages)" +else + check_fail "Cannot reach PyPI - check your internet connection or VPN" +fi +echo "" + +# ------------------------------------------- +# Summary +# ------------------------------------------- +echo "===========================================" +echo " Results" +echo "===========================================" +echo "" +echo -e " ${GREEN}Passed: $PASS${NC} ${RED}Failed: $FAIL${NC} ${YELLOW}Warnings: $WARN${NC}" +echo "" + +if [[ $FAIL -eq 0 ]]; then + echo -e " ${GREEN}You're good to go!${NC}" + echo "" + echo " Next step: Run the installer:" + echo "" + echo " curl -sSL https://raw.githubusercontent.com/alexrich700/GAds-MCP/main/scripts/install.sh | bash" + echo "" +else + echo -e " ${RED}Fix the failures above, then run this pre-flight check again.${NC}" + echo "" + echo " If you're stuck, screenshot this output and send it to Alex." + echo "" +fi From 9537386b8a463388e8398369a0f731b932ac6e1e Mon Sep 17 00:00:00 2001 From: alexrich700 Date: Fri, 20 Mar 2026 19:59:20 -0500 Subject: [PATCH 02/36] Add GAds-MCP installation script This script installs GAds-MCP (AdLoop) and connects it to Claude, checking prerequisites, installing dependencies, and configuring settings. --- scripts/install.sh | 301 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 scripts/install.sh diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..7526f11 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,301 @@ +#!/bin/bash +# ============================================================================== +# GAds-MCP Installer +# Rossman Media - Google Ads MCP Setup +# +# This script installs GAds-MCP (AdLoop) and connects it to Claude. +# Run the pre-flight check first to make sure your machine is ready. +# +# Usage: curl -sSL https://raw.githubusercontent.com/alexrich700/GAds-MCP/main/scripts/install.sh | bash +# ============================================================================== + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +BOLD='\033[1m' +NC='\033[0m' + +REPO_URL="https://github.com/alexrich700/GAds-MCP.git" +INSTALL_DIR="$HOME/.gads-mcp" +CONFIG_DIR="$HOME/.adloop" +CLAUDE_CONFIG_DIR="$HOME/Library/Application Support/Claude" +CLAUDE_CONFIG_FILE="$CLAUDE_CONFIG_DIR/claude_desktop_config.json" + +echo "" +echo "===========================================" +echo " GAds-MCP Installer" +echo " Rossman Media" +echo "===========================================" +echo "" + +# ------------------------------------------- +# 1. Check prerequisites +# ------------------------------------------- +echo -e "${BOLD}Step 1/6: Checking prerequisites...${NC}" + +# Python +if ! command -v python3 &> /dev/null; then + echo -e "${RED}Error: Python 3 not found. Run the pre-flight check first.${NC}" + exit 1 +fi + +PY_VERSION=$(python3 --version 2>&1 | awk '{print $2}') +PY_MINOR=$(echo "$PY_VERSION" | cut -d. -f2) +if [[ "$PY_MINOR" -lt 11 ]]; then + echo -e "${RED}Error: Python $PY_VERSION found but 3.11+ required. Run the pre-flight check first.${NC}" + exit 1 +fi +echo -e " ${GREEN}Python $PY_VERSION${NC}" + +# Git +if ! command -v git &> /dev/null; then + echo -e "${RED}Error: Git not found. Run the pre-flight check first.${NC}" + exit 1 +fi +echo -e " ${GREEN}Git OK${NC}" +echo "" + +# ------------------------------------------- +# 2. Install uv if needed +# ------------------------------------------- +echo -e "${BOLD}Step 2/6: Setting up package manager...${NC}" + +if ! command -v uv &> /dev/null; then + echo " Installing uv..." + curl -LsSf https://astral.sh/uv/install.sh | sh 2>/dev/null + + # Source the env so uv is available in this session + if [[ -f "$HOME/.local/bin/env" ]]; then + source "$HOME/.local/bin/env" + fi + # Also add to path directly in case the source above doesn't work + export PATH="$HOME/.local/bin:$PATH" + + if command -v uv &> /dev/null; then + echo -e " ${GREEN}uv installed successfully${NC}" + else + echo -e "${RED}Error: uv installation failed. Try manually: curl -LsSf https://astral.sh/uv/install.sh | sh${NC}" + exit 1 + fi +else + echo -e " ${GREEN}uv already installed${NC}" +fi +echo "" + +# ------------------------------------------- +# 3. Clone or update the repo +# ------------------------------------------- +echo -e "${BOLD}Step 3/6: Getting GAds-MCP...${NC}" + +if [[ -d "$INSTALL_DIR" ]]; then + echo " Found existing installation, updating..." + cd "$INSTALL_DIR" + git pull --quiet origin main + echo -e " ${GREEN}Updated to latest version${NC}" +else + echo " Cloning from GitHub..." + git clone --quiet "$REPO_URL" "$INSTALL_DIR" + echo -e " ${GREEN}Downloaded${NC}" +fi + +cd "$INSTALL_DIR" +echo "" + +# ------------------------------------------- +# 4. Install Python dependencies +# ------------------------------------------- +echo -e "${BOLD}Step 4/6: Installing dependencies...${NC}" + +uv sync --quiet 2>/dev/null || uv sync +echo -e " ${GREEN}Dependencies installed${NC}" +echo "" + +# ------------------------------------------- +# 5. Run adloop init (OAuth + config) +# ------------------------------------------- +echo -e "${BOLD}Step 5/6: Setting up Google Ads connection...${NC}" +echo "" +echo -e " ${YELLOW}This will open your browser for Google sign-in.${NC}" +echo -e " ${YELLOW}Sign in with your Google account that has access to the MCC.${NC}" +echo "" +echo -e " ${BLUE}You'll need the following info (Alex can provide these):${NC}" +echo " - Google Cloud Project ID" +echo " - Google Ads Developer Token" +echo " - OAuth Client ID and Client Secret" +echo "" +read -p " Ready? Press Enter to continue (or Ctrl+C to exit)... " +echo "" + +# Run the init wizard +uv run adloop init + +echo "" +echo -e " ${GREEN}Google Ads connection configured${NC}" +echo "" + +# ------------------------------------------- +# 6. Configure Claude MCP +# ------------------------------------------- +echo -e "${BOLD}Step 6/6: Connecting to Claude...${NC}" + +# Get the full path to the Python in the venv +PYTHON_PATH="$INSTALL_DIR/.venv/bin/python" + +if [[ ! -f "$PYTHON_PATH" ]]; then + # Fallback: find the python in the venv + PYTHON_PATH=$(find "$INSTALL_DIR/.venv" -name "python3" -type f 2>/dev/null | head -1) +fi + +if [[ -z "$PYTHON_PATH" || ! -f "$PYTHON_PATH" ]]; then + echo -e "${RED}Error: Could not find Python in the virtual environment.${NC}" + echo " Please contact Alex for help." + exit 1 +fi + +# The MCP server entry we need to add +MCP_ENTRY=$(cat </dev/null; then + echo -e " ${GREEN}Claude Desktop already configured${NC}" + CLAUDE_CONFIGURED=true + else + # Need to merge. Use python for safe JSON manipulation. + python3 << PYEOF +import json +import sys +import shutil + +config_file = "$CLAUDE_CONFIG_FILE" +python_path = "$PYTHON_PATH" + +try: + with open(config_file, 'r') as f: + config = json.load(f) +except (json.JSONDecodeError, FileNotFoundError): + config = {} + +# Create backup +shutil.copy2(config_file, config_file + ".backup") + +# Add or update mcpServers +if 'mcpServers' not in config: + config['mcpServers'] = {} + +config['mcpServers']['gads-mcp'] = { + "command": python_path, + "args": ["-m", "adloop"] +} + +with open(config_file, 'w') as f: + json.dump(config, f, indent=2) + +print(" Config updated (backup saved as claude_desktop_config.json.backup)") +PYEOF + echo -e " ${GREEN}Claude Desktop configured${NC}" + CLAUDE_CONFIGURED=true + fi + else + # No config file yet, create one + echo "$MCP_ENTRY" > "$CLAUDE_CONFIG_FILE" + echo -e " ${GREEN}Claude Desktop config created${NC}" + CLAUDE_CONFIGURED=true + fi + fi +fi + +# --- Claude Code config (project-level .mcp.json) --- +echo "" +echo " For Claude Code, the MCP is configured per-project." +echo " When you open a project in Claude Code, create a .mcp.json file" +echo " in the project root with this content:" +echo "" +echo -e " ${BLUE}$(cat < "$MCP_JSON_FILE" < Date: Fri, 20 Mar 2026 20:12:45 -0500 Subject: [PATCH 03/36] Refactor Python version check in preflight script --- scripts/preflight.sh | 70 ++++++++++++++++++++++++++++++++------------ 1 file changed, 51 insertions(+), 19 deletions(-) diff --git a/scripts/preflight.sh b/scripts/preflight.sh index 0465635..9588bce 100644 --- a/scripts/preflight.sh +++ b/scripts/preflight.sh @@ -44,6 +44,48 @@ check_warn() { WARN=$((WARN + 1)) } +# ------------------------------------------- +# Helper: Find a suitable Python 3.11+ +# Checks versioned commands, Homebrew paths, then generic python3 +# ------------------------------------------- +find_python() { + for cmd in python3.13 python3.12 python3.11; do + if command -v "$cmd" &> /dev/null; then + local ver + ver=$("$cmd" --version 2>&1 | awk '{print $2}') + local minor + minor=$(echo "$ver" | cut -d. -f2) + if [[ "$minor" -ge 11 ]]; then + echo "$cmd" + return 0 + fi + fi + done + for brew_cmd in /opt/homebrew/bin/python3.13 /opt/homebrew/bin/python3.12 /opt/homebrew/bin/python3.11 /usr/local/bin/python3.13 /usr/local/bin/python3.12 /usr/local/bin/python3.11; do + if [[ -x "$brew_cmd" ]]; then + local ver + ver=$("$brew_cmd" --version 2>&1 | awk '{print $2}') + local minor + minor=$(echo "$ver" | cut -d. -f2) + if [[ "$minor" -ge 11 ]]; then + echo "$brew_cmd" + return 0 + fi + fi + done + if command -v python3 &> /dev/null; then + local ver + ver=$(python3 --version 2>&1 | awk '{print $2}') + local minor + minor=$(echo "$ver" | cut -d. -f2) + if [[ "$minor" -ge 11 ]]; then + echo "python3" + return 0 + fi + fi + return 1 +} + # ------------------------------------------- # 1. Operating System # ------------------------------------------- @@ -79,28 +121,18 @@ fi # 3. Python 3.11+ # ------------------------------------------- echo "Checking Python..." -if command -v python3 &> /dev/null; then - PY_VERSION=$(python3 --version 2>&1 | awk '{print $2}') - PY_MAJOR=$(echo "$PY_VERSION" | cut -d. -f1) - PY_MINOR=$(echo "$PY_VERSION" | cut -d. -f2) +FOUND_PYTHON=$(find_python || true) - if [[ "$PY_MAJOR" -ge 3 ]] && [[ "$PY_MINOR" -ge 11 ]]; then - check_pass "Python $PY_VERSION (3.11+ required)" +if [[ -n "$FOUND_PYTHON" ]]; then + PY_VERSION=$("$FOUND_PYTHON" --version 2>&1 | awk '{print $2}') + check_pass "Python $PY_VERSION found at: $FOUND_PYTHON" +else + if command -v python3 &> /dev/null; then + OLD_VERSION=$(python3 --version 2>&1 | awk '{print $2}') + check_fail "Python $OLD_VERSION found, but 3.11+ is required" else - check_fail "Python $PY_VERSION found, but 3.11+ is required" - echo "" - if [[ "$OSTYPE" == "darwin"* ]]; then - echo -e " ${BLUE}Fix: Run this command, then re-run the pre-flight check:${NC}" - echo "" - echo " brew install python@3.12" - echo "" - else - echo -e " ${BLUE}Fix: Install Python 3.12 from https://www.python.org/downloads/${NC}" - echo "" - fi + check_fail "Python 3 not found" fi -else - check_fail "Python 3 not found" echo "" if [[ "$OSTYPE" == "darwin"* ]]; then echo -e " ${BLUE}Fix: Run this command, then re-run the pre-flight check:${NC}" From e91d5531da72aaa5d636ef2b314b2ce0c816bea3 Mon Sep 17 00:00:00 2001 From: alexrich700 Date: Fri, 20 Mar 2026 20:12:56 -0500 Subject: [PATCH 04/36] Update Python version check in install script --- scripts/install.sh | 50 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 7526f11..2b0ae12 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -37,19 +37,47 @@ echo "" # ------------------------------------------- echo -e "${BOLD}Step 1/6: Checking prerequisites...${NC}" -# Python -if ! command -v python3 &> /dev/null; then - echo -e "${RED}Error: Python 3 not found. Run the pre-flight check first.${NC}" - exit 1 +# Find Python 3.11+ (check versioned commands, Homebrew paths, then generic python3) +PYTHON_CMD="" +for cmd in python3.13 python3.12 python3.11; do + if command -v "$cmd" &> /dev/null; then + ver=$("$cmd" --version 2>&1 | awk '{print $2}') + minor=$(echo "$ver" | cut -d. -f2) + if [[ "$minor" -ge 11 ]]; then + PYTHON_CMD="$cmd" + break + fi + fi +done +if [[ -z "$PYTHON_CMD" ]]; then + for brew_cmd in /opt/homebrew/bin/python3.13 /opt/homebrew/bin/python3.12 /opt/homebrew/bin/python3.11 /usr/local/bin/python3.13 /usr/local/bin/python3.12 /usr/local/bin/python3.11; do + if [[ -x "$brew_cmd" ]]; then + ver=$("$brew_cmd" --version 2>&1 | awk '{print $2}') + minor=$(echo "$ver" | cut -d. -f2) + if [[ "$minor" -ge 11 ]]; then + PYTHON_CMD="$brew_cmd" + break + fi + fi + done fi - -PY_VERSION=$(python3 --version 2>&1 | awk '{print $2}') -PY_MINOR=$(echo "$PY_VERSION" | cut -d. -f2) -if [[ "$PY_MINOR" -lt 11 ]]; then - echo -e "${RED}Error: Python $PY_VERSION found but 3.11+ required. Run the pre-flight check first.${NC}" +if [[ -z "$PYTHON_CMD" ]]; then + if command -v python3 &> /dev/null; then + ver=$(python3 --version 2>&1 | awk '{print $2}') + minor=$(echo "$ver" | cut -d. -f2) + if [[ "$minor" -ge 11 ]]; then + PYTHON_CMD="python3" + fi + fi +fi +if [[ -z "$PYTHON_CMD" ]]; then + echo -e "${RED}Error: Python 3.11+ not found. Run the pre-flight check first.${NC}" + echo -e " Tried: python3.12, python3.11, /opt/homebrew/bin/python3.12, python3" exit 1 fi -echo -e " ${GREEN}Python $PY_VERSION${NC}" + +PY_VERSION=$("$PYTHON_CMD" --version 2>&1 | awk '{print $2}') +echo -e " ${GREEN}Python $PY_VERSION (using: $PYTHON_CMD)${NC}" # Git if ! command -v git &> /dev/null; then @@ -110,7 +138,7 @@ echo "" # ------------------------------------------- echo -e "${BOLD}Step 4/6: Installing dependencies...${NC}" -uv sync --quiet 2>/dev/null || uv sync +uv sync --python "$PYTHON_CMD" --quiet 2>/dev/null || uv sync --python "$PYTHON_CMD" echo -e " ${GREEN}Dependencies installed${NC}" echo "" From 7c6054b57ea6acdffe98d057adf051abe405af5f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 02:50:13 +0000 Subject: [PATCH 05/36] Add draft_ad_group tool for creating ad groups in existing campaigns Adds the ability to create additional ad groups within existing campaigns, closing the gap where only draft_campaign could create the initial ad group. The new tool follows the same two-step draft/confirm pattern with BROAD match safety checks, keyword validation, and atomic execution via GoogleAdsService.mutate(). https://claude.ai/code/session_01NNbaDSsxSFVeTEPgRWqVVM --- .claude/rules/adloop.md | 14 +++ .cursor/rules/adloop.mdc | 14 +++ CLAUDE.md | 4 +- src/adloop/ads/write.py | 180 ++++++++++++++++++++++++++++++++++ src/adloop/server.py | 33 +++++++ tests/test_draft_ad_group.py | 183 +++++++++++++++++++++++++++++++++++ 6 files changed, 426 insertions(+), 2 deletions(-) create mode 100644 tests/test_draft_ad_group.py diff --git a/.claude/rules/adloop.md b/.claude/rules/adloop.md index f4becb7..3db768a 100644 --- a/.claude/rules/adloop.md +++ b/.claude/rules/adloop.md @@ -73,6 +73,7 @@ These tools call both APIs internally and return unified results with computed ` | Tool | What It Does | Validation | |------|-------------|------------| | `draft_campaign` | Create full campaign structure (budget + campaign + ad group + keywords + geo/language targeting) | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), keywords validated | +| `draft_ad_group` | Create a new ad group within an existing campaign (does NOT publish) | `campaign_id` (REQUIRED), `ad_group_name` (REQUIRED), `keywords` (optional list of {text, match_type}), `cpc_bid_micros` (optional) | | `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids` | | `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each) | | `draft_sitelinks` | Create sitelink extensions for a campaign (does NOT publish) | `campaign_id`, `sitelinks` list of {link_text ≤25 chars, final_url, description1 ≤35 chars, description2 ≤35 chars} | @@ -204,9 +205,22 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has 7. After campaign creation, remind the user to: - Add ads via `draft_responsive_search_ad` (with display paths set) - Add sitelinks via `draft_sitelinks` (at least 4 recommended) + - If the user needs multiple ad groups (e.g., different keyword themes), use `draft_ad_group` to add additional ad groups after the initial campaign is created and confirmed - Enable the campaign via `enable_entity` only after ads and sitelinks are in place 8. Wait for explicit user approval before calling `confirm_and_apply` +### When user wants to add an ad group to an existing campaign + +1. Call `get_campaign_performance` to identify the target campaign and verify it exists +2. **Pre-write checks (CRITICAL):** + - Check the campaign's bidding strategy — if MANUAL_CPC, only use EXACT or PHRASE match keywords + - Check if conversion tracking is active (zero conversions + high spend = problem to fix first) + - Check existing ad groups via `run_gaql`: `SELECT ad_group.id, ad_group.name FROM ad_group WHERE campaign.id = {campaign_id}` — avoid duplicate ad group names +3. Call `draft_ad_group` with `campaign_id`, `ad_group_name`, and optional `keywords` +4. Present the complete preview to the user +5. Wait for explicit user approval before calling `confirm_and_apply` +6. After the ad group is created, remind the user to add RSAs via `draft_responsive_search_ad` using the new `ad_group_id` from the result — an ad group without ads won't serve + ### When user wants to change campaign settings (bid strategy, targeting, budget) 1. Call `get_campaign_performance` to identify the campaign and its current settings diff --git a/.cursor/rules/adloop.mdc b/.cursor/rules/adloop.mdc index 41944f3..0334493 100644 --- a/.cursor/rules/adloop.mdc +++ b/.cursor/rules/adloop.mdc @@ -78,6 +78,7 @@ These tools call both APIs internally and return unified results with computed ` | Tool | What It Does | Validation | |------|-------------|------------| | `draft_campaign` | Create full campaign structure (budget + campaign + ad group + keywords + geo/language targeting) | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), keywords validated | +| `draft_ad_group` | Create a new ad group within an existing campaign (does NOT publish) | `campaign_id` (REQUIRED), `ad_group_name` (REQUIRED), `keywords` (optional list of {text, match_type}), `cpc_bid_micros` (optional) | | `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids` | | `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each) | | `draft_sitelinks` | Create sitelink extensions for a campaign (does NOT publish) | `campaign_id`, `sitelinks` list of {link_text ≤25 chars, final_url, description1 ≤35 chars, description2 ≤35 chars} | @@ -209,9 +210,22 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has 7. After campaign creation, remind the user to: - Add ads via `draft_responsive_search_ad` (with display paths set) - Add sitelinks via `draft_sitelinks` (at least 4 recommended) + - If the user needs multiple ad groups (e.g., different keyword themes), use `draft_ad_group` to add additional ad groups after the initial campaign is created and confirmed - Enable the campaign via `enable_entity` only after ads and sitelinks are in place 8. Wait for explicit user approval before calling `confirm_and_apply` +### When user wants to add an ad group to an existing campaign + +1. Call `get_campaign_performance` to identify the target campaign and verify it exists +2. **Pre-write checks (CRITICAL):** + - Check the campaign's bidding strategy — if MANUAL_CPC, only use EXACT or PHRASE match keywords + - Check if conversion tracking is active (zero conversions + high spend = problem to fix first) + - Check existing ad groups via `run_gaql`: `SELECT ad_group.id, ad_group.name FROM ad_group WHERE campaign.id = {campaign_id}` — avoid duplicate ad group names +3. Call `draft_ad_group` with `campaign_id`, `ad_group_name`, and optional `keywords` +4. Present the complete preview to the user +5. Wait for explicit user approval before calling `confirm_and_apply` +6. After the ad group is created, remind the user to add RSAs via `draft_responsive_search_ad` using the new `ad_group_id` from the result — an ad group without ads won't serve + ### When user wants to change campaign settings (bid strategy, targeting, budget) 1. Call `get_campaign_performance` to identify the campaign and its current settings diff --git a/CLAUDE.md b/CLAUDE.md index 61c27fe..fc69b1a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ python scripts/sync-rules.py # Sync rules: .cursor/rules/ -> .claude/rules/ ``` src/adloop/ ├── __init__.py # Entry point — routes 'adloop init' vs MCP server -├── server.py # FastMCP server — 26 tool registrations +├── server.py # FastMCP server — 27 tool registrations ├── config.py # Config loader (~/.adloop/config.yaml) ├── auth.py # OAuth 2.0 + service account + token refresh ├── cli.py # Interactive setup wizard @@ -34,7 +34,7 @@ All tool usage rules, safety protocols, orchestration patterns, GAQL reference, **Read and follow `.claude/rules/adloop.md` for all AdLoop MCP tool orchestration.** -That file is the complete guide for combining AdLoop's 26 tools. It covers: +That file is the complete guide for combining AdLoop's 27 tools. It covers: - Tool inventory with parameters and when to use each - 8 safety rules (budget caps, dry-run defaults, Broad Match prevention, pre-write validation) - 12 orchestration patterns (performance review, ad creation, tracking diagnosis, etc.) diff --git a/src/adloop/ads/write.py b/src/adloop/ads/write.py index d7646d0..8e32293 100644 --- a/src/adloop/ads/write.py +++ b/src/adloop/ads/write.py @@ -372,6 +372,66 @@ def draft_campaign( return preview +def draft_ad_group( + config: AdLoopConfig, + *, + customer_id: str = "", + campaign_id: str = "", + ad_group_name: str = "", + keywords: list[dict] | None = None, + cpc_bid_micros: int = 0, +) -> dict: + """Draft a new ad group within an existing campaign — returns preview. + + Creates: AdGroup (ENABLED, SEARCH_STANDARD) + optional Keywords. + Ads are NOT included — use draft_responsive_search_ad separately + after the ad group is created. + + cpc_bid_micros: Optional ad-group-level CPC bid in micros. Only relevant + for campaigns using MANUAL_CPC bidding. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("create_ad_group", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + errors = _validate_ad_group( + campaign_id=campaign_id, + ad_group_name=ad_group_name, + keywords=keywords, + cpc_bid_micros=cpc_bid_micros, + ) + if errors: + return {"error": "Validation failed", "details": errors} + + warnings: list[str] = [] + keywords = keywords or [] + if keywords: + warnings = _check_broad_match_safety_by_campaign( + config, customer_id, campaign_id, keywords + ) + + plan = ChangePlan( + operation="create_ad_group", + entity_type="ad_group", + customer_id=customer_id, + changes={ + "campaign_id": campaign_id, + "ad_group_name": ad_group_name, + "keywords": keywords, + "cpc_bid_micros": cpc_bid_micros, + }, + ) + store_plan(plan) + preview = plan.to_preview() + if warnings: + preview["warnings"] = warnings + return preview + + def update_campaign( config: AdLoopConfig, *, @@ -878,6 +938,77 @@ def _validate_keywords(ad_group_id: str, keywords: list[dict]) -> list[str]: return errors +def _validate_ad_group( + *, + campaign_id: str, + ad_group_name: str, + keywords: list[dict] | None, + cpc_bid_micros: int, +) -> list[str]: + """Validate inputs for draft_ad_group.""" + errors = [] + if not campaign_id: + errors.append("campaign_id is required") + if not ad_group_name or not ad_group_name.strip(): + errors.append("ad_group_name is required") + if cpc_bid_micros < 0: + errors.append("cpc_bid_micros must be >= 0") + if keywords: + for i, kw in enumerate(keywords): + if not kw.get("text"): + errors.append(f"Keyword {i + 1} has no text") + mt = kw.get("match_type", "").upper() + if mt not in _VALID_MATCH_TYPES: + errors.append( + f"Keyword {i + 1} has invalid match_type '{mt}' " + "(must be EXACT, PHRASE, or BROAD)" + ) + return errors + + +def _check_broad_match_safety_by_campaign( + config: AdLoopConfig, + customer_id: str, + campaign_id: str, + keywords: list[dict], +) -> list[str]: + """Warn if BROAD match keywords target a non-Smart Bidding campaign (by campaign_id).""" + has_broad = any( + kw.get("match_type", "").upper() == "BROAD" for kw in keywords + ) + if not has_broad: + return [] + + try: + from adloop.ads.gaql import execute_query + + query = f""" + SELECT campaign.bidding_strategy_type, campaign.name + FROM campaign + WHERE campaign.id = {campaign_id} + """ + rows = execute_query(config, customer_id, query) + if not rows: + return [] + + bidding = rows[0].get("campaign.bidding_strategy_type", "") + campaign_name = rows[0].get("campaign.name", "") + + if bidding not in _SMART_BIDDING_STRATEGIES: + return [ + f"DANGEROUS: Adding BROAD match keywords to campaign " + f"'{campaign_name}' which uses {bidding} bidding. " + f"Broad Match without Smart Bidding (tCPA/tROAS/Maximize Conversions) " + f"leads to irrelevant matches and wasted budget. " + f"Use PHRASE or EXACT match instead, or switch the campaign " + f"to Smart Bidding first." + ] + except Exception: + pass + + return [] + + def _draft_status_change( config: AdLoopConfig, operation: str, @@ -929,6 +1060,7 @@ def _execute_plan(config: AdLoopConfig, plan: object) -> dict: dispatch = { "create_campaign": _apply_create_campaign, + "create_ad_group": _apply_create_ad_group, "update_campaign": _apply_update_campaign, "create_responsive_search_ad": _apply_create_rsa, "add_keywords": _apply_add_keywords, @@ -1095,6 +1227,54 @@ def _apply_create_campaign(client: object, cid: str, changes: dict) -> dict: return results +def _apply_create_ad_group(client: object, cid: str, changes: dict) -> dict: + """Create ad group + optional keywords in an existing campaign atomically.""" + service = client.get_service("GoogleAdsService") + campaign_service = client.get_service("CampaignService") + ad_group_service = client.get_service("AdGroupService") + + operations: list = [] + + # 1. AdGroup (temp ID: -1, references existing campaign) + ag_op = client.get_type("MutateOperation") + ad_group = ag_op.ad_group_operation.create + ad_group.resource_name = ad_group_service.ad_group_path(cid, "-1") + ad_group.name = changes["ad_group_name"] + ad_group.campaign = campaign_service.campaign_path(cid, changes["campaign_id"]) + ad_group.status = client.enums.AdGroupStatusEnum.ENABLED + ad_group.type_ = client.enums.AdGroupTypeEnum.SEARCH_STANDARD + if changes.get("cpc_bid_micros"): + ad_group.cpc_bid_micros = changes["cpc_bid_micros"] + operations.append(ag_op) + + # 2. Keywords (reference ad_group -1) + kw_list = changes.get("keywords") or [] + for kw in kw_list: + kw_op = client.get_type("MutateOperation") + criterion = kw_op.ad_group_criterion_operation.create + criterion.ad_group = ad_group_service.ad_group_path(cid, "-1") + criterion.keyword.text = kw["text"] + criterion.keyword.match_type = getattr( + client.enums.KeywordMatchTypeEnum, kw["match_type"].upper() + ) + operations.append(kw_op) + + response = service.mutate(customer_id=cid, mutate_operations=operations) + + results: dict = {} + for i, resp in enumerate(response.mutate_operation_responses): + resp_type = resp.WhichOneof("response") + if resp_type: + inner = getattr(resp, resp_type) + resource = getattr(inner, "resource_name", str(inner)) + if i == 0: + results["ad_group"] = resource + else: + results.setdefault("keywords", []).append(resource) + + return results + + def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict: """Update an existing campaign's settings.""" from google.protobuf import field_mask_pb2 diff --git a/src/adloop/server.py b/src/adloop/server.py index a4e118c..7f0f223 100644 --- a/src/adloop/server.py +++ b/src/adloop/server.py @@ -507,6 +507,39 @@ def draft_campaign( ) +@mcp.tool(annotations=_WRITE) +@_safe +def draft_ad_group( + campaign_id: str, + ad_group_name: str, + keywords: list[dict] | None = None, + customer_id: str = "", + cpc_bid_micros: int = 0, +) -> dict: + """Draft a new ad group within an existing campaign — returns a PREVIEW, does NOT create. + + Creates an ad group (ENABLED, type SEARCH_STANDARD) in the specified campaign. + Optionally includes keywords in the same atomic operation. + + campaign_id: The campaign to add the ad group to (get from get_campaign_performance). + ad_group_name: Name for the new ad group. + keywords: Optional list of {"text": "keyword", "match_type": "EXACT|PHRASE|BROAD"}. + cpc_bid_micros: Optional ad group CPC bid in micros (only for MANUAL_CPC campaigns). + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.write import draft_ad_group as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + campaign_id=campaign_id, + ad_group_name=ad_group_name, + keywords=keywords, + cpc_bid_micros=cpc_bid_micros, + ) + + @mcp.tool(annotations=_WRITE) @_safe def update_campaign( diff --git a/tests/test_draft_ad_group.py b/tests/test_draft_ad_group.py new file mode 100644 index 0000000..bc42029 --- /dev/null +++ b/tests/test_draft_ad_group.py @@ -0,0 +1,183 @@ +"""Tests for draft_ad_group validation and plan creation.""" + +from unittest.mock import patch + +import pytest + +from adloop.ads.write import ( + _check_broad_match_safety_by_campaign, + _validate_ad_group, + draft_ad_group, +) +from adloop.config import AdLoopConfig, AdsConfig, SafetyConfig +from adloop.safety.preview import get_plan, remove_plan + + +@pytest.fixture +def config(): + return AdLoopConfig( + ads=AdsConfig(customer_id="1234567890", developer_token="test"), + safety=SafetyConfig(max_daily_budget=50.0, require_dry_run=True), + ) + + +class TestValidateAdGroup: + def test_valid_inputs(self): + errors = _validate_ad_group( + campaign_id="123", + ad_group_name="Test Ad Group", + keywords=None, + cpc_bid_micros=0, + ) + assert errors == [] + + def test_missing_campaign_id(self): + errors = _validate_ad_group( + campaign_id="", + ad_group_name="Test", + keywords=None, + cpc_bid_micros=0, + ) + assert any("campaign_id" in e for e in errors) + + def test_missing_ad_group_name(self): + errors = _validate_ad_group( + campaign_id="123", + ad_group_name="", + keywords=None, + cpc_bid_micros=0, + ) + assert any("ad_group_name" in e for e in errors) + + def test_whitespace_ad_group_name(self): + errors = _validate_ad_group( + campaign_id="123", + ad_group_name=" ", + keywords=None, + cpc_bid_micros=0, + ) + assert any("ad_group_name" in e for e in errors) + + def test_negative_cpc_bid(self): + errors = _validate_ad_group( + campaign_id="123", + ad_group_name="Test", + keywords=None, + cpc_bid_micros=-100, + ) + assert any("cpc_bid_micros" in e for e in errors) + + def test_valid_with_keywords(self): + errors = _validate_ad_group( + campaign_id="123", + ad_group_name="Test", + keywords=[ + {"text": "buy shoes", "match_type": "EXACT"}, + {"text": "running shoes", "match_type": "PHRASE"}, + ], + cpc_bid_micros=0, + ) + assert errors == [] + + def test_keyword_missing_text(self): + errors = _validate_ad_group( + campaign_id="123", + ad_group_name="Test", + keywords=[{"text": "", "match_type": "EXACT"}], + cpc_bid_micros=0, + ) + assert any("no text" in e for e in errors) + + def test_keyword_invalid_match_type(self): + errors = _validate_ad_group( + campaign_id="123", + ad_group_name="Test", + keywords=[{"text": "shoes", "match_type": "INVALID"}], + cpc_bid_micros=0, + ) + assert any("invalid match_type" in e for e in errors) + + +class TestDraftAdGroup: + def test_returns_preview_with_plan_id(self, config): + result = draft_ad_group( + config, + customer_id="1234567890", + campaign_id="999", + ad_group_name="My Ad Group", + ) + assert "plan_id" in result + assert result["operation"] == "create_ad_group" + assert result["changes"]["campaign_id"] == "999" + assert result["changes"]["ad_group_name"] == "My Ad Group" + + # Clean up stored plan + remove_plan(result["plan_id"]) + + def test_stores_plan(self, config): + result = draft_ad_group( + config, + customer_id="1234567890", + campaign_id="999", + ad_group_name="My Ad Group", + ) + plan = get_plan(result["plan_id"]) + assert plan is not None + assert plan.operation == "create_ad_group" + assert plan.entity_type == "ad_group" + + remove_plan(result["plan_id"]) + + def test_includes_keywords_in_plan(self, config): + keywords = [{"text": "buy shoes", "match_type": "EXACT"}] + result = draft_ad_group( + config, + customer_id="1234567890", + campaign_id="999", + ad_group_name="Shoes Group", + keywords=keywords, + ) + assert result["changes"]["keywords"] == keywords + + remove_plan(result["plan_id"]) + + def test_includes_cpc_bid(self, config): + result = draft_ad_group( + config, + customer_id="1234567890", + campaign_id="999", + ad_group_name="Test", + cpc_bid_micros=500000, + ) + assert result["changes"]["cpc_bid_micros"] == 500000 + + remove_plan(result["plan_id"]) + + def test_validation_error_missing_campaign_id(self, config): + result = draft_ad_group( + config, + customer_id="1234567890", + campaign_id="", + ad_group_name="Test", + ) + assert "error" in result + + def test_validation_error_missing_name(self, config): + result = draft_ad_group( + config, + customer_id="1234567890", + campaign_id="999", + ad_group_name="", + ) + assert "error" in result + + def test_blocked_operation(self, config): + config.safety.blocked_operations = ["create_ad_group"] + result = draft_ad_group( + config, + customer_id="1234567890", + campaign_id="999", + ad_group_name="Test", + ) + assert "error" in result + config.safety.blocked_operations = [] From 0eb72b9c7182b9174f013b4042c9e25ad0cea5e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 02:56:34 +0000 Subject: [PATCH 06/36] Add pre-flight checks to draft_ad_group: campaign type, duplicate name, CPC bid Three new safety checks before creating an ad group: 1. Reject non-SEARCH campaigns (DISPLAY/SHOPPING) since ad group type is SEARCH_STANDARD 2. Warn if an ad group with the same name already exists (duplicate name confusion) 3. Warn if cpc_bid_micros is set on a Smart Bidding campaign (bid will be ignored) Also consolidates the broad match safety check into the same preflight function, reducing API calls by combining campaign info into a single GAQL query. https://claude.ai/code/session_01NNbaDSsxSFVeTEPgRWqVVM --- src/adloop/ads/write.py | 104 +++++++++++++++++------ tests/test_draft_ad_group.py | 157 ++++++++++++++++++++++++++++++++++- 2 files changed, 235 insertions(+), 26 deletions(-) diff --git a/src/adloop/ads/write.py b/src/adloop/ads/write.py index 8e32293..db92268 100644 --- a/src/adloop/ads/write.py +++ b/src/adloop/ads/write.py @@ -407,12 +407,12 @@ def draft_ad_group( if errors: return {"error": "Validation failed", "details": errors} - warnings: list[str] = [] keywords = keywords or [] - if keywords: - warnings = _check_broad_match_safety_by_campaign( - config, customer_id, campaign_id, keywords - ) + preflight_errors, warnings = _preflight_ad_group_checks( + config, customer_id, campaign_id, ad_group_name, keywords, cpc_bid_micros + ) + if preflight_errors: + return {"error": "Pre-flight check failed", "details": preflight_errors} plan = ChangePlan( operation="create_ad_group", @@ -966,47 +966,101 @@ def _validate_ad_group( return errors -def _check_broad_match_safety_by_campaign( +def _preflight_ad_group_checks( config: AdLoopConfig, customer_id: str, campaign_id: str, + ad_group_name: str, keywords: list[dict], -) -> list[str]: - """Warn if BROAD match keywords target a non-Smart Bidding campaign (by campaign_id).""" - has_broad = any( - kw.get("match_type", "").upper() == "BROAD" for kw in keywords - ) - if not has_broad: - return [] + cpc_bid_micros: int, +) -> tuple[list[str], list[str]]: + """Run pre-flight checks before creating an ad group. + + Returns (errors, warnings). Errors block the draft; warnings are informational. + + Checks performed: + 1. Campaign must be a SEARCH campaign (error if not). + 2. Warn if an ad group with the same name already exists in the campaign. + 3. Warn if cpc_bid_micros is set but campaign uses Smart Bidding (ignored). + 4. Warn if BROAD match keywords + non-Smart Bidding campaign. + """ + errors: list[str] = [] + warnings: list[str] = [] try: from adloop.ads.gaql import execute_query - query = f""" - SELECT campaign.bidding_strategy_type, campaign.name + # Query 1: campaign info (type, bidding, name) + campaign_query = f""" + SELECT campaign.advertising_channel_type, + campaign.bidding_strategy_type, + campaign.name FROM campaign WHERE campaign.id = {campaign_id} """ - rows = execute_query(config, customer_id, query) + rows = execute_query(config, customer_id, campaign_query) if not rows: - return [] + errors.append( + f"Campaign {campaign_id} not found. Verify the campaign ID " + "using get_campaign_performance." + ) + return errors, warnings - bidding = rows[0].get("campaign.bidding_strategy_type", "") - campaign_name = rows[0].get("campaign.name", "") + row = rows[0] + channel_type = row.get("campaign.advertising_channel_type", "") + bidding = row.get("campaign.bidding_strategy_type", "") + campaign_name = row.get("campaign.name", "") - if bidding not in _SMART_BIDDING_STRATEGIES: - return [ + # Check 1: campaign type must be SEARCH + if channel_type and channel_type != "SEARCH": + errors.append( + f"Campaign '{campaign_name}' is a {channel_type} campaign. " + "draft_ad_group only supports SEARCH campaigns." + ) + + # Check 3: cpc_bid_micros on Smart Bidding is ignored + if cpc_bid_micros and bidding in _SMART_BIDDING_STRATEGIES: + warnings.append( + f"Campaign '{campaign_name}' uses {bidding} (Smart Bidding). " + "The cpc_bid_micros value will be ignored — Smart Bidding " + "sets bids automatically." + ) + + # Check 4: BROAD match + non-Smart Bidding + has_broad = any( + kw.get("match_type", "").upper() == "BROAD" for kw in keywords + ) + if has_broad and bidding not in _SMART_BIDDING_STRATEGIES: + warnings.append( f"DANGEROUS: Adding BROAD match keywords to campaign " f"'{campaign_name}' which uses {bidding} bidding. " - f"Broad Match without Smart Bidding (tCPA/tROAS/Maximize Conversions) " - f"leads to irrelevant matches and wasted budget. " + f"Broad Match without Smart Bidding (tCPA/tROAS/Maximize " + f"Conversions) leads to irrelevant matches and wasted budget. " f"Use PHRASE or EXACT match instead, or switch the campaign " f"to Smart Bidding first." - ] + ) + + # Query 2: existing ad groups (duplicate name check) + ag_query = f""" + SELECT ad_group.name + FROM ad_group + WHERE campaign.id = {campaign_id} + """ + ag_rows = execute_query(config, customer_id, ag_query) + existing_names = {r.get("ad_group.name", "") for r in ag_rows} + if ad_group_name in existing_names: + warnings.append( + f"An ad group named '{ad_group_name}' already exists in " + f"campaign '{campaign_name}'. This will create a duplicate. " + f"Consider using a different name to avoid confusion." + ) + except Exception: + # If API calls fail, allow the draft to proceed — the real + # validation happens at confirm_and_apply time. pass - return [] + return errors, warnings def _draft_status_change( diff --git a/tests/test_draft_ad_group.py b/tests/test_draft_ad_group.py index bc42029..6f86850 100644 --- a/tests/test_draft_ad_group.py +++ b/tests/test_draft_ad_group.py @@ -5,7 +5,7 @@ import pytest from adloop.ads.write import ( - _check_broad_match_safety_by_campaign, + _preflight_ad_group_checks, _validate_ad_group, draft_ad_group, ) @@ -98,6 +98,161 @@ def test_keyword_invalid_match_type(self): assert any("invalid match_type" in e for e in errors) +class TestPreflightAdGroupChecks: + """Tests for _preflight_ad_group_checks using mocked GAQL queries.""" + + def _mock_execute(self, campaign_rows, ad_group_rows=None): + """Return a side_effect function that returns different results per query.""" + ad_group_rows = ad_group_rows or [] + + def side_effect(config, customer_id, query): + if "campaign.advertising_channel_type" in query: + return campaign_rows + if "ad_group.name" in query: + return ad_group_rows + return [] + + return side_effect + + @patch("adloop.ads.gaql.execute_query") + def test_search_campaign_no_issues(self, mock_query, config): + mock_query.side_effect = self._mock_execute( + [{"campaign.advertising_channel_type": "SEARCH", + "campaign.bidding_strategy_type": "MAXIMIZE_CONVERSIONS", + "campaign.name": "My Campaign"}], + ) + errors, warnings = _preflight_ad_group_checks( + config, "1234567890", "999", "New Group", [], 0 + ) + assert errors == [] + assert warnings == [] + + @patch("adloop.ads.gaql.execute_query") + def test_display_campaign_rejected(self, mock_query, config): + mock_query.side_effect = self._mock_execute( + [{"campaign.advertising_channel_type": "DISPLAY", + "campaign.bidding_strategy_type": "MAXIMIZE_CONVERSIONS", + "campaign.name": "Display Campaign"}], + ) + errors, warnings = _preflight_ad_group_checks( + config, "1234567890", "999", "New Group", [], 0 + ) + assert any("DISPLAY" in e for e in errors) + assert any("only supports SEARCH" in e for e in errors) + + @patch("adloop.ads.gaql.execute_query") + def test_shopping_campaign_rejected(self, mock_query, config): + mock_query.side_effect = self._mock_execute( + [{"campaign.advertising_channel_type": "SHOPPING", + "campaign.bidding_strategy_type": "MAXIMIZE_CONVERSIONS", + "campaign.name": "Shopping Campaign"}], + ) + errors, warnings = _preflight_ad_group_checks( + config, "1234567890", "999", "New Group", [], 0 + ) + assert any("SHOPPING" in e for e in errors) + + @patch("adloop.ads.gaql.execute_query") + def test_campaign_not_found(self, mock_query, config): + mock_query.side_effect = self._mock_execute([]) + errors, warnings = _preflight_ad_group_checks( + config, "1234567890", "999", "New Group", [], 0 + ) + assert any("not found" in e for e in errors) + + @patch("adloop.ads.gaql.execute_query") + def test_duplicate_ad_group_name_warns(self, mock_query, config): + mock_query.side_effect = self._mock_execute( + [{"campaign.advertising_channel_type": "SEARCH", + "campaign.bidding_strategy_type": "MAXIMIZE_CONVERSIONS", + "campaign.name": "My Campaign"}], + [{"ad_group.name": "Existing Group"}, {"ad_group.name": "Another"}], + ) + errors, warnings = _preflight_ad_group_checks( + config, "1234567890", "999", "Existing Group", [], 0 + ) + assert errors == [] + assert any("already exists" in w for w in warnings) + + @patch("adloop.ads.gaql.execute_query") + def test_no_duplicate_name_no_warning(self, mock_query, config): + mock_query.side_effect = self._mock_execute( + [{"campaign.advertising_channel_type": "SEARCH", + "campaign.bidding_strategy_type": "MAXIMIZE_CONVERSIONS", + "campaign.name": "My Campaign"}], + [{"ad_group.name": "Other Group"}], + ) + errors, warnings = _preflight_ad_group_checks( + config, "1234567890", "999", "New Group", [], 0 + ) + assert errors == [] + assert not any("already exists" in w for w in warnings) + + @patch("adloop.ads.gaql.execute_query") + def test_cpc_bid_on_smart_bidding_warns(self, mock_query, config): + mock_query.side_effect = self._mock_execute( + [{"campaign.advertising_channel_type": "SEARCH", + "campaign.bidding_strategy_type": "MAXIMIZE_CONVERSIONS", + "campaign.name": "Smart Campaign"}], + ) + errors, warnings = _preflight_ad_group_checks( + config, "1234567890", "999", "New Group", [], cpc_bid_micros=500000 + ) + assert errors == [] + assert any("ignored" in w for w in warnings) + + @patch("adloop.ads.gaql.execute_query") + def test_cpc_bid_on_manual_cpc_no_warning(self, mock_query, config): + mock_query.side_effect = self._mock_execute( + [{"campaign.advertising_channel_type": "SEARCH", + "campaign.bidding_strategy_type": "MANUAL_CPC", + "campaign.name": "Manual Campaign"}], + ) + errors, warnings = _preflight_ad_group_checks( + config, "1234567890", "999", "New Group", [], cpc_bid_micros=500000 + ) + assert errors == [] + assert not any("ignored" in w for w in warnings) + + @patch("adloop.ads.gaql.execute_query") + def test_broad_match_non_smart_bidding_warns(self, mock_query, config): + mock_query.side_effect = self._mock_execute( + [{"campaign.advertising_channel_type": "SEARCH", + "campaign.bidding_strategy_type": "MANUAL_CPC", + "campaign.name": "Manual Campaign"}], + ) + keywords = [{"text": "shoes", "match_type": "BROAD"}] + errors, warnings = _preflight_ad_group_checks( + config, "1234567890", "999", "New Group", keywords, 0 + ) + assert errors == [] + assert any("DANGEROUS" in w and "BROAD" in w for w in warnings) + + @patch("adloop.ads.gaql.execute_query") + def test_broad_match_smart_bidding_no_warning(self, mock_query, config): + mock_query.side_effect = self._mock_execute( + [{"campaign.advertising_channel_type": "SEARCH", + "campaign.bidding_strategy_type": "TARGET_CPA", + "campaign.name": "Smart Campaign"}], + ) + keywords = [{"text": "shoes", "match_type": "BROAD"}] + errors, warnings = _preflight_ad_group_checks( + config, "1234567890", "999", "New Group", keywords, 0 + ) + assert errors == [] + assert not any("BROAD" in w for w in warnings) + + @patch("adloop.ads.gaql.execute_query") + def test_api_failure_passes_through(self, mock_query, config): + """If API calls fail, preflight should not block the draft.""" + mock_query.side_effect = Exception("API unavailable") + errors, warnings = _preflight_ad_group_checks( + config, "1234567890", "999", "New Group", [], 0 + ) + assert errors == [] + assert warnings == [] + + class TestDraftAdGroup: def test_returns_preview_with_plan_id(self, config): result = draft_ad_group( From 51804875e3dffbbe98a6e2f181465b6ac6200f76 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 03:00:47 +0000 Subject: [PATCH 07/36] Fix comment numbering in preflight checks and correct tool count to 29 - Renumber check comments sequentially (1, 2, 3, 4) and align docstring - Fix CLAUDE.md tool count: actual @mcp.tool registrations is 29, not 27 https://claude.ai/code/session_01NNbaDSsxSFVeTEPgRWqVVM --- CLAUDE.md | 4 ++-- src/adloop/ads/write.py | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fc69b1a..2e041e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ python scripts/sync-rules.py # Sync rules: .cursor/rules/ -> .claude/rules/ ``` src/adloop/ ├── __init__.py # Entry point — routes 'adloop init' vs MCP server -├── server.py # FastMCP server — 27 tool registrations +├── server.py # FastMCP server — 29 tool registrations ├── config.py # Config loader (~/.adloop/config.yaml) ├── auth.py # OAuth 2.0 + service account + token refresh ├── cli.py # Interactive setup wizard @@ -34,7 +34,7 @@ All tool usage rules, safety protocols, orchestration patterns, GAQL reference, **Read and follow `.claude/rules/adloop.md` for all AdLoop MCP tool orchestration.** -That file is the complete guide for combining AdLoop's 27 tools. It covers: +That file is the complete guide for combining AdLoop's 29 tools. It covers: - Tool inventory with parameters and when to use each - 8 safety rules (budget caps, dry-run defaults, Broad Match prevention, pre-write validation) - 12 orchestration patterns (performance review, ad creation, tracking diagnosis, etc.) diff --git a/src/adloop/ads/write.py b/src/adloop/ads/write.py index db92268..7f2ca70 100644 --- a/src/adloop/ads/write.py +++ b/src/adloop/ads/write.py @@ -980,9 +980,9 @@ def _preflight_ad_group_checks( Checks performed: 1. Campaign must be a SEARCH campaign (error if not). - 2. Warn if an ad group with the same name already exists in the campaign. - 3. Warn if cpc_bid_micros is set but campaign uses Smart Bidding (ignored). - 4. Warn if BROAD match keywords + non-Smart Bidding campaign. + 2. Warn if cpc_bid_micros is set but campaign uses Smart Bidding (ignored). + 3. Warn if BROAD match keywords + non-Smart Bidding campaign. + 4. Warn if an ad group with the same name already exists in the campaign. """ errors: list[str] = [] warnings: list[str] = [] @@ -1018,7 +1018,7 @@ def _preflight_ad_group_checks( "draft_ad_group only supports SEARCH campaigns." ) - # Check 3: cpc_bid_micros on Smart Bidding is ignored + # Check 2: cpc_bid_micros on Smart Bidding is ignored if cpc_bid_micros and bidding in _SMART_BIDDING_STRATEGIES: warnings.append( f"Campaign '{campaign_name}' uses {bidding} (Smart Bidding). " @@ -1026,7 +1026,7 @@ def _preflight_ad_group_checks( "sets bids automatically." ) - # Check 4: BROAD match + non-Smart Bidding + # Check 3: BROAD match + non-Smart Bidding has_broad = any( kw.get("match_type", "").upper() == "BROAD" for kw in keywords ) @@ -1040,7 +1040,7 @@ def _preflight_ad_group_checks( f"to Smart Bidding first." ) - # Query 2: existing ad groups (duplicate name check) + # Check 4: existing ad groups (duplicate name check) ag_query = f""" SELECT ad_group.name FROM ad_group From 7d7ccfeeea12982f825cbbf24b4a59ce130f4858 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 03:34:42 +0000 Subject: [PATCH 08/36] Fix null match_type handling and surface preflight failures as warnings - Use `(kw.get("match_type") or "").upper()` to handle null/None match_type from MCP/JSON callers instead of raising AttributeError - Replace blanket `except Exception: pass` in preflight checks with a warning so users know validations were skipped - Add tests for null and missing match_type keys https://claude.ai/code/session_01NNbaDSsxSFVeTEPgRWqVVM --- src/adloop/ads/write.py | 24 ++++++++++++++---------- tests/test_draft_ad_group.py | 28 +++++++++++++++++++++++++--- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/src/adloop/ads/write.py b/src/adloop/ads/write.py index 7f2ca70..f60af5c 100644 --- a/src/adloop/ads/write.py +++ b/src/adloop/ads/write.py @@ -758,7 +758,7 @@ def _check_broad_match_safety( ) -> list[str]: """Warn if BROAD match keywords are being added to a non-Smart Bidding campaign.""" has_broad = any( - kw.get("match_type", "").upper() == "BROAD" for kw in keywords + (kw.get("match_type") or "").upper() == "BROAD" for kw in keywords ) if not has_broad: return [] @@ -885,7 +885,7 @@ def _validate_campaign( if keywords: has_broad = any( - kw.get("match_type", "").upper() == "BROAD" for kw in keywords + (kw.get("match_type") or "").upper() == "BROAD" for kw in keywords ) if has_broad and bs not in _SMART_BIDDING_STRATEGIES: errors.append( @@ -897,7 +897,7 @@ def _validate_campaign( for i, kw in enumerate(keywords): if not kw.get("text"): errors.append(f"Keyword {i + 1} has no text") - mt = kw.get("match_type", "").upper() + mt = (kw.get("match_type") or "").upper() if mt not in _VALID_MATCH_TYPES: errors.append( f"Keyword {i + 1} has invalid match_type '{mt}' " @@ -929,7 +929,7 @@ def _validate_keywords(ad_group_id: str, keywords: list[dict]) -> list[str]: for i, kw in enumerate(keywords): if not kw.get("text"): errors.append(f"Keyword {i + 1} has no text") - mt = kw.get("match_type", "").upper() + mt = (kw.get("match_type") or "").upper() if mt not in _VALID_MATCH_TYPES: errors.append( f"Keyword {i + 1} has invalid match_type '{mt}' " @@ -957,7 +957,7 @@ def _validate_ad_group( for i, kw in enumerate(keywords): if not kw.get("text"): errors.append(f"Keyword {i + 1} has no text") - mt = kw.get("match_type", "").upper() + mt = (kw.get("match_type") or "").upper() if mt not in _VALID_MATCH_TYPES: errors.append( f"Keyword {i + 1} has invalid match_type '{mt}' " @@ -1028,7 +1028,7 @@ def _preflight_ad_group_checks( # Check 3: BROAD match + non-Smart Bidding has_broad = any( - kw.get("match_type", "").upper() == "BROAD" for kw in keywords + (kw.get("match_type") or "").upper() == "BROAD" for kw in keywords ) if has_broad and bidding not in _SMART_BIDDING_STRATEGIES: warnings.append( @@ -1055,10 +1055,14 @@ def _preflight_ad_group_checks( f"Consider using a different name to avoid confusion." ) - except Exception: - # If API calls fail, allow the draft to proceed — the real - # validation happens at confirm_and_apply time. - pass + except Exception as exc: + # Surface preflight failures as warnings so users know checks + # were skipped, rather than silently producing a clean preview. + warnings.append( + f"Preflight checks could not complete ({exc}). " + "The draft will proceed, but some validations were skipped. " + "Full validation happens at confirm_and_apply time." + ) return errors, warnings diff --git a/tests/test_draft_ad_group.py b/tests/test_draft_ad_group.py index 6f86850..941c025 100644 --- a/tests/test_draft_ad_group.py +++ b/tests/test_draft_ad_group.py @@ -97,6 +97,26 @@ def test_keyword_invalid_match_type(self): ) assert any("invalid match_type" in e for e in errors) + def test_keyword_null_match_type(self): + """MCP/JSON callers can send null for match_type.""" + errors = _validate_ad_group( + campaign_id="123", + ad_group_name="Test", + keywords=[{"text": "shoes", "match_type": None}], + cpc_bid_micros=0, + ) + assert any("invalid match_type" in e for e in errors) + + def test_keyword_missing_match_type_key(self): + """Keywords without a match_type key should get a validation error.""" + errors = _validate_ad_group( + campaign_id="123", + ad_group_name="Test", + keywords=[{"text": "shoes"}], + cpc_bid_micros=0, + ) + assert any("invalid match_type" in e for e in errors) + class TestPreflightAdGroupChecks: """Tests for _preflight_ad_group_checks using mocked GAQL queries.""" @@ -243,14 +263,16 @@ def test_broad_match_smart_bidding_no_warning(self, mock_query, config): assert not any("BROAD" in w for w in warnings) @patch("adloop.ads.gaql.execute_query") - def test_api_failure_passes_through(self, mock_query, config): - """If API calls fail, preflight should not block the draft.""" + def test_api_failure_surfaces_warning(self, mock_query, config): + """If API calls fail, preflight should warn but not block the draft.""" mock_query.side_effect = Exception("API unavailable") errors, warnings = _preflight_ad_group_checks( config, "1234567890", "999", "New Group", [], 0 ) assert errors == [] - assert warnings == [] + assert len(warnings) == 1 + assert "Preflight checks could not complete" in warnings[0] + assert "API unavailable" in warnings[0] class TestDraftAdGroup: From a156a812517ee68d49f9bc9135ec1690ccad4b36 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 04:29:16 +0000 Subject: [PATCH 09/36] Add automatic Final URL Suffix (UTM tracking) to search campaign creation SEARCH campaigns created via draft_campaign now auto-set the Final URL Suffix with standard UTM parameters using Google Ads ValueTrack macros: utm_source=google&utm_medium=cpc&utm_campaign={campaignid}&utm_content={adgroupid}&utm_term={keyword} - Add _DEFAULT_FINAL_URL_SUFFIX constant in write.py - Add final_url_suffix param to draft_campaign (auto-applied for SEARCH, pass "" to disable) - Set campaign.final_url_suffix on the proto in _apply_create_campaign - Add final_url_suffix param to update_campaign for changing/clearing it - Apply final_url_suffix via field mask in _apply_update_campaign - Expose final_url_suffix in both MCP tool registrations in server.py - Update orchestration docs in .claude/rules and .cursor/rules https://claude.ai/code/session_01SE2vhVvPdaWTGvm2T1mpoF --- .claude/rules/adloop.md | 4 ++-- .cursor/rules/adloop.mdc | 4 ++-- src/adloop/ads/write.py | 37 +++++++++++++++++++++++++++++++++++++ src/adloop/server.py | 8 ++++++++ 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/.claude/rules/adloop.md b/.claude/rules/adloop.md index 3db768a..8d782e2 100644 --- a/.claude/rules/adloop.md +++ b/.claude/rules/adloop.md @@ -72,9 +72,9 @@ These tools call both APIs internally and return unified results with computed ` | Tool | What It Does | Validation | |------|-------------|------------| -| `draft_campaign` | Create full campaign structure (budget + campaign + ad group + keywords + geo/language targeting) | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), keywords validated | +| `draft_campaign` | Create full campaign structure (budget + campaign + ad group + keywords + geo/language targeting). Auto-sets Final URL Suffix with UTM tracking for SEARCH campaigns. | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), keywords validated, `final_url_suffix` (auto-set for SEARCH, pass "" to disable) | | `draft_ad_group` | Create a new ad group within an existing campaign (does NOT publish) | `campaign_id` (REQUIRED), `ad_group_name` (REQUIRED), `keywords` (optional list of {text, match_type}), `cpc_bid_micros` (optional) | -| `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids` | +| `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, Final URL suffix | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `final_url_suffix` | | `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each) | | `draft_sitelinks` | Create sitelink extensions for a campaign (does NOT publish) | `campaign_id`, `sitelinks` list of {link_text ≤25 chars, final_url, description1 ≤35 chars, description2 ≤35 chars} | | `draft_keywords` | Propose keyword additions (does NOT add) | Each keyword needs `text` and `match_type` (EXACT/PHRASE/BROAD) | diff --git a/.cursor/rules/adloop.mdc b/.cursor/rules/adloop.mdc index 0334493..e5f1d6d 100644 --- a/.cursor/rules/adloop.mdc +++ b/.cursor/rules/adloop.mdc @@ -77,9 +77,9 @@ These tools call both APIs internally and return unified results with computed ` | Tool | What It Does | Validation | |------|-------------|------------| -| `draft_campaign` | Create full campaign structure (budget + campaign + ad group + keywords + geo/language targeting) | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), keywords validated | +| `draft_campaign` | Create full campaign structure (budget + campaign + ad group + keywords + geo/language targeting). Auto-sets Final URL Suffix with UTM tracking for SEARCH campaigns. | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), keywords validated, `final_url_suffix` (auto-set for SEARCH, pass "" to disable) | | `draft_ad_group` | Create a new ad group within an existing campaign (does NOT publish) | `campaign_id` (REQUIRED), `ad_group_name` (REQUIRED), `keywords` (optional list of {text, match_type}), `cpc_bid_micros` (optional) | -| `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids` | +| `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, Final URL suffix | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `final_url_suffix` | | `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each) | | `draft_sitelinks` | Create sitelink extensions for a campaign (does NOT publish) | `campaign_id`, `sitelinks` list of {link_text ≤25 chars, final_url, description1 ≤35 chars, description2 ≤35 chars} | | `draft_keywords` | Propose keyword additions (does NOT add) | Each keyword needs `text` and `match_type` (EXACT/PHRASE/BROAD) | diff --git a/src/adloop/ads/write.py b/src/adloop/ads/write.py index f60af5c..4b07745 100644 --- a/src/adloop/ads/write.py +++ b/src/adloop/ads/write.py @@ -304,6 +304,7 @@ def draft_campaign( keywords: list[dict] | None = None, geo_target_ids: list[str] | None = None, language_ids: list[str] | None = None, + final_url_suffix: str | None = None, ) -> dict: """Draft a full campaign structure — returns preview, does NOT execute. @@ -315,6 +316,8 @@ def draft_campaign( ["2840"] for USA). REQUIRED — campaigns must target specific countries. language_ids: list of language constant IDs (e.g. ["1001"] for German, ["1000"] for English). REQUIRED — campaigns must target specific languages. + final_url_suffix: UTM suffix auto-applied to SEARCH campaigns. Pass "" to + disable. Defaults to standard UTM tracking with ValueTrack parameters. """ from adloop.safety.guards import ( SafetyViolation, @@ -348,6 +351,10 @@ def draft_campaign( except SafetyViolation as e: return {"error": str(e)} + # Resolve final_url_suffix: explicit param > hardcoded default (SEARCH only) + if final_url_suffix is None and channel_type.upper() == "SEARCH": + final_url_suffix = _DEFAULT_FINAL_URL_SUFFIX + plan = ChangePlan( operation="create_campaign", entity_type="campaign", @@ -363,6 +370,7 @@ def draft_campaign( "keywords": keywords, "geo_target_ids": geo_target_ids or [], "language_ids": language_ids or [], + "final_url_suffix": final_url_suffix or "", }, ) store_plan(plan) @@ -443,11 +451,13 @@ def update_campaign( daily_budget: float = 0, geo_target_ids: list[str] | None = None, language_ids: list[str] | None = None, + final_url_suffix: str | None = None, ) -> dict: """Draft an update to an existing campaign — returns preview, does NOT execute. All parameters except campaign_id are optional — only include what you want to change. Geo/language targets are REPLACED entirely (not appended). + final_url_suffix: set or change the campaign's Final URL suffix. Pass "" to clear. """ from adloop.safety.guards import ( SafetyViolation, @@ -494,6 +504,7 @@ def update_campaign( has_any_change = any([ bs, daily_budget, geo_target_ids is not None, language_ids is not None, + final_url_suffix is not None, ]) if not has_any_change: errors.append("No changes specified — provide at least one parameter to update") @@ -526,6 +537,8 @@ def update_campaign( changes["geo_target_ids"] = geo_target_ids if language_ids is not None: changes["language_ids"] = language_ids + if final_url_suffix is not None: + changes["final_url_suffix"] = final_url_suffix plan = ChangePlan( operation="update_campaign", @@ -742,6 +755,13 @@ def confirm_and_apply( _VALID_ENTITY_TYPES = {"campaign", "ad_group", "ad", "keyword"} _REMOVABLE_ENTITY_TYPES = _VALID_ENTITY_TYPES | {"negative_keyword", "campaign_asset"} +_DEFAULT_FINAL_URL_SUFFIX = ( + "utm_source=google&utm_medium=cpc" + "&utm_campaign={campaignid}" + "&utm_content={adgroupid}" + "&utm_term={keyword}" +) + _SMART_BIDDING_STRATEGIES = { "MAXIMIZE_CONVERSIONS", "MAXIMIZE_CONVERSION_VALUE", @@ -1214,6 +1234,11 @@ def _apply_create_campaign(client: object, cid: str, changes: dict) -> dict: client.enums.EuPoliticalAdvertisingStatusEnum.DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING ) + # Final URL suffix — auto-set for SEARCH campaigns (UTM tracking) + suffix = changes.get("final_url_suffix") + if suffix: + campaign.final_url_suffix = suffix + operations.append(campaign_op) # 3. AdGroup (temp ID: -3, references campaign -2) @@ -1409,6 +1434,18 @@ def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict: ) operations.append(budget_op) + # Final URL suffix change + new_suffix = changes.get("final_url_suffix") + if new_suffix is not None: + suffix_op = client.get_type("MutateOperation") + suffix_campaign = suffix_op.campaign_operation.update + suffix_campaign.resource_name = resource_name + suffix_campaign.final_url_suffix = new_suffix + suffix_op.campaign_operation.update_mask.CopyFrom( + field_mask_pb2.FieldMask(paths=["final_url_suffix"]) + ) + operations.append(suffix_op) + # Geo targeting — remove existing, add new geo_ids = changes.get("geo_target_ids") if geo_ids is not None: diff --git a/src/adloop/server.py b/src/adloop/server.py index 7f0f223..d46cd22 100644 --- a/src/adloop/server.py +++ b/src/adloop/server.py @@ -468,6 +468,7 @@ def draft_campaign( channel_type: str = "SEARCH", ad_group_name: str = "", keywords: list[dict] | None = None, + final_url_suffix: str | None = None, ) -> dict: """Draft a full campaign structure — returns a PREVIEW, does NOT create anything. @@ -486,6 +487,9 @@ def draft_campaign( language_ids: REQUIRED list of language constant IDs Common: "1001" German, "1000" English, "1002" French, "1004" Spanish, "1014" Portuguese. Full list: Google Ads API language constants. + final_url_suffix: UTM suffix auto-applied to SEARCH campaigns. Pass "" to + disable. Defaults to standard UTM tracking with ValueTrack parameters: + utm_source=google&utm_medium=cpc&utm_campaign={campaignid}&utm_content={adgroupid}&utm_term={keyword} Call confirm_and_apply with the returned plan_id to execute. """ @@ -504,6 +508,7 @@ def draft_campaign( keywords=keywords, geo_target_ids=geo_target_ids, language_ids=language_ids, + final_url_suffix=final_url_suffix, ) @@ -551,6 +556,7 @@ def update_campaign( daily_budget: float = 0, geo_target_ids: list[str] | None = None, language_ids: list[str] | None = None, + final_url_suffix: str | None = None, ) -> dict: """Draft an update to an existing campaign — returns a PREVIEW, does NOT apply. @@ -566,6 +572,7 @@ def update_campaign( "2040" Austria, "2756" Switzerland, "2840" USA, "2826" UK language_ids: REPLACES all language targets. Common IDs: "1001" German, "1000" English, "1002" French, "1004" Spanish + final_url_suffix: set or change the campaign's Final URL suffix. Pass "" to clear. Call confirm_and_apply with the returned plan_id to execute. """ @@ -581,6 +588,7 @@ def update_campaign( daily_budget=daily_budget, geo_target_ids=geo_target_ids, language_ids=language_ids, + final_url_suffix=final_url_suffix, ) From 73a448f3b29d560d489cc8984fe71bc9ae6d4b2c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 05:20:44 +0000 Subject: [PATCH 10/36] Add draft_rsa_replacement tool for editing existing RSA ad copy The Google Ads API does not reliably support in-place updates to RSA headlines/descriptions (IMMUTABLE_FIELD errors are common). This adds a composite tool that automates the industry-standard workaround: create a new RSA with updated copy and pause (or optionally remove) the old one. - _fetch_existing_rsa: GAQL helper to retrieve current ad details - draft_rsa_replacement: validates new copy, shows old-vs-new diff preview - _apply_replace_rsa: execution handler (create new + pause/remove old) - Inherits ad_group_id and final_url from the old ad automatically - remove_old=true triggers double confirmation (default: pause) - 12 unit tests covering validation, inheritance, and safety checks - Orchestration rules added for both .claude/ and .cursor/ rule files https://claude.ai/code/session_01DXyhvDjrZE96YUEUA9ia5c --- .claude/rules/adloop.md | 17 ++ .cursor/rules/adloop.mdc | 17 ++ src/adloop/ads/write.py | 225 +++++++++++++++++++++++ src/adloop/server.py | 36 ++++ tests/test_draft_rsa_replacement.py | 267 ++++++++++++++++++++++++++++ 5 files changed, 562 insertions(+) create mode 100644 tests/test_draft_rsa_replacement.py diff --git a/.claude/rules/adloop.md b/.claude/rules/adloop.md index 8d782e2..02e3f5f 100644 --- a/.claude/rules/adloop.md +++ b/.claude/rules/adloop.md @@ -76,6 +76,7 @@ These tools call both APIs internally and return unified results with computed ` | `draft_ad_group` | Create a new ad group within an existing campaign (does NOT publish) | `campaign_id` (REQUIRED), `ad_group_name` (REQUIRED), `keywords` (optional list of {text, match_type}), `cpc_bid_micros` (optional) | | `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, Final URL suffix | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `final_url_suffix` | | `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each) | +| `draft_rsa_replacement` | Replace an existing RSA with updated copy (creates new + pauses old) | `ad_id` (REQUIRED), 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), `final_url` (inherits from old ad if blank), path1/path2 (≤15 chars each), `remove_old` (default false) | | `draft_sitelinks` | Create sitelink extensions for a campaign (does NOT publish) | `campaign_id`, `sitelinks` list of {link_text ≤25 chars, final_url, description1 ≤35 chars, description2 ≤35 chars} | | `draft_keywords` | Propose keyword additions (does NOT add) | Each keyword needs `text` and `match_type` (EXACT/PHRASE/BROAD) | | `add_negative_keywords` | Propose negative keywords (does NOT add) | `campaign_id`, keyword list, `match_type` | @@ -176,6 +177,22 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has 9. After the ad is created, **suggest sitelinks** if the campaign doesn't have any. Use `draft_sitelinks` with at least 4 relevant links (key pages like pricing, features, signup, etc.). Sitelinks increase ad real estate and CTR. 10. Wait for explicit user approval before calling `confirm_and_apply` +### When user wants to edit or fix an existing RSA + +1. Call `get_ad_performance` to find the ad ID and see current copy +2. **Pre-write checks (same as ad creation):** + - Is the campaign's bidding strategy appropriate? + - Does the campaign have conversions? + - What are quality scores? +3. Review the current headlines and descriptions — identify exactly what needs changing +4. Write the complete new set of headlines (3-15) and descriptions (2-4), following the "Ad Copy Character Limits" section. Count characters for every headline before generating. +5. If the user hasn't specified a `final_url`, the tool inherits it from the old ad — no need to provide it +6. Call `draft_rsa_replacement` with the old `ad_id` and the complete new copy +7. Present the diff preview (old vs new) to the user — the preview shows both old and new copy side-by-side plus whether the old ad will be paused or removed +8. Wait for explicit user approval before calling `confirm_and_apply` +9. The new ad is created as PAUSED. Remind the user to enable it via `enable_entity` after reviewing in Google Ads UI +10. By default the old ad is paused (recoverable). Only use `remove_old=true` if the user explicitly asks for permanent removal + ### When user wants to add keywords 1. Call `get_campaign_performance` to identify the target campaign and its **bidding strategy** diff --git a/.cursor/rules/adloop.mdc b/.cursor/rules/adloop.mdc index e5f1d6d..c993f73 100644 --- a/.cursor/rules/adloop.mdc +++ b/.cursor/rules/adloop.mdc @@ -81,6 +81,7 @@ These tools call both APIs internally and return unified results with computed ` | `draft_ad_group` | Create a new ad group within an existing campaign (does NOT publish) | `campaign_id` (REQUIRED), `ad_group_name` (REQUIRED), `keywords` (optional list of {text, match_type}), `cpc_bid_micros` (optional) | | `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, Final URL suffix | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `final_url_suffix` | | `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each) | +| `draft_rsa_replacement` | Replace an existing RSA with updated copy (creates new + pauses old) | `ad_id` (REQUIRED), 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), `final_url` (inherits from old ad if blank), path1/path2 (≤15 chars each), `remove_old` (default false) | | `draft_sitelinks` | Create sitelink extensions for a campaign (does NOT publish) | `campaign_id`, `sitelinks` list of {link_text ≤25 chars, final_url, description1 ≤35 chars, description2 ≤35 chars} | | `draft_keywords` | Propose keyword additions (does NOT add) | Each keyword needs `text` and `match_type` (EXACT/PHRASE/BROAD) | | `add_negative_keywords` | Propose negative keywords (does NOT add) | `campaign_id`, keyword list, `match_type` | @@ -181,6 +182,22 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has 9. After the ad is created, **suggest sitelinks** if the campaign doesn't have any. Use `draft_sitelinks` with at least 4 relevant links (key pages like pricing, features, signup, etc.). Sitelinks increase ad real estate and CTR. 10. Wait for explicit user approval before calling `confirm_and_apply` +### When user wants to edit or fix an existing RSA + +1. Call `get_ad_performance` to find the ad ID and see current copy +2. **Pre-write checks (same as ad creation):** + - Is the campaign's bidding strategy appropriate? + - Does the campaign have conversions? + - What are quality scores? +3. Review the current headlines and descriptions — identify exactly what needs changing +4. Write the complete new set of headlines (3-15) and descriptions (2-4), following the "Ad Copy Character Limits" section. Count characters for every headline before generating. +5. If the user hasn't specified a `final_url`, the tool inherits it from the old ad — no need to provide it +6. Call `draft_rsa_replacement` with the old `ad_id` and the complete new copy +7. Present the diff preview (old vs new) to the user — the preview shows both old and new copy side-by-side plus whether the old ad will be paused or removed +8. Wait for explicit user approval before calling `confirm_and_apply` +9. The new ad is created as PAUSED. Remind the user to enable it via `enable_entity` after reviewing in Google Ads UI +10. By default the old ad is paused (recoverable). Only use `remove_old=true` if the user explicitly asks for permanent removal + ### When user wants to add keywords 1. Call `get_campaign_performance` to identify the target campaign and its **bidding strategy** diff --git a/src/adloop/ads/write.py b/src/adloop/ads/write.py index 4b07745..efddd84 100644 --- a/src/adloop/ads/write.py +++ b/src/adloop/ads/write.py @@ -58,6 +58,43 @@ def _validate_urls(urls: list[str], timeout: int = 10) -> dict[str, str | None]: return results +# --------------------------------------------------------------------------- +# Fetch existing ad — used by replacement tools +# --------------------------------------------------------------------------- + + +def _fetch_existing_rsa( + config: AdLoopConfig, + customer_id: str, + ad_id: str, +) -> dict | None: + """Fetch an existing RSA's headlines, descriptions, URLs, paths, and ad_group_id. + + Returns a flat dict from GAQL, or ``None`` if the ad is not found or not an RSA. + """ + from adloop.ads.gaql import execute_query + + query = ( + "SELECT ad_group.id, ad_group_ad.ad.id, ad_group_ad.ad.type, " + "ad_group_ad.ad.responsive_search_ad.headlines, " + "ad_group_ad.ad.responsive_search_ad.descriptions, " + "ad_group_ad.ad.final_urls, " + "ad_group_ad.ad.responsive_search_ad.path1, " + "ad_group_ad.ad.responsive_search_ad.path2, " + "ad_group_ad.status " + f"FROM ad_group_ad WHERE ad_group_ad.ad.id = {ad_id} LIMIT 1" + ) + rows = execute_query(config, customer_id, query) + if not rows: + return None + + row = rows[0] + if row.get("ad_group_ad.ad.type") != "RESPONSIVE_SEARCH_AD": + return None + + return row + + # --------------------------------------------------------------------------- # Draft tools — validate inputs, create a ChangePlan, return preview # --------------------------------------------------------------------------- @@ -132,6 +169,143 @@ def draft_responsive_search_ad( return preview +def draft_rsa_replacement( + config: AdLoopConfig, + *, + customer_id: str = "", + ad_id: str = "", + headlines: list[str] | None = None, + descriptions: list[str] | None = None, + final_url: str = "", + path1: str = "", + path2: str = "", + remove_old: bool = False, +) -> dict: + """Draft an RSA replacement — creates new ad and pauses/removes the old one. + + Fetches the existing ad's details, validates the new copy, and returns a + preview with an old-vs-new diff. Does NOT execute until confirmed. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("replace_responsive_search_ad", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + headlines = headlines or [] + descriptions = descriptions or [] + + if not ad_id: + return {"error": "Validation failed", "details": ["ad_id is required."]} + + # Fetch current ad ------------------------------------------------------- + existing = _fetch_existing_rsa(config, customer_id, ad_id) + if existing is None: + return { + "error": f"Ad ID {ad_id} not found or is not a Responsive Search Ad." + } + + old_status = existing.get("ad_group_ad.status", "") + if old_status == "REMOVED": + return {"error": f"Ad ID {ad_id} has already been removed."} + + ad_group_id = str(existing.get("ad_group.id", "")) + + # Inherit final_url from old ad if not provided -------------------------- + if not final_url: + old_urls = existing.get("ad_group_ad.ad.final_urls", []) + if isinstance(old_urls, list) and old_urls: + final_url = old_urls[0] if isinstance(old_urls[0], str) else str(old_urls[0]) + + # Validate new copy ------------------------------------------------------ + errors = _validate_rsa(ad_group_id, headlines, descriptions, final_url) + if errors: + return {"error": "Validation failed", "details": errors} + + url_check = _validate_urls([final_url]) + if url_check.get(final_url): + return { + "error": "URL validation failed", + "details": [ + f"final_url '{final_url}' is not reachable: {url_check[final_url]}. " + f"Ads MUST point to working URLs." + ], + } + + warnings: list[str] = [] + if len(headlines) < 8: + warnings.append( + f"Only {len(headlines)} headlines provided. Google recommends 8-15 " + "diverse headlines for optimal RSA performance." + ) + if len(descriptions) < 3: + warnings.append( + f"Only {len(descriptions)} descriptions provided. Google recommends " + "3-4 descriptions for optimal RSA performance." + ) + + # Build old copy for diff preview ---------------------------------------- + old_headlines = existing.get( + "ad_group_ad.ad.responsive_search_ad.headlines", [] + ) + old_descriptions = existing.get( + "ad_group_ad.ad.responsive_search_ad.descriptions", [] + ) + # GAQL _to_python already converts AdTextAsset → str, but handle dicts defensively + if old_headlines and isinstance(old_headlines[0], dict): + old_headlines = [h.get("text", str(h)) for h in old_headlines] + if old_descriptions and isinstance(old_descriptions[0], dict): + old_descriptions = [d.get("text", str(d)) for d in old_descriptions] + + old_final_urls = existing.get("ad_group_ad.ad.final_urls", []) + old_copy = { + "headlines": old_headlines, + "descriptions": old_descriptions, + "final_url": old_final_urls[0] if old_final_urls else "", + "path1": existing.get("ad_group_ad.ad.responsive_search_ad.path1", ""), + "path2": existing.get("ad_group_ad.ad.responsive_search_ad.path2", ""), + } + + plan = ChangePlan( + operation="replace_responsive_search_ad", + entity_type="ad", + entity_id=ad_id, + customer_id=customer_id, + changes={ + "old_ad_id": ad_id, + "ad_group_id": ad_group_id, + "headlines": headlines, + "descriptions": descriptions, + "final_url": final_url, + "path1": path1, + "path2": path2, + "remove_old": remove_old, + "old_copy": old_copy, + }, + ) + if remove_old: + plan.requires_double_confirm = True + store_plan(plan) + + preview = plan.to_preview() + preview["diff"] = { + "old": old_copy, + "new": { + "headlines": headlines, + "descriptions": descriptions, + "final_url": final_url, + "path1": path1, + "path2": path2, + }, + "old_ad_action": "REMOVE" if remove_old else "PAUSE", + } + if warnings: + preview["warnings"] = warnings + return preview + + def draft_keywords( config: AdLoopConfig, *, @@ -1141,6 +1315,7 @@ def _execute_plan(config: AdLoopConfig, plan: object) -> dict: "create_ad_group": _apply_create_ad_group, "update_campaign": _apply_update_campaign, "create_responsive_search_ad": _apply_create_rsa, + "replace_responsive_search_ad": _apply_replace_rsa, "add_keywords": _apply_add_keywords, "add_negative_keywords": _apply_add_negative_keywords, "pause_entity": _apply_status_change, @@ -1548,6 +1723,56 @@ def _apply_create_rsa(client: object, cid: str, changes: dict) -> dict: return {"resource_name": response.results[0].resource_name} +def _apply_replace_rsa(client: object, cid: str, changes: dict) -> dict: + """Create a new RSA and pause/remove the old one.""" + # Step 1: Create the replacement ad + create_changes = { + "ad_group_id": changes["ad_group_id"], + "headlines": changes["headlines"], + "descriptions": changes["descriptions"], + "final_url": changes["final_url"], + "path1": changes.get("path1", ""), + "path2": changes.get("path2", ""), + } + new_ad_result = _apply_create_rsa(client, cid, create_changes) + + # Step 2: Pause or remove the old ad + old_ad_id = changes["old_ad_id"] + try: + if changes.get("remove_old"): + old_ad_result = _apply_remove(client, cid, "ad", old_ad_id) + old_action = "REMOVED" + else: + old_ad_result = _apply_status_change( + client, cid, "ad", old_ad_id, "PAUSED" + ) + old_action = "PAUSED" + except Exception as exc: + # New ad was already created — report partial success so user can + # pause the old ad manually. + return { + "new_ad": new_ad_result, + "old_ad": { + "ad_id": old_ad_id, + "action": "FAILED", + "error": ( + f"New ad created successfully but failed to " + f"{'remove' if changes.get('remove_old') else 'pause'} " + f"old ad: {exc}" + ), + }, + } + + return { + "new_ad": new_ad_result, + "old_ad": { + "ad_id": old_ad_id, + "action": old_action, + "result": old_ad_result, + }, + } + + def _apply_add_keywords(client: object, cid: str, changes: dict) -> dict: service = client.get_service("AdGroupCriterionService") ad_group_path = client.get_service("AdGroupService").ad_group_path( diff --git a/src/adloop/server.py b/src/adloop/server.py index d46cd22..ccef422 100644 --- a/src/adloop/server.py +++ b/src/adloop/server.py @@ -622,6 +622,42 @@ def draft_responsive_search_ad( ) +@mcp.tool(annotations=_WRITE) +@_safe +def draft_rsa_replacement( + ad_id: str, + headlines: list[str], + descriptions: list[str], + final_url: str = "", + customer_id: str = "", + path1: str = "", + path2: str = "", + remove_old: bool = False, +) -> dict: + """Draft an RSA replacement — creates a new ad and pauses the old one. + + Provide the ad_id of the existing RSA to replace, plus the complete new copy. + The tool fetches the old ad's details and shows a side-by-side diff preview. + The new ad inherits the ad group from the old one and is created as PAUSED. + If final_url is omitted, the old ad's URL is reused. + By default the old ad is paused; set remove_old=true for permanent removal. + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.write import draft_rsa_replacement as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + ad_id=ad_id, + headlines=headlines, + descriptions=descriptions, + final_url=final_url, + path1=path1, + path2=path2, + remove_old=remove_old, + ) + + @mcp.tool(annotations=_WRITE) @_safe def draft_keywords( diff --git a/tests/test_draft_rsa_replacement.py b/tests/test_draft_rsa_replacement.py new file mode 100644 index 0000000..5ec691f --- /dev/null +++ b/tests/test_draft_rsa_replacement.py @@ -0,0 +1,267 @@ +"""Tests for draft_rsa_replacement validation and plan creation.""" + +from unittest.mock import patch + +import pytest + +from adloop.ads.write import draft_rsa_replacement +from adloop.config import AdLoopConfig, AdsConfig, SafetyConfig +from adloop.safety.preview import get_plan, remove_plan + + +@pytest.fixture +def config(): + return AdLoopConfig( + ads=AdsConfig(customer_id="1234567890", developer_token="test"), + safety=SafetyConfig(max_daily_budget=50.0, require_dry_run=True), + ) + + +VALID_HEADLINES = [ + "Headline One Here", + "Headline Two Here", + "Headline Three Here", + "Headline Four Here", + "Headline Five Here", + "Headline Six Here", + "Headline Seven Here", + "Headline Eight Here", +] + +VALID_DESCRIPTIONS = [ + "This is a valid description that fits within the ninety character limit easily.", + "Second description for testing purposes, also well within the character limit.", + "Third description here for completeness and to meet the recommended minimum.", +] + +EXISTING_RSA = { + "ad_group.id": 777, + "ad_group_ad.ad.id": 12345, + "ad_group_ad.ad.type": "RESPONSIVE_SEARCH_AD", + "ad_group_ad.ad.responsive_search_ad.headlines": [ + "Old Headline One", + "Old Headline Two", + "Old Headline Three", + ], + "ad_group_ad.ad.responsive_search_ad.descriptions": [ + "Old description one that is short enough.", + "Old description two that is also fine.", + ], + "ad_group_ad.ad.final_urls": ["https://example.com/landing"], + "ad_group_ad.ad.responsive_search_ad.path1": "old", + "ad_group_ad.ad.responsive_search_ad.path2": "path", + "ad_group_ad.status": "ENABLED", +} + + +class TestDraftRsaReplacement: + @patch("adloop.ads.write._validate_urls", return_value={}) + @patch("adloop.ads.write._fetch_existing_rsa") + def test_happy_path_returns_preview_with_diff( + self, mock_fetch, mock_urls, config + ): + mock_fetch.return_value = EXISTING_RSA + result = draft_rsa_replacement( + config, + customer_id="1234567890", + ad_id="12345", + headlines=VALID_HEADLINES, + descriptions=VALID_DESCRIPTIONS, + final_url="https://example.com/new", + path1="new", + path2="page", + ) + assert "plan_id" in result + assert result["operation"] == "replace_responsive_search_ad" + assert "diff" in result + assert result["diff"]["old"]["headlines"] == EXISTING_RSA[ + "ad_group_ad.ad.responsive_search_ad.headlines" + ] + assert result["diff"]["new"]["headlines"] == VALID_HEADLINES + assert result["diff"]["old_ad_action"] == "PAUSE" + + # Verify plan stored correctly + plan = get_plan(result["plan_id"]) + assert plan is not None + assert plan.operation == "replace_responsive_search_ad" + assert plan.entity_id == "12345" + assert plan.changes["ad_group_id"] == "777" + assert plan.changes["old_ad_id"] == "12345" + assert not plan.requires_double_confirm + remove_plan(result["plan_id"]) + + def test_missing_ad_id(self, config): + result = draft_rsa_replacement( + config, + customer_id="1234567890", + ad_id="", + headlines=VALID_HEADLINES, + descriptions=VALID_DESCRIPTIONS, + ) + assert "error" in result + details = result.get("details", []) + error_text = result.get("error", "") + " ".join(details) + assert "ad_id" in error_text.lower() + + @patch("adloop.ads.write._fetch_existing_rsa", return_value=None) + def test_ad_not_found(self, mock_fetch, config): + result = draft_rsa_replacement( + config, + customer_id="1234567890", + ad_id="99999", + headlines=VALID_HEADLINES, + descriptions=VALID_DESCRIPTIONS, + ) + assert "error" in result + assert "99999" in result["error"] + + @patch("adloop.ads.write._fetch_existing_rsa") + def test_ad_already_removed(self, mock_fetch, config): + removed_rsa = {**EXISTING_RSA, "ad_group_ad.status": "REMOVED"} + mock_fetch.return_value = removed_rsa + result = draft_rsa_replacement( + config, + customer_id="1234567890", + ad_id="12345", + headlines=VALID_HEADLINES, + descriptions=VALID_DESCRIPTIONS, + ) + assert "error" in result + assert "removed" in result["error"].lower() + + @patch("adloop.ads.write._validate_urls", return_value={}) + @patch("adloop.ads.write._fetch_existing_rsa") + def test_too_few_headlines(self, mock_fetch, mock_urls, config): + mock_fetch.return_value = EXISTING_RSA + result = draft_rsa_replacement( + config, + customer_id="1234567890", + ad_id="12345", + headlines=["H1", "H2"], # Need at least 3 + descriptions=VALID_DESCRIPTIONS, + final_url="https://example.com", + ) + assert "error" in result + + @patch("adloop.ads.write._validate_urls", return_value={}) + @patch("adloop.ads.write._fetch_existing_rsa") + def test_headline_over_30_chars(self, mock_fetch, mock_urls, config): + mock_fetch.return_value = EXISTING_RSA + long_headlines = [ + "This headline is way over the thirty character maximum allowed", + "Headline Two", + "Headline Three", + ] + result = draft_rsa_replacement( + config, + customer_id="1234567890", + ad_id="12345", + headlines=long_headlines, + descriptions=VALID_DESCRIPTIONS, + final_url="https://example.com", + ) + assert "error" in result + + @patch("adloop.ads.write._validate_urls", return_value={}) + @patch("adloop.ads.write._fetch_existing_rsa") + def test_inherits_final_url_from_old_ad(self, mock_fetch, mock_urls, config): + mock_fetch.return_value = EXISTING_RSA + result = draft_rsa_replacement( + config, + customer_id="1234567890", + ad_id="12345", + headlines=VALID_HEADLINES, + descriptions=VALID_DESCRIPTIONS, + # no final_url provided — should inherit from old ad + ) + assert "plan_id" in result + assert result["diff"]["new"]["final_url"] == "https://example.com/landing" + + remove_plan(result["plan_id"]) + + @patch("adloop.ads.write._validate_urls", return_value={}) + @patch("adloop.ads.write._fetch_existing_rsa") + def test_remove_old_requires_double_confirm( + self, mock_fetch, mock_urls, config + ): + mock_fetch.return_value = EXISTING_RSA + result = draft_rsa_replacement( + config, + customer_id="1234567890", + ad_id="12345", + headlines=VALID_HEADLINES, + descriptions=VALID_DESCRIPTIONS, + remove_old=True, + ) + assert "plan_id" in result + assert result["diff"]["old_ad_action"] == "REMOVE" + + plan = get_plan(result["plan_id"]) + assert plan.requires_double_confirm is True + remove_plan(result["plan_id"]) + + @patch("adloop.ads.write._validate_urls", return_value={}) + @patch("adloop.ads.write._fetch_existing_rsa") + def test_default_does_not_require_double_confirm( + self, mock_fetch, mock_urls, config + ): + mock_fetch.return_value = EXISTING_RSA + result = draft_rsa_replacement( + config, + customer_id="1234567890", + ad_id="12345", + headlines=VALID_HEADLINES, + descriptions=VALID_DESCRIPTIONS, + ) + plan = get_plan(result["plan_id"]) + assert plan.requires_double_confirm is not True + remove_plan(result["plan_id"]) + + def test_blocked_operation(self, config): + config.safety.blocked_operations = ["replace_responsive_search_ad"] + result = draft_rsa_replacement( + config, + customer_id="1234567890", + ad_id="12345", + headlines=VALID_HEADLINES, + descriptions=VALID_DESCRIPTIONS, + ) + assert "error" in result + config.safety.blocked_operations = [] + + @patch( + "adloop.ads.write._validate_urls", + return_value={"https://broken.example.com": "Connection refused"}, + ) + @patch("adloop.ads.write._fetch_existing_rsa") + def test_url_validation_failure(self, mock_fetch, mock_urls, config): + mock_fetch.return_value = EXISTING_RSA + result = draft_rsa_replacement( + config, + customer_id="1234567890", + ad_id="12345", + headlines=VALID_HEADLINES, + descriptions=VALID_DESCRIPTIONS, + final_url="https://broken.example.com", + ) + assert "error" in result + assert "not reachable" in result.get("details", [""])[0] + + @patch("adloop.ads.write._validate_urls", return_value={}) + @patch("adloop.ads.write._fetch_existing_rsa") + def test_old_copy_in_changes(self, mock_fetch, mock_urls, config): + """The plan changes should include old_copy for audit/diff purposes.""" + mock_fetch.return_value = EXISTING_RSA + result = draft_rsa_replacement( + config, + customer_id="1234567890", + ad_id="12345", + headlines=VALID_HEADLINES, + descriptions=VALID_DESCRIPTIONS, + ) + plan = get_plan(result["plan_id"]) + assert "old_copy" in plan.changes + assert plan.changes["old_copy"]["headlines"] == EXISTING_RSA[ + "ad_group_ad.ad.responsive_search_ad.headlines" + ] + remove_plan(result["plan_id"]) From 42a6df525346018544dd4c990c5f34df24242149 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 05:30:24 +0000 Subject: [PATCH 11/36] Default to removing old ad in draft_rsa_replacement This tool is for fixing broken ads, not A/B testing. Defaulting to remove_old=True ensures the broken ad cannot be accidentally re-enabled. Docstrings and orchestration rules now clearly distinguish: - draft_rsa_replacement: fix issues with existing ads (removes old) - draft_responsive_search_ad: create new ad variants for testing https://claude.ai/code/session_01DXyhvDjrZE96YUEUA9ia5c --- .claude/rules/adloop.md | 21 +++++++++++++-------- .cursor/rules/adloop.mdc | 21 +++++++++++++-------- src/adloop/ads/write.py | 11 +++++++++-- src/adloop/server.py | 13 +++++++++---- tests/test_draft_rsa_replacement.py | 13 ++++++++----- 5 files changed, 52 insertions(+), 27 deletions(-) diff --git a/.claude/rules/adloop.md b/.claude/rules/adloop.md index 02e3f5f..d590805 100644 --- a/.claude/rules/adloop.md +++ b/.claude/rules/adloop.md @@ -76,7 +76,7 @@ These tools call both APIs internally and return unified results with computed ` | `draft_ad_group` | Create a new ad group within an existing campaign (does NOT publish) | `campaign_id` (REQUIRED), `ad_group_name` (REQUIRED), `keywords` (optional list of {text, match_type}), `cpc_bid_micros` (optional) | | `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, Final URL suffix | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `final_url_suffix` | | `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each) | -| `draft_rsa_replacement` | Replace an existing RSA with updated copy (creates new + pauses old) | `ad_id` (REQUIRED), 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), `final_url` (inherits from old ad if blank), path1/path2 (≤15 chars each), `remove_old` (default false) | +| `draft_rsa_replacement` | **Fix** an existing RSA — creates corrected replacement and removes the old ad. Use for copy errors, not A/B testing. For testing variants, use `draft_responsive_search_ad` instead. | `ad_id` (REQUIRED), 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), `final_url` (inherits from old ad if blank), path1/path2 (≤15 chars each), `remove_old` (default true) | | `draft_sitelinks` | Create sitelink extensions for a campaign (does NOT publish) | `campaign_id`, `sitelinks` list of {link_text ≤25 chars, final_url, description1 ≤35 chars, description2 ≤35 chars} | | `draft_keywords` | Propose keyword additions (does NOT add) | Each keyword needs `text` and `match_type` (EXACT/PHRASE/BROAD) | | `add_negative_keywords` | Propose negative keywords (does NOT add) | `campaign_id`, keyword list, `match_type` | @@ -177,21 +177,26 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has 9. After the ad is created, **suggest sitelinks** if the campaign doesn't have any. Use `draft_sitelinks` with at least 4 relevant links (key pages like pricing, features, signup, etc.). Sitelinks increase ad real estate and CTR. 10. Wait for explicit user approval before calling `confirm_and_apply` -### When user wants to edit or fix an existing RSA +### When user wants to fix issues with an existing RSA + +**Use `draft_rsa_replacement` when an ad has problems that need correcting** — wrong data (e.g. "200+ reviews" should be "300+"), truncated city names, mixed A/B messaging, character limit violations, broken display paths, etc. This tool removes the broken ad and creates a corrected replacement. + +**Do NOT use this for A/B testing or creating ad variants.** For that, use `draft_responsive_search_ad` to add a new ad alongside the existing one, and optionally `pause_entity` to pause the old one. 1. Call `get_ad_performance` to find the ad ID and see current copy 2. **Pre-write checks (same as ad creation):** - Is the campaign's bidding strategy appropriate? - Does the campaign have conversions? - What are quality scores? -3. Review the current headlines and descriptions — identify exactly what needs changing -4. Write the complete new set of headlines (3-15) and descriptions (2-4), following the "Ad Copy Character Limits" section. Count characters for every headline before generating. +3. Review the current headlines and descriptions — identify exactly what needs fixing +4. Write the complete corrected set of headlines (3-15) and descriptions (2-4), following the "Ad Copy Character Limits" section. Count characters for every headline before generating. 5. If the user hasn't specified a `final_url`, the tool inherits it from the old ad — no need to provide it -6. Call `draft_rsa_replacement` with the old `ad_id` and the complete new copy -7. Present the diff preview (old vs new) to the user — the preview shows both old and new copy side-by-side plus whether the old ad will be paused or removed +6. Call `draft_rsa_replacement` with the old `ad_id` and the corrected copy +7. Present the diff preview (old vs new) to the user — the preview shows both old and new copy side-by-side 8. Wait for explicit user approval before calling `confirm_and_apply` -9. The new ad is created as PAUSED. Remind the user to enable it via `enable_entity` after reviewing in Google Ads UI -10. By default the old ad is paused (recoverable). Only use `remove_old=true` if the user explicitly asks for permanent removal +9. The old ad is **permanently removed** by default (so it can't be accidentally re-enabled). The new ad is created as PAUSED. +10. Remind the user to enable the new ad via `enable_entity` after reviewing in Google Ads UI +11. Only pass `remove_old=false` if the user explicitly wants to keep the old ad around (paused) for reference ### When user wants to add keywords diff --git a/.cursor/rules/adloop.mdc b/.cursor/rules/adloop.mdc index c993f73..1fe9915 100644 --- a/.cursor/rules/adloop.mdc +++ b/.cursor/rules/adloop.mdc @@ -81,7 +81,7 @@ These tools call both APIs internally and return unified results with computed ` | `draft_ad_group` | Create a new ad group within an existing campaign (does NOT publish) | `campaign_id` (REQUIRED), `ad_group_name` (REQUIRED), `keywords` (optional list of {text, match_type}), `cpc_bid_micros` (optional) | | `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, Final URL suffix | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `final_url_suffix` | | `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each) | -| `draft_rsa_replacement` | Replace an existing RSA with updated copy (creates new + pauses old) | `ad_id` (REQUIRED), 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), `final_url` (inherits from old ad if blank), path1/path2 (≤15 chars each), `remove_old` (default false) | +| `draft_rsa_replacement` | **Fix** an existing RSA — creates corrected replacement and removes the old ad. Use for copy errors, not A/B testing. For testing variants, use `draft_responsive_search_ad` instead. | `ad_id` (REQUIRED), 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), `final_url` (inherits from old ad if blank), path1/path2 (≤15 chars each), `remove_old` (default true) | | `draft_sitelinks` | Create sitelink extensions for a campaign (does NOT publish) | `campaign_id`, `sitelinks` list of {link_text ≤25 chars, final_url, description1 ≤35 chars, description2 ≤35 chars} | | `draft_keywords` | Propose keyword additions (does NOT add) | Each keyword needs `text` and `match_type` (EXACT/PHRASE/BROAD) | | `add_negative_keywords` | Propose negative keywords (does NOT add) | `campaign_id`, keyword list, `match_type` | @@ -182,21 +182,26 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has 9. After the ad is created, **suggest sitelinks** if the campaign doesn't have any. Use `draft_sitelinks` with at least 4 relevant links (key pages like pricing, features, signup, etc.). Sitelinks increase ad real estate and CTR. 10. Wait for explicit user approval before calling `confirm_and_apply` -### When user wants to edit or fix an existing RSA +### When user wants to fix issues with an existing RSA + +**Use `draft_rsa_replacement` when an ad has problems that need correcting** — wrong data (e.g. "200+ reviews" should be "300+"), truncated city names, mixed A/B messaging, character limit violations, broken display paths, etc. This tool removes the broken ad and creates a corrected replacement. + +**Do NOT use this for A/B testing or creating ad variants.** For that, use `draft_responsive_search_ad` to add a new ad alongside the existing one, and optionally `pause_entity` to pause the old one. 1. Call `get_ad_performance` to find the ad ID and see current copy 2. **Pre-write checks (same as ad creation):** - Is the campaign's bidding strategy appropriate? - Does the campaign have conversions? - What are quality scores? -3. Review the current headlines and descriptions — identify exactly what needs changing -4. Write the complete new set of headlines (3-15) and descriptions (2-4), following the "Ad Copy Character Limits" section. Count characters for every headline before generating. +3. Review the current headlines and descriptions — identify exactly what needs fixing +4. Write the complete corrected set of headlines (3-15) and descriptions (2-4), following the "Ad Copy Character Limits" section. Count characters for every headline before generating. 5. If the user hasn't specified a `final_url`, the tool inherits it from the old ad — no need to provide it -6. Call `draft_rsa_replacement` with the old `ad_id` and the complete new copy -7. Present the diff preview (old vs new) to the user — the preview shows both old and new copy side-by-side plus whether the old ad will be paused or removed +6. Call `draft_rsa_replacement` with the old `ad_id` and the corrected copy +7. Present the diff preview (old vs new) to the user — the preview shows both old and new copy side-by-side 8. Wait for explicit user approval before calling `confirm_and_apply` -9. The new ad is created as PAUSED. Remind the user to enable it via `enable_entity` after reviewing in Google Ads UI -10. By default the old ad is paused (recoverable). Only use `remove_old=true` if the user explicitly asks for permanent removal +9. The old ad is **permanently removed** by default (so it can't be accidentally re-enabled). The new ad is created as PAUSED. +10. Remind the user to enable the new ad via `enable_entity` after reviewing in Google Ads UI +11. Only pass `remove_old=false` if the user explicitly wants to keep the old ad around (paused) for reference ### When user wants to add keywords diff --git a/src/adloop/ads/write.py b/src/adloop/ads/write.py index efddd84..13414ae 100644 --- a/src/adloop/ads/write.py +++ b/src/adloop/ads/write.py @@ -179,9 +179,16 @@ def draft_rsa_replacement( final_url: str = "", path1: str = "", path2: str = "", - remove_old: bool = False, + remove_old: bool = True, ) -> dict: - """Draft an RSA replacement — creates new ad and pauses/removes the old one. + """Draft an RSA replacement — creates new ad and removes the old one. + + Use this to **fix issues** with an existing RSA (wrong copy, character + errors, data inconsistencies, truncated city names, etc.). The old ad + is removed by default so it cannot be accidentally re-enabled. + + For A/B testing or creating ad variants, use ``draft_responsive_search_ad`` + to add a new ad alongside the existing one instead. Fetches the existing ad's details, validates the new copy, and returns a preview with an old-vs-new diff. Does NOT execute until confirmed. diff --git a/src/adloop/server.py b/src/adloop/server.py index ccef422..9257a9e 100644 --- a/src/adloop/server.py +++ b/src/adloop/server.py @@ -632,15 +632,20 @@ def draft_rsa_replacement( customer_id: str = "", path1: str = "", path2: str = "", - remove_old: bool = False, + remove_old: bool = True, ) -> dict: - """Draft an RSA replacement — creates a new ad and pauses the old one. + """Fix an existing RSA — creates a corrected replacement and removes the old ad. - Provide the ad_id of the existing RSA to replace, plus the complete new copy. + Use this to fix issues with an existing RSA: wrong copy, character errors, + data inconsistencies, truncated names, etc. The old ad is REMOVED by default + so it cannot be accidentally re-enabled. + + For A/B testing or adding ad variants, use draft_responsive_search_ad instead. + + Provide the ad_id of the RSA to fix, plus the complete corrected copy. The tool fetches the old ad's details and shows a side-by-side diff preview. The new ad inherits the ad group from the old one and is created as PAUSED. If final_url is omitted, the old ad's URL is reused. - By default the old ad is paused; set remove_old=true for permanent removal. Call confirm_and_apply with the returned plan_id to execute. """ from adloop.ads.write import draft_rsa_replacement as _impl diff --git a/tests/test_draft_rsa_replacement.py b/tests/test_draft_rsa_replacement.py index 5ec691f..eeae5a8 100644 --- a/tests/test_draft_rsa_replacement.py +++ b/tests/test_draft_rsa_replacement.py @@ -78,7 +78,7 @@ def test_happy_path_returns_preview_with_diff( "ad_group_ad.ad.responsive_search_ad.headlines" ] assert result["diff"]["new"]["headlines"] == VALID_HEADLINES - assert result["diff"]["old_ad_action"] == "PAUSE" + assert result["diff"]["old_ad_action"] == "REMOVE" # Verify plan stored correctly plan = get_plan(result["plan_id"]) @@ -87,7 +87,7 @@ def test_happy_path_returns_preview_with_diff( assert plan.entity_id == "12345" assert plan.changes["ad_group_id"] == "777" assert plan.changes["old_ad_id"] == "12345" - assert not plan.requires_double_confirm + assert plan.requires_double_confirm # default is remove remove_plan(result["plan_id"]) def test_missing_ad_id(self, config): @@ -181,9 +181,10 @@ def test_inherits_final_url_from_old_ad(self, mock_fetch, mock_urls, config): @patch("adloop.ads.write._validate_urls", return_value={}) @patch("adloop.ads.write._fetch_existing_rsa") - def test_remove_old_requires_double_confirm( + def test_default_removes_old_with_double_confirm( self, mock_fetch, mock_urls, config ): + """Default behavior removes old ad, which requires double confirmation.""" mock_fetch.return_value = EXISTING_RSA result = draft_rsa_replacement( config, @@ -191,7 +192,6 @@ def test_remove_old_requires_double_confirm( ad_id="12345", headlines=VALID_HEADLINES, descriptions=VALID_DESCRIPTIONS, - remove_old=True, ) assert "plan_id" in result assert result["diff"]["old_ad_action"] == "REMOVE" @@ -202,9 +202,10 @@ def test_remove_old_requires_double_confirm( @patch("adloop.ads.write._validate_urls", return_value={}) @patch("adloop.ads.write._fetch_existing_rsa") - def test_default_does_not_require_double_confirm( + def test_keep_old_paused_no_double_confirm( self, mock_fetch, mock_urls, config ): + """When remove_old=False, old ad is paused (no double confirm needed).""" mock_fetch.return_value = EXISTING_RSA result = draft_rsa_replacement( config, @@ -212,7 +213,9 @@ def test_default_does_not_require_double_confirm( ad_id="12345", headlines=VALID_HEADLINES, descriptions=VALID_DESCRIPTIONS, + remove_old=False, ) + assert result["diff"]["old_ad_action"] == "PAUSE" plan = get_plan(result["plan_id"]) assert plan.requires_double_confirm is not True remove_plan(result["plan_id"]) From c0bd4f5d6a879689dae1ff0602fcdd0d06e90c47 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 15:44:39 +0000 Subject: [PATCH 12/36] Add 9 new Google Ads insights tools for performance analysis New read-only MCP tools: get_impression_share, get_change_history, get_device_performance, get_location_performance, get_quality_score_details, get_bid_strategy_status, get_budget_pacing, get_ad_schedule_performance, get_auction_insights. Includes 32 tests, orchestration rules, and updated analyze-performance command. https://claude.ai/code/session_01EtMthH7NLzMFcy6hsDUfQt --- .claude/commands/analyze-performance.md | 9 +- .claude/rules/adloop.md | 80 +++- src/adloop/ads/read.py | 507 +++++++++++++++++++- src/adloop/server.py | 252 ++++++++++ tests/test_read_tools.py | 597 ++++++++++++++++++++++++ 5 files changed, 1438 insertions(+), 7 deletions(-) create mode 100644 tests/test_read_tools.py diff --git a/.claude/commands/analyze-performance.md b/.claude/commands/analyze-performance.md index 32bea82..add98a8 100644 --- a/.claude/commands/analyze-performance.md +++ b/.claude/commands/analyze-performance.md @@ -8,19 +8,26 @@ Analyze Google Ads and GA4 performance: $ARGUMENTS ## 1. Pull data (AdLoop MCP) - `get_campaign_performance` — relevant date range (default: last 30 days) +- `get_impression_share` — visibility and lost opportunity analysis +- `get_bid_strategy_status` — check learning status and strategy health - `analyze_campaign_conversions` — cross-referenced Ads + GA4 data with GDPR gap detection - If specific campaigns mentioned, filter by name - If keywords are relevant, also pull `get_keyword_performance` and `get_search_terms` +- If budget concerns, pull `get_budget_pacing` for month-to-date pacing ## 2. Analyze - Spend, Clicks, Conversions, CPA, CTR per campaign +- Impression share: search IS, budget-lost IS, rank-lost IS — identify visibility gaps +- Bid strategy status: any campaigns in learning phase? Appropriate strategy type? - Paid vs organic comparison (from non_paid_channels) - GDPR gap (clicks vs sessions ratio — 2:1 to 5:1 is normal in EU) -- Flag: zero conversions with significant spend, CPA > 3x target, QS < 5, wasteful search terms +- Flag: zero conversions with significant spend, CPA > 3x target, QS < 5, wasteful search terms, high budget-lost IS If conversion issues found: run `attribution_check` If landing page problems suspected: run `landing_page_analysis` +If quality scores are low: run `get_quality_score_details` for component breakdowns +If device performance varies: run `get_device_performance` to compare mobile vs desktop ## 3. Present results diff --git a/.claude/rules/adloop.md b/.claude/rules/adloop.md index d590805..18557c9 100644 --- a/.claude/rules/adloop.md +++ b/.claude/rules/adloop.md @@ -39,6 +39,29 @@ You have access to AdLoop MCP tools that connect Google Ads and Google Analytics - `metrics.average_cpc_eur` is also pre-computed where available. - `get_ad_performance` returns full `headlines` and `descriptions` lists for RSAs. +### Google Ads Insights Tools + +| Tool | When to Use | Key Parameters | +|------|-------------|----------------| +| `get_impression_share` | Check-ins, "why aren't my ads showing?", visibility analysis | `level` (campaign/ad_group/keyword), `date_range_start`, `date_range_end` | +| `get_change_history` | Correlate performance shifts with account changes | `resource_type`, `operation_type`, `date_range_start`, `date_range_end`, `limit` | +| `get_device_performance` | Mobile vs desktop analysis, local service businesses | `level` (campaign/ad_group), `date_range_start`, `date_range_end` | +| `get_location_performance` | Geographic analysis, local service area optimization | `date_range_start`, `date_range_end` | +| `get_quality_score_details` | Deep keyword quality analysis with component breakdowns | `campaign_id` (optional), `date_range_start`, `date_range_end` | +| `get_bid_strategy_status` | Check learning status before making changes | `campaign_id` (optional) | +| `get_budget_pacing` | Monthly budget tracking, over/under pacing | `campaign_id` (optional) | +| `get_ad_schedule_performance` | Hour/day performance patterns for scheduling optimization | `campaign_id` (optional), `date_range_start`, `date_range_end` | +| `get_auction_insights` | Competitive analysis (requires allowlisted account) | `campaign_id` (optional), `date_range_start`, `date_range_end` | + +**Insights tool notes:** +- `get_impression_share` adds `_pct` suffixed fields (e.g. `metrics.search_impression_share_pct` = "45.0%") alongside raw fractions. +- `get_change_history` uses `change_event.change_date_time` (not `segments.date`). Max 30 days back. Default last 14 days. +- `get_device_performance` adds `metrics.conversion_rate` (percentage). +- `get_quality_score_details` returns component scores: `creative_quality_score` (ad relevance), `post_click_quality_score` (landing page), `search_predicted_ctr` (expected CTR). Values are ABOVE_AVERAGE, AVERAGE, or BELOW_AVERAGE. +- `get_bid_strategy_status` shows `campaign.bidding_strategy_system_status` — check for LEARNING or LEARNING_LIMITED before recommending changes. +- `get_budget_pacing` computes `pace_pct` (100% = on track, >100% = overspending, <100% = underspending). +- `get_auction_insights` may return an error dict if the account is not allowlisted — always check for the `"error"` key. + ### Cross-Reference Tools (GA4 + Ads combined) | Tool | When to Use | Key Parameters | @@ -146,11 +169,13 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has ### When user asks about performance or "how are my ads doing" 1. Call `get_campaign_performance` for the relevant date range -2. If they mention conversions, CPA, or "is it worth it", call `analyze_campaign_conversions` instead — it gives Ads + GA4 data in one call with GDPR-aware cost-per-conversion -3. If they mention specific keywords or search terms, also call `get_keyword_performance` or `get_search_terms` -4. Present a summary with the key metrics: spend (`metrics.cost`), clicks, conversions, CPA (`metrics.cpa`), CTR -5. Highlight anything concerning: zero conversions, high CPA, low quality scores, wasteful search terms -6. Compare against best practices (see Marketing Best Practices section) +2. Call `get_impression_share` to see visibility and lost opportunity +3. Call `get_bid_strategy_status` to check learning status and strategy health +4. If they mention conversions, CPA, or "is it worth it", call `analyze_campaign_conversions` instead — it gives Ads + GA4 data in one call with GDPR-aware cost-per-conversion +5. If they mention specific keywords or search terms, also call `get_keyword_performance` or `get_search_terms` +6. Present a summary with the key metrics: spend (`metrics.cost`), clicks, conversions, CPA (`metrics.cpa`), CTR, impression share +7. Highlight anything concerning: zero conversions, high CPA, low quality scores, wasteful search terms, high budget-lost IS, campaigns in learning phase +8. Compare against best practices (see Marketing Best Practices section) ### When user asks about conversions or conversion drops @@ -312,6 +337,49 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has 2. Compare `campaigns[].ga4_conversion_rate` (paid) vs `non_paid_channels[].conversion_rate` (organic/direct/referral) 3. If paid conversion rate is significantly lower, investigate landing page relevance and ad targeting before increasing spend +### When user asks about impression share or "why aren't my ads showing" + +1. Call `get_impression_share` at campaign level for the relevant date range +2. If `search_budget_lost_impression_share` is high → budget is the bottleneck, recommend a budget increase or narrower targeting +3. If `search_rank_lost_impression_share` is high → ad rank is the issue, check quality scores via `get_quality_score_details` and consider bid strategy changes +4. For keyword-level drill-down, call `get_impression_share` with `level="keyword"` +5. Check `search_top_impression_share` — if the user wants to appear at the top of the page, this shows how often they do + +### When user asks why performance changed or "what happened" + +1. Call `get_change_history` for the relevant date range to see what was modified +2. Call `get_campaign_performance` to see the performance shift +3. Correlate: did a bid strategy change, budget change, or keyword modification coincide with the performance shift? +4. If no account changes explain it, investigate external factors: seasonality, competitor moves (via `get_auction_insights` if available), or tracking issues + +### When user asks about mobile vs desktop performance + +1. Call `get_device_performance` for the relevant date range +2. Compare CPA and conversion rates across MOBILE, DESKTOP, TABLET +3. For local service businesses, highlight mobile performance — mobile users have higher intent (calling, directions) +4. If one device has significantly worse CPA, consider device bid adjustments or separate campaigns + +### When user asks about geographic or location performance + +1. Call `get_location_performance` for the relevant date range +2. Identify locations with high spend and low/zero conversions — potential waste +3. Compare against the user's target service areas +4. Recommend negative location targeting for areas outside the service radius + +### When user asks about budget pacing or "am I spending too much/little" + +1. Call `get_budget_pacing` to see month-to-date spend vs budget +2. If `pace_pct` > 110%: campaign is overspending — may run out of budget before month end +3. If `pace_pct` < 80%: campaign is underspending — budget may be too high or targeting too narrow +4. Cross-reference with `get_impression_share` — if budget-lost IS is high and pace is low, something is off + +### When user asks about ad scheduling or "when should my ads run" + +1. Call `get_ad_schedule_performance` for the relevant date range (ideally last 30+ days for enough data) +2. Identify hours and days with highest conversions and lowest CPA +3. Identify off-peak hours with high spend and zero conversions +4. Recommend ad schedule adjustments or bid modifiers for peak/off-peak hours + ## Default Parameters When the user doesn't specify: @@ -347,6 +415,8 @@ LIMIT n | `campaign_budget` | Budget information | | `bidding_strategy` | Bidding strategy details | | `customer_client` | List accounts under an MCC (uses login_customer_id) | +| `change_event` | Account change history (max 30 days back) | +| `geographic_view` | Performance by geographic location | ### Common Fields diff --git a/src/adloop/ads/read.py b/src/adloop/ads/read.py index a85c375..c38b1ac 100644 --- a/src/adloop/ads/read.py +++ b/src/adloop/ads/read.py @@ -1,4 +1,4 @@ -"""Google Ads read tools — campaign, ad, keyword, and search term performance.""" +"""Google Ads read tools — campaign, ad, keyword, search term, and insights performance.""" from __future__ import annotations @@ -211,6 +211,470 @@ def get_negative_keywords( return {"negative_keywords": rows, "total_negative_keywords": len(rows)} +# --------------------------------------------------------------------------- +# Impression Share & Insights Tools +# --------------------------------------------------------------------------- + + +def get_impression_share( + config: AdLoopConfig, + *, + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", + level: str = "campaign", +) -> dict: + """Get impression share metrics segmented by campaign, ad group, or keyword.""" + from adloop.ads.gaql import execute_query + + date_clause = _date_clause(date_range_start, date_range_end) + + share_metrics = """metrics.impressions, metrics.clicks, metrics.cost_micros, + metrics.search_impression_share, + metrics.search_budget_lost_impression_share, + metrics.search_rank_lost_impression_share, + metrics.search_exact_match_impression_share, + metrics.search_top_impression_share, + metrics.search_absolute_top_impression_share""" + + if level == "ad_group": + query = f""" + SELECT campaign.name, ad_group.id, ad_group.name, + {share_metrics} + FROM ad_group + WHERE ad_group.status != 'REMOVED' + {date_clause} + ORDER BY metrics.impressions DESC + """ + elif level == "keyword": + query = f""" + SELECT campaign.name, ad_group.name, + ad_group_criterion.keyword.text, + ad_group_criterion.keyword.match_type, + {share_metrics} + FROM keyword_view + WHERE ad_group_criterion.status != 'REMOVED' + {date_clause} + ORDER BY metrics.impressions DESC + """ + else: + query = f""" + SELECT campaign.id, campaign.name, campaign.status, + {share_metrics} + FROM campaign + WHERE campaign.status != 'REMOVED' + {date_clause} + ORDER BY metrics.impressions DESC + """ + + rows = execute_query(config, customer_id, query) + _enrich_cost_fields(rows) + _enrich_impression_share_fields(rows) + + return {"impression_share": rows, "total_rows": len(rows), "level": level} + + +def get_change_history( + config: AdLoopConfig, + *, + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", + resource_type: str = "", + operation_type: str = "", + limit: int = 100, +) -> dict: + """Get account change history from the change_event resource.""" + from adloop.ads.gaql import execute_query + + date_clause = _change_event_date_clause(date_range_start, date_range_end) + + resource_filter = "" + if resource_type: + resource_filter = ( + f"AND change_event.change_resource_type = '{resource_type}'" + ) + + operation_filter = "" + if operation_type: + operation_filter = ( + f"AND change_event.resource_change_operation = '{operation_type}'" + ) + + query = f""" + SELECT change_event.change_date_time, + change_event.user_email, + change_event.change_resource_type, + change_event.resource_change_operation, + change_event.changed_fields, + change_event.old_resource, + change_event.new_resource, + change_event.resource_name + FROM change_event + WHERE change_event.change_date_time DURING LAST_14_DAYS + {date_clause} + {resource_filter} + {operation_filter} + ORDER BY change_event.change_date_time DESC + LIMIT {limit} + """ + + # If explicit dates given, replace the default DURING clause + if date_range_start and date_range_end: + query = f""" + SELECT change_event.change_date_time, + change_event.user_email, + change_event.change_resource_type, + change_event.resource_change_operation, + change_event.changed_fields, + change_event.old_resource, + change_event.new_resource, + change_event.resource_name + FROM change_event + WHERE change_event.change_date_time BETWEEN '{date_range_start}' AND '{date_range_end}' + {resource_filter} + {operation_filter} + ORDER BY change_event.change_date_time DESC + LIMIT {limit} + """ + + rows = execute_query(config, customer_id, query) + return {"changes": rows, "total_changes": len(rows)} + + +def get_device_performance( + config: AdLoopConfig, + *, + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", + level: str = "campaign", +) -> dict: + """Get performance segmented by device (MOBILE, DESKTOP, TABLET).""" + from adloop.ads.gaql import execute_query + + date_clause = _date_clause(date_range_start, date_range_end) + + if level == "ad_group": + query = f""" + SELECT campaign.name, ad_group.id, ad_group.name, + segments.device, + metrics.impressions, metrics.clicks, metrics.ctr, + metrics.cost_micros, metrics.average_cpc, + metrics.conversions, metrics.conversions_value + FROM ad_group + WHERE ad_group.status != 'REMOVED' + {date_clause} + ORDER BY ad_group.name, metrics.cost_micros DESC + """ + else: + query = f""" + SELECT campaign.id, campaign.name, + segments.device, + metrics.impressions, metrics.clicks, metrics.ctr, + metrics.cost_micros, metrics.average_cpc, + metrics.conversions, metrics.conversions_value + FROM campaign + WHERE campaign.status != 'REMOVED' + {date_clause} + ORDER BY campaign.name, metrics.cost_micros DESC + """ + + rows = execute_query(config, customer_id, query) + _enrich_cost_fields(rows) + _enrich_conversion_rate(rows) + + return {"device_performance": rows, "total_rows": len(rows), "level": level} + + +def get_location_performance( + config: AdLoopConfig, + *, + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", +) -> dict: + """Get performance segmented by geographic location.""" + from adloop.ads.gaql import execute_query + + # geographic_view requires segments.date in WHERE, like search_term_view. + if date_range_start and date_range_end: + query = f""" + SELECT geographic_view.country_criterion_id, + geographic_view.location_type, + campaign.name, + metrics.impressions, metrics.clicks, metrics.ctr, + metrics.cost_micros, metrics.conversions, + metrics.conversions_value + FROM geographic_view + WHERE segments.date BETWEEN '{date_range_start}' AND '{date_range_end}' + ORDER BY metrics.cost_micros DESC + LIMIT 200 + """ + else: + query = """ + SELECT geographic_view.country_criterion_id, + geographic_view.location_type, + campaign.name, + metrics.impressions, metrics.clicks, metrics.ctr, + metrics.cost_micros, metrics.conversions, + metrics.conversions_value + FROM geographic_view + WHERE segments.date DURING LAST_30_DAYS + ORDER BY metrics.cost_micros DESC + LIMIT 200 + """ + + rows = execute_query(config, customer_id, query) + _enrich_cost_fields(rows) + _enrich_conversion_rate(rows) + + return {"locations": rows, "total_locations": len(rows)} + + +def get_quality_score_details( + config: AdLoopConfig, + *, + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", + campaign_id: str = "", +) -> dict: + """Get keyword-level Quality Score with component breakdowns.""" + from adloop.ads.gaql import execute_query + + date_clause = _date_clause(date_range_start, date_range_end) + + campaign_filter = "" + if campaign_id: + campaign_filter = f"AND campaign.id = {campaign_id}" + + query = f""" + SELECT campaign.name, campaign.id, ad_group.name, + ad_group_criterion.keyword.text, + ad_group_criterion.keyword.match_type, + ad_group_criterion.quality_info.quality_score, + ad_group_criterion.quality_info.creative_quality_score, + ad_group_criterion.quality_info.post_click_quality_score, + ad_group_criterion.quality_info.search_predicted_ctr, + metrics.impressions, metrics.clicks, metrics.cost_micros, + metrics.conversions + FROM keyword_view + WHERE ad_group_criterion.status != 'REMOVED' + {date_clause} + {campaign_filter} + ORDER BY metrics.cost_micros DESC + """ + + rows = execute_query(config, customer_id, query) + _enrich_cost_fields(rows) + + return {"quality_scores": rows, "total_keywords": len(rows)} + + +def get_bid_strategy_status( + config: AdLoopConfig, + *, + customer_id: str = "", + campaign_id: str = "", +) -> dict: + """Get bid strategy type, learning status, and budget for each campaign.""" + from adloop.ads.gaql import execute_query + + campaign_filter = "" + if campaign_id: + campaign_filter = f"AND campaign.id = {campaign_id}" + + query = f""" + SELECT campaign.id, campaign.name, campaign.status, + campaign.bidding_strategy_type, + campaign.bidding_strategy_system_status, + campaign_budget.amount_micros, + metrics.conversions, metrics.cost_micros + FROM campaign + WHERE campaign.status != 'REMOVED' + {campaign_filter} + AND segments.date DURING LAST_30_DAYS + ORDER BY metrics.cost_micros DESC + """ + + rows = execute_query(config, customer_id, query) + _enrich_cost_fields(rows) + _enrich_budget_fields(rows) + + return {"strategies": rows, "total_campaigns": len(rows)} + + +def get_budget_pacing( + config: AdLoopConfig, + *, + customer_id: str = "", + campaign_id: str = "", +) -> dict: + """Get monthly budget pacing — spend-to-date, projected spend, pace %.""" + import calendar + from datetime import date + + from adloop.ads.gaql import execute_query + + campaign_filter = "" + if campaign_id: + campaign_filter = f"AND campaign.id = {campaign_id}" + + # Query 1: budget settings + budget_query = f""" + SELECT campaign.id, campaign.name, campaign.status, + campaign_budget.amount_micros + FROM campaign + WHERE campaign.status != 'REMOVED' + {campaign_filter} + """ + + # Query 2: month-to-date spend (segments.date breaks down by day; we sum) + spend_query = f""" + SELECT campaign.id, metrics.cost_micros + FROM campaign + WHERE campaign.status != 'REMOVED' + AND segments.date DURING THIS_MONTH + {campaign_filter} + """ + + budget_rows = execute_query(config, customer_id, budget_query) + spend_rows = execute_query(config, customer_id, spend_query) + + # Aggregate daily spend per campaign + spend_by_campaign: dict[str, int] = {} + for row in spend_rows: + cid = row.get("campaign.id") + cost = row.get("metrics.cost_micros", 0) or 0 + spend_by_campaign[cid] = spend_by_campaign.get(cid, 0) + cost + + today = date.today() + days_in_month = calendar.monthrange(today.year, today.month)[1] + days_elapsed = today.day + days_remaining = days_in_month - days_elapsed + + pacing = [] + for row in budget_rows: + cid = row.get("campaign.id") + budget_micros = row.get("campaign_budget.amount_micros", 0) or 0 + daily_budget = round(budget_micros / 1_000_000, 2) + month_budget = round(daily_budget * days_in_month, 2) + + month_spend_micros = spend_by_campaign.get(cid, 0) + month_spend = round(month_spend_micros / 1_000_000, 2) + + daily_avg = round(month_spend / days_elapsed, 2) if days_elapsed > 0 else 0 + projected = round(daily_avg * days_in_month, 2) + pace_pct = round(projected / month_budget * 100, 1) if month_budget > 0 else 0 + + pacing.append({ + "campaign.id": cid, + "campaign.name": row.get("campaign.name"), + "campaign.status": row.get("campaign.status"), + "daily_budget": daily_budget, + "month_budget": month_budget, + "month_spend": month_spend, + "daily_avg_spend": daily_avg, + "projected_month_spend": projected, + "days_elapsed": days_elapsed, + "days_remaining": days_remaining, + "pace_pct": pace_pct, + }) + + return {"pacing": pacing, "total_campaigns": len(pacing)} + + +def get_ad_schedule_performance( + config: AdLoopConfig, + *, + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", + campaign_id: str = "", +) -> dict: + """Get performance by hour of day and day of week.""" + from adloop.ads.gaql import execute_query + + date_clause = _date_clause(date_range_start, date_range_end) + + campaign_filter = "" + if campaign_id: + campaign_filter = f"AND campaign.id = {campaign_id}" + + query = f""" + SELECT campaign.name, campaign.id, + segments.day_of_week, segments.hour, + metrics.impressions, metrics.clicks, metrics.ctr, + metrics.cost_micros, metrics.conversions + FROM campaign + WHERE campaign.status != 'REMOVED' + {date_clause} + {campaign_filter} + ORDER BY segments.day_of_week, segments.hour + """ + + rows = execute_query(config, customer_id, query) + _enrich_cost_fields(rows) + + return {"schedule_performance": rows, "total_rows": len(rows)} + + +def get_auction_insights( + config: AdLoopConfig, + *, + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", + campaign_id: str = "", +) -> dict: + """Get auction insights — competitor overlap, outranking share, position data. + + Note: only available for allowlisted accounts. Returns a helpful error + message if the account does not have access. + """ + from adloop.ads.gaql import execute_query + + date_clause = _date_clause(date_range_start, date_range_end) + + campaign_filter = "" + if campaign_id: + campaign_filter = f"AND campaign.id = {campaign_id}" + + query = f""" + SELECT campaign.name, campaign.id, + segments.auction_insight_domain, + metrics.auction_insight_search_impression_share, + metrics.auction_insight_search_overlap_rate, + metrics.auction_insight_search_outranking_share, + metrics.auction_insight_search_position_above_rate, + metrics.auction_insight_search_top_impression_percentage, + metrics.auction_insight_search_absolute_top_impression_percentage + FROM campaign + WHERE campaign.status != 'REMOVED' + {date_clause} + {campaign_filter} + ORDER BY metrics.auction_insight_search_impression_share DESC + """ + + try: + rows = execute_query(config, customer_id, query) + except Exception as exc: + err = str(exc) + if "QUERY_NOT_ALLOWED" in err or "not allowed" in err.lower(): + return { + "error": "Auction insights are not available for this account.", + "hint": ( + "Auction insights via GAQL require an allowlisted account. " + "Contact your Google account manager to request access, or " + "view auction insights in the Google Ads UI instead." + ), + } + raise + + return {"auction_insights": rows, "total_rows": len(rows)} + + # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- @@ -223,6 +687,13 @@ def _date_clause(start: str, end: str) -> str: return "AND segments.date DURING LAST_30_DAYS" +def _change_event_date_clause(start: str, end: str) -> str: + """Build a date clause for the change_event resource.""" + if start and end: + return f"AND change_event.change_date_time BETWEEN '{start}' AND '{end}'" + return "AND change_event.change_date_time DURING LAST_14_DAYS" + + def _enrich_cost_fields(rows: list[dict]) -> None: """Add human-readable cost and CPA fields computed from cost_micros.""" for row in rows: @@ -236,3 +707,37 @@ def _enrich_cost_fields(rows: list[dict]) -> None: avg_cpc_micros = row.get("metrics.average_cpc", 0) or 0 if avg_cpc_micros: row["metrics.average_cpc_eur"] = round(avg_cpc_micros / 1_000_000, 2) + + +def _enrich_impression_share_fields(rows: list[dict]) -> None: + """Convert impression share fractions (0.0-1.0) to readable percentages.""" + share_fields = [ + "metrics.search_impression_share", + "metrics.search_budget_lost_impression_share", + "metrics.search_rank_lost_impression_share", + "metrics.search_exact_match_impression_share", + "metrics.search_top_impression_share", + "metrics.search_absolute_top_impression_share", + ] + for row in rows: + for field in share_fields: + val = row.get(field) + if val is not None and isinstance(val, (int, float)): + row[field + "_pct"] = f"{val * 100:.1f}%" + + +def _enrich_conversion_rate(rows: list[dict]) -> None: + """Compute conversion rate percentage from clicks and conversions.""" + for row in rows: + clicks = row.get("metrics.clicks", 0) or 0 + conversions = row.get("metrics.conversions", 0) or 0 + if clicks > 0: + row["metrics.conversion_rate"] = round(conversions / clicks * 100, 2) + + +def _enrich_budget_fields(rows: list[dict]) -> None: + """Add human-readable budget amount from budget_micros.""" + for row in rows: + budget_micros = row.get("campaign_budget.amount_micros", 0) or 0 + if budget_micros: + row["campaign_budget.amount"] = round(budget_micros / 1_000_000, 2) diff --git a/src/adloop/server.py b/src/adloop/server.py index 9257a9e..5a3a236 100644 --- a/src/adloop/server.py +++ b/src/adloop/server.py @@ -338,6 +338,258 @@ def get_negative_keywords( ) +# --------------------------------------------------------------------------- +# Google Ads Insights Tools +# --------------------------------------------------------------------------- + + +@mcp.tool(annotations=_READONLY) +@_safe +def get_impression_share( + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", + level: str = "campaign", +) -> dict: + """Get impression share metrics — how much of available search traffic you're capturing. + + Shows search impression share, budget-lost share, rank-lost share, + top impression share, and absolute top impression share. + + level: "campaign" (default), "ad_group", or "keyword" + Date format: "YYYY-MM-DD". Empty = last 30 days. + """ + from adloop.ads.read import get_impression_share as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + date_range_start=date_range_start, + date_range_end=date_range_end, + level=level, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def get_change_history( + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", + resource_type: str = "", + operation_type: str = "", + limit: int = 100, +) -> dict: + """Get recent account change history — who changed what and when. + + Critical for correlating performance shifts with account changes. + Goes back up to 30 days (API limit). Default shows last 14 days. + + resource_type: filter by type — "CAMPAIGN", "AD_GROUP", "AD", + "AD_GROUP_CRITERION", "CAMPAIGN_BUDGET", "BIDDING_STRATEGY" + operation_type: filter by action — "CREATE", "UPDATE", "REMOVE" + Date format: "YYYY-MM-DD". Empty = last 14 days. + """ + from adloop.ads.read import get_change_history as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + date_range_start=date_range_start, + date_range_end=date_range_end, + resource_type=resource_type, + operation_type=operation_type, + limit=limit, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def get_device_performance( + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", + level: str = "campaign", +) -> dict: + """Get performance segmented by device — MOBILE, DESKTOP, TABLET. + + Essential for local service businesses where mobile intent differs + dramatically from desktop. Shows clicks, cost, conversions, and + conversion rate per device. + + level: "campaign" (default) or "ad_group" + Date format: "YYYY-MM-DD". Empty = last 30 days. + """ + from adloop.ads.read import get_device_performance as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + date_range_start=date_range_start, + date_range_end=date_range_end, + level=level, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def get_location_performance( + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", +) -> dict: + """Get performance segmented by geographic location. + + Shows impressions, clicks, cost, and conversions per location. + Useful for identifying underperforming service areas or wasted spend + outside the target service radius. + + Date format: "YYYY-MM-DD". Empty = last 30 days. + """ + from adloop.ads.read import get_location_performance as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + date_range_start=date_range_start, + date_range_end=date_range_end, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def get_quality_score_details( + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", + campaign_id: str = "", +) -> dict: + """Get keyword Quality Score with component breakdowns. + + Returns quality_score (1-10), creative_quality_score (ad relevance), + post_click_quality_score (landing page), and search_predicted_ctr + (expected CTR). Sorted by spend so high-cost low-QS keywords surface first. + + campaign_id: optional filter to a specific campaign. + Date format: "YYYY-MM-DD". Empty = last 30 days. + """ + from adloop.ads.read import get_quality_score_details as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + date_range_start=date_range_start, + date_range_end=date_range_end, + campaign_id=campaign_id, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def get_bid_strategy_status( + customer_id: str = "", + campaign_id: str = "", +) -> dict: + """Get bid strategy type, system status, and learning state per campaign. + + Shows bidding_strategy_type (MAXIMIZE_CONVERSIONS, TARGET_CPA, etc.), + bidding_strategy_system_status (LEARNING, ELIGIBLE, LIMITED, etc.), + daily budget, and last-30-day metrics. + + Use this before recommending changes — don't edit campaigns in a learning phase. + campaign_id: optional filter to a specific campaign. + """ + from adloop.ads.read import get_bid_strategy_status as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + campaign_id=campaign_id, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def get_budget_pacing( + customer_id: str = "", + campaign_id: str = "", +) -> dict: + """Get monthly budget pacing — spend-to-date, projected spend, pace percentage. + + Shows daily budget, month-to-date spend, daily average spend, + projected month-end spend, and whether each campaign is over or under pace. + + campaign_id: optional filter to a specific campaign. + """ + from adloop.ads.read import get_budget_pacing as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + campaign_id=campaign_id, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def get_ad_schedule_performance( + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", + campaign_id: str = "", +) -> dict: + """Get performance by hour of day and day of week. + + Identifies peak and off-peak patterns. Important for local service + businesses (e.g. emergency plumber at 2am vs 2pm). + + campaign_id: optional filter to a specific campaign. + Date format: "YYYY-MM-DD". Empty = last 30 days. + """ + from adloop.ads.read import get_ad_schedule_performance as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + date_range_start=date_range_start, + date_range_end=date_range_end, + campaign_id=campaign_id, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def get_auction_insights( + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", + campaign_id: str = "", +) -> dict: + """Get auction insights — competitor overlap rate, outranking share, position data. + + Shows which competitors appear alongside your ads and how often you + outrank them. Requires an allowlisted Google Ads account — returns a + helpful error if access is not available. + + campaign_id: optional filter to a specific campaign. + Date format: "YYYY-MM-DD". Empty = last 30 days. + """ + from adloop.ads.read import get_auction_insights as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + date_range_start=date_range_start, + date_range_end=date_range_end, + campaign_id=campaign_id, + ) + + +# --------------------------------------------------------------------------- +# Cross-Reference Tools (GA4 + Ads combined) +# --------------------------------------------------------------------------- + + @mcp.tool(annotations=_READONLY) @_safe def analyze_campaign_conversions( diff --git a/tests/test_read_tools.py b/tests/test_read_tools.py new file mode 100644 index 0000000..742fb2e --- /dev/null +++ b/tests/test_read_tools.py @@ -0,0 +1,597 @@ +"""Tests for Google Ads read and insights tools.""" + +from unittest.mock import patch + +import pytest + +from adloop.ads.read import ( + get_ad_schedule_performance, + get_auction_insights, + get_bid_strategy_status, + get_budget_pacing, + get_change_history, + get_device_performance, + get_impression_share, + get_location_performance, + get_quality_score_details, +) +from adloop.config import AdLoopConfig, AdsConfig, SafetyConfig + + +@pytest.fixture +def config(): + return AdLoopConfig( + ads=AdsConfig(customer_id="1234567890", developer_token="test"), + safety=SafetyConfig(max_daily_budget=50.0, require_dry_run=True), + ) + + +# --------------------------------------------------------------------------- +# get_impression_share +# --------------------------------------------------------------------------- + + +class TestGetImpressionShare: + @patch("adloop.ads.gaql.execute_query") + def test_campaign_level(self, mock_query, config): + mock_query.return_value = [ + { + "campaign.id": 111, + "campaign.name": "Test Campaign", + "campaign.status": "ENABLED", + "metrics.impressions": 1000, + "metrics.clicks": 100, + "metrics.cost_micros": 50_000_000, + "metrics.search_impression_share": 0.45, + "metrics.search_budget_lost_impression_share": 0.20, + "metrics.search_rank_lost_impression_share": 0.35, + "metrics.search_exact_match_impression_share": 0.60, + "metrics.search_top_impression_share": 0.30, + "metrics.search_absolute_top_impression_share": 0.10, + } + ] + + result = get_impression_share(config, customer_id="1234567890") + + assert "impression_share" in result + assert result["total_rows"] == 1 + assert result["level"] == "campaign" + row = result["impression_share"][0] + assert row["metrics.cost"] == 50.0 + assert row["metrics.search_impression_share_pct"] == "45.0%" + assert row["metrics.search_budget_lost_impression_share_pct"] == "20.0%" + assert row["metrics.search_rank_lost_impression_share_pct"] == "35.0%" + + @patch("adloop.ads.gaql.execute_query") + def test_ad_group_level(self, mock_query, config): + mock_query.return_value = [ + { + "campaign.name": "Camp", + "ad_group.id": 222, + "ad_group.name": "AG1", + "metrics.impressions": 500, + "metrics.clicks": 50, + "metrics.cost_micros": 10_000_000, + "metrics.search_impression_share": 0.80, + "metrics.search_budget_lost_impression_share": 0.05, + "metrics.search_rank_lost_impression_share": 0.15, + "metrics.search_exact_match_impression_share": 0.90, + "metrics.search_top_impression_share": 0.50, + "metrics.search_absolute_top_impression_share": 0.25, + } + ] + + result = get_impression_share( + config, customer_id="1234567890", level="ad_group" + ) + + assert result["level"] == "ad_group" + assert result["total_rows"] == 1 + # Verify ad_group query was built + call_query = mock_query.call_args[0][2] + assert "FROM ad_group" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_keyword_level(self, mock_query, config): + mock_query.return_value = [] + + result = get_impression_share( + config, customer_id="1234567890", level="keyword" + ) + + assert result["level"] == "keyword" + assert result["total_rows"] == 0 + call_query = mock_query.call_args[0][2] + assert "FROM keyword_view" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_empty_results(self, mock_query, config): + mock_query.return_value = [] + + result = get_impression_share(config, customer_id="1234567890") + + assert result["impression_share"] == [] + assert result["total_rows"] == 0 + + +# --------------------------------------------------------------------------- +# get_change_history +# --------------------------------------------------------------------------- + + +class TestGetChangeHistory: + @patch("adloop.ads.gaql.execute_query") + def test_default_query(self, mock_query, config): + mock_query.return_value = [ + { + "change_event.change_date_time": "2026-03-25 10:00:00", + "change_event.user_email": "user@example.com", + "change_event.change_resource_type": "CAMPAIGN", + "change_event.resource_change_operation": "UPDATE", + "change_event.changed_fields": "budget", + "change_event.old_resource": None, + "change_event.new_resource": None, + "change_event.resource_name": "customers/123/campaigns/456", + } + ] + + result = get_change_history(config, customer_id="1234567890") + + assert "changes" in result + assert result["total_changes"] == 1 + assert result["changes"][0]["change_event.user_email"] == "user@example.com" + + @patch("adloop.ads.gaql.execute_query") + def test_resource_type_filter(self, mock_query, config): + mock_query.return_value = [] + + get_change_history( + config, customer_id="1234567890", resource_type="CAMPAIGN" + ) + + call_query = mock_query.call_args[0][2] + assert "change_resource_type = 'CAMPAIGN'" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_operation_type_filter(self, mock_query, config): + mock_query.return_value = [] + + get_change_history( + config, customer_id="1234567890", operation_type="UPDATE" + ) + + call_query = mock_query.call_args[0][2] + assert "resource_change_operation = 'UPDATE'" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_date_range_override(self, mock_query, config): + mock_query.return_value = [] + + get_change_history( + config, + customer_id="1234567890", + date_range_start="2026-03-01", + date_range_end="2026-03-27", + ) + + call_query = mock_query.call_args[0][2] + assert "BETWEEN '2026-03-01' AND '2026-03-27'" in call_query + assert "DURING LAST_14_DAYS" not in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_empty_results(self, mock_query, config): + mock_query.return_value = [] + + result = get_change_history(config, customer_id="1234567890") + + assert result["changes"] == [] + assert result["total_changes"] == 0 + + +# --------------------------------------------------------------------------- +# get_device_performance +# --------------------------------------------------------------------------- + + +class TestGetDevicePerformance: + @patch("adloop.ads.gaql.execute_query") + def test_campaign_level(self, mock_query, config): + mock_query.return_value = [ + { + "campaign.id": 111, + "campaign.name": "Test", + "segments.device": "MOBILE", + "metrics.impressions": 800, + "metrics.clicks": 80, + "metrics.ctr": 0.10, + "metrics.cost_micros": 20_000_000, + "metrics.average_cpc": 250_000, + "metrics.conversions": 4, + "metrics.conversions_value": 200.0, + }, + { + "campaign.id": 111, + "campaign.name": "Test", + "segments.device": "DESKTOP", + "metrics.impressions": 500, + "metrics.clicks": 50, + "metrics.ctr": 0.10, + "metrics.cost_micros": 15_000_000, + "metrics.average_cpc": 300_000, + "metrics.conversions": 3, + "metrics.conversions_value": 150.0, + }, + ] + + result = get_device_performance(config, customer_id="1234567890") + + assert result["total_rows"] == 2 + assert result["level"] == "campaign" + mobile = result["device_performance"][0] + assert mobile["metrics.cost"] == 20.0 + assert mobile["metrics.conversion_rate"] == 5.0 # 4/80 * 100 + + @patch("adloop.ads.gaql.execute_query") + def test_ad_group_level(self, mock_query, config): + mock_query.return_value = [] + + result = get_device_performance( + config, customer_id="1234567890", level="ad_group" + ) + + assert result["level"] == "ad_group" + call_query = mock_query.call_args[0][2] + assert "ad_group.id" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_empty_results(self, mock_query, config): + mock_query.return_value = [] + + result = get_device_performance(config, customer_id="1234567890") + + assert result["device_performance"] == [] + assert result["total_rows"] == 0 + + +# --------------------------------------------------------------------------- +# get_location_performance +# --------------------------------------------------------------------------- + + +class TestGetLocationPerformance: + @patch("adloop.ads.gaql.execute_query") + def test_default_query(self, mock_query, config): + mock_query.return_value = [ + { + "geographic_view.country_criterion_id": 2276, + "geographic_view.location_type": "LOCATION_OF_PRESENCE", + "campaign.name": "Germany Campaign", + "metrics.impressions": 500, + "metrics.clicks": 50, + "metrics.ctr": 0.10, + "metrics.cost_micros": 25_000_000, + "metrics.conversions": 5, + "metrics.conversions_value": 250.0, + } + ] + + result = get_location_performance(config, customer_id="1234567890") + + assert "locations" in result + assert result["total_locations"] == 1 + row = result["locations"][0] + assert row["metrics.cost"] == 25.0 + assert row["metrics.conversion_rate"] == 10.0 # 5/50 * 100 + + @patch("adloop.ads.gaql.execute_query") + def test_with_date_range(self, mock_query, config): + mock_query.return_value = [] + + get_location_performance( + config, + customer_id="1234567890", + date_range_start="2026-03-01", + date_range_end="2026-03-27", + ) + + call_query = mock_query.call_args[0][2] + assert "BETWEEN '2026-03-01' AND '2026-03-27'" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_empty_results(self, mock_query, config): + mock_query.return_value = [] + + result = get_location_performance(config, customer_id="1234567890") + + assert result["locations"] == [] + assert result["total_locations"] == 0 + + +# --------------------------------------------------------------------------- +# get_quality_score_details +# --------------------------------------------------------------------------- + + +class TestGetQualityScoreDetails: + @patch("adloop.ads.gaql.execute_query") + def test_default_query(self, mock_query, config): + mock_query.return_value = [ + { + "campaign.name": "Test", + "campaign.id": 111, + "ad_group.name": "AG1", + "ad_group_criterion.keyword.text": "test keyword", + "ad_group_criterion.keyword.match_type": "EXACT", + "ad_group_criterion.quality_info.quality_score": 7, + "ad_group_criterion.quality_info.creative_quality_score": "ABOVE_AVERAGE", + "ad_group_criterion.quality_info.post_click_quality_score": "AVERAGE", + "ad_group_criterion.quality_info.search_predicted_ctr": "ABOVE_AVERAGE", + "metrics.impressions": 200, + "metrics.clicks": 20, + "metrics.cost_micros": 10_000_000, + "metrics.conversions": 2, + } + ] + + result = get_quality_score_details(config, customer_id="1234567890") + + assert "quality_scores" in result + assert result["total_keywords"] == 1 + row = result["quality_scores"][0] + assert row["ad_group_criterion.quality_info.quality_score"] == 7 + assert row["metrics.cost"] == 10.0 + assert row["metrics.cpa"] == 5.0 + + @patch("adloop.ads.gaql.execute_query") + def test_campaign_filter(self, mock_query, config): + mock_query.return_value = [] + + get_quality_score_details( + config, customer_id="1234567890", campaign_id="999" + ) + + call_query = mock_query.call_args[0][2] + assert "campaign.id = 999" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_empty_results(self, mock_query, config): + mock_query.return_value = [] + + result = get_quality_score_details(config, customer_id="1234567890") + + assert result["quality_scores"] == [] + assert result["total_keywords"] == 0 + + +# --------------------------------------------------------------------------- +# get_bid_strategy_status +# --------------------------------------------------------------------------- + + +class TestGetBidStrategyStatus: + @patch("adloop.ads.gaql.execute_query") + def test_default_query(self, mock_query, config): + mock_query.return_value = [ + { + "campaign.id": 111, + "campaign.name": "Test Campaign", + "campaign.status": "ENABLED", + "campaign.bidding_strategy_type": "MAXIMIZE_CONVERSIONS", + "campaign.bidding_strategy_system_status": "LEARNING", + "campaign_budget.amount_micros": 30_000_000, + "metrics.conversions": 10, + "metrics.cost_micros": 100_000_000, + } + ] + + result = get_bid_strategy_status(config, customer_id="1234567890") + + assert "strategies" in result + assert result["total_campaigns"] == 1 + row = result["strategies"][0] + assert row["campaign.bidding_strategy_system_status"] == "LEARNING" + assert row["campaign_budget.amount"] == 30.0 + assert row["metrics.cost"] == 100.0 + + @patch("adloop.ads.gaql.execute_query") + def test_campaign_filter(self, mock_query, config): + mock_query.return_value = [] + + get_bid_strategy_status( + config, customer_id="1234567890", campaign_id="999" + ) + + call_query = mock_query.call_args[0][2] + assert "campaign.id = 999" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_empty_results(self, mock_query, config): + mock_query.return_value = [] + + result = get_bid_strategy_status(config, customer_id="1234567890") + + assert result["strategies"] == [] + assert result["total_campaigns"] == 0 + + +# --------------------------------------------------------------------------- +# get_budget_pacing +# --------------------------------------------------------------------------- + + +class TestGetBudgetPacing: + def _mock_execute(self, budget_rows, spend_rows): + """Return a side_effect that returns different results per query.""" + def side_effect(config, customer_id, query): + if "THIS_MONTH" in query: + return spend_rows + return budget_rows + return side_effect + + @patch("adloop.ads.gaql.execute_query") + def test_basic_pacing(self, mock_query, config): + budget_rows = [ + { + "campaign.id": 111, + "campaign.name": "Test Campaign", + "campaign.status": "ENABLED", + "campaign_budget.amount_micros": 10_000_000, # 10 EUR/day + } + ] + spend_rows = [ + {"campaign.id": 111, "metrics.cost_micros": 5_000_000}, + {"campaign.id": 111, "metrics.cost_micros": 8_000_000}, + {"campaign.id": 111, "metrics.cost_micros": 7_000_000}, + ] + mock_query.side_effect = self._mock_execute(budget_rows, spend_rows) + + result = get_budget_pacing(config, customer_id="1234567890") + + assert "pacing" in result + assert result["total_campaigns"] == 1 + row = result["pacing"][0] + assert row["campaign.id"] == 111 + assert row["daily_budget"] == 10.0 + assert row["month_spend"] == 20.0 # 5 + 8 + 7 = 20 EUR + assert "days_elapsed" in row + assert "days_remaining" in row + assert "projected_month_spend" in row + assert "pace_pct" in row + + @patch("adloop.ads.gaql.execute_query") + def test_campaign_filter(self, mock_query, config): + mock_query.return_value = [] + + get_budget_pacing(config, customer_id="1234567890", campaign_id="999") + + # Both queries should have the campaign filter + for call in mock_query.call_args_list: + assert "campaign.id = 999" in call[0][2] + + @patch("adloop.ads.gaql.execute_query") + def test_empty_results(self, mock_query, config): + mock_query.return_value = [] + + result = get_budget_pacing(config, customer_id="1234567890") + + assert result["pacing"] == [] + assert result["total_campaigns"] == 0 + + +# --------------------------------------------------------------------------- +# get_ad_schedule_performance +# --------------------------------------------------------------------------- + + +class TestGetAdSchedulePerformance: + @patch("adloop.ads.gaql.execute_query") + def test_default_query(self, mock_query, config): + mock_query.return_value = [ + { + "campaign.name": "Test", + "campaign.id": 111, + "segments.day_of_week": "MONDAY", + "segments.hour": 9, + "metrics.impressions": 100, + "metrics.clicks": 10, + "metrics.ctr": 0.10, + "metrics.cost_micros": 5_000_000, + "metrics.conversions": 1, + } + ] + + result = get_ad_schedule_performance(config, customer_id="1234567890") + + assert "schedule_performance" in result + assert result["total_rows"] == 1 + row = result["schedule_performance"][0] + assert row["segments.day_of_week"] == "MONDAY" + assert row["segments.hour"] == 9 + assert row["metrics.cost"] == 5.0 + + @patch("adloop.ads.gaql.execute_query") + def test_campaign_filter(self, mock_query, config): + mock_query.return_value = [] + + get_ad_schedule_performance( + config, customer_id="1234567890", campaign_id="999" + ) + + call_query = mock_query.call_args[0][2] + assert "campaign.id = 999" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_empty_results(self, mock_query, config): + mock_query.return_value = [] + + result = get_ad_schedule_performance(config, customer_id="1234567890") + + assert result["schedule_performance"] == [] + assert result["total_rows"] == 0 + + +# --------------------------------------------------------------------------- +# get_auction_insights +# --------------------------------------------------------------------------- + + +class TestGetAuctionInsights: + @patch("adloop.ads.gaql.execute_query") + def test_successful_query(self, mock_query, config): + mock_query.return_value = [ + { + "campaign.name": "Test", + "campaign.id": 111, + "segments.auction_insight_domain": "competitor.com", + "metrics.auction_insight_search_impression_share": 0.35, + "metrics.auction_insight_search_overlap_rate": 0.50, + "metrics.auction_insight_search_outranking_share": 0.40, + "metrics.auction_insight_search_position_above_rate": 0.20, + "metrics.auction_insight_search_top_impression_percentage": 0.30, + "metrics.auction_insight_search_absolute_top_impression_percentage": 0.10, + } + ] + + result = get_auction_insights(config, customer_id="1234567890") + + assert "auction_insights" in result + assert result["total_rows"] == 1 + + @patch("adloop.ads.gaql.execute_query") + def test_not_allowlisted(self, mock_query, config): + mock_query.side_effect = Exception( + "QUERY_NOT_ALLOWED: This query type is not supported" + ) + + result = get_auction_insights(config, customer_id="1234567890") + + assert "error" in result + assert "not available" in result["error"] + assert "hint" in result + + @patch("adloop.ads.gaql.execute_query") + def test_other_error_reraises(self, mock_query, config): + mock_query.side_effect = Exception("NETWORK_ERROR: connection failed") + + with pytest.raises(Exception, match="NETWORK_ERROR"): + get_auction_insights(config, customer_id="1234567890") + + @patch("adloop.ads.gaql.execute_query") + def test_campaign_filter(self, mock_query, config): + mock_query.return_value = [] + + get_auction_insights( + config, customer_id="1234567890", campaign_id="999" + ) + + call_query = mock_query.call_args[0][2] + assert "campaign.id = 999" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_empty_results(self, mock_query, config): + mock_query.return_value = [] + + result = get_auction_insights(config, customer_id="1234567890") + + assert result["auction_insights"] == [] + assert result["total_rows"] == 0 From e26151d4ae61b9e642e1ae87f99bb3682b41e529 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 16:08:26 +0000 Subject: [PATCH 13/36] Clean up get_change_history date logic and expand analyze-performance command Simplify get_change_history to use a single code path instead of duplicate query construction with redundant date clauses. Remove the unused _change_event_date_clause helper. Add missing conditional tool references (change_history, location, schedule, auction) to the analyze-performance slash command. https://claude.ai/code/session_01EtMthH7NLzMFcy6hsDUfQt --- .claude/commands/analyze-performance.md | 4 +++ src/adloop/ads/read.py | 39 +++++++------------------ 2 files changed, 14 insertions(+), 29 deletions(-) diff --git a/.claude/commands/analyze-performance.md b/.claude/commands/analyze-performance.md index add98a8..754a041 100644 --- a/.claude/commands/analyze-performance.md +++ b/.claude/commands/analyze-performance.md @@ -28,6 +28,10 @@ If conversion issues found: run `attribution_check` If landing page problems suspected: run `landing_page_analysis` If quality scores are low: run `get_quality_score_details` for component breakdowns If device performance varies: run `get_device_performance` to compare mobile vs desktop +If performance changed unexpectedly: run `get_change_history` to correlate with account changes +If geographic waste suspected: run `get_location_performance` to identify underperforming areas +If timing patterns matter: run `get_ad_schedule_performance` for hour/day analysis +If competitive context needed: run `get_auction_insights` (requires allowlisted account) ## 3. Present results diff --git a/src/adloop/ads/read.py b/src/adloop/ads/read.py index c38b1ac..e4a3177 100644 --- a/src/adloop/ads/read.py +++ b/src/adloop/ads/read.py @@ -287,8 +287,6 @@ def get_change_history( """Get account change history from the change_event resource.""" from adloop.ads.gaql import execute_query - date_clause = _change_event_date_clause(date_range_start, date_range_end) - resource_filter = "" if resource_type: resource_filter = ( @@ -301,6 +299,15 @@ def get_change_history( f"AND change_event.resource_change_operation = '{operation_type}'" ) + # change_event uses change_date_time, not segments.date. + if date_range_start and date_range_end: + date_where = ( + f"change_event.change_date_time BETWEEN " + f"'{date_range_start}' AND '{date_range_end}'" + ) + else: + date_where = "change_event.change_date_time DURING LAST_14_DAYS" + query = f""" SELECT change_event.change_date_time, change_event.user_email, @@ -311,33 +318,13 @@ def get_change_history( change_event.new_resource, change_event.resource_name FROM change_event - WHERE change_event.change_date_time DURING LAST_14_DAYS - {date_clause} + WHERE {date_where} {resource_filter} {operation_filter} ORDER BY change_event.change_date_time DESC LIMIT {limit} """ - # If explicit dates given, replace the default DURING clause - if date_range_start and date_range_end: - query = f""" - SELECT change_event.change_date_time, - change_event.user_email, - change_event.change_resource_type, - change_event.resource_change_operation, - change_event.changed_fields, - change_event.old_resource, - change_event.new_resource, - change_event.resource_name - FROM change_event - WHERE change_event.change_date_time BETWEEN '{date_range_start}' AND '{date_range_end}' - {resource_filter} - {operation_filter} - ORDER BY change_event.change_date_time DESC - LIMIT {limit} - """ - rows = execute_query(config, customer_id, query) return {"changes": rows, "total_changes": len(rows)} @@ -687,12 +674,6 @@ def _date_clause(start: str, end: str) -> str: return "AND segments.date DURING LAST_30_DAYS" -def _change_event_date_clause(start: str, end: str) -> str: - """Build a date clause for the change_event resource.""" - if start and end: - return f"AND change_event.change_date_time BETWEEN '{start}' AND '{end}'" - return "AND change_event.change_date_time DURING LAST_14_DAYS" - def _enrich_cost_fields(rows: list[dict]) -> None: """Add human-readable cost and CPA fields computed from cost_micros.""" From e9b183075dd85f77ee9e4f98bf5b1a1d16784b5f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 16:50:39 +0000 Subject: [PATCH 14/36] Fix change_history end-of-day timestamp and clamp limit to API max Codex review feedback: - P1: change_date_time is timestamp-based, so BETWEEN with date-only end bound truncates at midnight. Now appends 23:59:59 to bare dates. - P2: change_event has a hard 10,000 row API limit. Now clamps caller- provided limit to 1..10000 before interpolating into the query. https://claude.ai/code/session_01EtMthH7NLzMFcy6hsDUfQt --- src/adloop/ads/read.py | 14 ++++++++++--- tests/test_read_tools.py | 44 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/adloop/ads/read.py b/src/adloop/ads/read.py index e4a3177..202074b 100644 --- a/src/adloop/ads/read.py +++ b/src/adloop/ads/read.py @@ -287,6 +287,9 @@ def get_change_history( """Get account change history from the change_event resource.""" from adloop.ads.gaql import execute_query + # change_event has a hard API max of 10,000 rows. + limit = max(1, min(limit, 10_000)) + resource_filter = "" if resource_type: resource_filter = ( @@ -299,11 +302,16 @@ def get_change_history( f"AND change_event.resource_change_operation = '{operation_type}'" ) - # change_event uses change_date_time, not segments.date. + # change_event uses change_date_time (a timestamp), not segments.date. + # A bare date like '2026-03-27' means midnight, which misses the rest + # of that day. Append end-of-day time when only a date is provided. if date_range_start and date_range_end: + end = date_range_end + if "T" not in end and " " not in end: + end = f"{end} 23:59:59" date_where = ( - f"change_event.change_date_time BETWEEN " - f"'{date_range_start}' AND '{date_range_end}'" + f"change_event.change_date_time >= '{date_range_start}'" + f" AND change_event.change_date_time <= '{end}'" ) else: date_where = "change_event.change_date_time DURING LAST_14_DAYS" diff --git a/tests/test_read_tools.py b/tests/test_read_tools.py index 742fb2e..4de806d 100644 --- a/tests/test_read_tools.py +++ b/tests/test_read_tools.py @@ -164,7 +164,7 @@ def test_operation_type_filter(self, mock_query, config): assert "resource_change_operation = 'UPDATE'" in call_query @patch("adloop.ads.gaql.execute_query") - def test_date_range_override(self, mock_query, config): + def test_date_range_appends_end_of_day(self, mock_query, config): mock_query.return_value = [] get_change_history( @@ -175,9 +175,49 @@ def test_date_range_override(self, mock_query, config): ) call_query = mock_query.call_args[0][2] - assert "BETWEEN '2026-03-01' AND '2026-03-27'" in call_query + # End date should have 23:59:59 appended for timestamp comparison + assert "2026-03-27 23:59:59" in call_query + assert ">= '2026-03-01'" in call_query assert "DURING LAST_14_DAYS" not in call_query + @patch("adloop.ads.gaql.execute_query") + def test_date_range_preserves_explicit_time(self, mock_query, config): + mock_query.return_value = [] + + get_change_history( + config, + customer_id="1234567890", + date_range_start="2026-03-01", + date_range_end="2026-03-27T15:00:00", + ) + + call_query = mock_query.call_args[0][2] + # Should NOT append 23:59:59 when caller already provided a time + assert "2026-03-27T15:00:00" in call_query + assert "23:59:59" not in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_limit_clamped_to_api_max(self, mock_query, config): + mock_query.return_value = [] + + get_change_history( + config, customer_id="1234567890", limit=50_000 + ) + + call_query = mock_query.call_args[0][2] + assert "LIMIT 10000" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_limit_clamped_to_minimum(self, mock_query, config): + mock_query.return_value = [] + + get_change_history( + config, customer_id="1234567890", limit=-5 + ) + + call_query = mock_query.call_args[0][2] + assert "LIMIT 1" in call_query + @patch("adloop.ads.gaql.execute_query") def test_empty_results(self, mock_query, config): mock_query.return_value = [] From b724ff338403d0ff64d76dc38d607b391088514b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 17:21:29 +0000 Subject: [PATCH 15/36] Add MCP tool enhancements for ppc-search-checkin skill - run_ga4_report: add dimension_filter parameter for server-side filtering (e.g. sessionSource=google + sessionMedium=cpc for paid traffic isolation) - get_keyword_performance: add ad_group.id and criterion_id to GAQL SELECT so callers can construct entity_id strings for pause_entity - get_search_terms: add optional campaign_id filter parameter - get_ad_schedule_performance: add conversion_rate enrichment via existing _enrich_conversion_rate function - validate_tracking: add optional customer_id to cross-reference GA4 events with Google Ads conversion actions - Update server.py wrappers, docstrings, and orchestration rules - Add tests for all new functionality (101 tests passing) https://claude.ai/code/session_01Hbuiiebx4iQBDnnUtcknFP --- .claude/rules/adloop.md | 93 ++++++----------------------------- .cursor/rules/adloop.mdc | 10 ++-- src/adloop/ads/read.py | 24 ++++----- src/adloop/ga4/reports.py | 32 +++++++++++- src/adloop/server.py | 29 +++++++++-- src/adloop/tracking.py | 52 +++++++++++++++++++- tests/test_ga4_reports.py | 95 +++++++++++++++++++++++++++++++++++ tests/test_read_tools.py | 97 ++++++++++++++++++++++++++++++++++++ tests/test_tracking.py | 101 ++++++++++++++++++++++++++++++++++++++ 9 files changed, 433 insertions(+), 100 deletions(-) create mode 100644 tests/test_ga4_reports.py create mode 100644 tests/test_tracking.py diff --git a/.claude/rules/adloop.md b/.claude/rules/adloop.md index 18557c9..daec4f5 100644 --- a/.claude/rules/adloop.md +++ b/.claude/rules/adloop.md @@ -1,3 +1,6 @@ +--- +description: AdLoop MCP orchestration — Google Ads + GA4 + codebase intelligence +--- # AdLoop — AI Orchestration Rules @@ -18,10 +21,12 @@ You have access to AdLoop MCP tools that connect Google Ads and Google Analytics | Tool | When to Use | Key Parameters | |------|-------------|----------------| | `get_account_summaries` | First-time discovery — find which GA4 properties exist | (none — uses config) | -| `run_ga4_report` | Any analytics question — sessions, users, conversions, page performance | `dimensions`, `metrics`, `date_range_start`, `date_range_end`, `limit` | +| `run_ga4_report` | Any analytics question — sessions, users, conversions, page performance | `dimensions`, `metrics`, `date_range_start`, `date_range_end`, `limit`, `dimension_filter` | | `run_realtime_report` | After code deploys — verify tracking fires correctly | `dimensions`, `metrics` | | `get_tracking_events` | Understanding what events are configured and their volume | `date_range_start`, `date_range_end` | +**GA4 report filtering:** `run_ga4_report` supports a `dimension_filter` parameter — a dict of `dimension_name -> exact_value` pairs combined with AND logic. Use `{"sessionSource": "google", "sessionMedium": "cpc"}` to isolate paid search traffic server-side instead of pulling all sources and filtering in post-processing. + ### Google Ads Read Tools | Tool | When to Use | Key Parameters | @@ -30,7 +35,7 @@ You have access to AdLoop MCP tools that connect Google Ads and Google Analytics | `get_campaign_performance` | Campaign-level metrics — impressions, clicks, cost, conversions | `date_range_start`, `date_range_end` | | `get_ad_performance` | Ad copy analysis — which headlines/descriptions work | `date_range_start`, `date_range_end` | | `get_keyword_performance` | Keyword analysis — quality scores, competitive metrics | `date_range_start`, `date_range_end` | -| `get_search_terms` | Find negative keyword opportunities and understand user intent | `date_range_start`, `date_range_end` | +| `get_search_terms` | Find negative keyword opportunities and understand user intent | `date_range_start`, `date_range_end`, `campaign_id` (optional) | | `get_negative_keywords` | List existing negative keywords for a campaign or all campaigns | `campaign_id` (optional) | | `run_gaql` | Custom queries not covered by other tools | `query`, `format` (table/json/csv) | @@ -38,29 +43,8 @@ You have access to AdLoop MCP tools that connect Google Ads and Google Analytics - Ads read tools automatically compute `metrics.cost` (EUR) and `metrics.cpa` from `metrics.cost_micros` — no manual division needed. - `metrics.average_cpc_eur` is also pre-computed where available. - `get_ad_performance` returns full `headlines` and `descriptions` lists for RSAs. - -### Google Ads Insights Tools - -| Tool | When to Use | Key Parameters | -|------|-------------|----------------| -| `get_impression_share` | Check-ins, "why aren't my ads showing?", visibility analysis | `level` (campaign/ad_group/keyword), `date_range_start`, `date_range_end` | -| `get_change_history` | Correlate performance shifts with account changes | `resource_type`, `operation_type`, `date_range_start`, `date_range_end`, `limit` | -| `get_device_performance` | Mobile vs desktop analysis, local service businesses | `level` (campaign/ad_group), `date_range_start`, `date_range_end` | -| `get_location_performance` | Geographic analysis, local service area optimization | `date_range_start`, `date_range_end` | -| `get_quality_score_details` | Deep keyword quality analysis with component breakdowns | `campaign_id` (optional), `date_range_start`, `date_range_end` | -| `get_bid_strategy_status` | Check learning status before making changes | `campaign_id` (optional) | -| `get_budget_pacing` | Monthly budget tracking, over/under pacing | `campaign_id` (optional) | -| `get_ad_schedule_performance` | Hour/day performance patterns for scheduling optimization | `campaign_id` (optional), `date_range_start`, `date_range_end` | -| `get_auction_insights` | Competitive analysis (requires allowlisted account) | `campaign_id` (optional), `date_range_start`, `date_range_end` | - -**Insights tool notes:** -- `get_impression_share` adds `_pct` suffixed fields (e.g. `metrics.search_impression_share_pct` = "45.0%") alongside raw fractions. -- `get_change_history` uses `change_event.change_date_time` (not `segments.date`). Max 30 days back. Default last 14 days. -- `get_device_performance` adds `metrics.conversion_rate` (percentage). -- `get_quality_score_details` returns component scores: `creative_quality_score` (ad relevance), `post_click_quality_score` (landing page), `search_predicted_ctr` (expected CTR). Values are ABOVE_AVERAGE, AVERAGE, or BELOW_AVERAGE. -- `get_bid_strategy_status` shows `campaign.bidding_strategy_system_status` — check for LEARNING or LEARNING_LIMITED before recommending changes. -- `get_budget_pacing` computes `pace_pct` (100% = on track, >100% = overspending, <100% = underspending). -- `get_auction_insights` may return an error dict if the account is not allowlisted — always check for the `"error"` key. +- `get_keyword_performance` returns `ad_group.id` and `ad_group_criterion.criterion_id` — use these to construct `entity_id` strings (e.g. `"adGroupId~criterionId"`) for `pause_entity` calls. +- `get_search_terms` returns `metrics.cost` per search term — use for negative keyword analysis (flag terms spending > 2-3x CPA with zero conversions). ### Cross-Reference Tools (GA4 + Ads combined) @@ -76,7 +60,7 @@ These tools call both APIs internally and return unified results with computed ` | Tool | When to Use | Key Parameters | |------|-------------|----------------| -| `validate_tracking` | Compare codebase event code against actual GA4 events — find missing/broken tracking | `expected_events` (list of event names found in code), `date_range_start`, `date_range_end` | +| `validate_tracking` | Compare codebase event code against actual GA4 events — find missing/broken tracking | `expected_events` (list of event names found in code), `date_range_start`, `date_range_end`, `customer_id` (optional — cross-refs Ads conversion actions) | | `generate_tracking_code` | Generate ready-to-paste GA4 gtag JavaScript for an event | `event_name`, `event_params` (optional), `trigger` (form_submit/button_click/page_load) | `validate_tracking` requires the AI to first search the codebase for `gtag('event', ...)` or `dataLayer.push({event: ...})` calls, extract event names, then pass them to the tool. The tool queries GA4 and returns a structured comparison (matched, missing, unexpected, auto-collected). @@ -169,13 +153,11 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has ### When user asks about performance or "how are my ads doing" 1. Call `get_campaign_performance` for the relevant date range -2. Call `get_impression_share` to see visibility and lost opportunity -3. Call `get_bid_strategy_status` to check learning status and strategy health -4. If they mention conversions, CPA, or "is it worth it", call `analyze_campaign_conversions` instead — it gives Ads + GA4 data in one call with GDPR-aware cost-per-conversion -5. If they mention specific keywords or search terms, also call `get_keyword_performance` or `get_search_terms` -6. Present a summary with the key metrics: spend (`metrics.cost`), clicks, conversions, CPA (`metrics.cpa`), CTR, impression share -7. Highlight anything concerning: zero conversions, high CPA, low quality scores, wasteful search terms, high budget-lost IS, campaigns in learning phase -8. Compare against best practices (see Marketing Best Practices section) +2. If they mention conversions, CPA, or "is it worth it", call `analyze_campaign_conversions` instead — it gives Ads + GA4 data in one call with GDPR-aware cost-per-conversion +3. If they mention specific keywords or search terms, also call `get_keyword_performance` or `get_search_terms` +4. Present a summary with the key metrics: spend (`metrics.cost`), clicks, conversions, CPA (`metrics.cpa`), CTR +5. Highlight anything concerning: zero conversions, high CPA, low quality scores, wasteful search terms +6. Compare against best practices (see Marketing Best Practices section) ### When user asks about conversions or conversion drops @@ -337,49 +319,6 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has 2. Compare `campaigns[].ga4_conversion_rate` (paid) vs `non_paid_channels[].conversion_rate` (organic/direct/referral) 3. If paid conversion rate is significantly lower, investigate landing page relevance and ad targeting before increasing spend -### When user asks about impression share or "why aren't my ads showing" - -1. Call `get_impression_share` at campaign level for the relevant date range -2. If `search_budget_lost_impression_share` is high → budget is the bottleneck, recommend a budget increase or narrower targeting -3. If `search_rank_lost_impression_share` is high → ad rank is the issue, check quality scores via `get_quality_score_details` and consider bid strategy changes -4. For keyword-level drill-down, call `get_impression_share` with `level="keyword"` -5. Check `search_top_impression_share` — if the user wants to appear at the top of the page, this shows how often they do - -### When user asks why performance changed or "what happened" - -1. Call `get_change_history` for the relevant date range to see what was modified -2. Call `get_campaign_performance` to see the performance shift -3. Correlate: did a bid strategy change, budget change, or keyword modification coincide with the performance shift? -4. If no account changes explain it, investigate external factors: seasonality, competitor moves (via `get_auction_insights` if available), or tracking issues - -### When user asks about mobile vs desktop performance - -1. Call `get_device_performance` for the relevant date range -2. Compare CPA and conversion rates across MOBILE, DESKTOP, TABLET -3. For local service businesses, highlight mobile performance — mobile users have higher intent (calling, directions) -4. If one device has significantly worse CPA, consider device bid adjustments or separate campaigns - -### When user asks about geographic or location performance - -1. Call `get_location_performance` for the relevant date range -2. Identify locations with high spend and low/zero conversions — potential waste -3. Compare against the user's target service areas -4. Recommend negative location targeting for areas outside the service radius - -### When user asks about budget pacing or "am I spending too much/little" - -1. Call `get_budget_pacing` to see month-to-date spend vs budget -2. If `pace_pct` > 110%: campaign is overspending — may run out of budget before month end -3. If `pace_pct` < 80%: campaign is underspending — budget may be too high or targeting too narrow -4. Cross-reference with `get_impression_share` — if budget-lost IS is high and pace is low, something is off - -### When user asks about ad scheduling or "when should my ads run" - -1. Call `get_ad_schedule_performance` for the relevant date range (ideally last 30+ days for enough data) -2. Identify hours and days with highest conversions and lowest CPA -3. Identify off-peak hours with high spend and zero conversions -4. Recommend ad schedule adjustments or bid modifiers for peak/off-peak hours - ## Default Parameters When the user doesn't specify: @@ -415,8 +354,6 @@ LIMIT n | `campaign_budget` | Budget information | | `bidding_strategy` | Bidding strategy details | | `customer_client` | List accounts under an MCC (uses login_customer_id) | -| `change_event` | Account change history (max 30 days back) | -| `geographic_view` | Performance by geographic location | ### Common Fields diff --git a/.cursor/rules/adloop.mdc b/.cursor/rules/adloop.mdc index 1fe9915..d448d9c 100644 --- a/.cursor/rules/adloop.mdc +++ b/.cursor/rules/adloop.mdc @@ -23,10 +23,12 @@ You have access to AdLoop MCP tools that connect Google Ads and Google Analytics | Tool | When to Use | Key Parameters | |------|-------------|----------------| | `get_account_summaries` | First-time discovery — find which GA4 properties exist | (none — uses config) | -| `run_ga4_report` | Any analytics question — sessions, users, conversions, page performance | `dimensions`, `metrics`, `date_range_start`, `date_range_end`, `limit` | +| `run_ga4_report` | Any analytics question — sessions, users, conversions, page performance | `dimensions`, `metrics`, `date_range_start`, `date_range_end`, `limit`, `dimension_filter` | | `run_realtime_report` | After code deploys — verify tracking fires correctly | `dimensions`, `metrics` | | `get_tracking_events` | Understanding what events are configured and their volume | `date_range_start`, `date_range_end` | +**GA4 report filtering:** `run_ga4_report` supports a `dimension_filter` parameter — a dict of `dimension_name -> exact_value` pairs combined with AND logic. Use `{"sessionSource": "google", "sessionMedium": "cpc"}` to isolate paid search traffic server-side instead of pulling all sources and filtering in post-processing. + ### Google Ads Read Tools | Tool | When to Use | Key Parameters | @@ -35,7 +37,7 @@ You have access to AdLoop MCP tools that connect Google Ads and Google Analytics | `get_campaign_performance` | Campaign-level metrics — impressions, clicks, cost, conversions | `date_range_start`, `date_range_end` | | `get_ad_performance` | Ad copy analysis — which headlines/descriptions work | `date_range_start`, `date_range_end` | | `get_keyword_performance` | Keyword analysis — quality scores, competitive metrics | `date_range_start`, `date_range_end` | -| `get_search_terms` | Find negative keyword opportunities and understand user intent | `date_range_start`, `date_range_end` | +| `get_search_terms` | Find negative keyword opportunities and understand user intent | `date_range_start`, `date_range_end`, `campaign_id` (optional) | | `get_negative_keywords` | List existing negative keywords for a campaign or all campaigns | `campaign_id` (optional) | | `run_gaql` | Custom queries not covered by other tools | `query`, `format` (table/json/csv) | @@ -43,6 +45,8 @@ You have access to AdLoop MCP tools that connect Google Ads and Google Analytics - Ads read tools automatically compute `metrics.cost` (EUR) and `metrics.cpa` from `metrics.cost_micros` — no manual division needed. - `metrics.average_cpc_eur` is also pre-computed where available. - `get_ad_performance` returns full `headlines` and `descriptions` lists for RSAs. +- `get_keyword_performance` returns `ad_group.id` and `ad_group_criterion.criterion_id` — use these to construct `entity_id` strings (e.g. `"adGroupId~criterionId"`) for `pause_entity` calls. +- `get_search_terms` returns `metrics.cost` per search term — use for negative keyword analysis (flag terms spending > 2-3x CPA with zero conversions). ### Cross-Reference Tools (GA4 + Ads combined) @@ -58,7 +62,7 @@ These tools call both APIs internally and return unified results with computed ` | Tool | When to Use | Key Parameters | |------|-------------|----------------| -| `validate_tracking` | Compare codebase event code against actual GA4 events — find missing/broken tracking | `expected_events` (list of event names found in code), `date_range_start`, `date_range_end` | +| `validate_tracking` | Compare codebase event code against actual GA4 events — find missing/broken tracking | `expected_events` (list of event names found in code), `date_range_start`, `date_range_end`, `customer_id` (optional — cross-refs Ads conversion actions) | | `generate_tracking_code` | Generate ready-to-paste GA4 gtag JavaScript for an event | `event_name`, `event_params` (optional), `trigger` (form_submit/button_click/page_load) | `validate_tracking` requires the AI to first search the codebase for `gtag('event', ...)` or `dataLayer.push({event: ...})` calls, extract event names, then pass them to the tool. The tool queries GA4 and returns a structured comparison (matched, missing, unexpected, auto-collected). diff --git a/src/adloop/ads/read.py b/src/adloop/ads/read.py index 202074b..c807d17 100644 --- a/src/adloop/ads/read.py +++ b/src/adloop/ads/read.py @@ -108,7 +108,8 @@ def get_keyword_performance( date_clause = _date_clause(date_range_start, date_range_end) query = f""" - SELECT campaign.name, ad_group.name, + SELECT campaign.name, ad_group.name, ad_group.id, + ad_group_criterion.criterion_id, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type, ad_group_criterion.quality_info.quality_score, @@ -133,23 +134,15 @@ def get_search_terms( customer_id: str = "", date_range_start: str = "", date_range_end: str = "", + campaign_id: str = "", ) -> dict: """Get search terms report — what users actually typed before clicking ads.""" from adloop.ads.gaql import execute_query - date_clause = _date_clause(date_range_start, date_range_end) + campaign_filter = "" + if campaign_id: + campaign_filter = f"AND campaign.id = {campaign_id}" - query = f""" - SELECT search_term_view.search_term, - campaign.name, ad_group.name, - metrics.impressions, metrics.clicks, - metrics.cost_micros, metrics.conversions - FROM search_term_view - WHERE segments.date DURING LAST_30_DAYS - {f"AND segments.date BETWEEN '{date_range_start}' AND '{date_range_end}'" if date_range_start and date_range_end else ""} - ORDER BY metrics.clicks DESC - LIMIT 200 - """ # search_term_view requires an explicit date segment, so we always # include DURING LAST_30_DAYS as baseline and override if dates given. if date_range_start and date_range_end: @@ -160,17 +153,19 @@ def get_search_terms( metrics.cost_micros, metrics.conversions FROM search_term_view WHERE segments.date BETWEEN '{date_range_start}' AND '{date_range_end}' + {campaign_filter} ORDER BY metrics.clicks DESC LIMIT 200 """ else: - query = """ + query = f""" SELECT search_term_view.search_term, campaign.name, ad_group.name, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions FROM search_term_view WHERE segments.date DURING LAST_30_DAYS + {campaign_filter} ORDER BY metrics.clicks DESC LIMIT 200 """ @@ -611,6 +606,7 @@ def get_ad_schedule_performance( rows = execute_query(config, customer_id, query) _enrich_cost_fields(rows) + _enrich_conversion_rate(rows) return {"schedule_performance": rows, "total_rows": len(rows)} diff --git a/src/adloop/ga4/reports.py b/src/adloop/ga4/reports.py index 6425d8f..9b7f636 100644 --- a/src/adloop/ga4/reports.py +++ b/src/adloop/ga4/reports.py @@ -45,11 +45,20 @@ def run_ga4_report( date_range_start: str = "7daysAgo", date_range_end: str = "today", limit: int = 100, + dimension_filter: dict[str, str] | None = None, ) -> dict: - """Run a GA4 report with specified dimensions, metrics, and date range.""" + """Run a GA4 report with specified dimensions, metrics, and date range. + + dimension_filter: optional dict of dimension_name -> exact match value. + Multiple entries are combined with AND logic. Example: + {"sessionSource": "google", "sessionMedium": "cpc"} filters to paid search. + """ from google.analytics.data_v1beta.types import ( DateRange, Dimension, + Filter, + FilterExpression, + FilterExpressionList, Metric, RunReportRequest, ) @@ -69,6 +78,27 @@ def run_ga4_report( limit=limit, ) + if dimension_filter: + filter_exprs = [] + for field_name, value in dimension_filter.items(): + filter_exprs.append( + FilterExpression( + filter=Filter( + field_name=field_name, + string_filter=Filter.StringFilter( + value=value, + match_type=Filter.StringFilter.MatchType.EXACT, + ), + ) + ) + ) + if len(filter_exprs) == 1: + request.dimension_filter = filter_exprs[0] + else: + request.dimension_filter = FilterExpression( + and_group=FilterExpressionList(expressions=filter_exprs) + ) + response = client.run_report(request) dim_headers = [h.name for h in response.dimension_headers] diff --git a/src/adloop/server.py b/src/adloop/server.py index 5a3a236..0d2f548 100644 --- a/src/adloop/server.py +++ b/src/adloop/server.py @@ -144,6 +144,7 @@ def run_ga4_report( date_range_end: str = "today", property_id: str = "", limit: int = 100, + dimension_filter: dict[str, str] | None = None, ) -> dict: """Run a custom GA4 report with specified dimensions, metrics, and date range. @@ -152,6 +153,11 @@ def run_ga4_report( Date formats: "today", "yesterday", "7daysAgo", "28daysAgo", "90daysAgo", or "YYYY-MM-DD". If property_id is empty, uses the default from config. + + dimension_filter: optional dict of dimension_name -> exact match value to + filter results server-side. Multiple entries are combined with AND logic. + Example: {"sessionSource": "google", "sessionMedium": "cpc"} returns only + paid search traffic. """ from adloop.ga4.reports import run_ga4_report as _impl @@ -163,6 +169,7 @@ def run_ga4_report( date_range_start=date_range_start, date_range_end=date_range_end, limit=limit, + dimension_filter=dimension_filter, ) @@ -283,8 +290,10 @@ def get_keyword_performance( ) -> dict: """Get keyword metrics including quality scores and competitive data. - Returns: keyword text, match type, quality score, impressions, - clicks, CTR, CPC, conversions for each keyword. + Returns: keyword text, match type, quality score, ad_group_id, criterion_id, + impressions, clicks, CTR, CPC, cost, conversions for each keyword. + The ad_group_id and criterion_id can be used to construct entity_id + strings (e.g. "adGroupId~criterionId") for pause_entity calls. """ from adloop.ads.read import get_keyword_performance as _impl @@ -302,11 +311,15 @@ def get_search_terms( customer_id: str = "", date_range_start: str = "", date_range_end: str = "", + campaign_id: str = "", ) -> dict: """Get search terms report — what users actually typed before clicking your ads. Critical for finding negative keyword opportunities and understanding user intent. - Returns: search term, campaign, ad group, impressions, clicks, conversions. + Returns: search term, campaign, ad group, impressions, clicks, cost, conversions. + + campaign_id: optional filter to a specific campaign. When omitted, returns + search terms across all campaigns. """ from adloop.ads.read import get_search_terms as _impl @@ -315,6 +328,7 @@ def get_search_terms( customer_id=customer_id or _config.ads.customer_id, date_range_start=date_range_start, date_range_end=date_range_end, + campaign_id=campaign_id, ) @@ -543,6 +557,9 @@ def get_ad_schedule_performance( Identifies peak and off-peak patterns. Important for local service businesses (e.g. emergency plumber at 2am vs 2pm). + Returns: campaign, day_of_week, hour, impressions, clicks, CTR, cost, + conversions, conversion_rate, CPA for each time slot. + campaign_id: optional filter to a specific campaign. Date format: "YYYY-MM-DD". Empty = last 30 days. """ @@ -1109,6 +1126,7 @@ def validate_tracking( property_id: str = "", date_range_start: str = "28daysAgo", date_range_end: str = "today", + customer_id: str = "", ) -> dict: """Compare tracking events found in the codebase against actual GA4 data. @@ -1118,6 +1136,10 @@ def validate_tracking( Returns: matched events, events missing from GA4, unexpected GA4 events, and auto-collected events (page_view, session_start, etc.). + + customer_id: optional — when provided, also pulls Google Ads conversion + actions and checks which expected events have matching Ads conversion + actions configured. """ from adloop.tracking import validate_tracking as _impl @@ -1127,6 +1149,7 @@ def validate_tracking( property_id=property_id or _config.ga4.property_id, date_range_start=date_range_start, date_range_end=date_range_end, + customer_id=customer_id, ) diff --git a/src/adloop/tracking.py b/src/adloop/tracking.py index 083c9eb..f24484e 100644 --- a/src/adloop/tracking.py +++ b/src/adloop/tracking.py @@ -72,12 +72,17 @@ def validate_tracking( property_id: str = "", date_range_start: str = "28daysAgo", date_range_end: str = "today", + customer_id: str = "", ) -> dict: """Compare expected tracking events (from codebase) against actual GA4 data. The AI searches the user's codebase for gtag/dataLayer event calls, extracts event names, and passes them here. This tool queries GA4 for actual events and returns a structured comparison. + + When customer_id is provided, also pulls Google Ads conversion actions and + includes them in the comparison — showing which expected events have a + matching Ads conversion action and which are missing. """ from adloop.ga4.tracking import get_tracking_events @@ -135,7 +140,49 @@ def validate_tracking( f"zero count — it may not be triggering for real users." ) - return { + # Optionally cross-reference with Google Ads conversion actions + ads_conversion_actions: list[dict] = [] + if customer_id: + from adloop.ads.gaql import execute_query + + conv_query = """ + SELECT conversion_action.name, conversion_action.type, + conversion_action.status + FROM conversion_action + WHERE conversion_action.status = 'ENABLED' + """ + try: + conv_rows = execute_query(config, customer_id, conv_query) + ads_conversion_names: set[str] = set() + for row in conv_rows: + name = row.get("conversion_action.name", "") + ads_conversion_names.add(name) + ads_conversion_actions.append({ + "name": name, + "type": row.get("conversion_action.type", ""), + }) + + matched_in_ads = sorted(expected_set & ads_conversion_names) + missing_from_ads = sorted(expected_set - ads_conversion_names) + + if missing_from_ads: + insights.append( + f"{len(missing_from_ads)} expected event(s) have no matching " + f"Google Ads conversion action: {', '.join(missing_from_ads)}. " + f"These events may fire in GA4 but are not imported as Ads conversions." + ) + if matched_in_ads: + insights.append( + f"{len(matched_in_ads)} expected event(s) match Ads conversion " + f"actions: {', '.join(matched_in_ads)}." + ) + except Exception: + insights.append( + "Could not retrieve Google Ads conversion actions — " + "check customer_id and API access." + ) + + result = { "matched": matched, "missing_from_ga4": missing_from_ga4, "unexpected_in_ga4": unexpected, @@ -145,6 +192,9 @@ def validate_tracking( "start": date_range_start, "end": date_range_end, }), } + if ads_conversion_actions: + result["ads_conversion_actions"] = ads_conversion_actions + return result # --------------------------------------------------------------------------- diff --git a/tests/test_ga4_reports.py b/tests/test_ga4_reports.py new file mode 100644 index 0000000..d931504 --- /dev/null +++ b/tests/test_ga4_reports.py @@ -0,0 +1,95 @@ +"""Tests for GA4 report tools.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from adloop.config import AdLoopConfig, GA4Config, SafetyConfig +from adloop.ga4.reports import run_ga4_report + + +@pytest.fixture +def config(): + return AdLoopConfig( + ga4=GA4Config(property_id="properties/123456"), + safety=SafetyConfig(max_daily_budget=50.0, require_dry_run=True), + ) + + +def _mock_response(rows=None): + """Build a mock RunReportResponse.""" + response = MagicMock() + response.dimension_headers = [MagicMock(name="sessionSource")] + response.metric_headers = [MagicMock(name="sessions")] + response.row_count = len(rows or []) + response.rows = rows or [] + # Fix the .name property on headers (MagicMock name= is special) + response.dimension_headers[0].name = "sessionSource" + response.metric_headers[0].name = "sessions" + return response + + +class TestRunGa4Report: + @patch("adloop.ga4.client.get_data_client") + def test_no_filter(self, mock_client_fn, config): + mock_client = MagicMock() + mock_client.run_report.return_value = _mock_response() + mock_client_fn.return_value = mock_client + + run_ga4_report( + config, + property_id="properties/123456", + dimensions=["sessionSource"], + metrics=["sessions"], + ) + + request = mock_client.run_report.call_args[0][0] + # No dimension_filter should be set + assert not request.dimension_filter.filter.field_name + + @patch("adloop.ga4.client.get_data_client") + def test_single_filter(self, mock_client_fn, config): + mock_client = MagicMock() + mock_client.run_report.return_value = _mock_response() + mock_client_fn.return_value = mock_client + + run_ga4_report( + config, + property_id="properties/123456", + dimensions=["sessionSource"], + metrics=["sessions"], + dimension_filter={"sessionSource": "google"}, + ) + + request = mock_client.run_report.call_args[0][0] + dim_filter = request.dimension_filter + assert dim_filter.filter.field_name == "sessionSource" + assert dim_filter.filter.string_filter.value == "google" + + @patch("adloop.ga4.client.get_data_client") + def test_multiple_filters(self, mock_client_fn, config): + mock_client = MagicMock() + mock_client.run_report.return_value = _mock_response() + mock_client_fn.return_value = mock_client + + run_ga4_report( + config, + property_id="properties/123456", + dimensions=["sessionSource", "sessionMedium"], + metrics=["sessions"], + dimension_filter={"sessionSource": "google", "sessionMedium": "cpc"}, + ) + + request = mock_client.run_report.call_args[0][0] + dim_filter = request.dimension_filter + # Multiple filters should be wrapped in and_group + assert len(dim_filter.and_group.expressions) == 2 + field_names = { + expr.filter.field_name + for expr in dim_filter.and_group.expressions + } + assert field_names == {"sessionSource", "sessionMedium"} + + def test_no_dimensions_or_metrics(self, config): + result = run_ga4_report(config, property_id="properties/123456") + assert result == {"error": "At least one dimension or metric must be specified."} diff --git a/tests/test_read_tools.py b/tests/test_read_tools.py index 4de806d..6d9ab9d 100644 --- a/tests/test_read_tools.py +++ b/tests/test_read_tools.py @@ -12,8 +12,10 @@ get_change_history, get_device_performance, get_impression_share, + get_keyword_performance, get_location_performance, get_quality_score_details, + get_search_terms, ) from adloop.config import AdLoopConfig, AdsConfig, SafetyConfig @@ -548,6 +550,8 @@ def test_default_query(self, mock_query, config): assert row["segments.day_of_week"] == "MONDAY" assert row["segments.hour"] == 9 assert row["metrics.cost"] == 5.0 + assert row["metrics.conversion_rate"] == 10.0 # 1/10 * 100 + assert row["metrics.cpa"] == 5.0 # 5.0 / 1 @patch("adloop.ads.gaql.execute_query") def test_campaign_filter(self, mock_query, config): @@ -635,3 +639,96 @@ def test_empty_results(self, mock_query, config): assert result["auction_insights"] == [] assert result["total_rows"] == 0 + + +# --------------------------------------------------------------------------- +# get_keyword_performance +# --------------------------------------------------------------------------- + + +class TestGetKeywordPerformance: + @patch("adloop.ads.gaql.execute_query") + def test_returns_ids(self, mock_query, config): + mock_query.return_value = [ + { + "campaign.name": "Campaign A", + "ad_group.name": "Ad Group 1", + "ad_group.id": 555, + "ad_group_criterion.criterion_id": 777, + "ad_group_criterion.keyword.text": "test keyword", + "ad_group_criterion.keyword.match_type": "EXACT", + "ad_group_criterion.quality_info.quality_score": 7, + "metrics.impressions": 500, + "metrics.clicks": 50, + "metrics.ctr": 0.1, + "metrics.average_cpc": 1_000_000, + "metrics.cost_micros": 50_000_000, + "metrics.conversions": 2, + } + ] + + result = get_keyword_performance(config, customer_id="1234567890") + + assert result["total_keywords"] == 1 + row = result["keywords"][0] + assert row["ad_group.id"] == 555 + assert row["ad_group_criterion.criterion_id"] == 777 + assert row["metrics.cost"] == 50.0 + assert row["metrics.cpa"] == 25.0 + + @patch("adloop.ads.gaql.execute_query") + def test_query_includes_id_fields(self, mock_query, config): + mock_query.return_value = [] + + get_keyword_performance(config, customer_id="1234567890") + + call_query = mock_query.call_args[0][2] + assert "ad_group.id" in call_query + assert "ad_group_criterion.criterion_id" in call_query + + +# --------------------------------------------------------------------------- +# get_search_terms +# --------------------------------------------------------------------------- + + +class TestGetSearchTerms: + @patch("adloop.ads.gaql.execute_query") + def test_default_query(self, mock_query, config): + mock_query.return_value = [ + { + "search_term_view.search_term": "test query", + "campaign.name": "Campaign A", + "ad_group.name": "Ad Group 1", + "metrics.impressions": 100, + "metrics.clicks": 10, + "metrics.cost_micros": 5_000_000, + "metrics.conversions": 1, + } + ] + + result = get_search_terms(config, customer_id="1234567890") + + assert result["total_search_terms"] == 1 + row = result["search_terms"][0] + assert row["metrics.cost"] == 5.0 + + @patch("adloop.ads.gaql.execute_query") + def test_campaign_filter(self, mock_query, config): + mock_query.return_value = [] + + get_search_terms( + config, customer_id="1234567890", campaign_id="999" + ) + + call_query = mock_query.call_args[0][2] + assert "campaign.id = 999" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_no_campaign_filter_by_default(self, mock_query, config): + mock_query.return_value = [] + + get_search_terms(config, customer_id="1234567890") + + call_query = mock_query.call_args[0][2] + assert "campaign.id" not in call_query diff --git a/tests/test_tracking.py b/tests/test_tracking.py new file mode 100644 index 0000000..dee31e4 --- /dev/null +++ b/tests/test_tracking.py @@ -0,0 +1,101 @@ +"""Tests for tracking validation tools.""" + +from unittest.mock import patch + +import pytest + +from adloop.config import AdLoopConfig, AdsConfig, GA4Config, SafetyConfig +from adloop.tracking import validate_tracking + + +@pytest.fixture +def config(): + return AdLoopConfig( + ads=AdsConfig(customer_id="1234567890", developer_token="test"), + ga4=GA4Config(property_id="properties/123456"), + safety=SafetyConfig(max_daily_budget=50.0, require_dry_run=True), + ) + + +class TestValidateTracking: + @patch("adloop.ga4.tracking.get_tracking_events") + def test_without_customer_id(self, mock_ga4, config): + mock_ga4.return_value = { + "rows": [ + {"eventName": "purchase", "eventCount": "50"}, + {"eventName": "page_view", "eventCount": "1000"}, + ], + "date_range": {"start": "28daysAgo", "end": "today"}, + } + + result = validate_tracking( + config, + expected_events=["purchase", "sign_up"], + ) + + assert len(result["matched"]) == 1 + assert result["matched"][0]["event_name"] == "purchase" + assert "sign_up" in result["missing_from_ga4"] + assert "ads_conversion_actions" not in result + + @patch("adloop.ads.gaql.execute_query") + @patch("adloop.ga4.tracking.get_tracking_events") + def test_with_customer_id(self, mock_ga4, mock_gaql, config): + mock_ga4.return_value = { + "rows": [ + {"eventName": "purchase", "eventCount": "50"}, + ], + "date_range": {"start": "28daysAgo", "end": "today"}, + } + mock_gaql.return_value = [ + { + "conversion_action.name": "purchase", + "conversion_action.type": "WEBPAGE", + "conversion_action.status": "ENABLED", + }, + { + "conversion_action.name": "phone_call", + "conversion_action.type": "PHONE_CALL", + "conversion_action.status": "ENABLED", + }, + ] + + result = validate_tracking( + config, + expected_events=["purchase", "sign_up"], + customer_id="1234567890", + ) + + assert "ads_conversion_actions" in result + assert len(result["ads_conversion_actions"]) == 2 + # sign_up should be flagged as missing from Ads conversion actions + ads_missing_insight = [ + i for i in result["insights"] + if "no matching Google Ads conversion action" in i + ] + assert len(ads_missing_insight) == 1 + assert "sign_up" in ads_missing_insight[0] + + @patch("adloop.ads.gaql.execute_query") + @patch("adloop.ga4.tracking.get_tracking_events") + def test_customer_id_api_error(self, mock_ga4, mock_gaql, config): + mock_ga4.return_value = { + "rows": [ + {"eventName": "purchase", "eventCount": "50"}, + ], + "date_range": {"start": "28daysAgo", "end": "today"}, + } + mock_gaql.side_effect = Exception("API error") + + result = validate_tracking( + config, + expected_events=["purchase"], + customer_id="1234567890", + ) + + error_insight = [ + i for i in result["insights"] + if "Could not retrieve Google Ads conversion actions" in i + ] + assert len(error_insight) == 1 + assert "ads_conversion_actions" not in result From 61af2653a5e1517acac8045ea6b45012fb1863b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 17:27:06 +0000 Subject: [PATCH 16/36] Add campaign_id to search terms, discrepancy_pct to campaign conversions - get_search_terms: add campaign.id to GAQL SELECT so callers can pass it directly to add_negative_keywords without a separate lookup - analyze_campaign_conversions: add campaign_id and conversion_discrepancy_pct fields to each per-campaign row - Update server.py docstrings and orchestration rules - Add tests for crossref tool and search terms campaign.id field Items already done from first round (no changes needed): - get_ad_schedule_performance conversion_rate + CPA (already enriched) - get_keyword_performance ad_group.name (already in GAQL SELECT) https://claude.ai/code/session_01Hbuiiebx4iQBDnnUtcknFP --- .claude/rules/adloop.md | 6 +- .cursor/rules/adloop.mdc | 6 +- src/adloop/ads/read.py | 4 +- src/adloop/crossref.py | 6 ++ src/adloop/server.py | 8 +- tests/test_crossref.py | 167 +++++++++++++++++++++++++++++++++++++++ tests/test_read_tools.py | 25 +++++- 7 files changed, 214 insertions(+), 8 deletions(-) create mode 100644 tests/test_crossref.py diff --git a/.claude/rules/adloop.md b/.claude/rules/adloop.md index daec4f5..9d95e14 100644 --- a/.claude/rules/adloop.md +++ b/.claude/rules/adloop.md @@ -43,8 +43,8 @@ You have access to AdLoop MCP tools that connect Google Ads and Google Analytics - Ads read tools automatically compute `metrics.cost` (EUR) and `metrics.cpa` from `metrics.cost_micros` — no manual division needed. - `metrics.average_cpc_eur` is also pre-computed where available. - `get_ad_performance` returns full `headlines` and `descriptions` lists for RSAs. -- `get_keyword_performance` returns `ad_group.id` and `ad_group_criterion.criterion_id` — use these to construct `entity_id` strings (e.g. `"adGroupId~criterionId"`) for `pause_entity` calls. -- `get_search_terms` returns `metrics.cost` per search term — use for negative keyword analysis (flag terms spending > 2-3x CPA with zero conversions). +- `get_keyword_performance` returns `ad_group.id`, `ad_group.name`, and `ad_group_criterion.criterion_id` — use these to construct `entity_id` strings (e.g. `"adGroupId~criterionId"`) for `pause_entity` calls, and `ad_group.name` for human-readable reporting. +- `get_search_terms` returns `campaign.id` and `metrics.cost` per search term — use `campaign.id` for `add_negative_keywords`, and cost for negative keyword analysis (flag terms spending > 2-3x CPA with zero conversions). ### Cross-Reference Tools (GA4 + Ads combined) @@ -56,6 +56,8 @@ You have access to AdLoop MCP tools that connect Google Ads and Google Analytics These tools call both APIs internally and return unified results with computed `insights[]`. They are read-only — no mutations. Each returns a `date_range` and auto-generates conditional warnings (GDPR gaps, zero conversions, attribution mismatches, orphaned URLs). +**`analyze_campaign_conversions` details:** Returns one row per campaign (with `campaign_id`) including `conversion_discrepancy_pct` between Ads and GA4. When `campaign_name` is omitted, all campaigns are returned — no need to call once per campaign. + ### Tracking Tools | Tool | When to Use | Key Parameters | diff --git a/.cursor/rules/adloop.mdc b/.cursor/rules/adloop.mdc index d448d9c..797c4dd 100644 --- a/.cursor/rules/adloop.mdc +++ b/.cursor/rules/adloop.mdc @@ -45,8 +45,8 @@ You have access to AdLoop MCP tools that connect Google Ads and Google Analytics - Ads read tools automatically compute `metrics.cost` (EUR) and `metrics.cpa` from `metrics.cost_micros` — no manual division needed. - `metrics.average_cpc_eur` is also pre-computed where available. - `get_ad_performance` returns full `headlines` and `descriptions` lists for RSAs. -- `get_keyword_performance` returns `ad_group.id` and `ad_group_criterion.criterion_id` — use these to construct `entity_id` strings (e.g. `"adGroupId~criterionId"`) for `pause_entity` calls. -- `get_search_terms` returns `metrics.cost` per search term — use for negative keyword analysis (flag terms spending > 2-3x CPA with zero conversions). +- `get_keyword_performance` returns `ad_group.id`, `ad_group.name`, and `ad_group_criterion.criterion_id` — use these to construct `entity_id` strings (e.g. `"adGroupId~criterionId"`) for `pause_entity` calls, and `ad_group.name` for human-readable reporting. +- `get_search_terms` returns `campaign.id` and `metrics.cost` per search term — use `campaign.id` for `add_negative_keywords`, and cost for negative keyword analysis (flag terms spending > 2-3x CPA with zero conversions). ### Cross-Reference Tools (GA4 + Ads combined) @@ -58,6 +58,8 @@ You have access to AdLoop MCP tools that connect Google Ads and Google Analytics These tools call both APIs internally and return unified results with computed `insights[]`. They are read-only — no mutations. Each returns a `date_range` and auto-generates conditional warnings (GDPR gaps, zero conversions, attribution mismatches, orphaned URLs). +**`analyze_campaign_conversions` details:** Returns one row per campaign (with `campaign_id`) including `conversion_discrepancy_pct` between Ads and GA4. When `campaign_name` is omitted, all campaigns are returned — no need to call once per campaign. + ### Tracking Tools | Tool | When to Use | Key Parameters | diff --git a/src/adloop/ads/read.py b/src/adloop/ads/read.py index c807d17..db8cc10 100644 --- a/src/adloop/ads/read.py +++ b/src/adloop/ads/read.py @@ -148,7 +148,7 @@ def get_search_terms( if date_range_start and date_range_end: query = f""" SELECT search_term_view.search_term, - campaign.name, ad_group.name, + campaign.id, campaign.name, ad_group.name, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions FROM search_term_view @@ -160,7 +160,7 @@ def get_search_terms( else: query = f""" SELECT search_term_view.search_term, - campaign.name, ad_group.name, + campaign.id, campaign.name, ad_group.name, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions FROM search_term_view diff --git a/src/adloop/crossref.py b/src/adloop/crossref.py index 9ab9531..1a1cea8 100644 --- a/src/adloop/crossref.py +++ b/src/adloop/crossref.py @@ -131,7 +131,12 @@ def analyze_campaign_conversions( conv_rate = _safe_div(ga4_conversions, ga4_sessions) cost_per_conv = _safe_div(ads_cost, ga4_conversions) + # Conversion discrepancy between Ads and GA4 + denom = max(ads_conversions, ga4_conversions, 1) + discrepancy = round(abs(ads_conversions - ga4_conversions) / denom * 100, 1) + entry = { + "campaign_id": str(camp.get("campaign.id", "")), "campaign_name": name, "campaign_status": camp.get("campaign.status", ""), "ads_clicks": ads_clicks, @@ -142,6 +147,7 @@ def analyze_campaign_conversions( "click_to_session_ratio": ratio, "ga4_conversion_rate": conv_rate, "cost_per_ga4_conversion": cost_per_conv, + "conversion_discrepancy_pct": discrepancy, } campaigns.append(entry) diff --git a/src/adloop/server.py b/src/adloop/server.py index 0d2f548..a1a742c 100644 --- a/src/adloop/server.py +++ b/src/adloop/server.py @@ -316,7 +316,9 @@ def get_search_terms( """Get search terms report — what users actually typed before clicking your ads. Critical for finding negative keyword opportunities and understanding user intent. - Returns: search term, campaign, ad group, impressions, clicks, cost, conversions. + Returns: search term, campaign_id, campaign_name, ad group, impressions, + clicks, cost, conversions. Each row includes campaign.id so you can pass + it directly to add_negative_keywords. campaign_id: optional filter to a specific campaign. When omitted, returns search terms across all campaigns. @@ -622,6 +624,10 @@ def analyze_campaign_conversions( reveal click-to-session ratios (GDPR indicator), compare Ads-reported vs GA4-reported conversions, and compute cost-per-GA4-conversion. + Returns one row per campaign (with campaign_id) including + conversion_discrepancy_pct between Ads and GA4. When campaign_name is + provided, filters to matching campaigns. + Also returns non-paid channel conversion rates for comparison context. Date format: "YYYY-MM-DD". Empty = last 30 days. """ diff --git a/tests/test_crossref.py b/tests/test_crossref.py new file mode 100644 index 0000000..3516b0e --- /dev/null +++ b/tests/test_crossref.py @@ -0,0 +1,167 @@ +"""Tests for cross-reference tools (GA4 + Ads combined).""" + +from unittest.mock import patch + +import pytest + +from adloop.config import AdLoopConfig, AdsConfig, GA4Config, SafetyConfig +from adloop.crossref import analyze_campaign_conversions + + +@pytest.fixture +def config(): + return AdLoopConfig( + ads=AdsConfig(customer_id="1234567890", developer_token="test"), + ga4=GA4Config(property_id="properties/123456"), + safety=SafetyConfig(max_daily_budget=50.0, require_dry_run=True), + ) + + +class TestAnalyzeCampaignConversions: + @patch("adloop.ga4.reports.run_ga4_report") + @patch("adloop.ads.read.get_campaign_performance") + def test_returns_per_campaign_with_id(self, mock_ads, mock_ga4, config): + mock_ads.return_value = { + "campaigns": [ + { + "campaign.id": 111, + "campaign.name": "Campaign A", + "campaign.status": "ENABLED", + "metrics.clicks": 100, + "metrics.cost": 50.0, + "metrics.conversions": 10, + }, + { + "campaign.id": 222, + "campaign.name": "Campaign B", + "campaign.status": "ENABLED", + "metrics.clicks": 200, + "metrics.cost": 100.0, + "metrics.conversions": 5, + }, + ], + } + mock_ga4.return_value = { + "rows": [ + { + "sessionCampaignName": "Campaign A", + "sessionSource": "google", + "sessionMedium": "cpc", + "sessions": "80", + "conversions": "8", + "engagedSessions": "60", + "totalUsers": "75", + }, + { + "sessionCampaignName": "Campaign B", + "sessionSource": "google", + "sessionMedium": "cpc", + "sessions": "150", + "conversions": "3", + "engagedSessions": "120", + "totalUsers": "140", + }, + { + "sessionCampaignName": "(not set)", + "sessionSource": "organic", + "sessionMedium": "search", + "sessions": "500", + "conversions": "20", + "engagedSessions": "400", + "totalUsers": "480", + }, + ], + } + + result = analyze_campaign_conversions( + config, customer_id="1234567890", property_id="properties/123456" + ) + + assert len(result["campaigns"]) == 2 + + camp_a = result["campaigns"][0] + assert camp_a["campaign_id"] == "111" + assert camp_a["campaign_name"] == "Campaign A" + assert camp_a["ads_clicks"] == 100 + assert camp_a["ga4_paid_sessions"] == 80 + assert camp_a["ga4_paid_conversions"] == 8 + assert "conversion_discrepancy_pct" in camp_a + + camp_b = result["campaigns"][1] + assert camp_b["campaign_id"] == "222" + assert camp_b["campaign_name"] == "Campaign B" + + # Non-paid channels should be present + assert len(result["non_paid_channels"]) >= 1 + + @patch("adloop.ga4.reports.run_ga4_report") + @patch("adloop.ads.read.get_campaign_performance") + def test_conversion_discrepancy_pct(self, mock_ads, mock_ga4, config): + mock_ads.return_value = { + "campaigns": [ + { + "campaign.id": 111, + "campaign.name": "Test", + "campaign.status": "ENABLED", + "metrics.clicks": 100, + "metrics.cost": 50.0, + "metrics.conversions": 10, + }, + ], + } + mock_ga4.return_value = { + "rows": [ + { + "sessionCampaignName": "Test", + "sessionSource": "google", + "sessionMedium": "cpc", + "sessions": "80", + "conversions": "6", + "engagedSessions": "60", + "totalUsers": "75", + }, + ], + } + + result = analyze_campaign_conversions( + config, customer_id="1234567890", property_id="properties/123456" + ) + + camp = result["campaigns"][0] + # Ads: 10, GA4: 6 -> discrepancy = |10-6|/10 * 100 = 40% + assert camp["conversion_discrepancy_pct"] == 40.0 + + @patch("adloop.ga4.reports.run_ga4_report") + @patch("adloop.ads.read.get_campaign_performance") + def test_campaign_name_filter(self, mock_ads, mock_ga4, config): + mock_ads.return_value = { + "campaigns": [ + { + "campaign.id": 111, + "campaign.name": "Campaign A", + "campaign.status": "ENABLED", + "metrics.clicks": 100, + "metrics.cost": 50.0, + "metrics.conversions": 10, + }, + { + "campaign.id": 222, + "campaign.name": "Campaign B", + "campaign.status": "ENABLED", + "metrics.clicks": 200, + "metrics.cost": 100.0, + "metrics.conversions": 5, + }, + ], + } + mock_ga4.return_value = {"rows": []} + + result = analyze_campaign_conversions( + config, + customer_id="1234567890", + property_id="properties/123456", + campaign_name="Campaign A", + ) + + assert len(result["campaigns"]) == 1 + assert result["campaigns"][0]["campaign_name"] == "Campaign A" diff --git a/tests/test_read_tools.py b/tests/test_read_tools.py index 6d9ab9d..ccf6573 100644 --- a/tests/test_read_tools.py +++ b/tests/test_read_tools.py @@ -731,4 +731,27 @@ def test_no_campaign_filter_by_default(self, mock_query, config): get_search_terms(config, customer_id="1234567890") call_query = mock_query.call_args[0][2] - assert "campaign.id" not in call_query + assert "campaign.id =" not in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_query_includes_campaign_id_field(self, mock_query, config): + mock_query.return_value = [ + { + "search_term_view.search_term": "test", + "campaign.id": 12345, + "campaign.name": "Campaign A", + "ad_group.name": "Ad Group 1", + "metrics.impressions": 100, + "metrics.clicks": 10, + "metrics.cost_micros": 5_000_000, + "metrics.conversions": 1, + } + ] + + result = get_search_terms(config, customer_id="1234567890") + + row = result["search_terms"][0] + assert row["campaign.id"] == 12345 + # Verify campaign.id is in the SELECT clause + call_query = mock_query.call_args[0][2] + assert "campaign.id" in call_query From 28d97ccfc5467be55e068fdb4ab42fe5400348fb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 17:31:44 +0000 Subject: [PATCH 17/36] Fix review issues: campaign_id validation, conversion_rate edge case, docstring - Add _campaign_filter_clause helper that validates campaign_id is numeric before interpolating into GAQL queries (prevents injection) - Fix _enrich_conversion_rate to always set metrics.conversion_rate (0.0 when clicks=0 instead of omitting the field) - Fix server.py docstring: ad_group_id -> ad_group.id to match actual GAQL field names returned - Add tests for invalid campaign_id and zero-click conversion_rate https://claude.ai/code/session_01Hbuiiebx4iQBDnnUtcknFP --- src/adloop/ads/read.py | 17 ++++++++++++----- src/adloop/server.py | 8 ++++---- tests/test_read_tools.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/adloop/ads/read.py b/src/adloop/ads/read.py index db8cc10..5a0fe25 100644 --- a/src/adloop/ads/read.py +++ b/src/adloop/ads/read.py @@ -139,9 +139,7 @@ def get_search_terms( """Get search terms report — what users actually typed before clicking ads.""" from adloop.ads.gaql import execute_query - campaign_filter = "" - if campaign_id: - campaign_filter = f"AND campaign.id = {campaign_id}" + campaign_filter = _campaign_filter_clause(campaign_id) # search_term_view requires an explicit date segment, so we always # include DURING LAST_30_DAYS as baseline and override if dates given. @@ -678,6 +676,16 @@ def _date_clause(start: str, end: str) -> str: return "AND segments.date DURING LAST_30_DAYS" +def _campaign_filter_clause(campaign_id: str) -> str: + """Build a GAQL campaign filter, validating the ID is numeric.""" + if not campaign_id: + return "" + stripped = campaign_id.replace("-", "").strip() + if not stripped.isdigit(): + raise ValueError(f"Invalid campaign_id: {campaign_id!r} — must be numeric") + return f"AND campaign.id = {stripped}" + + def _enrich_cost_fields(rows: list[dict]) -> None: """Add human-readable cost and CPA fields computed from cost_micros.""" @@ -716,8 +724,7 @@ def _enrich_conversion_rate(rows: list[dict]) -> None: for row in rows: clicks = row.get("metrics.clicks", 0) or 0 conversions = row.get("metrics.conversions", 0) or 0 - if clicks > 0: - row["metrics.conversion_rate"] = round(conversions / clicks * 100, 2) + row["metrics.conversion_rate"] = round(conversions / clicks * 100, 2) if clicks > 0 else 0.0 def _enrich_budget_fields(rows: list[dict]) -> None: diff --git a/src/adloop/server.py b/src/adloop/server.py index a1a742c..df4f921 100644 --- a/src/adloop/server.py +++ b/src/adloop/server.py @@ -290,10 +290,10 @@ def get_keyword_performance( ) -> dict: """Get keyword metrics including quality scores and competitive data. - Returns: keyword text, match type, quality score, ad_group_id, criterion_id, - impressions, clicks, CTR, CPC, cost, conversions for each keyword. - The ad_group_id and criterion_id can be used to construct entity_id - strings (e.g. "adGroupId~criterionId") for pause_entity calls. + Returns: keyword text, match type, quality score, ad_group.id, ad_group.name, + ad_group_criterion.criterion_id, impressions, clicks, CTR, CPC, cost, + conversions for each keyword. Use ad_group.id and criterion_id to + construct entity_id strings (e.g. "adGroupId~criterionId") for pause_entity. """ from adloop.ads.read import get_keyword_performance as _impl diff --git a/tests/test_read_tools.py b/tests/test_read_tools.py index ccf6573..ae2f671 100644 --- a/tests/test_read_tools.py +++ b/tests/test_read_tools.py @@ -553,6 +553,28 @@ def test_default_query(self, mock_query, config): assert row["metrics.conversion_rate"] == 10.0 # 1/10 * 100 assert row["metrics.cpa"] == 5.0 # 5.0 / 1 + @patch("adloop.ads.gaql.execute_query") + def test_zero_clicks_conversion_rate(self, mock_query, config): + mock_query.return_value = [ + { + "campaign.name": "Test", + "campaign.id": 111, + "segments.day_of_week": "SUNDAY", + "segments.hour": 3, + "metrics.impressions": 50, + "metrics.clicks": 0, + "metrics.ctr": 0.0, + "metrics.cost_micros": 0, + "metrics.conversions": 0, + } + ] + + result = get_ad_schedule_performance(config, customer_id="1234567890") + + row = result["schedule_performance"][0] + assert row["metrics.conversion_rate"] == 0.0 + assert "metrics.cpa" not in row # no conversions = no CPA + @patch("adloop.ads.gaql.execute_query") def test_campaign_filter(self, mock_query, config): mock_query.return_value = [] @@ -755,3 +777,9 @@ def test_query_includes_campaign_id_field(self, mock_query, config): # Verify campaign.id is in the SELECT clause call_query = mock_query.call_args[0][2] assert "campaign.id" in call_query + + def test_invalid_campaign_id_raises(self, config): + with pytest.raises(ValueError, match="must be numeric"): + get_search_terms( + config, customer_id="1234567890", campaign_id="DROP TABLE" + ) From 8868b66fb5bd9adb564f0d5f61e63608b2aebc77 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 18:36:27 +0000 Subject: [PATCH 18/36] Remove "local service" phrasing from tool docstrings to prevent LSA confusion The phrase "local service businesses" in device and hourly performance tool descriptions was causing Claude to incorrectly associate the MCP with Local Service Ads (LSA). Simplified to "businesses" / "service businesses" to eliminate the ambiguity. https://claude.ai/code/session_01SMZJ8aG5M8u2EX2RJods52 --- src/adloop/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/adloop/server.py b/src/adloop/server.py index df4f921..71fa8db 100644 --- a/src/adloop/server.py +++ b/src/adloop/server.py @@ -429,7 +429,7 @@ def get_device_performance( ) -> dict: """Get performance segmented by device — MOBILE, DESKTOP, TABLET. - Essential for local service businesses where mobile intent differs + Essential for businesses where mobile intent differs dramatically from desktop. Shows clicks, cost, conversions, and conversion rate per device. @@ -556,7 +556,7 @@ def get_ad_schedule_performance( ) -> dict: """Get performance by hour of day and day of week. - Identifies peak and off-peak patterns. Important for local service + Identifies peak and off-peak patterns. Important for service businesses (e.g. emergency plumber at 2am vs 2pm). Returns: campaign, day_of_week, hour, impressions, clicks, CTR, cost, From 643257453b4ae2ce8caba3f989e61c8d773d224b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 18:39:03 +0000 Subject: [PATCH 19/36] Fix trailing slash mismatch in landing_page_analysis URL matching Ad URLs were normalized (trailing slash stripped) but GA4 pagePath values were not, causing false "orphaned page" warnings when both had trailing slashes (e.g. /locations/plano-tx/ in ads vs GA4). https://claude.ai/code/session_01B7ykudEJX1LWdMA4ZkbTGY --- src/adloop/crossref.py | 1 + tests/test_crossref.py | 46 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/adloop/crossref.py b/src/adloop/crossref.py index 1a1cea8..e82e26b 100644 --- a/src/adloop/crossref.py +++ b/src/adloop/crossref.py @@ -256,6 +256,7 @@ def landing_page_analysis( if source != "google" or medium != "cpc": continue path = row.get("pagePath", "/") + path = path.rstrip("/") or "/" bucket = ga4_by_path.setdefault(path, { "sessions": 0, "conversions": 0, "engaged": 0, "bounce_rate_sum": 0.0, "count": 0, }) diff --git a/tests/test_crossref.py b/tests/test_crossref.py index 3516b0e..f74c175 100644 --- a/tests/test_crossref.py +++ b/tests/test_crossref.py @@ -5,7 +5,7 @@ import pytest from adloop.config import AdLoopConfig, AdsConfig, GA4Config, SafetyConfig -from adloop.crossref import analyze_campaign_conversions +from adloop.crossref import analyze_campaign_conversions, landing_page_analysis @pytest.fixture @@ -165,3 +165,47 @@ def test_campaign_name_filter(self, mock_ads, mock_ga4, config): assert len(result["campaigns"]) == 1 assert result["campaigns"][0]["campaign_name"] == "Campaign A" + + +class TestLandingPageAnalysis: + @patch("adloop.ga4.reports.run_ga4_report") + @patch("adloop.ads.read.get_ad_performance") + def test_trailing_slash_matching(self, mock_ads, mock_ga4, config): + """Ad URLs with trailing slashes should match GA4 paths with trailing slashes.""" + mock_ads.return_value = { + "ads": [ + { + "ad_group_ad.ad.id": 1, + "ad_group_ad.ad.final_urls": ["https://example.com/locations/plano-tx/"], + "campaign.name": "City Search", + "ad_group.name": "Plano", + "metrics.clicks": 87, + "metrics.cost": 45.0, + }, + ], + } + mock_ga4.return_value = { + "rows": [ + { + "pagePath": "/locations/plano-tx/", + "sessionSource": "google", + "sessionMedium": "cpc", + "sessions": "88", + "conversions": "5", + "engagedSessions": "70", + "bounceRate": "0.2", + }, + ], + } + + result = landing_page_analysis( + config, customer_id="1234567890", property_id="properties/123456" + ) + + # Should merge into a single path, not create two separate entries + assert len(result["landing_pages"]) == 1 + page = result["landing_pages"][0] + assert page["total_ad_clicks"] == 87 + assert page["ga4_paid_sessions"] == 88 + # Should NOT be flagged as orphaned + assert len(result.get("orphaned_urls", [])) == 0 From d6bf5c9d26a849a4516dca747e8a8bd73153fc03 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 18:57:26 +0000 Subject: [PATCH 20/36] Fix GA4 tool schema types: replace anyOf unions with direct type fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP clients (like Claude) don't handle Pydantic's anyOf JSON schemas from `list[str] | None = None` unions — they serialize arrays/dicts as strings instead of native types. Changed 10 optional list/dict parameters across 6 tools from `X | None = None` to `X = []` (or `X = {}`) so Pydantic generates schemas with a direct "type": "array" / "type": "object" field. For update_campaign's sentinel parameters (geo_target_ids, language_ids), empty list is converted back to None at the server.py boundary to preserve the "don't change" semantics in the implementation layer. https://claude.ai/code/session_01SDbWis2165nVMS1nszVFjm --- src/adloop/server.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/adloop/server.py b/src/adloop/server.py index 71fa8db..9a98a03 100644 --- a/src/adloop/server.py +++ b/src/adloop/server.py @@ -138,13 +138,13 @@ def get_account_summaries() -> dict: @mcp.tool(annotations=_READONLY) @_safe def run_ga4_report( - dimensions: list[str] | None = None, - metrics: list[str] | None = None, + dimensions: list[str] = [], + metrics: list[str] = [], date_range_start: str = "7daysAgo", date_range_end: str = "today", property_id: str = "", limit: int = 100, - dimension_filter: dict[str, str] | None = None, + dimension_filter: dict[str, str] = {}, ) -> dict: """Run a custom GA4 report with specified dimensions, metrics, and date range. @@ -176,8 +176,8 @@ def run_ga4_report( @mcp.tool(annotations=_READONLY) @_safe def run_realtime_report( - dimensions: list[str] | None = None, - metrics: list[str] | None = None, + dimensions: list[str] = [], + metrics: list[str] = [], property_id: str = "", ) -> dict: """Run a GA4 realtime report showing current active users and events. @@ -676,7 +676,7 @@ def attribution_check( date_range_end: str = "", customer_id: str = "", property_id: str = "", - conversion_events: list[str] | None = None, + conversion_events: list[str] = [], ) -> dict: """Compare Ads-reported conversions vs GA4 — find tracking discrepancies. @@ -742,7 +742,7 @@ def draft_campaign( target_roas: float = 0, channel_type: str = "SEARCH", ad_group_name: str = "", - keywords: list[dict] | None = None, + keywords: list[dict] = [], final_url_suffix: str | None = None, ) -> dict: """Draft a full campaign structure — returns a PREVIEW, does NOT create anything. @@ -792,7 +792,7 @@ def draft_campaign( def draft_ad_group( campaign_id: str, ad_group_name: str, - keywords: list[dict] | None = None, + keywords: list[dict] = [], customer_id: str = "", cpc_bid_micros: int = 0, ) -> dict: @@ -829,8 +829,8 @@ def update_campaign( target_cpa: float = 0, target_roas: float = 0, daily_budget: float = 0, - geo_target_ids: list[str] | None = None, - language_ids: list[str] | None = None, + geo_target_ids: list[str] = [], + language_ids: list[str] = [], final_url_suffix: str | None = None, ) -> dict: """Draft an update to an existing campaign — returns a PREVIEW, does NOT apply. @@ -861,8 +861,8 @@ def update_campaign( target_cpa=target_cpa, target_roas=target_roas, daily_budget=daily_budget, - geo_target_ids=geo_target_ids, - language_ids=language_ids, + geo_target_ids=geo_target_ids or None, + language_ids=language_ids or None, final_url_suffix=final_url_suffix, ) From 900a74ae17f8cd13af0bc06a9154701a607ebb03 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 22:46:06 +0000 Subject: [PATCH 21/36] Add headline/description pinning support for Responsive Search Ads Support optional pinning of RSA headlines and descriptions to specific positions (HEADLINE_1/2/3, DESCRIPTION_1/2) via the Google Ads API AdTextAsset.pinned_field. Headlines/descriptions now accept either plain strings (unpinned, backward compatible) or dicts with {"text": "...", "pinned_to": "HEADLINE_1"}. Changes: - gaql.py: _to_python preserves pinned_field on AdTextAsset reads - write.py: Add _normalize_assets helper, update _validate_rsa for pinning validation, update draft/apply functions to handle dicts - server.py: Update tool signatures and docstrings for pinning format - Rules: Document pinning in orchestration rules (.claude + .cursor) - Tests: Update RSA replacement tests for normalized dict format https://claude.ai/code/session_01QX9Uk947P69wPMfeFufQF7 --- .claude/rules/adloop.md | 4 +- .cursor/rules/adloop.mdc | 4 +- src/adloop/ads/gaql.py | 11 +++- src/adloop/ads/write.py | 97 ++++++++++++++++++++++------- src/adloop/server.py | 19 ++++-- tests/test_draft_rsa_replacement.py | 14 +++-- 6 files changed, 112 insertions(+), 37 deletions(-) diff --git a/.claude/rules/adloop.md b/.claude/rules/adloop.md index 9d95e14..77d6499 100644 --- a/.claude/rules/adloop.md +++ b/.claude/rules/adloop.md @@ -84,8 +84,8 @@ These tools call both APIs internally and return unified results with computed ` | `draft_campaign` | Create full campaign structure (budget + campaign + ad group + keywords + geo/language targeting). Auto-sets Final URL Suffix with UTM tracking for SEARCH campaigns. | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), keywords validated, `final_url_suffix` (auto-set for SEARCH, pass "" to disable) | | `draft_ad_group` | Create a new ad group within an existing campaign (does NOT publish) | `campaign_id` (REQUIRED), `ad_group_name` (REQUIRED), `keywords` (optional list of {text, match_type}), `cpc_bid_micros` (optional) | | `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, Final URL suffix | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `final_url_suffix` | -| `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each) | -| `draft_rsa_replacement` | **Fix** an existing RSA — creates corrected replacement and removes the old ad. Use for copy errors, not A/B testing. For testing variants, use `draft_responsive_search_ad` instead. | `ad_id` (REQUIRED), 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), `final_url` (inherits from old ad if blank), path1/path2 (≤15 chars each), `remove_old` (default true) | +| `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each). Headlines/descriptions support optional **pinning**: pass `{"text": "...", "pinned_to": "HEADLINE_1"}` instead of a plain string. Valid pins: HEADLINE_1/2/3 for headlines, DESCRIPTION_1/2 for descriptions. Plain strings are unpinned. | +| `draft_rsa_replacement` | **Fix** an existing RSA — creates corrected replacement and removes the old ad. Use for copy errors, not A/B testing. For testing variants, use `draft_responsive_search_ad` instead. | `ad_id` (REQUIRED), 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), `final_url` (inherits from old ad if blank), path1/path2 (≤15 chars each), `remove_old` (default true). Supports **pinning** — same format as `draft_responsive_search_ad`. | | `draft_sitelinks` | Create sitelink extensions for a campaign (does NOT publish) | `campaign_id`, `sitelinks` list of {link_text ≤25 chars, final_url, description1 ≤35 chars, description2 ≤35 chars} | | `draft_keywords` | Propose keyword additions (does NOT add) | Each keyword needs `text` and `match_type` (EXACT/PHRASE/BROAD) | | `add_negative_keywords` | Propose negative keywords (does NOT add) | `campaign_id`, keyword list, `match_type` | diff --git a/.cursor/rules/adloop.mdc b/.cursor/rules/adloop.mdc index 797c4dd..713dbe1 100644 --- a/.cursor/rules/adloop.mdc +++ b/.cursor/rules/adloop.mdc @@ -86,8 +86,8 @@ These tools call both APIs internally and return unified results with computed ` | `draft_campaign` | Create full campaign structure (budget + campaign + ad group + keywords + geo/language targeting). Auto-sets Final URL Suffix with UTM tracking for SEARCH campaigns. | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), keywords validated, `final_url_suffix` (auto-set for SEARCH, pass "" to disable) | | `draft_ad_group` | Create a new ad group within an existing campaign (does NOT publish) | `campaign_id` (REQUIRED), `ad_group_name` (REQUIRED), `keywords` (optional list of {text, match_type}), `cpc_bid_micros` (optional) | | `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, Final URL suffix | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `final_url_suffix` | -| `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each) | -| `draft_rsa_replacement` | **Fix** an existing RSA — creates corrected replacement and removes the old ad. Use for copy errors, not A/B testing. For testing variants, use `draft_responsive_search_ad` instead. | `ad_id` (REQUIRED), 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), `final_url` (inherits from old ad if blank), path1/path2 (≤15 chars each), `remove_old` (default true) | +| `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each). Headlines/descriptions support optional **pinning**: pass `{"text": "...", "pinned_to": "HEADLINE_1"}` instead of a plain string. Valid pins: HEADLINE_1/2/3 for headlines, DESCRIPTION_1/2 for descriptions. Plain strings are unpinned. | +| `draft_rsa_replacement` | **Fix** an existing RSA — creates corrected replacement and removes the old ad. Use for copy errors, not A/B testing. For testing variants, use `draft_responsive_search_ad` instead. | `ad_id` (REQUIRED), 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), `final_url` (inherits from old ad if blank), path1/path2 (≤15 chars each), `remove_old` (default true). Supports **pinning** — same format as `draft_responsive_search_ad`. | | `draft_sitelinks` | Create sitelink extensions for a campaign (does NOT publish) | `campaign_id`, `sitelinks` list of {link_text ≤25 chars, final_url, description1 ≤35 chars, description2 ≤35 chars} | | `draft_keywords` | Propose keyword additions (does NOT add) | Each keyword needs `text` and `match_type` (EXACT/PHRASE/BROAD) | | `add_negative_keywords` | Propose negative keywords (does NOT add) | `campaign_id`, keyword list, `match_type` | diff --git a/src/adloop/ads/gaql.py b/src/adloop/ads/gaql.py index c2ef741..ce412e1 100644 --- a/src/adloop/ads/gaql.py +++ b/src/adloop/ads/gaql.py @@ -124,8 +124,17 @@ def _to_python(obj: object) -> object: return [_to_python(item) for item in obj] except TypeError: pass - # AdTextAsset and similar message types + # AdTextAsset and similar message types — preserve pinning info if hasattr(obj, "text") and isinstance(getattr(obj, "text", None), str): + pinned = getattr(obj, "pinned_field", None) + if pinned is not None: + # Proto-plus enums are int subclasses with a .name attribute; + # 0 / UNSPECIFIED means "not pinned". + pin_name = getattr(pinned, "name", None) + if pin_name and pin_name != "UNSPECIFIED": + return {"text": obj.text, "pinned_to": pin_name} + if isinstance(pinned, int) and pinned != 0: + return {"text": obj.text, "pinned_to": str(pinned)} return obj.text return str(obj) diff --git a/src/adloop/ads/write.py b/src/adloop/ads/write.py index 13414ae..a90196c 100644 --- a/src/adloop/ads/write.py +++ b/src/adloop/ads/write.py @@ -105,8 +105,8 @@ def draft_responsive_search_ad( *, customer_id: str = "", ad_group_id: str = "", - headlines: list[str] | None = None, - descriptions: list[str] | None = None, + headlines: list[str | dict] | None = None, + descriptions: list[str | dict] | None = None, final_url: str = "", path1: str = "", path2: str = "", @@ -120,8 +120,8 @@ def draft_responsive_search_ad( except SafetyViolation as e: return {"error": str(e)} - headlines = headlines or [] - descriptions = descriptions or [] + headlines = _normalize_assets(headlines or []) + descriptions = _normalize_assets(descriptions or []) errors = _validate_rsa(ad_group_id, headlines, descriptions, final_url) if errors: @@ -174,8 +174,8 @@ def draft_rsa_replacement( *, customer_id: str = "", ad_id: str = "", - headlines: list[str] | None = None, - descriptions: list[str] | None = None, + headlines: list[str | dict] | None = None, + descriptions: list[str | dict] | None = None, final_url: str = "", path1: str = "", path2: str = "", @@ -201,8 +201,8 @@ def draft_rsa_replacement( except SafetyViolation as e: return {"error": str(e)} - headlines = headlines or [] - descriptions = descriptions or [] + headlines = _normalize_assets(headlines or []) + descriptions = _normalize_assets(descriptions or []) if not ad_id: return {"error": "Validation failed", "details": ["ad_id is required."]} @@ -260,11 +260,10 @@ def draft_rsa_replacement( old_descriptions = existing.get( "ad_group_ad.ad.responsive_search_ad.descriptions", [] ) - # GAQL _to_python already converts AdTextAsset → str, but handle dicts defensively - if old_headlines and isinstance(old_headlines[0], dict): - old_headlines = [h.get("text", str(h)) for h in old_headlines] - if old_descriptions and isinstance(old_descriptions[0], dict): - old_descriptions = [d.get("text", str(d)) for d in old_descriptions] + # Normalize old assets to the same dict format used for new copy so + # the diff preview consistently shows pinning info. + old_headlines = _normalize_assets(old_headlines) + old_descriptions = _normalize_assets(old_descriptions) old_final_urls = existing.get("ad_group_ad.ad.final_urls", []) old_copy = { @@ -994,10 +993,30 @@ def _check_broad_match_safety( return [] +_VALID_HEADLINE_PINS = {None, "HEADLINE_1", "HEADLINE_2", "HEADLINE_3"} +_VALID_DESCRIPTION_PINS = {None, "DESCRIPTION_1", "DESCRIPTION_2"} + + +def _normalize_assets(items: list[str | dict]) -> list[dict]: + """Normalize a mixed ``str | dict`` asset list to uniform dicts. + + Each returned dict has ``{"text": str, "pinned_to": str | None}``. + """ + result: list[dict] = [] + for item in items: + if isinstance(item, str): + result.append({"text": item, "pinned_to": None}) + elif isinstance(item, dict): + result.append({"text": item.get("text", ""), "pinned_to": item.get("pinned_to")}) + else: + result.append({"text": str(item), "pinned_to": None}) + return result + + def _validate_rsa( ad_group_id: str, - headlines: list[str], - descriptions: list[str], + headlines: list[dict], + descriptions: list[dict], final_url: str, ) -> list[str]: errors = [] @@ -1014,11 +1033,29 @@ def _validate_rsa( if len(descriptions) > 4: errors.append(f"Maximum 4 descriptions, got {len(descriptions)}") for i, h in enumerate(headlines): - if len(h) > 30: - errors.append(f"Headline {i + 1} exceeds 30 chars ({len(h)}): '{h}'") + text = h.get("text", "") + if not text: + errors.append(f"Headline {i + 1} is missing required 'text' field.") + elif len(text) > 30: + errors.append(f"Headline {i + 1} exceeds 30 chars ({len(text)}): '{text}'") + pin = h.get("pinned_to") + if pin not in _VALID_HEADLINE_PINS: + errors.append( + f"Headline {i + 1} has invalid pinned_to '{pin}'. " + "Must be HEADLINE_1, HEADLINE_2, or HEADLINE_3." + ) for i, d in enumerate(descriptions): - if len(d) > 90: - errors.append(f"Description {i + 1} exceeds 90 chars ({len(d)}): '{d}'") + text = d.get("text", "") + if not text: + errors.append(f"Description {i + 1} is missing required 'text' field.") + elif len(text) > 90: + errors.append(f"Description {i + 1} exceeds 90 chars ({len(text)}): '{text}'") + pin = d.get("pinned_to") + if pin not in _VALID_DESCRIPTION_PINS: + errors.append( + f"Description {i + 1} has invalid pinned_to '{pin}'. " + "Must be DESCRIPTION_1 or DESCRIPTION_2." + ) return errors @@ -1709,14 +1746,28 @@ def _apply_create_rsa(client: object, cid: str, changes: dict) -> dict: ad = ad_group_ad.ad ad.final_urls.append(changes["final_url"]) - for text in changes["headlines"]: + for item in changes["headlines"]: asset = client.get_type("AdTextAsset") - asset.text = text + if isinstance(item, str): + asset.text = item + else: + asset.text = item["text"] + if item.get("pinned_to"): + asset.pinned_field = client.enums.ServedAssetFieldTypeEnum[ + item["pinned_to"] + ] ad.responsive_search_ad.headlines.append(asset) - for text in changes["descriptions"]: + for item in changes["descriptions"]: asset = client.get_type("AdTextAsset") - asset.text = text + if isinstance(item, str): + asset.text = item + else: + asset.text = item["text"] + if item.get("pinned_to"): + asset.pinned_field = client.enums.ServedAssetFieldTypeEnum[ + item["pinned_to"] + ] ad.responsive_search_ad.descriptions.append(asset) if changes.get("path1"): diff --git a/src/adloop/server.py b/src/adloop/server.py index 9a98a03..10f5663 100644 --- a/src/adloop/server.py +++ b/src/adloop/server.py @@ -871,8 +871,8 @@ def update_campaign( @_safe def draft_responsive_search_ad( ad_group_id: str, - headlines: list[str], - descriptions: list[str], + headlines: list[str | dict], + descriptions: list[str | dict], final_url: str, customer_id: str = "", path1: str = "", @@ -882,6 +882,12 @@ def draft_responsive_search_ad( Provide 3-15 headlines (max 30 chars each) and 2-4 descriptions (max 90 chars each). The preview shows exactly what will be created. Call confirm_and_apply to execute. + + Each headline/description can be a plain string (unpinned) or a dict with + optional pinning: {"text": "...", "pinned_to": "HEADLINE_1"}. + Valid headline pins: HEADLINE_1, HEADLINE_2, HEADLINE_3. + Valid description pins: DESCRIPTION_1, DESCRIPTION_2. + Multiple assets can be pinned to the same position (they rotate). """ from adloop.ads.write import draft_responsive_search_ad as _impl @@ -901,8 +907,8 @@ def draft_responsive_search_ad( @_safe def draft_rsa_replacement( ad_id: str, - headlines: list[str], - descriptions: list[str], + headlines: list[str | dict], + descriptions: list[str | dict], final_url: str = "", customer_id: str = "", path1: str = "", @@ -922,6 +928,11 @@ def draft_rsa_replacement( The new ad inherits the ad group from the old one and is created as PAUSED. If final_url is omitted, the old ad's URL is reused. Call confirm_and_apply with the returned plan_id to execute. + + Each headline/description can be a plain string (unpinned) or a dict with + optional pinning: {"text": "...", "pinned_to": "HEADLINE_1"}. + Valid headline pins: HEADLINE_1, HEADLINE_2, HEADLINE_3. + Valid description pins: DESCRIPTION_1, DESCRIPTION_2. """ from adloop.ads.write import draft_rsa_replacement as _impl diff --git a/tests/test_draft_rsa_replacement.py b/tests/test_draft_rsa_replacement.py index eeae5a8..96fbbcc 100644 --- a/tests/test_draft_rsa_replacement.py +++ b/tests/test_draft_rsa_replacement.py @@ -74,10 +74,13 @@ def test_happy_path_returns_preview_with_diff( assert "plan_id" in result assert result["operation"] == "replace_responsive_search_ad" assert "diff" in result - assert result["diff"]["old"]["headlines"] == EXISTING_RSA[ - "ad_group_ad.ad.responsive_search_ad.headlines" + assert result["diff"]["old"]["headlines"] == [ + {"text": h, "pinned_to": None} + for h in EXISTING_RSA["ad_group_ad.ad.responsive_search_ad.headlines"] + ] + assert result["diff"]["new"]["headlines"] == [ + {"text": h, "pinned_to": None} for h in VALID_HEADLINES ] - assert result["diff"]["new"]["headlines"] == VALID_HEADLINES assert result["diff"]["old_ad_action"] == "REMOVE" # Verify plan stored correctly @@ -264,7 +267,8 @@ def test_old_copy_in_changes(self, mock_fetch, mock_urls, config): ) plan = get_plan(result["plan_id"]) assert "old_copy" in plan.changes - assert plan.changes["old_copy"]["headlines"] == EXISTING_RSA[ - "ad_group_ad.ad.responsive_search_ad.headlines" + assert plan.changes["old_copy"]["headlines"] == [ + {"text": h, "pinned_to": None} + for h in EXISTING_RSA["ad_group_ad.ad.responsive_search_ad.headlines"] ] remove_plan(result["plan_id"]) From cbb820794e48568c83061de1f43d0963a2ff2939 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 22:55:54 +0000 Subject: [PATCH 22/36] Fix critical enum accessor bug and add pinning test coverage - Fix _apply_create_rsa: use getattr() instead of bracket notation for ServedAssetFieldTypeEnum, matching the pattern used everywhere else in the module. Bracket notation fails on proto-plus enums at runtime. - Fix _to_python: use explicit `is not None` check instead of truthiness for pin_name to avoid silently skipping edge cases. - Improve _format_table/_format_csv: render pinned assets as "Text [HEADLINE_1]" instead of raw dict repr. - Add 15 new tests covering: _normalize_assets, pinning validation (valid/invalid pins, cross-type rejection, missing text), pinned headlines/descriptions in draft plans and diffs. https://claude.ai/code/session_01QX9Uk947P69wPMfeFufQF7 --- src/adloop/ads/gaql.py | 14 +- src/adloop/ads/write.py | 12 +- tests/test_draft_rsa_replacement.py | 245 +++++++++++++++++++++++++++- 3 files changed, 260 insertions(+), 11 deletions(-) diff --git a/src/adloop/ads/gaql.py b/src/adloop/ads/gaql.py index ce412e1..068010a 100644 --- a/src/adloop/ads/gaql.py +++ b/src/adloop/ads/gaql.py @@ -131,7 +131,7 @@ def _to_python(obj: object) -> object: # Proto-plus enums are int subclasses with a .name attribute; # 0 / UNSPECIFIED means "not pinned". pin_name = getattr(pinned, "name", None) - if pin_name and pin_name != "UNSPECIFIED": + if pin_name is not None and pin_name != "UNSPECIFIED": return {"text": obj.text, "pinned_to": pin_name} if isinstance(pinned, int) and pinned != 0: return {"text": obj.text, "pinned_to": str(pinned)} @@ -139,6 +139,14 @@ def _to_python(obj: object) -> object: return str(obj) +def _format_asset_item(v: object) -> str: + """Format a single list item, rendering pinned AdTextAsset dicts nicely.""" + if isinstance(v, dict) and "text" in v: + pinned = v.get("pinned_to") + return f"{v['text']} [{pinned}]" if pinned else v["text"] + return str(v) + + def _format_table(rows: list[dict], query: str) -> dict: """Format query results as an aligned text table.""" if not rows: @@ -153,7 +161,7 @@ def _format_table(rows: list[dict], query: str) -> dict: for h in headers: val = row.get(h) if isinstance(val, list): - s = ", ".join(str(v) for v in val) + s = ", ".join(_format_asset_item(v) for v in val) else: s = str(val) if val is not None else "" sr[h] = s @@ -182,6 +190,6 @@ def _format_csv(rows: list[dict], query: str) -> dict: writer = csv.DictWriter(output, fieldnames=rows[0].keys()) writer.writeheader() for row in rows: - writer.writerow({k: v if not isinstance(v, list) else "; ".join(str(i) for i in v) for k, v in row.items()}) + writer.writerow({k: v if not isinstance(v, list) else "; ".join(_format_asset_item(i) for i in v) for k, v in row.items()}) return {"csv": output.getvalue(), "row_count": len(rows), "query": query} diff --git a/src/adloop/ads/write.py b/src/adloop/ads/write.py index a90196c..a66c67a 100644 --- a/src/adloop/ads/write.py +++ b/src/adloop/ads/write.py @@ -1753,9 +1753,9 @@ def _apply_create_rsa(client: object, cid: str, changes: dict) -> dict: else: asset.text = item["text"] if item.get("pinned_to"): - asset.pinned_field = client.enums.ServedAssetFieldTypeEnum[ - item["pinned_to"] - ] + asset.pinned_field = getattr( + client.enums.ServedAssetFieldTypeEnum, item["pinned_to"] + ) ad.responsive_search_ad.headlines.append(asset) for item in changes["descriptions"]: @@ -1765,9 +1765,9 @@ def _apply_create_rsa(client: object, cid: str, changes: dict) -> dict: else: asset.text = item["text"] if item.get("pinned_to"): - asset.pinned_field = client.enums.ServedAssetFieldTypeEnum[ - item["pinned_to"] - ] + asset.pinned_field = getattr( + client.enums.ServedAssetFieldTypeEnum, item["pinned_to"] + ) ad.responsive_search_ad.descriptions.append(asset) if changes.get("path1"): diff --git a/tests/test_draft_rsa_replacement.py b/tests/test_draft_rsa_replacement.py index 96fbbcc..29b295c 100644 --- a/tests/test_draft_rsa_replacement.py +++ b/tests/test_draft_rsa_replacement.py @@ -1,10 +1,15 @@ -"""Tests for draft_rsa_replacement validation and plan creation.""" +"""Tests for draft_rsa_replacement validation, plan creation, and pinning.""" from unittest.mock import patch import pytest -from adloop.ads.write import draft_rsa_replacement +from adloop.ads.write import ( + _normalize_assets, + _validate_rsa, + draft_responsive_search_ad, + draft_rsa_replacement, +) from adloop.config import AdLoopConfig, AdsConfig, SafetyConfig from adloop.safety.preview import get_plan, remove_plan @@ -272,3 +277,239 @@ def test_old_copy_in_changes(self, mock_fetch, mock_urls, config): for h in EXISTING_RSA["ad_group_ad.ad.responsive_search_ad.headlines"] ] remove_plan(result["plan_id"]) + + +class TestNormalizeAssets: + """Tests for the _normalize_assets helper.""" + + def test_plain_strings(self): + result = _normalize_assets(["Hello", "World"]) + assert result == [ + {"text": "Hello", "pinned_to": None}, + {"text": "World", "pinned_to": None}, + ] + + def test_dicts_with_pinning(self): + result = _normalize_assets([ + {"text": "Pinned One", "pinned_to": "HEADLINE_1"}, + {"text": "Unpinned"}, + ]) + assert result == [ + {"text": "Pinned One", "pinned_to": "HEADLINE_1"}, + {"text": "Unpinned", "pinned_to": None}, + ] + + def test_mixed_str_and_dict(self): + result = _normalize_assets([ + "Plain string", + {"text": "Pinned", "pinned_to": "HEADLINE_2"}, + {"text": "Dict no pin"}, + ]) + assert len(result) == 3 + assert result[0] == {"text": "Plain string", "pinned_to": None} + assert result[1] == {"text": "Pinned", "pinned_to": "HEADLINE_2"} + assert result[2] == {"text": "Dict no pin", "pinned_to": None} + + def test_empty_list(self): + assert _normalize_assets([]) == [] + + +class TestValidateRsaPinning: + """Tests for pinning validation in _validate_rsa.""" + + def test_valid_headline_pins(self): + headlines = [ + {"text": "H1", "pinned_to": "HEADLINE_1"}, + {"text": "H2", "pinned_to": "HEADLINE_2"}, + {"text": "H3", "pinned_to": "HEADLINE_3"}, + ] + descs = [ + {"text": "D1 description that is long enough.", "pinned_to": None}, + {"text": "D2 description that is also fine.", "pinned_to": None}, + ] + errors = _validate_rsa("ag123", headlines, descs, "https://example.com") + assert errors == [] + + def test_valid_description_pins(self): + headlines = [ + {"text": "H1", "pinned_to": None}, + {"text": "H2", "pinned_to": None}, + {"text": "H3", "pinned_to": None}, + ] + descs = [ + {"text": "D1 description pinned to slot.", "pinned_to": "DESCRIPTION_1"}, + {"text": "D2 description pinned too.", "pinned_to": "DESCRIPTION_2"}, + ] + errors = _validate_rsa("ag123", headlines, descs, "https://example.com") + assert errors == [] + + def test_invalid_headline_pin_rejected(self): + headlines = [ + {"text": "H1", "pinned_to": "HEADLINE_4"}, + {"text": "H2", "pinned_to": None}, + {"text": "H3", "pinned_to": None}, + ] + descs = [ + {"text": "D1 description text here.", "pinned_to": None}, + {"text": "D2 description text here.", "pinned_to": None}, + ] + errors = _validate_rsa("ag123", headlines, descs, "https://example.com") + assert any("HEADLINE_4" in e for e in errors) + + def test_description_pin_on_headline_rejected(self): + headlines = [ + {"text": "H1", "pinned_to": "DESCRIPTION_1"}, + {"text": "H2", "pinned_to": None}, + {"text": "H3", "pinned_to": None}, + ] + descs = [ + {"text": "D1 description text here.", "pinned_to": None}, + {"text": "D2 description text here.", "pinned_to": None}, + ] + errors = _validate_rsa("ag123", headlines, descs, "https://example.com") + assert any("DESCRIPTION_1" in e for e in errors) + + def test_headline_pin_on_description_rejected(self): + headlines = [ + {"text": "H1", "pinned_to": None}, + {"text": "H2", "pinned_to": None}, + {"text": "H3", "pinned_to": None}, + ] + descs = [ + {"text": "D1 description text here.", "pinned_to": "HEADLINE_1"}, + {"text": "D2 description text here.", "pinned_to": None}, + ] + errors = _validate_rsa("ag123", headlines, descs, "https://example.com") + assert any("HEADLINE_1" in e for e in errors) + + def test_missing_text_rejected(self): + headlines = [ + {"text": "", "pinned_to": None}, + {"text": "H2", "pinned_to": None}, + {"text": "H3", "pinned_to": None}, + ] + descs = [ + {"text": "D1 description text here.", "pinned_to": None}, + {"text": "D2 description text here.", "pinned_to": None}, + ] + errors = _validate_rsa("ag123", headlines, descs, "https://example.com") + assert any("missing" in e.lower() for e in errors) + + +class TestDraftRsaWithPinning: + """Tests for pinning in draft_responsive_search_ad.""" + + @patch("adloop.ads.write._validate_urls", return_value={}) + def test_pinned_headlines_stored_in_plan(self, mock_urls, config): + headlines = [ + {"text": "Pinned Headline", "pinned_to": "HEADLINE_1"}, + "Unpinned Headline Two", + "Unpinned Headline Three", + ] + descs = VALID_DESCRIPTIONS + result = draft_responsive_search_ad( + config, + customer_id="1234567890", + ad_group_id="ag123", + headlines=headlines, + descriptions=descs, + final_url="https://example.com", + ) + assert "plan_id" in result + plan = get_plan(result["plan_id"]) + stored = plan.changes["headlines"] + assert stored[0] == {"text": "Pinned Headline", "pinned_to": "HEADLINE_1"} + assert stored[1] == {"text": "Unpinned Headline Two", "pinned_to": None} + remove_plan(result["plan_id"]) + + @patch("adloop.ads.write._validate_urls", return_value={}) + def test_pinned_descriptions_stored_in_plan(self, mock_urls, config): + headlines = VALID_HEADLINES + descs = [ + {"text": "Pinned desc stored in plan changes.", "pinned_to": "DESCRIPTION_1"}, + "Unpinned description number two for testing.", + ] + result = draft_responsive_search_ad( + config, + customer_id="1234567890", + ad_group_id="ag123", + headlines=headlines, + descriptions=descs, + final_url="https://example.com", + ) + assert "plan_id" in result + plan = get_plan(result["plan_id"]) + stored = plan.changes["descriptions"] + assert stored[0]["pinned_to"] == "DESCRIPTION_1" + assert stored[1]["pinned_to"] is None + remove_plan(result["plan_id"]) + + @patch("adloop.ads.write._validate_urls", return_value={}) + def test_invalid_pin_rejected(self, mock_urls, config): + headlines = [ + {"text": "Bad Pin", "pinned_to": "HEADLINE_99"}, + "H2", + "H3", + ] + descs = VALID_DESCRIPTIONS + result = draft_responsive_search_ad( + config, + customer_id="1234567890", + ad_group_id="ag123", + headlines=headlines, + descriptions=descs, + final_url="https://example.com", + ) + assert "error" in result + assert "HEADLINE_99" in str(result["details"]) + + +class TestDraftRsaReplacementWithPinning: + """Tests for pinning in draft_rsa_replacement.""" + + @patch("adloop.ads.write._validate_urls", return_value={}) + @patch("adloop.ads.write._fetch_existing_rsa") + def test_pinned_new_headlines_in_diff(self, mock_fetch, mock_urls, config): + mock_fetch.return_value = EXISTING_RSA + headlines = [ + {"text": "Pinned Replacement", "pinned_to": "HEADLINE_1"}, + "Replacement Two", + "Replacement Three", + ] + result = draft_rsa_replacement( + config, + customer_id="1234567890", + ad_id="12345", + headlines=headlines, + descriptions=VALID_DESCRIPTIONS, + ) + assert "plan_id" in result + new_h = result["diff"]["new"]["headlines"] + assert new_h[0] == {"text": "Pinned Replacement", "pinned_to": "HEADLINE_1"} + assert new_h[1] == {"text": "Replacement Two", "pinned_to": None} + remove_plan(result["plan_id"]) + + @patch("adloop.ads.write._validate_urls", return_value={}) + @patch("adloop.ads.write._fetch_existing_rsa") + def test_pinned_old_headlines_preserved(self, mock_fetch, mock_urls, config): + """When the existing RSA has pinned assets (returned as dicts from GAQL), + the old_copy in the diff should preserve the pinning info.""" + pinned_existing = dict(EXISTING_RSA) + pinned_existing["ad_group_ad.ad.responsive_search_ad.headlines"] = [ + {"text": "Pinned Old", "pinned_to": "HEADLINE_1"}, + "Unpinned Old Two", + "Unpinned Old Three", + ] + mock_fetch.return_value = pinned_existing + result = draft_rsa_replacement( + config, + customer_id="1234567890", + ad_id="12345", + headlines=VALID_HEADLINES, + descriptions=VALID_DESCRIPTIONS, + ) + assert "plan_id" in result + old_h = result["diff"]["old"]["headlines"] + assert old_h[0] == {"text": "Pinned Old", "pinned_to": "HEADLINE_1"} + assert old_h[1] == {"text": "Unpinned Old Two", "pinned_to": None} + remove_plan(result["plan_id"]) From 3fe8bb1e5d61fea48bdc30e6026ff150d3da7f33 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 23:27:13 +0000 Subject: [PATCH 23/36] Coerce non-string text values in _normalize_assets to prevent TypeError When a dict asset has a non-string text value (e.g. {"text": 123}), _normalize_assets now coerces it to str instead of passing it through to _validate_rsa where len() would raise TypeError. https://claude.ai/code/session_01QX9Uk947P69wPMfeFufQF7 --- src/adloop/ads/write.py | 3 ++- tests/test_draft_rsa_replacement.py | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/adloop/ads/write.py b/src/adloop/ads/write.py index a66c67a..3f82746 100644 --- a/src/adloop/ads/write.py +++ b/src/adloop/ads/write.py @@ -1007,7 +1007,8 @@ def _normalize_assets(items: list[str | dict]) -> list[dict]: if isinstance(item, str): result.append({"text": item, "pinned_to": None}) elif isinstance(item, dict): - result.append({"text": item.get("text", ""), "pinned_to": item.get("pinned_to")}) + raw_text = item.get("text", "") + result.append({"text": str(raw_text) if raw_text is not None else "", "pinned_to": item.get("pinned_to")}) else: result.append({"text": str(item), "pinned_to": None}) return result diff --git a/tests/test_draft_rsa_replacement.py b/tests/test_draft_rsa_replacement.py index 29b295c..24ae2a1 100644 --- a/tests/test_draft_rsa_replacement.py +++ b/tests/test_draft_rsa_replacement.py @@ -313,6 +313,15 @@ def test_mixed_str_and_dict(self): def test_empty_list(self): assert _normalize_assets([]) == [] + def test_non_string_text_coerced(self): + """Non-string text values (e.g. int) should be coerced to str.""" + result = _normalize_assets([ + {"text": 123, "pinned_to": "HEADLINE_1"}, + {"text": None}, + ]) + assert result[0] == {"text": "123", "pinned_to": "HEADLINE_1"} + assert result[1] == {"text": "", "pinned_to": None} + class TestValidateRsaPinning: """Tests for pinning validation in _validate_rsa.""" From ca4530b5adfa505d9a5f35888f5dc1e600021bb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 18:12:17 +0000 Subject: [PATCH 24/36] Add Performance Max read tools and analyze_pmax_performance cross-ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of PMax support — diagnostics only, no write paths yet. - 7 PMax read tools in src/adloop/ads/pmax_read.py: get_pmax_campaigns, get_pmax_channel_breakdown, get_asset_groups, get_asset_group_assets, get_asset_group_signals, get_asset_group_top_combinations, get_pmax_search_terms (v23.2+ campaign_search_term_insight with fallback). - analyze_pmax_performance in crossref.py — single call returns campaign metrics + asset groups + asset performance labels + channel breakdown + GA4 paid sessions, with auto-generated insights[] for POOR/AVERAGE ad strength, LOW assets, channel skew, zero-conversion campaigns, and GDPR consent gaps. - All 8 tools registered in server.py. - Pinned Google Ads API to v24, bumped google-ads dep to >=30.0.0. - Rules file (.cursor/rules/adloop.mdc) extended with Performance Max read-tool inventory, PMax-specific orchestration pattern, GAQL resource reference for asset_group/asset_group_asset/asset_group_signal, example PMax queries, and a marketing best-practices note. - 30 new tests covering each tool's filters, enrichment, error paths, and the cross-ref aggregation. Full suite: 154 passed. https://claude.ai/code/session_019r7TECd9gTVkcUZqmQivwz --- .claude/rules/adloop.md | 75 +++- .cursor/rules/adloop.mdc | 75 +++- CLAUDE.md | 14 +- pyproject.toml | 2 +- src/adloop/ads/client.py | 2 +- src/adloop/ads/pmax_read.py | 421 ++++++++++++++++++++++ src/adloop/crossref.py | 258 ++++++++++++++ src/adloop/server.py | 245 +++++++++++++ tests/test_pmax_read.py | 681 ++++++++++++++++++++++++++++++++++++ 9 files changed, 1762 insertions(+), 11 deletions(-) create mode 100644 src/adloop/ads/pmax_read.py create mode 100644 tests/test_pmax_read.py diff --git a/.claude/rules/adloop.md b/.claude/rules/adloop.md index 77d6499..0e88a89 100644 --- a/.claude/rules/adloop.md +++ b/.claude/rules/adloop.md @@ -46,6 +46,30 @@ You have access to AdLoop MCP tools that connect Google Ads and Google Analytics - `get_keyword_performance` returns `ad_group.id`, `ad_group.name`, and `ad_group_criterion.criterion_id` — use these to construct `entity_id` strings (e.g. `"adGroupId~criterionId"`) for `pause_entity` calls, and `ad_group.name` for human-readable reporting. - `get_search_terms` returns `campaign.id` and `metrics.cost` per search term — use `campaign.id` for `add_negative_keywords`, and cost for negative keyword analysis (flag terms spending > 2-3x CPA with zero conversions). +### Performance Max Read Tools + +Performance Max (PMax) campaigns have a different structure than Search: +- **No ad groups, no keywords, no individual ads.** Instead, an `asset_group` bundles assets (headlines, descriptions, images, logos, videos) that Google assembles dynamically per impression. +- **Channel is decided at serve time.** A PMax campaign can serve on Search, Display, YouTube, Gmail, Discover, Maps, or Shopping — Google chooses based on signals. +- **Search terms are not exposed individually.** PMax surfaces aggregated category-level insights only. + +| Tool | When to Use | Key Parameters | +|------|-------------|----------------| +| `get_pmax_campaigns` | Discover PMax campaigns and their PMax-specific settings (URL expansion, brand guidelines) | `date_range_start`, `date_range_end` | +| `get_pmax_channel_breakdown` | "Where is my PMax spend going?" — Search vs YouTube vs Display etc. | `date_range_start`, `date_range_end`, `campaign_id` (optional) | +| `get_asset_groups` | List asset groups in a PMax campaign with ad strength + metrics | `campaign_id` (optional), `date_range_start`, `date_range_end` | +| `get_asset_group_assets` | Inspect every asset's `field_type`, `performance_label` (LOW/GOOD/BEST/PENDING), and content | `asset_group_id` OR `campaign_id` | +| `get_asset_group_signals` | List search themes and audience signals attached to an asset group | `asset_group_id` OR `campaign_id` | +| `get_asset_group_top_combinations` | See which headline+description+image combos Google assembled and how often they served | `asset_group_id` OR `campaign_id`, `date_range_start`, `date_range_end` | +| `get_pmax_search_terms` | Get aggregated category-level insights (not individual queries) for a PMax campaign | `campaign_id` (REQUIRED), `date_range_start`, `date_range_end` | + +**PMax read tool notes:** +- `get_pmax_campaigns` returns `metrics.cost`, `metrics.cpa`, `metrics.roas`, and `campaign_budget.amount` pre-computed. The Search-style `bidding_strategy_type` is shown — for PMax it's typically `MAXIMIZE_CONVERSIONS` or `MAXIMIZE_CONVERSION_VALUE` (Smart Bidding only). +- `get_pmax_channel_breakdown` is only reliable from **2025-06-01 onwards**. Earlier rows return `MIXED` for `segments.ad_network_type` because Google could not attribute. The tool emits a warning in `insights[]` when the date range overlaps that period. +- `get_asset_group_assets` returns `asset_group_asset.performance_label` — values are `LOW`, `GOOD`, `BEST`, `PENDING`. `PENDING` means Google hasn't gathered enough data yet (typically the first 1-2 weeks). `LOW` assets are clear replacement candidates. +- `get_asset_group_signals` returns `signal_type = SEARCH_THEME | AUDIENCE | UNKNOWN`. Search themes are immutable once created — to "edit", you remove and re-create. +- `get_pmax_search_terms` requires API v23.2+. Returns category labels only — Google deliberately does NOT expose individual search queries for PMax campaigns. Don't tell users they can see exactly what someone typed. + ### Cross-Reference Tools (GA4 + Ads combined) | Tool | When to Use | Key Parameters | @@ -53,11 +77,14 @@ You have access to AdLoop MCP tools that connect Google Ads and Google Analytics | `analyze_campaign_conversions` | "What's my real CPA?", paid vs organic comparison, GDPR gap analysis | `date_range_start`, `date_range_end`, `campaign_name` (optional filter) | | `landing_page_analysis` | "Which landing pages convert?", identify pages with traffic but no conversions | `date_range_start`, `date_range_end` | | `attribution_check` | "Are my conversions tracked correctly?", Ads vs GA4 conversion discrepancies | `date_range_start`, `date_range_end`, `conversion_events` (optional GA4 event names) | +| `analyze_pmax_performance` | One-call PMax diagnostic — campaign + asset groups + asset performance labels + channel mix + GA4 | `date_range_start`, `date_range_end`, `campaign_id` (optional filter) | These tools call both APIs internally and return unified results with computed `insights[]`. They are read-only — no mutations. Each returns a `date_range` and auto-generates conditional warnings (GDPR gaps, zero conversions, attribution mismatches, orphaned URLs). **`analyze_campaign_conversions` details:** Returns one row per campaign (with `campaign_id`) including `conversion_discrepancy_pct` between Ads and GA4. When `campaign_name` is omitted, all campaigns are returned — no need to call once per campaign. +**`analyze_pmax_performance` details:** Aggregates everything you can see about Performance Max in one call: campaign metrics + bidding/URL-expansion/brand-guidelines settings, every asset group with its `ad_strength`, individual asset `performance_label`s, the channel-mix breakdown, and (when a GA4 property is configured) GA4 paid sessions/conversions per campaign. The `insights[]` flag POOR/AVERAGE asset groups, LOW-labeled assets, channel-spend skew (>90% on a single surface), zero-conversion campaigns, GDPR consent gaps, and pre-2025-06-01 data caveats. Use this as the FIRST call when the user asks about PMax performance — it eliminates 4-5 separate read tool calls. + ### Tracking Tools | Tool | When to Use | Key Parameters | @@ -161,6 +188,17 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has 5. Highlight anything concerning: zero conversions, high CPA, low quality scores, wasteful search terms 6. Compare against best practices (see Marketing Best Practices section) +### When user asks about Performance Max performance + +PMax is structurally different from Search — different tools, different diagnostics, different levers. + +1. **Default to `analyze_pmax_performance`.** It pulls campaign + asset groups + assets + channel breakdown + GA4 in one call and returns auto-generated `insights[]`. Don't manually chain `get_pmax_campaigns` + `get_asset_groups` + `get_asset_group_assets` unless you need data the cross-ref tool doesn't surface. +2. If the user asks specifically about creative quality: present the asset groups sorted by `ad_strength`, then drill into LOW-labeled assets via the `low_performing_assets` arrays. +3. If the user asks "where is my budget going?": use the `channel_breakdown` array and emphasize that PMax decides channel mix at serve time. If one channel dominates (>90%), that's worth flagging — Google may be suppressing other surfaces due to weak creative for those formats. +4. **Do NOT compare PMax CPA to Search CPA directly.** PMax includes Display, YouTube, and Discovery surfaces that have intrinsically different conversion dynamics. Compare PMax CPA to the campaign's `target_cpa` (if set) or to historical PMax CPA, not to a Search benchmark. +5. **Search terms work differently for PMax.** When users ask "what are people searching for" — explain that PMax does NOT expose individual queries (Google's design), only aggregated category labels via `get_pmax_search_terms`. Don't promise data the API doesn't return. +6. **GDPR consent gaps apply identically** — the click-to-session ratio insight in `analyze_pmax_performance` factors this in. + ### When user asks about conversions or conversion drops 1. Call `attribution_check` with relevant date range and `conversion_events` if the user mentions specific events (e.g. sign_up, purchase) — this does the Ads vs GA4 comparison in one call and auto-generates insights @@ -356,6 +394,11 @@ LIMIT n | `campaign_budget` | Budget information | | `bidding_strategy` | Bidding strategy details | | `customer_client` | List accounts under an MCC (uses login_customer_id) | +| `asset_group` | Performance Max asset groups (PMax equivalent of ad groups) | +| `asset_group_asset` | Individual assets in PMax asset groups with field_type + performance_label | +| `asset_group_signal` | Search themes and audience signals attached to PMax asset groups | +| `asset_group_top_combination_view` | Top serving combinations Google has assembled for PMax | +| `campaign_search_term_insight` | Aggregated PMax search-term categories (v23.2+, no individual queries) | ### Common Fields @@ -388,7 +431,16 @@ LIMIT n **Segments (for time-based breakdowns):** - `segments.date` — daily breakdown - `segments.device` — MOBILE, DESKTOP, TABLET -- `segments.ad_network_type` — SEARCH, CONTENT, YOUTUBE +- `segments.ad_network_type` — SEARCH, CONTENT, YOUTUBE_WATCH, YOUTUBE_SEARCH, MIXED (PMax channel breakdown — only reliable from 2025-06-01 onwards) + +**Performance Max fields:** +- `campaign.advertising_channel_type = 'PERFORMANCE_MAX'` — filter for PMax campaigns +- `campaign.url_expansion_opt_out` — boolean; when true, PMax only sends traffic to provided final URLs +- `campaign.brand_guidelines_enabled` — boolean; when true, business name + logos are at campaign level not asset group +- `asset_group.id`, `asset_group.name`, `asset_group.status`, `asset_group.ad_strength` (POOR/AVERAGE/GOOD/EXCELLENT) +- `asset_group_asset.field_type` (HEADLINE, DESCRIPTION, MARKETING_IMAGE, LOGO, YOUTUBE_VIDEO, etc.) +- `asset_group_asset.performance_label` (LOW, GOOD, BEST, PENDING) +- `asset_group_signal.search_theme.text`, `asset_group_signal.audience.audience` ### Date Ranges @@ -447,6 +499,26 @@ FROM ad_group WHERE campaign.id = 12345678 ``` +**Performance Max asset groups with ad strength:** +```sql +SELECT asset_group.id, asset_group.name, asset_group.ad_strength, + campaign.name, metrics.cost_micros, metrics.conversions +FROM asset_group +WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX' + AND segments.date DURING LAST_30_DAYS +ORDER BY metrics.cost_micros DESC +``` + +**Performance Max LOW-performing assets (replacement candidates):** +```sql +SELECT asset_group.id, asset_group.name, + asset_group_asset.field_type, asset.text_asset.text, + asset.image_asset.full_size.url +FROM asset_group_asset +WHERE asset_group_asset.performance_label = 'LOW' + AND asset_group_asset.status != 'REMOVED' +``` + ## Ad Copy Character Limits Google Ads enforces hard character limits. The `draft_responsive_search_ad` tool will reject copy that exceeds them, but you must write copy that fits on the FIRST attempt — do not generate copy and hope it fits. @@ -519,3 +591,4 @@ When advising on Google Ads: - **Display paths**: Always set `path1` and `path2` on RSAs. They cost nothing, improve ad relevance, and make the display URL informative (e.g. `example.com/Features/Pricing` instead of bare `example.com`). Derive them from the landing page path or the ad's core message. Max 15 chars each. - **Sitelinks**: Every campaign should have at least 4 sitelinks. They increase ad real estate (more screen space = higher CTR), direct users to key pages, and are free. Good candidates: pricing, features, signup/trial, about, key product pages. Use `draft_sitelinks` to create them. Link text max 25 chars, descriptions max 35 chars each. - **Clicks vs sessions gap**: Never report a clicks > sessions discrepancy as a tracking bug without first accounting for GDPR consent. In the EU, 30-70% of users may reject analytics cookies. This is normal, not broken. +- **Performance Max specifics**: PMax campaigns are Smart Bidding only (`MAXIMIZE_CONVERSIONS` or `MAXIMIZE_CONVERSION_VALUE`) — no MANUAL_CPC option, so the BROAD-match-without-Smart-Bidding rule doesn't apply. Channel mix is decided by Google at serve time; you cannot directly target Search-only or YouTube-only. Asset groups should ideally have `ad_strength = GOOD` or better and zero `LOW`-labeled assets — replace LOW assets first before touching budget. PMax search-term insights expose categories only, not individual queries. diff --git a/.cursor/rules/adloop.mdc b/.cursor/rules/adloop.mdc index 713dbe1..efc352f 100644 --- a/.cursor/rules/adloop.mdc +++ b/.cursor/rules/adloop.mdc @@ -48,6 +48,30 @@ You have access to AdLoop MCP tools that connect Google Ads and Google Analytics - `get_keyword_performance` returns `ad_group.id`, `ad_group.name`, and `ad_group_criterion.criterion_id` — use these to construct `entity_id` strings (e.g. `"adGroupId~criterionId"`) for `pause_entity` calls, and `ad_group.name` for human-readable reporting. - `get_search_terms` returns `campaign.id` and `metrics.cost` per search term — use `campaign.id` for `add_negative_keywords`, and cost for negative keyword analysis (flag terms spending > 2-3x CPA with zero conversions). +### Performance Max Read Tools + +Performance Max (PMax) campaigns have a different structure than Search: +- **No ad groups, no keywords, no individual ads.** Instead, an `asset_group` bundles assets (headlines, descriptions, images, logos, videos) that Google assembles dynamically per impression. +- **Channel is decided at serve time.** A PMax campaign can serve on Search, Display, YouTube, Gmail, Discover, Maps, or Shopping — Google chooses based on signals. +- **Search terms are not exposed individually.** PMax surfaces aggregated category-level insights only. + +| Tool | When to Use | Key Parameters | +|------|-------------|----------------| +| `get_pmax_campaigns` | Discover PMax campaigns and their PMax-specific settings (URL expansion, brand guidelines) | `date_range_start`, `date_range_end` | +| `get_pmax_channel_breakdown` | "Where is my PMax spend going?" — Search vs YouTube vs Display etc. | `date_range_start`, `date_range_end`, `campaign_id` (optional) | +| `get_asset_groups` | List asset groups in a PMax campaign with ad strength + metrics | `campaign_id` (optional), `date_range_start`, `date_range_end` | +| `get_asset_group_assets` | Inspect every asset's `field_type`, `performance_label` (LOW/GOOD/BEST/PENDING), and content | `asset_group_id` OR `campaign_id` | +| `get_asset_group_signals` | List search themes and audience signals attached to an asset group | `asset_group_id` OR `campaign_id` | +| `get_asset_group_top_combinations` | See which headline+description+image combos Google assembled and how often they served | `asset_group_id` OR `campaign_id`, `date_range_start`, `date_range_end` | +| `get_pmax_search_terms` | Get aggregated category-level insights (not individual queries) for a PMax campaign | `campaign_id` (REQUIRED), `date_range_start`, `date_range_end` | + +**PMax read tool notes:** +- `get_pmax_campaigns` returns `metrics.cost`, `metrics.cpa`, `metrics.roas`, and `campaign_budget.amount` pre-computed. The Search-style `bidding_strategy_type` is shown — for PMax it's typically `MAXIMIZE_CONVERSIONS` or `MAXIMIZE_CONVERSION_VALUE` (Smart Bidding only). +- `get_pmax_channel_breakdown` is only reliable from **2025-06-01 onwards**. Earlier rows return `MIXED` for `segments.ad_network_type` because Google could not attribute. The tool emits a warning in `insights[]` when the date range overlaps that period. +- `get_asset_group_assets` returns `asset_group_asset.performance_label` — values are `LOW`, `GOOD`, `BEST`, `PENDING`. `PENDING` means Google hasn't gathered enough data yet (typically the first 1-2 weeks). `LOW` assets are clear replacement candidates. +- `get_asset_group_signals` returns `signal_type = SEARCH_THEME | AUDIENCE | UNKNOWN`. Search themes are immutable once created — to "edit", you remove and re-create. +- `get_pmax_search_terms` requires API v23.2+. Returns category labels only — Google deliberately does NOT expose individual search queries for PMax campaigns. Don't tell users they can see exactly what someone typed. + ### Cross-Reference Tools (GA4 + Ads combined) | Tool | When to Use | Key Parameters | @@ -55,11 +79,14 @@ You have access to AdLoop MCP tools that connect Google Ads and Google Analytics | `analyze_campaign_conversions` | "What's my real CPA?", paid vs organic comparison, GDPR gap analysis | `date_range_start`, `date_range_end`, `campaign_name` (optional filter) | | `landing_page_analysis` | "Which landing pages convert?", identify pages with traffic but no conversions | `date_range_start`, `date_range_end` | | `attribution_check` | "Are my conversions tracked correctly?", Ads vs GA4 conversion discrepancies | `date_range_start`, `date_range_end`, `conversion_events` (optional GA4 event names) | +| `analyze_pmax_performance` | One-call PMax diagnostic — campaign + asset groups + asset performance labels + channel mix + GA4 | `date_range_start`, `date_range_end`, `campaign_id` (optional filter) | These tools call both APIs internally and return unified results with computed `insights[]`. They are read-only — no mutations. Each returns a `date_range` and auto-generates conditional warnings (GDPR gaps, zero conversions, attribution mismatches, orphaned URLs). **`analyze_campaign_conversions` details:** Returns one row per campaign (with `campaign_id`) including `conversion_discrepancy_pct` between Ads and GA4. When `campaign_name` is omitted, all campaigns are returned — no need to call once per campaign. +**`analyze_pmax_performance` details:** Aggregates everything you can see about Performance Max in one call: campaign metrics + bidding/URL-expansion/brand-guidelines settings, every asset group with its `ad_strength`, individual asset `performance_label`s, the channel-mix breakdown, and (when a GA4 property is configured) GA4 paid sessions/conversions per campaign. The `insights[]` flag POOR/AVERAGE asset groups, LOW-labeled assets, channel-spend skew (>90% on a single surface), zero-conversion campaigns, GDPR consent gaps, and pre-2025-06-01 data caveats. Use this as the FIRST call when the user asks about PMax performance — it eliminates 4-5 separate read tool calls. + ### Tracking Tools | Tool | When to Use | Key Parameters | @@ -163,6 +190,17 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has 5. Highlight anything concerning: zero conversions, high CPA, low quality scores, wasteful search terms 6. Compare against best practices (see Marketing Best Practices section) +### When user asks about Performance Max performance + +PMax is structurally different from Search — different tools, different diagnostics, different levers. + +1. **Default to `analyze_pmax_performance`.** It pulls campaign + asset groups + assets + channel breakdown + GA4 in one call and returns auto-generated `insights[]`. Don't manually chain `get_pmax_campaigns` + `get_asset_groups` + `get_asset_group_assets` unless you need data the cross-ref tool doesn't surface. +2. If the user asks specifically about creative quality: present the asset groups sorted by `ad_strength`, then drill into LOW-labeled assets via the `low_performing_assets` arrays. +3. If the user asks "where is my budget going?": use the `channel_breakdown` array and emphasize that PMax decides channel mix at serve time. If one channel dominates (>90%), that's worth flagging — Google may be suppressing other surfaces due to weak creative for those formats. +4. **Do NOT compare PMax CPA to Search CPA directly.** PMax includes Display, YouTube, and Discovery surfaces that have intrinsically different conversion dynamics. Compare PMax CPA to the campaign's `target_cpa` (if set) or to historical PMax CPA, not to a Search benchmark. +5. **Search terms work differently for PMax.** When users ask "what are people searching for" — explain that PMax does NOT expose individual queries (Google's design), only aggregated category labels via `get_pmax_search_terms`. Don't promise data the API doesn't return. +6. **GDPR consent gaps apply identically** — the click-to-session ratio insight in `analyze_pmax_performance` factors this in. + ### When user asks about conversions or conversion drops 1. Call `attribution_check` with relevant date range and `conversion_events` if the user mentions specific events (e.g. sign_up, purchase) — this does the Ads vs GA4 comparison in one call and auto-generates insights @@ -358,6 +396,11 @@ LIMIT n | `campaign_budget` | Budget information | | `bidding_strategy` | Bidding strategy details | | `customer_client` | List accounts under an MCC (uses login_customer_id) | +| `asset_group` | Performance Max asset groups (PMax equivalent of ad groups) | +| `asset_group_asset` | Individual assets in PMax asset groups with field_type + performance_label | +| `asset_group_signal` | Search themes and audience signals attached to PMax asset groups | +| `asset_group_top_combination_view` | Top serving combinations Google has assembled for PMax | +| `campaign_search_term_insight` | Aggregated PMax search-term categories (v23.2+, no individual queries) | ### Common Fields @@ -390,7 +433,16 @@ LIMIT n **Segments (for time-based breakdowns):** - `segments.date` — daily breakdown - `segments.device` — MOBILE, DESKTOP, TABLET -- `segments.ad_network_type` — SEARCH, CONTENT, YOUTUBE +- `segments.ad_network_type` — SEARCH, CONTENT, YOUTUBE_WATCH, YOUTUBE_SEARCH, MIXED (PMax channel breakdown — only reliable from 2025-06-01 onwards) + +**Performance Max fields:** +- `campaign.advertising_channel_type = 'PERFORMANCE_MAX'` — filter for PMax campaigns +- `campaign.url_expansion_opt_out` — boolean; when true, PMax only sends traffic to provided final URLs +- `campaign.brand_guidelines_enabled` — boolean; when true, business name + logos are at campaign level not asset group +- `asset_group.id`, `asset_group.name`, `asset_group.status`, `asset_group.ad_strength` (POOR/AVERAGE/GOOD/EXCELLENT) +- `asset_group_asset.field_type` (HEADLINE, DESCRIPTION, MARKETING_IMAGE, LOGO, YOUTUBE_VIDEO, etc.) +- `asset_group_asset.performance_label` (LOW, GOOD, BEST, PENDING) +- `asset_group_signal.search_theme.text`, `asset_group_signal.audience.audience` ### Date Ranges @@ -449,6 +501,26 @@ FROM ad_group WHERE campaign.id = 12345678 ``` +**Performance Max asset groups with ad strength:** +```sql +SELECT asset_group.id, asset_group.name, asset_group.ad_strength, + campaign.name, metrics.cost_micros, metrics.conversions +FROM asset_group +WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX' + AND segments.date DURING LAST_30_DAYS +ORDER BY metrics.cost_micros DESC +``` + +**Performance Max LOW-performing assets (replacement candidates):** +```sql +SELECT asset_group.id, asset_group.name, + asset_group_asset.field_type, asset.text_asset.text, + asset.image_asset.full_size.url +FROM asset_group_asset +WHERE asset_group_asset.performance_label = 'LOW' + AND asset_group_asset.status != 'REMOVED' +``` + ## Ad Copy Character Limits Google Ads enforces hard character limits. The `draft_responsive_search_ad` tool will reject copy that exceeds them, but you must write copy that fits on the FIRST attempt — do not generate copy and hope it fits. @@ -521,3 +593,4 @@ When advising on Google Ads: - **Display paths**: Always set `path1` and `path2` on RSAs. They cost nothing, improve ad relevance, and make the display URL informative (e.g. `example.com/Features/Pricing` instead of bare `example.com`). Derive them from the landing page path or the ad's core message. Max 15 chars each. - **Sitelinks**: Every campaign should have at least 4 sitelinks. They increase ad real estate (more screen space = higher CTR), direct users to key pages, and are free. Good candidates: pricing, features, signup/trial, about, key product pages. Use `draft_sitelinks` to create them. Link text max 25 chars, descriptions max 35 chars each. - **Clicks vs sessions gap**: Never report a clicks > sessions discrepancy as a tracking bug without first accounting for GDPR consent. In the EU, 30-70% of users may reject analytics cookies. This is normal, not broken. +- **Performance Max specifics**: PMax campaigns are Smart Bidding only (`MAXIMIZE_CONVERSIONS` or `MAXIMIZE_CONVERSION_VALUE`) — no MANUAL_CPC option, so the BROAD-match-without-Smart-Bidding rule doesn't apply. Channel mix is decided by Google at serve time; you cannot directly target Search-only or YouTube-only. Asset groups should ideally have `ad_strength = GOOD` or better and zero `LOW`-labeled assets — replace LOW assets first before touching budget. PMax search-term insights expose categories only, not individual queries. diff --git a/CLAUDE.md b/CLAUDE.md index 2e041e0..f0f5038 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,14 +17,14 @@ python scripts/sync-rules.py # Sync rules: .cursor/rules/ -> .claude/rules/ ``` src/adloop/ ├── __init__.py # Entry point — routes 'adloop init' vs MCP server -├── server.py # FastMCP server — 29 tool registrations +├── server.py # FastMCP server — 37 tool registrations (incl. 8 PMax) ├── config.py # Config loader (~/.adloop/config.yaml) ├── auth.py # OAuth 2.0 + service account + token refresh ├── cli.py # Interactive setup wizard -├── crossref.py # Cross-reference tools (GA4 + Ads combined) +├── crossref.py # Cross-reference tools (GA4 + Ads, incl. analyze_pmax_performance) ├── tracking.py # Tracking validation + code generation ├── ga4/ # GA4 Data + Admin API (reports, realtime, events) -├── ads/ # Google Ads API (read, write, GAQL, forecasting) +├── ads/ # Google Ads API (read, write, GAQL, forecasting, pmax_read) └── safety/ # Guards, previews, audit logging ``` @@ -34,13 +34,13 @@ All tool usage rules, safety protocols, orchestration patterns, GAQL reference, **Read and follow `.claude/rules/adloop.md` for all AdLoop MCP tool orchestration.** -That file is the complete guide for combining AdLoop's 29 tools. It covers: +That file is the complete guide for combining AdLoop's 37 tools (Search + Performance Max). It covers: - Tool inventory with parameters and when to use each - 8 safety rules (budget caps, dry-run defaults, Broad Match prevention, pre-write validation) -- 12 orchestration patterns (performance review, ad creation, tracking diagnosis, etc.) -- GAQL quick reference with syntax, common queries, and gotchas +- 13 orchestration patterns (performance review, ad creation, tracking diagnosis, PMax diagnostics, etc.) +- GAQL quick reference with syntax, common queries, and gotchas (incl. asset_group, asset_group_asset) - GDPR consent awareness for EU markets -- Ad copy character limits and marketing best practices +- Ad copy character limits and marketing best practices (incl. PMax-specific notes) ## Safety Model (Summary) diff --git a/pyproject.toml b/pyproject.toml index 94313b3..b66c400 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ license = { text = "MIT" } keywords = ["mcp", "google-ads", "google-analytics", "ga4", "cursor", "marketing"] dependencies = [ "fastmcp>=3.0.0", - "google-ads>=29.0.0", + "google-ads>=30.0.0", "google-analytics-data>=0.20.0", "google-analytics-admin>=0.27.0", "google-auth-oauthlib>=1.0.0", diff --git a/src/adloop/ads/client.py b/src/adloop/ads/client.py index 2eaa932..7c29322 100644 --- a/src/adloop/ads/client.py +++ b/src/adloop/ads/client.py @@ -12,7 +12,7 @@ # Pin the API version so library upgrades don't silently break field names, # enum values, or mutate operation structures. Bump this deliberately when # migrating to a new API version — never let it float to the library default. -GOOGLE_ADS_API_VERSION = "v23" +GOOGLE_ADS_API_VERSION = "v24" def get_ads_client(config: AdLoopConfig) -> GoogleAdsClient: diff --git a/src/adloop/ads/pmax_read.py b/src/adloop/ads/pmax_read.py new file mode 100644 index 0000000..df1fd29 --- /dev/null +++ b/src/adloop/ads/pmax_read.py @@ -0,0 +1,421 @@ +"""Google Ads Performance Max read tools. + +Performance Max is structurally different from Search: +- No ad_groups — instead, a campaign contains asset_groups +- No keywords — instead, signals (search themes + audiences) hint at intent +- No ads — instead, asset groups bundle assets that Google assembles dynamically +- Channel mix (Search/Display/YouTube/Shopping/Maps/Discover/Gmail) is decided + by Google at serve time, surfaced via segments.asset_interaction_target + +These tools focus on the things you CAN inspect: campaign performance, asset +group structure, individual asset performance ratings, search themes, and +the post-2025-06-01 channel breakdown. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from adloop.config import AdLoopConfig + +# Channel breakdown is only reliable after this date — earlier dates return MIXED. +_CHANNEL_BREAKDOWN_AVAILABLE_FROM = "2025-06-01" + + +def get_pmax_campaigns( + config: AdLoopConfig, + *, + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", +) -> dict: + """Get Performance Max campaigns with PMax-specific settings + metrics.""" + from adloop.ads.gaql import execute_query + + date_clause = _date_clause(date_range_start, date_range_end) + + query = f""" + SELECT campaign.id, campaign.name, campaign.status, + campaign.advertising_channel_type, + campaign.bidding_strategy_type, + campaign.url_expansion_opt_out, + campaign.brand_guidelines_enabled, + campaign_budget.amount_micros, + metrics.impressions, metrics.clicks, metrics.cost_micros, + metrics.conversions, metrics.conversions_value, + metrics.ctr, metrics.average_cpc + FROM campaign + WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX' + AND campaign.status != 'REMOVED' + {date_clause} + ORDER BY metrics.cost_micros DESC + """ + + rows = execute_query(config, customer_id, query) + _enrich_cost_fields(rows) + _enrich_budget_fields(rows) + _enrich_roas(rows) + + return {"campaigns": rows, "total_campaigns": len(rows)} + + +def get_pmax_channel_breakdown( + config: AdLoopConfig, + *, + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", + campaign_id: str = "", +) -> dict: + """Get spend/conversions per serving surface (Search, Display, YouTube, Shopping, etc.). + + Uses segments.asset_interaction_target.interaction_on_this_asset = false to + isolate the channel attribution segment. Pre-2025-06-01 data returns MIXED + for most rows — a warning is added to insights when the date range overlaps + that period. + """ + from adloop.ads.gaql import execute_query + + date_clause = _date_clause(date_range_start, date_range_end) + + campaign_filter = "" + if campaign_id: + cid = _validate_numeric_id(campaign_id, "campaign_id") + campaign_filter = f"AND campaign.id = {cid}" + + query = f""" + SELECT campaign.id, campaign.name, + segments.ad_network_type, + metrics.impressions, metrics.clicks, metrics.cost_micros, + metrics.conversions, metrics.conversions_value + FROM campaign + WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX' + AND campaign.status != 'REMOVED' + {campaign_filter} + {date_clause} + ORDER BY campaign.id, metrics.cost_micros DESC + """ + + rows = execute_query(config, customer_id, query) + _enrich_cost_fields(rows) + _enrich_roas(rows) + + insights = [] + if date_range_start and date_range_start < _CHANNEL_BREAKDOWN_AVAILABLE_FROM: + insights.append( + f"Channel breakdown is only available from {_CHANNEL_BREAKDOWN_AVAILABLE_FROM} " + f"onwards. Earlier rows will appear as MIXED." + ) + + if any(r.get("segments.ad_network_type") == "MIXED" for r in rows): + insights.append( + "Some rows show MIXED ad_network_type — Google could not attribute " + "the spend to a specific channel (often historical pre-June-2025 data)." + ) + + return { + "channel_breakdown": rows, + "total_rows": len(rows), + "insights": insights, + } + + +def get_asset_groups( + config: AdLoopConfig, + *, + customer_id: str = "", + campaign_id: str = "", + date_range_start: str = "", + date_range_end: str = "", +) -> dict: + """List asset groups for a Performance Max campaign with their metrics and ad strength.""" + from adloop.ads.gaql import execute_query + + date_clause = _date_clause(date_range_start, date_range_end) + + campaign_filter = "" + if campaign_id: + cid = _validate_numeric_id(campaign_id, "campaign_id") + campaign_filter = f"AND campaign.id = {cid}" + + query = f""" + SELECT asset_group.id, asset_group.name, asset_group.status, + asset_group.final_urls, asset_group.path1, asset_group.path2, + asset_group.ad_strength, + campaign.id, campaign.name, + metrics.impressions, metrics.clicks, metrics.cost_micros, + metrics.conversions, metrics.conversions_value + FROM asset_group + WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX' + AND asset_group.status != 'REMOVED' + {campaign_filter} + {date_clause} + ORDER BY metrics.cost_micros DESC + """ + + rows = execute_query(config, customer_id, query) + _enrich_cost_fields(rows) + _enrich_roas(rows) + + return {"asset_groups": rows, "total_asset_groups": len(rows)} + + +def get_asset_group_assets( + config: AdLoopConfig, + *, + customer_id: str = "", + asset_group_id: str = "", + campaign_id: str = "", +) -> dict: + """List individual assets in PMax asset groups with field type and performance label. + + Returns asset text/url, the field_type (HEADLINE, DESCRIPTION, MARKETING_IMAGE, + LOGO, YOUTUBE_VIDEO, etc.), and performance_label (LOW, GOOD, BEST, PENDING) + that Google assigns based on actual serving data. + """ + from adloop.ads.gaql import execute_query + + filters = [] + if asset_group_id: + ag = _validate_numeric_id(asset_group_id, "asset_group_id") + filters.append(f"asset_group.id = {ag}") + if campaign_id: + cid = _validate_numeric_id(campaign_id, "campaign_id") + filters.append(f"campaign.id = {cid}") + extra_filter = ("AND " + " AND ".join(filters)) if filters else "" + + query = f""" + SELECT asset_group.id, asset_group.name, + asset_group_asset.field_type, + asset_group_asset.performance_label, + asset_group_asset.status, + asset.id, asset.type, + asset.text_asset.text, + asset.image_asset.full_size.url, + asset.youtube_video_asset.youtube_video_id, + asset.youtube_video_asset.youtube_video_title, + campaign.id, campaign.name + FROM asset_group_asset + WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX' + AND asset_group_asset.status != 'REMOVED' + {extra_filter} + ORDER BY asset_group.id, asset_group_asset.field_type + """ + + rows = execute_query(config, customer_id, query) + _enrich_youtube_url(rows) + + return {"assets": rows, "total_assets": len(rows)} + + +def get_asset_group_signals( + config: AdLoopConfig, + *, + customer_id: str = "", + asset_group_id: str = "", + campaign_id: str = "", +) -> dict: + """List audience and search-theme signals attached to PMax asset groups.""" + from adloop.ads.gaql import execute_query + + filters = [] + if asset_group_id: + ag = _validate_numeric_id(asset_group_id, "asset_group_id") + filters.append(f"asset_group.id = {ag}") + if campaign_id: + cid = _validate_numeric_id(campaign_id, "campaign_id") + filters.append(f"campaign.id = {cid}") + extra_filter = ("AND " + " AND ".join(filters)) if filters else "" + + query = f""" + SELECT asset_group.id, asset_group.name, + asset_group_signal.resource_name, + asset_group_signal.audience.audience, + asset_group_signal.search_theme.text, + campaign.id, campaign.name + FROM asset_group_signal + WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX' + {extra_filter} + ORDER BY asset_group.id + """ + + rows = execute_query(config, customer_id, query) + + for row in rows: + if row.get("asset_group_signal.search_theme.text"): + row["signal_type"] = "SEARCH_THEME" + elif row.get("asset_group_signal.audience.audience"): + row["signal_type"] = "AUDIENCE" + else: + row["signal_type"] = "UNKNOWN" + + return {"signals": rows, "total_signals": len(rows)} + + +def get_asset_group_top_combinations( + config: AdLoopConfig, + *, + customer_id: str = "", + asset_group_id: str = "", + campaign_id: str = "", + date_range_start: str = "", + date_range_end: str = "", +) -> dict: + """Get top-performing asset combinations Google has assembled at serve time. + + Each row represents a unique combination of headline + description + image + + (optional) video that has actually served, with its impression count. + """ + from adloop.ads.gaql import execute_query + + date_clause = _date_clause(date_range_start, date_range_end) + + filters = [] + if asset_group_id: + ag = _validate_numeric_id(asset_group_id, "asset_group_id") + filters.append(f"asset_group.id = {ag}") + if campaign_id: + cid = _validate_numeric_id(campaign_id, "campaign_id") + filters.append(f"campaign.id = {cid}") + extra_filter = ("AND " + " AND ".join(filters)) if filters else "" + + query = f""" + SELECT asset_group.id, asset_group.name, + asset_group_top_combination_view.asset_group_top_combinations, + metrics.impressions, + campaign.id, campaign.name + FROM asset_group_top_combination_view + WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX' + {extra_filter} + {date_clause} + ORDER BY metrics.impressions DESC + LIMIT 50 + """ + + rows = execute_query(config, customer_id, query) + + return {"combinations": rows, "total_rows": len(rows)} + + +def get_pmax_search_terms( + config: AdLoopConfig, + *, + customer_id: str = "", + campaign_id: str = "", + date_range_start: str = "", + date_range_end: str = "", +) -> dict: + """Get aggregated search-category insights for PMax (post-v23.2 surface). + + Tries `campaign_search_term_insight` first (the v23.2+ resource) and falls + back to category-only insights if the account doesn't yet support the + expanded view. Returns category labels and metrics — individual search terms + are NOT exposed for PMax campaigns by Google's design. + """ + from adloop.ads.gaql import execute_query + + date_clause = _date_clause(date_range_start, date_range_end) + + if not campaign_id: + return { + "error": "campaign_id is required for PMax search terms.", + "hint": ( + "PMax search-term insights are queried per-campaign. " + "Get a Performance Max campaign id from get_pmax_campaigns first." + ), + } + cid = _validate_numeric_id(campaign_id, "campaign_id") + + query = f""" + SELECT campaign_search_term_insight.id, + campaign_search_term_insight.category_label, + metrics.impressions, metrics.clicks, metrics.cost_micros, + metrics.conversions, metrics.conversions_value + FROM campaign_search_term_insight + WHERE campaign_search_term_insight.campaign_id = {cid} + {date_clause} + ORDER BY metrics.impressions DESC + LIMIT 200 + """ + + try: + rows = execute_query(config, customer_id, query) + except Exception as exc: + err = str(exc) + if "UNRECOGNIZED_FIELD" in err or "INVALID_RESOURCE_NAME" in err: + return { + "error": "PMax search term insights are not available on this API version.", + "hint": ( + "campaign_search_term_insight requires Google Ads API v23.2 or " + "later. Bump GOOGLE_ADS_API_VERSION in ads/client.py if needed." + ), + } + raise + + _enrich_cost_fields(rows) + _enrich_roas(rows) + + return {"search_term_categories": rows, "total_rows": len(rows)} + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _date_clause(start: str, end: str) -> str: + """Build a GAQL date WHERE fragment.""" + if start and end: + return f"AND segments.date BETWEEN '{start}' AND '{end}'" + return "AND segments.date DURING LAST_30_DAYS" + + +def _validate_numeric_id(value: str, name: str) -> str: + """Reject non-numeric IDs to prevent GAQL injection.""" + stripped = value.replace("-", "").strip() + if not stripped.isdigit(): + raise ValueError(f"Invalid {name}: {value!r} — must be numeric") + return stripped + + +def _enrich_cost_fields(rows: list[dict]) -> None: + """Add metrics.cost (EUR) and metrics.cpa from cost_micros.""" + for row in rows: + cost_micros = row.get("metrics.cost_micros", 0) or 0 + row["metrics.cost"] = round(cost_micros / 1_000_000, 2) + + conversions = row.get("metrics.conversions", 0) or 0 + if conversions > 0: + row["metrics.cpa"] = round(cost_micros / 1_000_000 / conversions, 2) + + avg_cpc_micros = row.get("metrics.average_cpc", 0) or 0 + if avg_cpc_micros: + row["metrics.average_cpc_eur"] = round(avg_cpc_micros / 1_000_000, 2) + + +def _enrich_budget_fields(rows: list[dict]) -> None: + """Compute human-readable daily budget from budget_micros.""" + for row in rows: + budget_micros = row.get("campaign_budget.amount_micros", 0) or 0 + if budget_micros: + row["campaign_budget.amount"] = round(budget_micros / 1_000_000, 2) + + +def _enrich_roas(rows: list[dict]) -> None: + """Compute ROAS = conversions_value / cost from cost_micros.""" + for row in rows: + cost_micros = row.get("metrics.cost_micros", 0) or 0 + value = row.get("metrics.conversions_value", 0) or 0 + if cost_micros > 0 and value: + row["metrics.roas"] = round(value / (cost_micros / 1_000_000), 2) + + +def _enrich_youtube_url(rows: list[dict]) -> None: + """Build a youtube_url shortcut from the youtube_video_id when present.""" + for row in rows: + vid = row.get("asset.youtube_video_asset.youtube_video_id") + if vid: + row["asset.youtube_video_asset.youtube_url"] = ( + f"https://www.youtube.com/watch?v={vid}" + ) diff --git a/src/adloop/crossref.py b/src/adloop/crossref.py index e82e26b..7520a7e 100644 --- a/src/adloop/crossref.py +++ b/src/adloop/crossref.py @@ -514,3 +514,261 @@ def attribution_check( "insights": insights, "date_range": {"start": start, "end": end}, } + + +# --------------------------------------------------------------------------- +# Tool 4: analyze_pmax_performance +# --------------------------------------------------------------------------- + + +def analyze_pmax_performance( + config: AdLoopConfig, + *, + customer_id: str = "", + property_id: str = "", + date_range_start: str = "", + date_range_end: str = "", + campaign_id: str = "", +) -> dict: + """Performance Max diagnostic — campaign + asset groups + assets + channels + GA4. + + Aggregates everything you can see about a PMax campaign in one place so the + AI can reason about it as a whole. Pulls campaign metrics, asset group ad + strength, individual asset performance labels, channel breakdown, and (when + a property is configured) GA4 paid sessions/conversions. + + Returns auto-generated insights[] flagging: + - Asset groups with POOR or AVERAGE ad strength + - Assets labeled LOW that should be replaced + - Channel skew (e.g. 90%+ of spend going to a single surface) + - Zero-conversion campaigns despite spend + - GDPR consent gaps (click-to-session ratio > 2:1) + - Pre-2025-06-01 channel breakdown caveats + """ + from adloop.ads.pmax_read import ( + get_asset_group_assets, + get_asset_groups, + get_pmax_campaigns, + get_pmax_channel_breakdown, + ) + from adloop.ga4.reports import run_ga4_report + + start, end = _default_date_range(date_range_start, date_range_end) + + campaigns_result = get_pmax_campaigns( + config, customer_id=customer_id, + date_range_start=start, date_range_end=end, + ) + if "error" in campaigns_result: + return campaigns_result + + pmax_campaigns = campaigns_result.get("campaigns", []) + if campaign_id: + pmax_campaigns = [ + c for c in pmax_campaigns if str(c.get("campaign.id", "")) == str(campaign_id) + ] + if not pmax_campaigns: + return { + "error": f"No PMax campaign found with id {campaign_id}.", + "hint": "Use get_pmax_campaigns to list available campaigns.", + } + + asset_groups_result = get_asset_groups( + config, customer_id=customer_id, campaign_id=campaign_id, + date_range_start=start, date_range_end=end, + ) + asset_groups = asset_groups_result.get("asset_groups", []) + + assets_result = get_asset_group_assets( + config, customer_id=customer_id, campaign_id=campaign_id, + ) + assets = assets_result.get("assets", []) + + channels_result = get_pmax_channel_breakdown( + config, customer_id=customer_id, campaign_id=campaign_id, + date_range_start=start, date_range_end=end, + ) + channels = channels_result.get("channel_breakdown", []) + + ga4_paid_by_campaign: dict[str, dict] = {} + if property_id: + try: + ga4_result = run_ga4_report( + config, property_id=property_id, + dimensions=["sessionCampaignName", "sessionSource", "sessionMedium"], + metrics=["sessions", "conversions", "engagedSessions"], + date_range_start=start, date_range_end=end, + limit=1000, + ) + for row in ga4_result.get("rows", []): + source = row.get("sessionSource", "") + medium = row.get("sessionMedium", "") + if source != "google" or medium != "cpc": + continue + name = row.get("sessionCampaignName", "") + bucket = ga4_paid_by_campaign.setdefault( + name, {"sessions": 0, "conversions": 0, "engaged": 0} + ) + bucket["sessions"] += _safe_int(row.get("sessions", 0)) + bucket["conversions"] += _safe_int(row.get("conversions", 0)) + bucket["engaged"] += _safe_int(row.get("engagedSessions", 0)) + except Exception: + ga4_paid_by_campaign = {} + + assets_by_group: dict[str, list[dict]] = {} + for asset in assets: + ag_id = str(asset.get("asset_group.id", "")) + assets_by_group.setdefault(ag_id, []).append(asset) + + channels_by_campaign: dict[str, list[dict]] = {} + for ch in channels: + cmp_id = str(ch.get("campaign.id", "")) + channels_by_campaign.setdefault(cmp_id, []).append(ch) + + summaries = [] + insights = [] + + for camp in pmax_campaigns: + cmp_id = str(camp.get("campaign.id", "")) + cmp_name = camp.get("campaign.name", "") + + cmp_clicks = _safe_int(camp.get("metrics.clicks", 0)) + cmp_cost = _safe_float(camp.get("metrics.cost", 0)) + cmp_conv = _safe_float(camp.get("metrics.conversions", 0)) + cmp_value = _safe_float(camp.get("metrics.conversions_value", 0)) + + ga4 = ga4_paid_by_campaign.get(cmp_name, {"sessions": 0, "conversions": 0}) + click_session_ratio = _safe_div(cmp_clicks, ga4["sessions"]) + + cmp_groups = [ + ag for ag in asset_groups + if str(ag.get("campaign.id", "")) == cmp_id + ] + weak_groups = [ + ag for ag in cmp_groups + if ag.get("asset_group.ad_strength") in ("POOR", "AVERAGE") + ] + + group_summaries = [] + for ag in cmp_groups: + ag_id = str(ag.get("asset_group.id", "")) + ag_assets = assets_by_group.get(ag_id, []) + + counts: dict[str, int] = {} + low_assets: list[dict] = [] + for a in ag_assets: + ftype = a.get("asset_group_asset.field_type", "UNKNOWN") + counts[ftype] = counts.get(ftype, 0) + 1 + if a.get("asset_group_asset.performance_label") == "LOW": + low_assets.append({ + "asset_id": str(a.get("asset.id", "")), + "field_type": ftype, + "text": a.get("asset.text_asset.text"), + "image_url": a.get("asset.image_asset.full_size.url"), + }) + + group_summaries.append({ + "asset_group_id": ag_id, + "asset_group_name": ag.get("asset_group.name", ""), + "ad_strength": ag.get("asset_group.ad_strength", ""), + "asset_counts_by_type": counts, + "low_performing_assets": low_assets, + "metrics": { + "cost": _safe_float(ag.get("metrics.cost", 0)), + "clicks": _safe_int(ag.get("metrics.clicks", 0)), + "conversions": _safe_float(ag.get("metrics.conversions", 0)), + }, + }) + + if low_assets: + insights.append( + f"{cmp_name} / {ag.get('asset_group.name', '')}: " + f"{len(low_assets)} LOW-performing asset(s) — " + f"replace via draft_pmax_assets + replace_pmax_asset" + ) + + if ag.get("asset_group.ad_strength") in ("POOR", "AVERAGE"): + insights.append( + f"{cmp_name} / {ag.get('asset_group.name', '')}: " + f"ad strength is {ag.get('asset_group.ad_strength')} — " + f"add more headlines/descriptions/images to improve" + ) + + cmp_channels = channels_by_campaign.get(cmp_id, []) + channel_summary = [] + total_channel_cost = sum( + _safe_float(c.get("metrics.cost", 0)) for c in cmp_channels + ) + for ch in cmp_channels: + ch_cost = _safe_float(ch.get("metrics.cost", 0)) + share = _safe_div(ch_cost, total_channel_cost) + channel_summary.append({ + "ad_network_type": ch.get("segments.ad_network_type", ""), + "cost": ch_cost, + "clicks": _safe_int(ch.get("metrics.clicks", 0)), + "conversions": _safe_float(ch.get("metrics.conversions", 0)), + "spend_share": share, + }) + + if total_channel_cost > 0: + top = max(channel_summary, key=lambda c: c["cost"]) + if top["spend_share"] is not None and top["spend_share"] > 0.90: + insights.append( + f"{cmp_name}: {top['spend_share']:.0%} of spend going to " + f"{top['ad_network_type']} — channel mix is heavily skewed, " + f"consider whether other surfaces are being suppressed" + ) + + if cmp_cost > 0 and cmp_conv == 0: + insights.append( + f"{cmp_name}: €{cmp_cost:.2f} spend with 0 conversions — " + f"check that conversion goals are linked and tracking fires" + ) + + if click_session_ratio is not None and click_session_ratio > 2.0 and cmp_clicks > 5: + lost_pct = round((1 - 1 / click_session_ratio) * 100) + insights.append( + f"{cmp_name}: click-to-session ratio is {click_session_ratio:.1f}:1 " + f"— ~{lost_pct}% of paid clicks not in GA4 (likely GDPR consent)" + ) + + summaries.append({ + "campaign_id": cmp_id, + "campaign_name": cmp_name, + "campaign_status": camp.get("campaign.status", ""), + "bidding_strategy_type": camp.get("campaign.bidding_strategy_type", ""), + "url_expansion_opt_out": camp.get("campaign.url_expansion_opt_out"), + "brand_guidelines_enabled": camp.get("campaign.brand_guidelines_enabled"), + "daily_budget": camp.get("campaign_budget.amount"), + "metrics": { + "clicks": cmp_clicks, + "cost": cmp_cost, + "conversions": cmp_conv, + "conversions_value": cmp_value, + "cpa": camp.get("metrics.cpa"), + "roas": camp.get("metrics.roas"), + }, + "ga4_paid": { + "sessions": ga4["sessions"], + "conversions": ga4["conversions"], + "click_to_session_ratio": click_session_ratio, + } if property_id else None, + "asset_groups": group_summaries, + "weak_asset_groups": len(weak_groups), + "channel_breakdown": channel_summary, + }) + + insights.extend(channels_result.get("insights", [])) + + if not pmax_campaigns: + insights.append( + "No Performance Max campaigns found in this account for the date range. " + "If you expected to see campaigns, check campaign.status filters or date range." + ) + + return { + "campaigns": summaries, + "total_campaigns": len(summaries), + "insights": insights, + "date_range": {"start": start, "end": end}, + } diff --git a/src/adloop/server.py b/src/adloop/server.py index 10f5663..2109947 100644 --- a/src/adloop/server.py +++ b/src/adloop/server.py @@ -700,6 +700,251 @@ def attribution_check( ) +# --------------------------------------------------------------------------- +# Performance Max Read Tools +# --------------------------------------------------------------------------- + + +@mcp.tool(annotations=_READONLY) +@_safe +def get_pmax_campaigns( + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", +) -> dict: + """Get Performance Max campaigns with PMax-specific settings and metrics. + + Returns: campaign id/name/status, bidding strategy, URL expansion setting, + brand guidelines flag, daily budget, impressions, clicks, cost, conversions, + conversions_value, CPA, and ROAS for each PMax campaign. + + Date format: "YYYY-MM-DD". Empty = last 30 days. + """ + from adloop.ads.pmax_read import get_pmax_campaigns as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + date_range_start=date_range_start, + date_range_end=date_range_end, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def get_pmax_channel_breakdown( + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", + campaign_id: str = "", +) -> dict: + """Get PMax spend/clicks/conversions per serving surface (Search/Display/YouTube/etc.). + + Uses segments.ad_network_type to break down where PMax actually served. + Channel-level data is only reliable from 2025-06-01 onwards — earlier + rows return MIXED. The tool emits a warning in insights when the date + range overlaps that period. + + campaign_id: optional filter to a single campaign. + Date format: "YYYY-MM-DD". Empty = last 30 days. + """ + from adloop.ads.pmax_read import get_pmax_channel_breakdown as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + date_range_start=date_range_start, + date_range_end=date_range_end, + campaign_id=campaign_id, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def get_asset_groups( + customer_id: str = "", + campaign_id: str = "", + date_range_start: str = "", + date_range_end: str = "", +) -> dict: + """List PMax asset groups with their final URLs, paths, ad strength, and metrics. + + Asset groups are the PMax equivalent of ad groups — each contains a bundle + of assets (headlines, descriptions, images, logos, videos) that Google + assembles dynamically. Ad strength values: POOR | AVERAGE | GOOD | EXCELLENT. + + campaign_id: optional filter to a single campaign. + Date format: "YYYY-MM-DD". Empty = last 30 days. + """ + from adloop.ads.pmax_read import get_asset_groups as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + campaign_id=campaign_id, + date_range_start=date_range_start, + date_range_end=date_range_end, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def get_asset_group_assets( + customer_id: str = "", + asset_group_id: str = "", + campaign_id: str = "", +) -> dict: + """List individual assets in PMax asset groups with field type and performance label. + + Returns asset id/type, field_type (HEADLINE, DESCRIPTION, MARKETING_IMAGE, + LOGO, YOUTUBE_VIDEO, etc.), performance_label (LOW, GOOD, BEST, PENDING), + text content, image URL, or YouTube video id/title/url depending on type. + + Use this to identify LOW-performing assets that should be replaced. + Provide either asset_group_id (single group) or campaign_id (all groups in + the campaign). With both empty, returns all assets across all PMax campaigns. + """ + from adloop.ads.pmax_read import get_asset_group_assets as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + asset_group_id=asset_group_id, + campaign_id=campaign_id, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def get_asset_group_signals( + customer_id: str = "", + asset_group_id: str = "", + campaign_id: str = "", +) -> dict: + """List audience and search-theme signals attached to PMax asset groups. + + Signals are not hard targeting — they are hints to Google's algorithm about + who and what kind of search intent the asset group should match. Each row + has signal_type = SEARCH_THEME or AUDIENCE. + + Provide either asset_group_id or campaign_id. Both empty returns all signals + across all PMax campaigns. + """ + from adloop.ads.pmax_read import get_asset_group_signals as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + asset_group_id=asset_group_id, + campaign_id=campaign_id, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def get_asset_group_top_combinations( + customer_id: str = "", + asset_group_id: str = "", + campaign_id: str = "", + date_range_start: str = "", + date_range_end: str = "", +) -> dict: + """Get top-performing asset combinations Google has assembled at serve time. + + Each row represents a unique combination (headline + description + image + + optional video) that has actually served, with its impression count. + Use this to understand which message/creative pairings work best. + + Provide either asset_group_id or campaign_id. Returns up to 50 rows ordered + by impressions DESC. + Date format: "YYYY-MM-DD". Empty = last 30 days. + """ + from adloop.ads.pmax_read import get_asset_group_top_combinations as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + asset_group_id=asset_group_id, + campaign_id=campaign_id, + date_range_start=date_range_start, + date_range_end=date_range_end, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def get_pmax_search_terms( + campaign_id: str, + customer_id: str = "", + date_range_start: str = "", + date_range_end: str = "", +) -> dict: + """Get aggregated search-term category insights for a Performance Max campaign. + + Note: PMax does NOT expose individual search terms (Google's design choice). + This returns category labels (e.g. "Buy women's running shoes") aggregated + across many real queries, with metrics. Useful for understanding what + search themes the campaign is matching. + + campaign_id is REQUIRED — these insights are queried per-campaign. + Date format: "YYYY-MM-DD". Empty = last 30 days. + """ + from adloop.ads.pmax_read import get_pmax_search_terms as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + campaign_id=campaign_id, + date_range_start=date_range_start, + date_range_end=date_range_end, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def analyze_pmax_performance( + date_range_start: str = "", + date_range_end: str = "", + customer_id: str = "", + property_id: str = "", + campaign_id: str = "", +) -> dict: + """Comprehensive PMax diagnostic — campaign + asset groups + assets + channels + GA4. + + Pulls everything you can inspect about Performance Max in one call: + campaign metrics + bidding/URL-expansion/brand-guidelines settings, every + asset group with its ad strength, individual asset performance labels, + channel-mix breakdown, and (when property_id is configured) GA4 paid + sessions/conversions per campaign. + + Returns auto-generated insights[] flagging: + - Asset groups with POOR or AVERAGE ad strength + - Individual assets labeled LOW that should be replaced + - Channel skew (e.g. >90% of spend on a single surface) + - Zero-conversion campaigns despite spend + - GDPR consent gaps (click-to-session ratio > 2:1) + - Pre-2025-06-01 channel breakdown caveats + + campaign_id: optional filter — when provided, returns only that PMax campaign. + Date format: "YYYY-MM-DD". Empty = last 30 days. + """ + from adloop.crossref import analyze_pmax_performance as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + property_id=property_id or _config.ga4.property_id, + date_range_start=date_range_start, + date_range_end=date_range_end, + campaign_id=campaign_id, + ) + + +# --------------------------------------------------------------------------- +# Custom GAQL +# --------------------------------------------------------------------------- + + @mcp.tool(annotations=_READONLY) @_safe def run_gaql( diff --git a/tests/test_pmax_read.py b/tests/test_pmax_read.py new file mode 100644 index 0000000..05320b8 --- /dev/null +++ b/tests/test_pmax_read.py @@ -0,0 +1,681 @@ +"""Tests for Performance Max read tools and analyze_pmax_performance cross-ref.""" + +from unittest.mock import patch + +import pytest + +from adloop.ads.pmax_read import ( + get_asset_group_assets, + get_asset_group_signals, + get_asset_group_top_combinations, + get_asset_groups, + get_pmax_campaigns, + get_pmax_channel_breakdown, + get_pmax_search_terms, +) +from adloop.config import AdLoopConfig, AdsConfig, GA4Config, SafetyConfig +from adloop.crossref import analyze_pmax_performance + + +@pytest.fixture +def config(): + return AdLoopConfig( + ads=AdsConfig(customer_id="1234567890", developer_token="test"), + ga4=GA4Config(property_id="properties/123456"), + safety=SafetyConfig(max_daily_budget=50.0, require_dry_run=True), + ) + + +# --------------------------------------------------------------------------- +# get_pmax_campaigns +# --------------------------------------------------------------------------- + + +class TestGetPmaxCampaigns: + @patch("adloop.ads.gaql.execute_query") + def test_filters_to_performance_max(self, mock_query, config): + mock_query.return_value = [] + + get_pmax_campaigns(config, customer_id="1234567890") + + call_query = mock_query.call_args[0][2] + assert "campaign.advertising_channel_type = 'PERFORMANCE_MAX'" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_enriches_cost_budget_roas(self, mock_query, config): + mock_query.return_value = [ + { + "campaign.id": 111, + "campaign.name": "PMax A", + "campaign.status": "ENABLED", + "campaign.advertising_channel_type": "PERFORMANCE_MAX", + "campaign.bidding_strategy_type": "MAXIMIZE_CONVERSIONS", + "campaign.url_expansion_opt_out": False, + "campaign.brand_guidelines_enabled": True, + "campaign_budget.amount_micros": 25_000_000, + "metrics.impressions": 10_000, + "metrics.clicks": 200, + "metrics.cost_micros": 80_000_000, + "metrics.conversions": 8, + "metrics.conversions_value": 800.0, + "metrics.ctr": 0.02, + "metrics.average_cpc": 400_000, + } + ] + + result = get_pmax_campaigns(config, customer_id="1234567890") + + assert result["total_campaigns"] == 1 + row = result["campaigns"][0] + assert row["metrics.cost"] == 80.0 + assert row["metrics.cpa"] == 10.0 + assert row["metrics.roas"] == 10.0 # 800 / 80 + assert row["campaign_budget.amount"] == 25.0 + assert row["metrics.average_cpc_eur"] == 0.4 + + @patch("adloop.ads.gaql.execute_query") + def test_with_date_range(self, mock_query, config): + mock_query.return_value = [] + + get_pmax_campaigns( + config, + customer_id="1234567890", + date_range_start="2026-04-01", + date_range_end="2026-04-30", + ) + + call_query = mock_query.call_args[0][2] + assert "BETWEEN '2026-04-01' AND '2026-04-30'" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_default_date_uses_last_30_days(self, mock_query, config): + mock_query.return_value = [] + + get_pmax_campaigns(config, customer_id="1234567890") + + call_query = mock_query.call_args[0][2] + assert "DURING LAST_30_DAYS" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_empty_results(self, mock_query, config): + mock_query.return_value = [] + + result = get_pmax_campaigns(config, customer_id="1234567890") + + assert result["campaigns"] == [] + assert result["total_campaigns"] == 0 + + +# --------------------------------------------------------------------------- +# get_pmax_channel_breakdown +# --------------------------------------------------------------------------- + + +class TestGetPmaxChannelBreakdown: + @patch("adloop.ads.gaql.execute_query") + def test_returns_per_channel(self, mock_query, config): + mock_query.return_value = [ + { + "campaign.id": 111, + "campaign.name": "PMax A", + "segments.ad_network_type": "SEARCH", + "metrics.impressions": 1000, + "metrics.clicks": 50, + "metrics.cost_micros": 30_000_000, + "metrics.conversions": 3, + "metrics.conversions_value": 300.0, + }, + { + "campaign.id": 111, + "campaign.name": "PMax A", + "segments.ad_network_type": "YOUTUBE_WATCH", + "metrics.impressions": 5000, + "metrics.clicks": 20, + "metrics.cost_micros": 10_000_000, + "metrics.conversions": 1, + "metrics.conversions_value": 100.0, + }, + ] + + result = get_pmax_channel_breakdown(config, customer_id="1234567890") + + assert result["total_rows"] == 2 + first = result["channel_breakdown"][0] + assert first["metrics.cost"] == 30.0 + assert first["metrics.roas"] == 10.0 # 300 / 30 + + @patch("adloop.ads.gaql.execute_query") + def test_warns_about_pre_june_2025(self, mock_query, config): + mock_query.return_value = [] + + result = get_pmax_channel_breakdown( + config, + customer_id="1234567890", + date_range_start="2025-04-01", + date_range_end="2025-04-30", + ) + + assert any("2025-06-01" in i for i in result["insights"]) + + @patch("adloop.ads.gaql.execute_query") + def test_warns_when_mixed_present(self, mock_query, config): + mock_query.return_value = [ + { + "campaign.id": 111, + "campaign.name": "PMax A", + "segments.ad_network_type": "MIXED", + "metrics.impressions": 100, + "metrics.clicks": 5, + "metrics.cost_micros": 1_000_000, + "metrics.conversions": 0, + } + ] + + result = get_pmax_channel_breakdown(config, customer_id="1234567890") + + assert any("MIXED" in i for i in result["insights"]) + + @patch("adloop.ads.gaql.execute_query") + def test_campaign_filter(self, mock_query, config): + mock_query.return_value = [] + + get_pmax_channel_breakdown( + config, customer_id="1234567890", campaign_id="999" + ) + + call_query = mock_query.call_args[0][2] + assert "campaign.id = 999" in call_query + + def test_invalid_campaign_id_raises(self, config): + with pytest.raises(ValueError, match="must be numeric"): + get_pmax_channel_breakdown( + config, customer_id="1234567890", campaign_id="DROP TABLE" + ) + + +# --------------------------------------------------------------------------- +# get_asset_groups +# --------------------------------------------------------------------------- + + +class TestGetAssetGroups: + @patch("adloop.ads.gaql.execute_query") + def test_returns_ad_strength_and_metrics(self, mock_query, config): + mock_query.return_value = [ + { + "asset_group.id": 555, + "asset_group.name": "Group 1", + "asset_group.status": "ENABLED", + "asset_group.final_urls": ["https://example.com/a"], + "asset_group.path1": "products", + "asset_group.path2": "shoes", + "asset_group.ad_strength": "GOOD", + "campaign.id": 111, + "campaign.name": "PMax A", + "metrics.impressions": 5000, + "metrics.clicks": 100, + "metrics.cost_micros": 40_000_000, + "metrics.conversions": 4, + "metrics.conversions_value": 400.0, + } + ] + + result = get_asset_groups(config, customer_id="1234567890") + + assert result["total_asset_groups"] == 1 + row = result["asset_groups"][0] + assert row["asset_group.ad_strength"] == "GOOD" + assert row["metrics.cost"] == 40.0 + assert row["metrics.roas"] == 10.0 + assert row["metrics.cpa"] == 10.0 + + @patch("adloop.ads.gaql.execute_query") + def test_campaign_filter(self, mock_query, config): + mock_query.return_value = [] + + get_asset_groups( + config, customer_id="1234567890", campaign_id="999" + ) + + call_query = mock_query.call_args[0][2] + assert "campaign.id = 999" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_filters_to_performance_max(self, mock_query, config): + mock_query.return_value = [] + + get_asset_groups(config, customer_id="1234567890") + + call_query = mock_query.call_args[0][2] + assert "campaign.advertising_channel_type = 'PERFORMANCE_MAX'" in call_query + + +# --------------------------------------------------------------------------- +# get_asset_group_assets +# --------------------------------------------------------------------------- + + +class TestGetAssetGroupAssets: + @patch("adloop.ads.gaql.execute_query") + def test_builds_youtube_url(self, mock_query, config): + mock_query.return_value = [ + { + "asset_group.id": 555, + "asset_group.name": "Group 1", + "asset_group_asset.field_type": "YOUTUBE_VIDEO", + "asset_group_asset.performance_label": "GOOD", + "asset_group_asset.status": "ENABLED", + "asset.id": 999, + "asset.type": "YOUTUBE_VIDEO", + "asset.text_asset.text": None, + "asset.image_asset.full_size.url": None, + "asset.youtube_video_asset.youtube_video_id": "dQw4w9WgXcQ", + "asset.youtube_video_asset.youtube_video_title": "Sample", + "campaign.id": 111, + "campaign.name": "PMax A", + } + ] + + result = get_asset_group_assets(config, customer_id="1234567890") + + row = result["assets"][0] + assert ( + row["asset.youtube_video_asset.youtube_url"] + == "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + ) + + @patch("adloop.ads.gaql.execute_query") + def test_asset_group_id_filter(self, mock_query, config): + mock_query.return_value = [] + + get_asset_group_assets( + config, customer_id="1234567890", asset_group_id="777" + ) + + call_query = mock_query.call_args[0][2] + assert "asset_group.id = 777" in call_query + + @patch("adloop.ads.gaql.execute_query") + def test_campaign_id_filter(self, mock_query, config): + mock_query.return_value = [] + + get_asset_group_assets( + config, customer_id="1234567890", campaign_id="999" + ) + + call_query = mock_query.call_args[0][2] + assert "campaign.id = 999" in call_query + + def test_invalid_asset_group_id_raises(self, config): + with pytest.raises(ValueError, match="must be numeric"): + get_asset_group_assets( + config, customer_id="1234567890", asset_group_id="abc" + ) + + +# --------------------------------------------------------------------------- +# get_asset_group_signals +# --------------------------------------------------------------------------- + + +class TestGetAssetGroupSignals: + @patch("adloop.ads.gaql.execute_query") + def test_classifies_search_theme_vs_audience(self, mock_query, config): + mock_query.return_value = [ + { + "asset_group.id": 555, + "asset_group.name": "Group 1", + "asset_group_signal.resource_name": "x/1", + "asset_group_signal.audience.audience": None, + "asset_group_signal.search_theme.text": "buy running shoes", + "campaign.id": 111, + "campaign.name": "PMax A", + }, + { + "asset_group.id": 555, + "asset_group.name": "Group 1", + "asset_group_signal.resource_name": "x/2", + "asset_group_signal.audience.audience": "customers/1/audiences/abc", + "asset_group_signal.search_theme.text": None, + "campaign.id": 111, + "campaign.name": "PMax A", + }, + ] + + result = get_asset_group_signals(config, customer_id="1234567890") + + signals = result["signals"] + assert signals[0]["signal_type"] == "SEARCH_THEME" + assert signals[1]["signal_type"] == "AUDIENCE" + + @patch("adloop.ads.gaql.execute_query") + def test_empty_signals(self, mock_query, config): + mock_query.return_value = [] + + result = get_asset_group_signals(config, customer_id="1234567890") + + assert result["total_signals"] == 0 + + +# --------------------------------------------------------------------------- +# get_asset_group_top_combinations +# --------------------------------------------------------------------------- + + +class TestGetAssetGroupTopCombinations: + @patch("adloop.ads.gaql.execute_query") + def test_basic_query(self, mock_query, config): + mock_query.return_value = [ + { + "asset_group.id": 555, + "asset_group.name": "Group 1", + "asset_group_top_combination_view.asset_group_top_combinations": "...", + "metrics.impressions": 1500, + "campaign.id": 111, + "campaign.name": "PMax A", + } + ] + + result = get_asset_group_top_combinations( + config, customer_id="1234567890", asset_group_id="555" + ) + + assert result["total_rows"] == 1 + + @patch("adloop.ads.gaql.execute_query") + def test_includes_limit(self, mock_query, config): + mock_query.return_value = [] + + get_asset_group_top_combinations(config, customer_id="1234567890") + + call_query = mock_query.call_args[0][2] + assert "LIMIT 50" in call_query + + +# --------------------------------------------------------------------------- +# get_pmax_search_terms +# --------------------------------------------------------------------------- + + +class TestGetPmaxSearchTerms: + def test_requires_campaign_id(self, config): + result = get_pmax_search_terms(config, customer_id="1234567890") + + assert "error" in result + assert "campaign_id" in result["error"] + + @patch("adloop.ads.gaql.execute_query") + def test_returns_categories(self, mock_query, config): + mock_query.return_value = [ + { + "campaign_search_term_insight.id": "abc/123", + "campaign_search_term_insight.category_label": "buy running shoes", + "metrics.impressions": 500, + "metrics.clicks": 30, + "metrics.cost_micros": 15_000_000, + "metrics.conversions": 2, + "metrics.conversions_value": 200.0, + } + ] + + result = get_pmax_search_terms( + config, customer_id="1234567890", campaign_id="111" + ) + + assert result["total_rows"] == 1 + row = result["search_term_categories"][0] + assert row["metrics.cost"] == 15.0 + assert row["metrics.cpa"] == 7.5 + + @patch("adloop.ads.gaql.execute_query") + def test_handles_unsupported_api_version(self, mock_query, config): + mock_query.side_effect = Exception( + "UNRECOGNIZED_FIELD: campaign_search_term_insight" + ) + + result = get_pmax_search_terms( + config, customer_id="1234567890", campaign_id="111" + ) + + assert "error" in result + assert "v23.2" in result["hint"] + + def test_invalid_campaign_id_raises(self, config): + with pytest.raises(ValueError, match="must be numeric"): + get_pmax_search_terms( + config, customer_id="1234567890", campaign_id="DROP TABLE" + ) + + +# --------------------------------------------------------------------------- +# analyze_pmax_performance (cross-ref) +# --------------------------------------------------------------------------- + + +class TestAnalyzePmaxPerformance: + @patch("adloop.ga4.reports.run_ga4_report") + @patch("adloop.ads.pmax_read.get_pmax_channel_breakdown") + @patch("adloop.ads.pmax_read.get_asset_group_assets") + @patch("adloop.ads.pmax_read.get_asset_groups") + @patch("adloop.ads.pmax_read.get_pmax_campaigns") + def test_aggregates_full_diagnostic( + self, mock_camps, mock_groups, mock_assets, mock_channels, mock_ga4, config + ): + mock_camps.return_value = { + "campaigns": [ + { + "campaign.id": 111, + "campaign.name": "PMax A", + "campaign.status": "ENABLED", + "campaign.bidding_strategy_type": "MAXIMIZE_CONVERSIONS", + "campaign.url_expansion_opt_out": False, + "campaign.brand_guidelines_enabled": True, + "campaign_budget.amount": 25.0, + "metrics.clicks": 200, + "metrics.cost": 80.0, + "metrics.conversions": 8, + "metrics.conversions_value": 800.0, + "metrics.cpa": 10.0, + "metrics.roas": 10.0, + } + ] + } + mock_groups.return_value = { + "asset_groups": [ + { + "asset_group.id": 555, + "asset_group.name": "Group 1", + "asset_group.ad_strength": "POOR", + "campaign.id": 111, + "metrics.cost": 50.0, + "metrics.clicks": 100, + "metrics.conversions": 3, + } + ] + } + mock_assets.return_value = { + "assets": [ + { + "asset.id": 999, + "asset_group.id": 555, + "asset_group_asset.field_type": "HEADLINE", + "asset_group_asset.performance_label": "LOW", + "asset.text_asset.text": "Bad headline", + "asset.image_asset.full_size.url": None, + }, + { + "asset.id": 1000, + "asset_group.id": 555, + "asset_group_asset.field_type": "HEADLINE", + "asset_group_asset.performance_label": "GOOD", + "asset.text_asset.text": "Good headline", + "asset.image_asset.full_size.url": None, + }, + ] + } + mock_channels.return_value = { + "channel_breakdown": [ + { + "campaign.id": 111, + "segments.ad_network_type": "YOUTUBE_WATCH", + "metrics.cost": 75.0, + "metrics.clicks": 150, + "metrics.conversions": 6, + }, + { + "campaign.id": 111, + "segments.ad_network_type": "SEARCH", + "metrics.cost": 5.0, + "metrics.clicks": 50, + "metrics.conversions": 2, + }, + ], + "insights": [], + } + mock_ga4.return_value = { + "rows": [ + { + "sessionCampaignName": "PMax A", + "sessionSource": "google", + "sessionMedium": "cpc", + "sessions": "60", + "conversions": "5", + "engagedSessions": "45", + } + ] + } + + result = analyze_pmax_performance( + config, + customer_id="1234567890", + property_id="properties/123456", + ) + + assert result["total_campaigns"] == 1 + camp = result["campaigns"][0] + assert camp["campaign_id"] == "111" + assert camp["weak_asset_groups"] == 1 + assert len(camp["asset_groups"]) == 1 + ag = camp["asset_groups"][0] + assert ag["ad_strength"] == "POOR" + assert len(ag["low_performing_assets"]) == 1 + assert ag["asset_counts_by_type"]["HEADLINE"] == 2 + # Channel skew check: YouTube is ~94% of spend (75/80) + skew_insights = [i for i in result["insights"] if "skewed" in i] + assert len(skew_insights) == 1 + # POOR ad strength insight + ad_strength_insights = [i for i in result["insights"] if "ad strength is POOR" in i] + assert len(ad_strength_insights) == 1 + # LOW asset insight + low_insights = [i for i in result["insights"] if "LOW-performing" in i] + assert len(low_insights) == 1 + # GA4 paid attached + assert camp["ga4_paid"]["sessions"] == 60 + assert camp["ga4_paid"]["conversions"] == 5 + + @patch("adloop.ads.pmax_read.get_pmax_channel_breakdown") + @patch("adloop.ads.pmax_read.get_asset_group_assets") + @patch("adloop.ads.pmax_read.get_asset_groups") + @patch("adloop.ads.pmax_read.get_pmax_campaigns") + def test_works_without_ga4_property( + self, mock_camps, mock_groups, mock_assets, mock_channels, config + ): + mock_camps.return_value = { + "campaigns": [ + { + "campaign.id": 111, + "campaign.name": "PMax A", + "campaign.status": "ENABLED", + "campaign.bidding_strategy_type": "MAXIMIZE_CONVERSIONS", + "metrics.clicks": 100, + "metrics.cost": 40.0, + "metrics.conversions": 4, + "metrics.conversions_value": 400.0, + } + ] + } + mock_groups.return_value = {"asset_groups": []} + mock_assets.return_value = {"assets": []} + mock_channels.return_value = {"channel_breakdown": [], "insights": []} + + result = analyze_pmax_performance( + config, customer_id="1234567890", property_id="" + ) + + assert result["campaigns"][0]["ga4_paid"] is None + + @patch("adloop.ads.pmax_read.get_pmax_channel_breakdown") + @patch("adloop.ads.pmax_read.get_asset_group_assets") + @patch("adloop.ads.pmax_read.get_asset_groups") + @patch("adloop.ads.pmax_read.get_pmax_campaigns") + def test_zero_conversion_warning( + self, mock_camps, mock_groups, mock_assets, mock_channels, config + ): + mock_camps.return_value = { + "campaigns": [ + { + "campaign.id": 111, + "campaign.name": "PMax A", + "campaign.status": "ENABLED", + "campaign.bidding_strategy_type": "MAXIMIZE_CONVERSIONS", + "metrics.clicks": 50, + "metrics.cost": 100.0, + "metrics.conversions": 0, + "metrics.conversions_value": 0, + } + ] + } + mock_groups.return_value = {"asset_groups": []} + mock_assets.return_value = {"assets": []} + mock_channels.return_value = {"channel_breakdown": [], "insights": []} + + result = analyze_pmax_performance(config, customer_id="1234567890") + + zero_conv = [i for i in result["insights"] if "0 conversions" in i] + assert len(zero_conv) == 1 + + @patch("adloop.ads.pmax_read.get_pmax_channel_breakdown") + @patch("adloop.ads.pmax_read.get_asset_group_assets") + @patch("adloop.ads.pmax_read.get_asset_groups") + @patch("adloop.ads.pmax_read.get_pmax_campaigns") + def test_campaign_id_filter_returns_error_when_missing( + self, mock_camps, mock_groups, mock_assets, mock_channels, config + ): + mock_camps.return_value = { + "campaigns": [ + { + "campaign.id": 111, + "campaign.name": "PMax A", + "campaign.status": "ENABLED", + } + ] + } + mock_groups.return_value = {"asset_groups": []} + mock_assets.return_value = {"assets": []} + mock_channels.return_value = {"channel_breakdown": [], "insights": []} + + result = analyze_pmax_performance( + config, customer_id="1234567890", campaign_id="999" + ) + + assert "error" in result + assert "999" in result["error"] + + @patch("adloop.ads.pmax_read.get_pmax_channel_breakdown") + @patch("adloop.ads.pmax_read.get_asset_group_assets") + @patch("adloop.ads.pmax_read.get_asset_groups") + @patch("adloop.ads.pmax_read.get_pmax_campaigns") + def test_no_pmax_campaigns_in_account( + self, mock_camps, mock_groups, mock_assets, mock_channels, config + ): + mock_camps.return_value = {"campaigns": []} + mock_groups.return_value = {"asset_groups": []} + mock_assets.return_value = {"assets": []} + mock_channels.return_value = {"channel_breakdown": [], "insights": []} + + result = analyze_pmax_performance(config, customer_id="1234567890") + + assert result["total_campaigns"] == 0 + assert any( + "No Performance Max campaigns found" in i for i in result["insights"] + ) From e85e24f02ce97b9138f50cd77dd436132cf6b081 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 18:32:36 +0000 Subject: [PATCH 25/36] =?UTF-8?q?Self-review=20fixes=20for=20Phase=201=20P?= =?UTF-8?q?Max=20=E2=80=94=20docstring=20accuracy,=20GA4=20failure=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - get_pmax_channel_breakdown: docstring claimed it filters with segments.asset_interaction_target.interaction_on_this_asset = false, but the query just uses segments.ad_network_type. Corrected to match actual behavior. - get_pmax_search_terms: docstring claimed a fallback path that doesn't exist in the code (we just return a structured error). Corrected. - analyze_pmax_performance: LOW-asset insight referenced draft_pmax_assets and replace_pmax_asset which don't exist yet (Phase 2/3). Made the message agnostic so users can act on it today. - analyze_pmax_performance: GA4 failures were silently swallowed. Now surfaced as a single warning in insights[] so the user knows why click-to-session and conversion comparisons are missing, while PMax metrics still render. - CLAUDE.md tool count: claimed 37 (was 29 before, also wrong); actual count is 47 @mcp.tool registrations. Pattern count corrected to 16. Tests: 154 passed. https://claude.ai/code/session_019r7TECd9gTVkcUZqmQivwz --- CLAUDE.md | 6 +++--- src/adloop/ads/pmax_read.py | 17 +++++++-------- src/adloop/crossref.py | 41 ++++++++++++++++++++++++------------- 3 files changed, 39 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f0f5038..09cde24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ python scripts/sync-rules.py # Sync rules: .cursor/rules/ -> .claude/rules/ ``` src/adloop/ ├── __init__.py # Entry point — routes 'adloop init' vs MCP server -├── server.py # FastMCP server — 37 tool registrations (incl. 8 PMax) +├── server.py # FastMCP server — 47 tool registrations (incl. 8 PMax) ├── config.py # Config loader (~/.adloop/config.yaml) ├── auth.py # OAuth 2.0 + service account + token refresh ├── cli.py # Interactive setup wizard @@ -34,10 +34,10 @@ All tool usage rules, safety protocols, orchestration patterns, GAQL reference, **Read and follow `.claude/rules/adloop.md` for all AdLoop MCP tool orchestration.** -That file is the complete guide for combining AdLoop's 37 tools (Search + Performance Max). It covers: +That file is the complete guide for combining AdLoop's 47 tools (Search + Performance Max). It covers: - Tool inventory with parameters and when to use each - 8 safety rules (budget caps, dry-run defaults, Broad Match prevention, pre-write validation) -- 13 orchestration patterns (performance review, ad creation, tracking diagnosis, PMax diagnostics, etc.) +- 16 orchestration patterns (performance review, ad creation, tracking diagnosis, PMax diagnostics, etc.) - GAQL quick reference with syntax, common queries, and gotchas (incl. asset_group, asset_group_asset) - GDPR consent awareness for EU markets - Ad copy character limits and marketing best practices (incl. PMax-specific notes) diff --git a/src/adloop/ads/pmax_read.py b/src/adloop/ads/pmax_read.py index df1fd29..9013116 100644 --- a/src/adloop/ads/pmax_read.py +++ b/src/adloop/ads/pmax_read.py @@ -70,10 +70,11 @@ def get_pmax_channel_breakdown( ) -> dict: """Get spend/conversions per serving surface (Search, Display, YouTube, Shopping, etc.). - Uses segments.asset_interaction_target.interaction_on_this_asset = false to - isolate the channel attribution segment. Pre-2025-06-01 data returns MIXED - for most rows — a warning is added to insights when the date range overlaps - that period. + Segments the campaign-level metrics by `segments.ad_network_type`. Channel + attribution for PMax is only reliable from 2025-06-01 onwards — earlier + rows return MIXED because Google could not attribute a specific channel. + The tool emits a warning in `insights[]` when the date range overlaps that + period or when MIXED rows are present in the result. """ from adloop.ads.gaql import execute_query @@ -308,10 +309,10 @@ def get_pmax_search_terms( ) -> dict: """Get aggregated search-category insights for PMax (post-v23.2 surface). - Tries `campaign_search_term_insight` first (the v23.2+ resource) and falls - back to category-only insights if the account doesn't yet support the - expanded view. Returns category labels and metrics — individual search terms - are NOT exposed for PMax campaigns by Google's design. + Queries `campaign_search_term_insight` (the v23.2+ resource). Returns + category labels and metrics — individual search terms are NOT exposed for + PMax campaigns by Google's design. Returns a structured error with a hint + when the resource isn't available on the configured API version. """ from adloop.ads.gaql import execute_query diff --git a/src/adloop/crossref.py b/src/adloop/crossref.py index 7520a7e..5a944bd 100644 --- a/src/adloop/crossref.py +++ b/src/adloop/crossref.py @@ -591,6 +591,7 @@ def analyze_pmax_performance( channels = channels_result.get("channel_breakdown", []) ga4_paid_by_campaign: dict[str, dict] = {} + ga4_warning: str | None = None if property_id: try: ga4_result = run_ga4_report( @@ -600,20 +601,30 @@ def analyze_pmax_performance( date_range_start=start, date_range_end=end, limit=1000, ) - for row in ga4_result.get("rows", []): - source = row.get("sessionSource", "") - medium = row.get("sessionMedium", "") - if source != "google" or medium != "cpc": - continue - name = row.get("sessionCampaignName", "") - bucket = ga4_paid_by_campaign.setdefault( - name, {"sessions": 0, "conversions": 0, "engaged": 0} + if "error" in ga4_result: + ga4_warning = ( + f"GA4 data could not be fetched ({ga4_result['error']}) — " + f"PMax metrics still shown but click-to-session and conversion " + f"comparisons are unavailable." ) - bucket["sessions"] += _safe_int(row.get("sessions", 0)) - bucket["conversions"] += _safe_int(row.get("conversions", 0)) - bucket["engaged"] += _safe_int(row.get("engagedSessions", 0)) - except Exception: - ga4_paid_by_campaign = {} + else: + for row in ga4_result.get("rows", []): + source = row.get("sessionSource", "") + medium = row.get("sessionMedium", "") + if source != "google" or medium != "cpc": + continue + name = row.get("sessionCampaignName", "") + bucket = ga4_paid_by_campaign.setdefault( + name, {"sessions": 0, "conversions": 0, "engaged": 0} + ) + bucket["sessions"] += _safe_int(row.get("sessions", 0)) + bucket["conversions"] += _safe_int(row.get("conversions", 0)) + bucket["engaged"] += _safe_int(row.get("engagedSessions", 0)) + except Exception as exc: + ga4_warning = ( + f"GA4 query failed ({exc}) — PMax metrics still shown but " + f"click-to-session and conversion comparisons are unavailable." + ) assets_by_group: dict[str, list[dict]] = {} for asset in assets: @@ -684,7 +695,7 @@ def analyze_pmax_performance( insights.append( f"{cmp_name} / {ag.get('asset_group.name', '')}: " f"{len(low_assets)} LOW-performing asset(s) — " - f"replace via draft_pmax_assets + replace_pmax_asset" + f"review the low_performing_assets list and replace these in Google Ads" ) if ag.get("asset_group.ad_strength") in ("POOR", "AVERAGE"): @@ -759,6 +770,8 @@ def analyze_pmax_performance( }) insights.extend(channels_result.get("insights", [])) + if ga4_warning: + insights.append(ga4_warning) if not pmax_campaigns: insights.append( From 0f6b0abb14ff53329ca898d4c955fa5c516abf4c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 18:40:23 +0000 Subject: [PATCH 26/36] Set ga4_paid=None on GA4 failure (Codex P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When run_ga4_report fails, the per-campaign loop was still falling back to {"sessions": 0, "conversions": 0} and emitting that as ga4_paid — making "GA4 unavailable" indistinguishable from "campaign has zero paid sessions" for any consumer reading the structured fields. Now ga4_paid is None whenever the GA4 fetch failed (error dict or exception), and the warning continues to surface in insights[]. Two regression tests cover both the error-dict and exception paths. https://claude.ai/code/session_019r7TECd9gTVkcUZqmQivwz --- src/adloop/crossref.py | 2 +- tests/test_pmax_read.py | 78 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/src/adloop/crossref.py b/src/adloop/crossref.py index 5a944bd..6154caa 100644 --- a/src/adloop/crossref.py +++ b/src/adloop/crossref.py @@ -763,7 +763,7 @@ def analyze_pmax_performance( "sessions": ga4["sessions"], "conversions": ga4["conversions"], "click_to_session_ratio": click_session_ratio, - } if property_id else None, + } if property_id and ga4_warning is None else None, "asset_groups": group_summaries, "weak_asset_groups": len(weak_groups), "channel_breakdown": channel_summary, diff --git a/tests/test_pmax_read.py b/tests/test_pmax_read.py index 05320b8..c3b28af 100644 --- a/tests/test_pmax_read.py +++ b/tests/test_pmax_read.py @@ -661,6 +661,84 @@ def test_campaign_id_filter_returns_error_when_missing( assert "error" in result assert "999" in result["error"] + @patch("adloop.ga4.reports.run_ga4_report") + @patch("adloop.ads.pmax_read.get_pmax_channel_breakdown") + @patch("adloop.ads.pmax_read.get_asset_group_assets") + @patch("adloop.ads.pmax_read.get_asset_groups") + @patch("adloop.ads.pmax_read.get_pmax_campaigns") + def test_ga4_paid_is_none_when_ga4_fails( + self, mock_camps, mock_groups, mock_assets, mock_channels, mock_ga4, config + ): + """When GA4 returns an error, ga4_paid must be None — not zeros that + look like real data. Otherwise consumers can't distinguish 'GA4 + unavailable' from 'campaign actually has zero paid sessions'.""" + mock_camps.return_value = { + "campaigns": [ + { + "campaign.id": 111, + "campaign.name": "PMax A", + "campaign.status": "ENABLED", + "campaign.bidding_strategy_type": "MAXIMIZE_CONVERSIONS", + "metrics.clicks": 100, + "metrics.cost": 40.0, + "metrics.conversions": 4, + "metrics.conversions_value": 400.0, + } + ] + } + mock_groups.return_value = {"asset_groups": []} + mock_assets.return_value = {"assets": []} + mock_channels.return_value = {"channel_breakdown": [], "insights": []} + mock_ga4.return_value = {"error": "GA4 property not configured"} + + result = analyze_pmax_performance( + config, + customer_id="1234567890", + property_id="properties/123456", + ) + + # ga4_paid must be None, NOT a zero-filled dict + assert result["campaigns"][0]["ga4_paid"] is None + # The warning should be in insights so the user knows why + ga4_warnings = [i for i in result["insights"] if "GA4" in i] + assert len(ga4_warnings) >= 1 + + @patch("adloop.ga4.reports.run_ga4_report") + @patch("adloop.ads.pmax_read.get_pmax_channel_breakdown") + @patch("adloop.ads.pmax_read.get_asset_group_assets") + @patch("adloop.ads.pmax_read.get_asset_groups") + @patch("adloop.ads.pmax_read.get_pmax_campaigns") + def test_ga4_paid_is_none_when_ga4_raises( + self, mock_camps, mock_groups, mock_assets, mock_channels, mock_ga4, config + ): + """Same guarantee when GA4 raises rather than returning an error dict.""" + mock_camps.return_value = { + "campaigns": [ + { + "campaign.id": 111, + "campaign.name": "PMax A", + "campaign.status": "ENABLED", + "campaign.bidding_strategy_type": "MAXIMIZE_CONVERSIONS", + "metrics.clicks": 100, + "metrics.cost": 40.0, + "metrics.conversions": 4, + "metrics.conversions_value": 400.0, + } + ] + } + mock_groups.return_value = {"asset_groups": []} + mock_assets.return_value = {"assets": []} + mock_channels.return_value = {"channel_breakdown": [], "insights": []} + mock_ga4.side_effect = RuntimeError("network down") + + result = analyze_pmax_performance( + config, + customer_id="1234567890", + property_id="properties/123456", + ) + + assert result["campaigns"][0]["ga4_paid"] is None + @patch("adloop.ads.pmax_read.get_pmax_channel_breakdown") @patch("adloop.ads.pmax_read.get_asset_group_assets") @patch("adloop.ads.pmax_read.get_asset_groups") From 2779edeb65a57b4bf8a966965d5ec3a277cd298e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 19:30:43 +0000 Subject: [PATCH 27/36] Fix PMax for Google Ads API v24 and add PMax write + label tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A live MCP test exposed broken queries and a malformed PMax create flow after the v24 pin. Five of eight PMax read tools failed, draft_campaign's PMax path was structurally wrong, and confirm_and_apply(dry_run=true) returned DRY_RUN_SUCCESS for plans the API would reject. This commit fixes those issues, adds the missing PMax write surface, and adds label tools that were previously absent. API v24 read-tool fixes: - get_pmax_campaigns: drop campaign.url_expansion_opt_out (removed in v24) - get_asset_group_assets: drop asset_group_asset.performance_label (removed in v24); return policy_summary.review_status instead. Update docstring to point users to asset_group_top_combination_view / asset_field_type_view for per-asset performance. - get_asset_group_top_combinations: drop metrics.* fields and ORDER BY metrics.impressions; the resource exposes no metrics in v24. - get_pmax_search_terms: drop metrics.cost_micros / conversions / conversions_value; campaign_search_term_insight rejects them with PROHIBITED_METRIC_IN_SELECT_OR_WHERE_CLAUSE. Surface a `note` so callers know cost is not available. - analyze_pmax_performance: replace the LOW-asset insight with a missing-asset-minimums insight that flags asset groups below Google's documented PMax minimums (3+ HEADLINE, 1+ LONG_HEADLINE, 2+ DESCRIPTION, 1+ BUSINESS_NAME, 1+ MARKETING_IMAGE / SQUARE_MARKETING_IMAGE / LOGO). confirm_and_apply dry_run safety: - dry_run=true now plumbs validate_only=True through each _apply_* helper to the Google Ads API. The API runs full server-side validation (PMax network_settings rules, enum values, references, etc.) and commits nothing. A failing validation now returns DRY_RUN_VALIDATION_FAILED with the actual API error rather than a false DRY_RUN_SUCCESS. PMax write tools (new ads/pmax_write.py): - draft_pmax_campaign: creates a CampaignBudget + Campaign (no network_settings, no advertising_channel_sub_type) + geo/language CampaignCriteria + AssetGroup + every Asset + every AssetGroupAsset link + every AssetGroupSignal in one bulk mutate. PMax requires this all-in-one shape per Google's documentation. - draft_asset_group: adds an asset group + assets + signals to an existing PMax campaign. - draft_asset_group_assets: extends an asset group with more headlines / descriptions / images / videos. - draft_asset_group_signal: adds one search theme or audience signal. - Smart-Bidding-only enforcement (rejects MANUAL_CPC and TARGET_SPEND). - Per-field-type asset minimums and char limits validated before drafting; image/logo assets must be pre-uploaded resource_names (binary upload is out of scope for this MCP). draft_campaign now rejects channel_type=PERFORMANCE_MAX with a clear error pointing to draft_pmax_campaign — the previous behavior produced a malformed mutate that the API rejected with four cascading errors. Asset group support in pause/enable/remove. AssetGroupService is now wired into the existing _apply_status_change and _apply_remove helpers; entity_type='asset_group' works for all three. Label tools (new ads/labels.py): - list_labels (read), draft_label, apply_label, unapply_label. - remove_entity entity_type='label' deletes the Label resource itself (cascades to all assignments). Rules and docs: - .cursor/rules/adloop.mdc rewritten for v24 reality: * removed claims about url_expansion_opt_out and performance_label * documented the prohibited metrics on campaign_search_term_insight and asset_group_top_combination_view * added "Removed in API v24" reference section * added the new write tools to the inventory table * added orchestration patterns for "create new PMax campaign", "add asset group / assets / signals to existing PMax campaign", and "add labels" * documented the new validate_only-backed dry_run behavior - scripts/sync-rules.py run to sync .claude/rules/adloop.md. - CLAUDE.md tool counts updated (47 -> 55). Tests: 192 passing (was 159). New test_pmax_write.py covers the four new draft tools' validation paths and the draft_campaign-rejects-PMax behavior. New test_labels.py covers list/draft/apply/unapply. test_pmax_read.py updated for the dropped fields and the new missing_asset_minimums insight. https://claude.ai/code/session_018uRgkMdVJKZcfCV6NE2MSP --- .claude/rules/adloop.md | 98 +++- .cursor/rules/adloop.mdc | 98 +++- CLAUDE.md | 6 +- src/adloop/ads/labels.py | 398 ++++++++++++++ src/adloop/ads/pmax_read.py | 51 +- src/adloop/ads/pmax_write.py | 985 +++++++++++++++++++++++++++++++++++ src/adloop/ads/write.py | 258 +++++++-- src/adloop/crossref.py | 42 +- src/adloop/server.py | 320 +++++++++++- tests/test_labels.py | 166 ++++++ tests/test_pmax_read.py | 69 ++- tests/test_pmax_write.py | 377 ++++++++++++++ 12 files changed, 2709 insertions(+), 159 deletions(-) create mode 100644 src/adloop/ads/labels.py create mode 100644 src/adloop/ads/pmax_write.py create mode 100644 tests/test_labels.py create mode 100644 tests/test_pmax_write.py diff --git a/.claude/rules/adloop.md b/.claude/rules/adloop.md index 0e88a89..c455d2f 100644 --- a/.claude/rules/adloop.md +++ b/.claude/rules/adloop.md @@ -55,20 +55,21 @@ Performance Max (PMax) campaigns have a different structure than Search: | Tool | When to Use | Key Parameters | |------|-------------|----------------| -| `get_pmax_campaigns` | Discover PMax campaigns and their PMax-specific settings (URL expansion, brand guidelines) | `date_range_start`, `date_range_end` | +| `get_pmax_campaigns` | Discover PMax campaigns and their PMax-specific settings (brand guidelines) | `date_range_start`, `date_range_end` | | `get_pmax_channel_breakdown` | "Where is my PMax spend going?" — Search vs YouTube vs Display etc. | `date_range_start`, `date_range_end`, `campaign_id` (optional) | | `get_asset_groups` | List asset groups in a PMax campaign with ad strength + metrics | `campaign_id` (optional), `date_range_start`, `date_range_end` | -| `get_asset_group_assets` | Inspect every asset's `field_type`, `performance_label` (LOW/GOOD/BEST/PENDING), and content | `asset_group_id` OR `campaign_id` | +| `get_asset_group_assets` | Inspect every asset's `field_type`, status, and policy `review_status` | `asset_group_id` OR `campaign_id` | | `get_asset_group_signals` | List search themes and audience signals attached to an asset group | `asset_group_id` OR `campaign_id` | -| `get_asset_group_top_combinations` | See which headline+description+image combos Google assembled and how often they served | `asset_group_id` OR `campaign_id`, `date_range_start`, `date_range_end` | +| `get_asset_group_top_combinations` | See which headline+description+image combos Google assembled (no metrics in v24) | `asset_group_id` OR `campaign_id`, `date_range_start`, `date_range_end` | | `get_pmax_search_terms` | Get aggregated category-level insights (not individual queries) for a PMax campaign | `campaign_id` (REQUIRED), `date_range_start`, `date_range_end` | **PMax read tool notes:** - `get_pmax_campaigns` returns `metrics.cost`, `metrics.cpa`, `metrics.roas`, and `campaign_budget.amount` pre-computed. The Search-style `bidding_strategy_type` is shown — for PMax it's typically `MAXIMIZE_CONVERSIONS` or `MAXIMIZE_CONVERSION_VALUE` (Smart Bidding only). - `get_pmax_channel_breakdown` is only reliable from **2025-06-01 onwards**. Earlier rows return `MIXED` for `segments.ad_network_type` because Google could not attribute. The tool emits a warning in `insights[]` when the date range overlaps that period. -- `get_asset_group_assets` returns `asset_group_asset.performance_label` — values are `LOW`, `GOOD`, `BEST`, `PENDING`. `PENDING` means Google hasn't gathered enough data yet (typically the first 1-2 weeks). `LOW` assets are clear replacement candidates. +- `get_asset_group_assets` no longer returns the `performance_label` (LOW/GOOD/BEST/PENDING) field — Google removed it from `asset_group_asset` in API v24. To judge per-asset performance now, look at `asset_group_top_combinations` (which assets actually serve together) or query `asset_field_type_view` for per-field-type metrics. - `get_asset_group_signals` returns `signal_type = SEARCH_THEME | AUDIENCE | UNKNOWN`. Search themes are immutable once created — to "edit", you remove and re-create. -- `get_pmax_search_terms` requires API v23.2+. Returns category labels only — Google deliberately does NOT expose individual search queries for PMax campaigns. Don't tell users they can see exactly what someone typed. +- `get_asset_group_top_combinations` does NOT return any `metrics.*` fields in v24 — the API rejects them with PROHIBITED_METRIC_IN_SELECT_OR_WHERE_CLAUSE. Combinations come pre-ordered by Google by serving frequency. +- `get_pmax_search_terms` requires API v23.2+. Returns `metrics.impressions` and `metrics.clicks` only — `metrics.cost_micros`, `metrics.conversions`, `metrics.conversions_value` are not selectable on `campaign_search_term_insight`. Google deliberately does NOT expose individual search queries for PMax campaigns. Don't tell users they can see exactly what someone typed, or that this tool surfaces per-category cost. ### Cross-Reference Tools (GA4 + Ads combined) @@ -77,13 +78,13 @@ Performance Max (PMax) campaigns have a different structure than Search: | `analyze_campaign_conversions` | "What's my real CPA?", paid vs organic comparison, GDPR gap analysis | `date_range_start`, `date_range_end`, `campaign_name` (optional filter) | | `landing_page_analysis` | "Which landing pages convert?", identify pages with traffic but no conversions | `date_range_start`, `date_range_end` | | `attribution_check` | "Are my conversions tracked correctly?", Ads vs GA4 conversion discrepancies | `date_range_start`, `date_range_end`, `conversion_events` (optional GA4 event names) | -| `analyze_pmax_performance` | One-call PMax diagnostic — campaign + asset groups + asset performance labels + channel mix + GA4 | `date_range_start`, `date_range_end`, `campaign_id` (optional filter) | +| `analyze_pmax_performance` | One-call PMax diagnostic — campaign + asset groups + asset counts + channel mix + GA4 | `date_range_start`, `date_range_end`, `campaign_id` (optional filter) | These tools call both APIs internally and return unified results with computed `insights[]`. They are read-only — no mutations. Each returns a `date_range` and auto-generates conditional warnings (GDPR gaps, zero conversions, attribution mismatches, orphaned URLs). **`analyze_campaign_conversions` details:** Returns one row per campaign (with `campaign_id`) including `conversion_discrepancy_pct` between Ads and GA4. When `campaign_name` is omitted, all campaigns are returned — no need to call once per campaign. -**`analyze_pmax_performance` details:** Aggregates everything you can see about Performance Max in one call: campaign metrics + bidding/URL-expansion/brand-guidelines settings, every asset group with its `ad_strength`, individual asset `performance_label`s, the channel-mix breakdown, and (when a GA4 property is configured) GA4 paid sessions/conversions per campaign. The `insights[]` flag POOR/AVERAGE asset groups, LOW-labeled assets, channel-spend skew (>90% on a single surface), zero-conversion campaigns, GDPR consent gaps, and pre-2025-06-01 data caveats. Use this as the FIRST call when the user asks about PMax performance — it eliminates 4-5 separate read tool calls. +**`analyze_pmax_performance` details:** Aggregates everything you can see about Performance Max in one call: campaign metrics + bidding/brand-guidelines settings, every asset group with its `ad_strength` and asset counts, the channel-mix breakdown, and (when a GA4 property is configured) GA4 paid sessions/conversions per campaign. The `insights[]` flag POOR/AVERAGE asset groups, asset groups missing the documented PMax minimums (3+ HEADLINE, 1+ LONG_HEADLINE, 2+ DESCRIPTION, 1+ BUSINESS_NAME, 1+ MARKETING_IMAGE, 1+ SQUARE_MARKETING_IMAGE, 1+ LOGO), channel-spend skew (>90% on a single surface), zero-conversion campaigns, GDPR consent gaps, and pre-2025-06-01 data caveats. Use this as the FIRST call when the user asks about PMax performance — it eliminates 4-5 separate read tool calls. ### Tracking Tools @@ -108,18 +109,26 @@ These tools call both APIs internally and return unified results with computed ` | Tool | What It Does | Validation | |------|-------------|------------| -| `draft_campaign` | Create full campaign structure (budget + campaign + ad group + keywords + geo/language targeting). Auto-sets Final URL Suffix with UTM tracking for SEARCH campaigns. | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), keywords validated, `final_url_suffix` (auto-set for SEARCH, pass "" to disable) | -| `draft_ad_group` | Create a new ad group within an existing campaign (does NOT publish) | `campaign_id` (REQUIRED), `ad_group_name` (REQUIRED), `keywords` (optional list of {text, match_type}), `cpc_bid_micros` (optional) | +| `draft_campaign` | Create a SEARCH campaign (budget + campaign + ad group + keywords + geo/language targeting). Auto-sets Final URL Suffix with UTM tracking. **Rejects channel_type=PERFORMANCE_MAX** — use `draft_pmax_campaign` instead. | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), keywords validated, `final_url_suffix` (auto-set for SEARCH, pass "" to disable) | +| `draft_pmax_campaign` | Create a Performance Max campaign with its first asset group + assets + signals atomically. PMax has no ad groups, no keywords, no `network_settings`. | `campaign_name`, `daily_budget`, `bidding_strategy` (Smart Bidding only), `geo_target_ids`, `language_ids`, `asset_group` dict (see PMax Write Tools section) | +| `draft_asset_group` | Add a new asset group (with assets + signals) to an existing PMax campaign | `campaign_id` (REQUIRED), `asset_group` dict | +| `draft_asset_group_assets` | Add headlines / long_headlines / descriptions / business_name / image refs / YouTube videos to an existing asset group | `asset_group_id` (REQUIRED), plus any of the asset arrays | +| `draft_asset_group_signal` | Add a single signal (search theme OR audience) to an asset group | `asset_group_id` (REQUIRED), plus exactly one of `search_theme` / `audience_resource_name` | +| `draft_ad_group` | Create a new ad group within an existing SEARCH campaign (does NOT publish) | `campaign_id` (REQUIRED), `ad_group_name` (REQUIRED), `keywords` (optional list of {text, match_type}), `cpc_bid_micros` (optional) | | `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, Final URL suffix | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `final_url_suffix` | | `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each). Headlines/descriptions support optional **pinning**: pass `{"text": "...", "pinned_to": "HEADLINE_1"}` instead of a plain string. Valid pins: HEADLINE_1/2/3 for headlines, DESCRIPTION_1/2 for descriptions. Plain strings are unpinned. | | `draft_rsa_replacement` | **Fix** an existing RSA — creates corrected replacement and removes the old ad. Use for copy errors, not A/B testing. For testing variants, use `draft_responsive_search_ad` instead. | `ad_id` (REQUIRED), 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), `final_url` (inherits from old ad if blank), path1/path2 (≤15 chars each), `remove_old` (default true). Supports **pinning** — same format as `draft_responsive_search_ad`. | | `draft_sitelinks` | Create sitelink extensions for a campaign (does NOT publish) | `campaign_id`, `sitelinks` list of {link_text ≤25 chars, final_url, description1 ≤35 chars, description2 ≤35 chars} | | `draft_keywords` | Propose keyword additions (does NOT add) | Each keyword needs `text` and `match_type` (EXACT/PHRASE/BROAD) | | `add_negative_keywords` | Propose negative keywords (does NOT add) | `campaign_id`, keyword list, `match_type` | -| `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", "campaign_asset"), `entity_id` | -| `confirm_and_apply` | Execute a previously previewed change | `plan_id` from a draft tool, `dry_run` (default true) | +| `list_labels` | Read tool — list all labels in the account | (none) | +| `draft_label` | Create a Label resource | `name` (REQUIRED), `description`, `background_color` (hex like '#FF5733') | +| `apply_label` | Attach an existing Label to a campaign / ad_group / ad / keyword | `entity_type`, `entity_id`, `label_id` | +| `unapply_label` | Detach a Label from one entity (does NOT delete the Label itself) | same as `apply_label` | +| `pause_entity` | Propose pausing a campaign / ad group / ad / keyword / asset_group | `entity_type`, `entity_id` | +| `enable_entity` | Propose enabling a paused entity (campaign / ad group / ad / keyword / asset_group) | `entity_type`, `entity_id` | +| `remove_entity` | Propose REMOVING an entity (irreversible) | `entity_type` (incl. "negative_keyword", "campaign_asset", "asset_group", "label"), `entity_id` | +| `confirm_and_apply` | Execute a previously previewed change. With `dry_run=true` (default), runs the plan against the Google Ads API with `validate_only=True` — full server-side validation, no changes committed. | `plan_id` from a draft tool, `dry_run` (default true) | **Write tool workflow:** 1. Call a `draft_*` tool → returns a preview with a `plan_id` @@ -128,12 +137,15 @@ These tools call both APIs internally and return unified results with computed ` 4. Only call with `dry_run=false` after explicit user confirmation **Safety behaviors:** -- New campaigns and RSAs are created as PAUSED — user must explicitly enable them after review. +- New campaigns, asset groups, and RSAs are created as PAUSED — user must explicitly enable them after review. - `draft_campaign` REQUIRES `geo_target_ids` and `language_ids` — campaigns without targeting waste budget. The tool rejects drafts with missing targeting. - `draft_campaign` enforces the `max_daily_budget` safety cap, rejects BROAD match + non-Smart Bidding, and warns if budget is below 5x target CPA. +- `draft_campaign` rejects `channel_type=PERFORMANCE_MAX` — PMax requires the asset_group + assets + signals to be created in the same mutate as the campaign, which the Search-shaped draft cannot produce. Use `draft_pmax_campaign` for PMax. +- `draft_pmax_campaign` enforces Smart Bidding (rejects MANUAL_CPC and TARGET_SPEND) and PMax asset minimums (3+ HEADLINE, 1+ LONG_HEADLINE, 2+ DESCRIPTION, 1+ BUSINESS_NAME, 1+ MARKETING_IMAGE / SQUARE_MARKETING_IMAGE / LOGO). Image and logo assets must be pre-uploaded — pass resource_names, not URLs. - `update_campaign` replaces geo/language targets entirely (not append). Pass the full desired list. - `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", "campaign_asset". Use "negative_keyword" to remove campaign-level negative keywords. Use "campaign_asset" to remove sitelinks and other asset links from a campaign. +- `remove_entity` supports `entity_type` values: "campaign", "ad_group", "ad", "keyword", "negative_keyword", "campaign_asset", "asset_group", "label". Use "negative_keyword" to remove campaign-level negative keywords. Use "campaign_asset" to remove sitelinks and other asset links from a campaign. Use "label" to delete a Label resource (cascade-removes all assignments — to detach a single assignment, use `unapply_label` instead). +- `confirm_and_apply` with `dry_run=true` runs the plan against the Google Ads API with `validate_only=True`. The API performs full validation server-side and returns errors if the plan is malformed (e.g. PMax with `network_settings`, invalid bidding strategy, dangling resource references) — but commits nothing. A passing dry run means the real apply will pass the same validation. A failing dry run returns `status: DRY_RUN_VALIDATION_FAILED` with the actual API error. - `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`. @@ -278,6 +290,40 @@ PMax is structurally different from Search — different tools, different diagno - Enable the campaign via `enable_entity` only after ads and sitelinks are in place 8. Wait for explicit user approval before calling `confirm_and_apply` +### When user wants to create a new Performance Max campaign + +PMax is structurally different — there is no `draft_campaign` path for it. PMax requires the campaign + asset group + assets + signals to be created in the same atomic mutate. + +1. Call `get_pmax_campaigns` to see existing PMax campaigns and avoid name duplicates +2. Confirm the user has uploaded the required image and logo assets to the account already (the API requires resource_names — image/logo binary upload is not supported through this MCP). If not, point them to the Google Ads UI or ask for resource_names of pre-uploaded assets. +3. **Pre-write checks (CRITICAL):** + - **Bidding strategy**: PMax accepts only Smart Bidding — `MAXIMIZE_CONVERSIONS`, `MAXIMIZE_CONVERSION_VALUE`, `TARGET_CPA`, `TARGET_ROAS`. The tool rejects MANUAL_CPC and TARGET_SPEND for PMax. + - **Geo targeting**: ALWAYS ask the user which countries/regions to target. + - **Language targeting**: ALWAYS ask the user which languages. + - **Asset minimums**: 3-5 headlines (≤30 chars), 1-5 long_headlines (≤90 chars), 2-5 descriptions (≤90 chars), 1 business_name (≤25 chars), at least 1 marketing_image, 1 square_marketing_image, 1 logo (resource_names of pre-uploaded assets). + - **Conversion tracking**: PMax depends heavily on Smart Bidding signals. Call `attribution_check` — if zero conversions across the board, WARN that PMax won't optimize without tracking working first. + - **Budget**: ideally ≥ 5x target CPA; the tool warns otherwise. +4. Call `draft_pmax_campaign` with campaign details + the full `asset_group` dict (name, final_urls, headlines, long_headlines, descriptions, business_name, marketing_image_assets, square_marketing_image_assets, logo_assets, and optionally search_themes / audience_resource_names). +5. Present the complete preview to the user — emphasize the campaign will be created as PAUSED. +6. Call `confirm_and_apply(plan_id=..., dry_run=true)` first — this runs `validate_only=True` against Google Ads and surfaces any API rejections (e.g. invalid asset shapes, missing minimums) before applying for real. +7. After dry run passes and user approves, call `confirm_and_apply(plan_id=..., dry_run=false)`. +8. Remind the user to enable the PMax campaign via `enable_entity(entity_type='campaign', entity_id=...)` after reviewing in Google Ads UI. + +### When user wants to add a new asset group, more assets, or signals to an existing PMax campaign + +- **New asset group**: `draft_asset_group(campaign_id, asset_group=...)` — same `asset_group` shape as `draft_pmax_campaign`. Each asset group has independent assets and is its own creative bundle. +- **More assets on an existing asset group**: `draft_asset_group_assets(asset_group_id, headlines=[...], long_headlines=[...], descriptions=[...], business_name=..., marketing_image_assets=[...], square_marketing_image_assets=[...], logo_assets=[...], youtube_video_ids=[...])` — pass only what you want to add. Text and YouTube assets are created inline; images/logos must already exist as Asset resources (pass resource_names). +- **New signal on an existing asset group**: `draft_asset_group_signal(asset_group_id, search_theme="..." OR audience_resource_name="customers/.../audiences/...")`. Pass exactly one. Search themes are immutable — to "edit" one, remove and re-add. + +To pause/enable an asset group, use `pause_entity`/`enable_entity` with `entity_type="asset_group"`. To remove one, use `remove_entity` (irreversible). + +### When user wants to add labels or filter by label + +1. Call `list_labels` to discover existing labels. +2. To create a new label, call `draft_label(name, description=..., background_color="#RRGGBB")`, then `confirm_and_apply`. +3. To attach a label, call `apply_label(entity_type='campaign'|'ad_group'|'ad'|'keyword', entity_id=..., label_id=...)`. The label must already exist — capture the `label_id` from `list_labels` or from the result of `draft_label` + apply. +4. To detach a label from one entity, call `unapply_label` with the same args. To delete the Label resource itself (cascades), use `remove_entity(entity_type='label', entity_id=...)`. + ### When user wants to add an ad group to an existing campaign 1. Call `get_campaign_performance` to identify the target campaign and verify it exists @@ -395,7 +441,7 @@ LIMIT n | `bidding_strategy` | Bidding strategy details | | `customer_client` | List accounts under an MCC (uses login_customer_id) | | `asset_group` | Performance Max asset groups (PMax equivalent of ad groups) | -| `asset_group_asset` | Individual assets in PMax asset groups with field_type + performance_label | +| `asset_group_asset` | Individual assets in PMax asset groups with field_type, status, policy review_status (no `performance_label` in v24) | | `asset_group_signal` | Search themes and audience signals attached to PMax asset groups | | `asset_group_top_combination_view` | Top serving combinations Google has assembled for PMax | | `campaign_search_term_insight` | Aggregated PMax search-term categories (v23.2+, no individual queries) | @@ -435,13 +481,18 @@ LIMIT n **Performance Max fields:** - `campaign.advertising_channel_type = 'PERFORMANCE_MAX'` — filter for PMax campaigns -- `campaign.url_expansion_opt_out` — boolean; when true, PMax only sends traffic to provided final URLs - `campaign.brand_guidelines_enabled` — boolean; when true, business name + logos are at campaign level not asset group - `asset_group.id`, `asset_group.name`, `asset_group.status`, `asset_group.ad_strength` (POOR/AVERAGE/GOOD/EXCELLENT) - `asset_group_asset.field_type` (HEADLINE, DESCRIPTION, MARKETING_IMAGE, LOGO, YOUTUBE_VIDEO, etc.) -- `asset_group_asset.performance_label` (LOW, GOOD, BEST, PENDING) +- `asset_group_asset.policy_summary.review_status`, `asset_group_asset.status` - `asset_group_signal.search_theme.text`, `asset_group_signal.audience.audience` +**Removed in API v24 — DO NOT use:** +- `campaign.url_expansion_opt_out` (and `campaign.url_expansion_optimization`) — replaced by `Campaign.asset_automation_settings` with `AssetAutomationType=FINAL_URL_EXPANSION_TEXT_ASSET_AUTOMATION`. +- `asset_group_asset.performance_label` — the LOW/GOOD/BEST/PENDING per-asset rating is no longer populated. Look at `asset_group_top_combination_view` (which assets actually serve) or query metrics on `asset_field_type_view` for per-field-type performance. +- `metrics.cost_micros`, `metrics.conversions`, `metrics.conversions_value` on `campaign_search_term_insight` — the API returns PROHIBITED_METRIC_IN_SELECT_OR_WHERE_CLAUSE. Only `metrics.clicks` and `metrics.impressions` are selectable on this resource. +- Any `metrics.*` on `asset_group_top_combination_view` — PROHIBITED_METRIC. The view exposes only `asset_group_top_combination_view.asset_group_top_combinations` (a repeated message). + ### Date Ranges ```sql @@ -509,16 +560,21 @@ WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX' ORDER BY metrics.cost_micros DESC ``` -**Performance Max LOW-performing assets (replacement candidates):** +**Performance Max — list all assets in an asset group:** ```sql SELECT asset_group.id, asset_group.name, - asset_group_asset.field_type, asset.text_asset.text, + asset_group_asset.field_type, + asset_group_asset.status, + asset_group_asset.policy_summary.review_status, + asset.text_asset.text, asset.image_asset.full_size.url FROM asset_group_asset -WHERE asset_group_asset.performance_label = 'LOW' +WHERE asset_group.id = 6572147947 AND asset_group_asset.status != 'REMOVED' ``` +(`asset_group_asset.performance_label` was removed in v24 — see "Removed in API v24" above.) + ## Ad Copy Character Limits Google Ads enforces hard character limits. The `draft_responsive_search_ad` tool will reject copy that exceeds them, but you must write copy that fits on the FIRST attempt — do not generate copy and hope it fits. diff --git a/.cursor/rules/adloop.mdc b/.cursor/rules/adloop.mdc index efc352f..c7adfe9 100644 --- a/.cursor/rules/adloop.mdc +++ b/.cursor/rules/adloop.mdc @@ -57,20 +57,21 @@ Performance Max (PMax) campaigns have a different structure than Search: | Tool | When to Use | Key Parameters | |------|-------------|----------------| -| `get_pmax_campaigns` | Discover PMax campaigns and their PMax-specific settings (URL expansion, brand guidelines) | `date_range_start`, `date_range_end` | +| `get_pmax_campaigns` | Discover PMax campaigns and their PMax-specific settings (brand guidelines) | `date_range_start`, `date_range_end` | | `get_pmax_channel_breakdown` | "Where is my PMax spend going?" — Search vs YouTube vs Display etc. | `date_range_start`, `date_range_end`, `campaign_id` (optional) | | `get_asset_groups` | List asset groups in a PMax campaign with ad strength + metrics | `campaign_id` (optional), `date_range_start`, `date_range_end` | -| `get_asset_group_assets` | Inspect every asset's `field_type`, `performance_label` (LOW/GOOD/BEST/PENDING), and content | `asset_group_id` OR `campaign_id` | +| `get_asset_group_assets` | Inspect every asset's `field_type`, status, and policy `review_status` | `asset_group_id` OR `campaign_id` | | `get_asset_group_signals` | List search themes and audience signals attached to an asset group | `asset_group_id` OR `campaign_id` | -| `get_asset_group_top_combinations` | See which headline+description+image combos Google assembled and how often they served | `asset_group_id` OR `campaign_id`, `date_range_start`, `date_range_end` | +| `get_asset_group_top_combinations` | See which headline+description+image combos Google assembled (no metrics in v24) | `asset_group_id` OR `campaign_id`, `date_range_start`, `date_range_end` | | `get_pmax_search_terms` | Get aggregated category-level insights (not individual queries) for a PMax campaign | `campaign_id` (REQUIRED), `date_range_start`, `date_range_end` | **PMax read tool notes:** - `get_pmax_campaigns` returns `metrics.cost`, `metrics.cpa`, `metrics.roas`, and `campaign_budget.amount` pre-computed. The Search-style `bidding_strategy_type` is shown — for PMax it's typically `MAXIMIZE_CONVERSIONS` or `MAXIMIZE_CONVERSION_VALUE` (Smart Bidding only). - `get_pmax_channel_breakdown` is only reliable from **2025-06-01 onwards**. Earlier rows return `MIXED` for `segments.ad_network_type` because Google could not attribute. The tool emits a warning in `insights[]` when the date range overlaps that period. -- `get_asset_group_assets` returns `asset_group_asset.performance_label` — values are `LOW`, `GOOD`, `BEST`, `PENDING`. `PENDING` means Google hasn't gathered enough data yet (typically the first 1-2 weeks). `LOW` assets are clear replacement candidates. +- `get_asset_group_assets` no longer returns the `performance_label` (LOW/GOOD/BEST/PENDING) field — Google removed it from `asset_group_asset` in API v24. To judge per-asset performance now, look at `asset_group_top_combinations` (which assets actually serve together) or query `asset_field_type_view` for per-field-type metrics. - `get_asset_group_signals` returns `signal_type = SEARCH_THEME | AUDIENCE | UNKNOWN`. Search themes are immutable once created — to "edit", you remove and re-create. -- `get_pmax_search_terms` requires API v23.2+. Returns category labels only — Google deliberately does NOT expose individual search queries for PMax campaigns. Don't tell users they can see exactly what someone typed. +- `get_asset_group_top_combinations` does NOT return any `metrics.*` fields in v24 — the API rejects them with PROHIBITED_METRIC_IN_SELECT_OR_WHERE_CLAUSE. Combinations come pre-ordered by Google by serving frequency. +- `get_pmax_search_terms` requires API v23.2+. Returns `metrics.impressions` and `metrics.clicks` only — `metrics.cost_micros`, `metrics.conversions`, `metrics.conversions_value` are not selectable on `campaign_search_term_insight`. Google deliberately does NOT expose individual search queries for PMax campaigns. Don't tell users they can see exactly what someone typed, or that this tool surfaces per-category cost. ### Cross-Reference Tools (GA4 + Ads combined) @@ -79,13 +80,13 @@ Performance Max (PMax) campaigns have a different structure than Search: | `analyze_campaign_conversions` | "What's my real CPA?", paid vs organic comparison, GDPR gap analysis | `date_range_start`, `date_range_end`, `campaign_name` (optional filter) | | `landing_page_analysis` | "Which landing pages convert?", identify pages with traffic but no conversions | `date_range_start`, `date_range_end` | | `attribution_check` | "Are my conversions tracked correctly?", Ads vs GA4 conversion discrepancies | `date_range_start`, `date_range_end`, `conversion_events` (optional GA4 event names) | -| `analyze_pmax_performance` | One-call PMax diagnostic — campaign + asset groups + asset performance labels + channel mix + GA4 | `date_range_start`, `date_range_end`, `campaign_id` (optional filter) | +| `analyze_pmax_performance` | One-call PMax diagnostic — campaign + asset groups + asset counts + channel mix + GA4 | `date_range_start`, `date_range_end`, `campaign_id` (optional filter) | These tools call both APIs internally and return unified results with computed `insights[]`. They are read-only — no mutations. Each returns a `date_range` and auto-generates conditional warnings (GDPR gaps, zero conversions, attribution mismatches, orphaned URLs). **`analyze_campaign_conversions` details:** Returns one row per campaign (with `campaign_id`) including `conversion_discrepancy_pct` between Ads and GA4. When `campaign_name` is omitted, all campaigns are returned — no need to call once per campaign. -**`analyze_pmax_performance` details:** Aggregates everything you can see about Performance Max in one call: campaign metrics + bidding/URL-expansion/brand-guidelines settings, every asset group with its `ad_strength`, individual asset `performance_label`s, the channel-mix breakdown, and (when a GA4 property is configured) GA4 paid sessions/conversions per campaign. The `insights[]` flag POOR/AVERAGE asset groups, LOW-labeled assets, channel-spend skew (>90% on a single surface), zero-conversion campaigns, GDPR consent gaps, and pre-2025-06-01 data caveats. Use this as the FIRST call when the user asks about PMax performance — it eliminates 4-5 separate read tool calls. +**`analyze_pmax_performance` details:** Aggregates everything you can see about Performance Max in one call: campaign metrics + bidding/brand-guidelines settings, every asset group with its `ad_strength` and asset counts, the channel-mix breakdown, and (when a GA4 property is configured) GA4 paid sessions/conversions per campaign. The `insights[]` flag POOR/AVERAGE asset groups, asset groups missing the documented PMax minimums (3+ HEADLINE, 1+ LONG_HEADLINE, 2+ DESCRIPTION, 1+ BUSINESS_NAME, 1+ MARKETING_IMAGE, 1+ SQUARE_MARKETING_IMAGE, 1+ LOGO), channel-spend skew (>90% on a single surface), zero-conversion campaigns, GDPR consent gaps, and pre-2025-06-01 data caveats. Use this as the FIRST call when the user asks about PMax performance — it eliminates 4-5 separate read tool calls. ### Tracking Tools @@ -110,18 +111,26 @@ These tools call both APIs internally and return unified results with computed ` | Tool | What It Does | Validation | |------|-------------|------------| -| `draft_campaign` | Create full campaign structure (budget + campaign + ad group + keywords + geo/language targeting). Auto-sets Final URL Suffix with UTM tracking for SEARCH campaigns. | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), keywords validated, `final_url_suffix` (auto-set for SEARCH, pass "" to disable) | -| `draft_ad_group` | Create a new ad group within an existing campaign (does NOT publish) | `campaign_id` (REQUIRED), `ad_group_name` (REQUIRED), `keywords` (optional list of {text, match_type}), `cpc_bid_micros` (optional) | +| `draft_campaign` | Create a SEARCH campaign (budget + campaign + ad group + keywords + geo/language targeting). Auto-sets Final URL Suffix with UTM tracking. **Rejects channel_type=PERFORMANCE_MAX** — use `draft_pmax_campaign` instead. | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), keywords validated, `final_url_suffix` (auto-set for SEARCH, pass "" to disable) | +| `draft_pmax_campaign` | Create a Performance Max campaign with its first asset group + assets + signals atomically. PMax has no ad groups, no keywords, no `network_settings`. | `campaign_name`, `daily_budget`, `bidding_strategy` (Smart Bidding only), `geo_target_ids`, `language_ids`, `asset_group` dict (see PMax Write Tools section) | +| `draft_asset_group` | Add a new asset group (with assets + signals) to an existing PMax campaign | `campaign_id` (REQUIRED), `asset_group` dict | +| `draft_asset_group_assets` | Add headlines / long_headlines / descriptions / business_name / image refs / YouTube videos to an existing asset group | `asset_group_id` (REQUIRED), plus any of the asset arrays | +| `draft_asset_group_signal` | Add a single signal (search theme OR audience) to an asset group | `asset_group_id` (REQUIRED), plus exactly one of `search_theme` / `audience_resource_name` | +| `draft_ad_group` | Create a new ad group within an existing SEARCH campaign (does NOT publish) | `campaign_id` (REQUIRED), `ad_group_name` (REQUIRED), `keywords` (optional list of {text, match_type}), `cpc_bid_micros` (optional) | | `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, Final URL suffix | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `final_url_suffix` | | `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each). Headlines/descriptions support optional **pinning**: pass `{"text": "...", "pinned_to": "HEADLINE_1"}` instead of a plain string. Valid pins: HEADLINE_1/2/3 for headlines, DESCRIPTION_1/2 for descriptions. Plain strings are unpinned. | | `draft_rsa_replacement` | **Fix** an existing RSA — creates corrected replacement and removes the old ad. Use for copy errors, not A/B testing. For testing variants, use `draft_responsive_search_ad` instead. | `ad_id` (REQUIRED), 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), `final_url` (inherits from old ad if blank), path1/path2 (≤15 chars each), `remove_old` (default true). Supports **pinning** — same format as `draft_responsive_search_ad`. | | `draft_sitelinks` | Create sitelink extensions for a campaign (does NOT publish) | `campaign_id`, `sitelinks` list of {link_text ≤25 chars, final_url, description1 ≤35 chars, description2 ≤35 chars} | | `draft_keywords` | Propose keyword additions (does NOT add) | Each keyword needs `text` and `match_type` (EXACT/PHRASE/BROAD) | | `add_negative_keywords` | Propose negative keywords (does NOT add) | `campaign_id`, keyword list, `match_type` | -| `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", "campaign_asset"), `entity_id` | -| `confirm_and_apply` | Execute a previously previewed change | `plan_id` from a draft tool, `dry_run` (default true) | +| `list_labels` | Read tool — list all labels in the account | (none) | +| `draft_label` | Create a Label resource | `name` (REQUIRED), `description`, `background_color` (hex like '#FF5733') | +| `apply_label` | Attach an existing Label to a campaign / ad_group / ad / keyword | `entity_type`, `entity_id`, `label_id` | +| `unapply_label` | Detach a Label from one entity (does NOT delete the Label itself) | same as `apply_label` | +| `pause_entity` | Propose pausing a campaign / ad group / ad / keyword / asset_group | `entity_type`, `entity_id` | +| `enable_entity` | Propose enabling a paused entity (campaign / ad group / ad / keyword / asset_group) | `entity_type`, `entity_id` | +| `remove_entity` | Propose REMOVING an entity (irreversible) | `entity_type` (incl. "negative_keyword", "campaign_asset", "asset_group", "label"), `entity_id` | +| `confirm_and_apply` | Execute a previously previewed change. With `dry_run=true` (default), runs the plan against the Google Ads API with `validate_only=True` — full server-side validation, no changes committed. | `plan_id` from a draft tool, `dry_run` (default true) | **Write tool workflow:** 1. Call a `draft_*` tool → returns a preview with a `plan_id` @@ -130,12 +139,15 @@ These tools call both APIs internally and return unified results with computed ` 4. Only call with `dry_run=false` after explicit user confirmation **Safety behaviors:** -- New campaigns and RSAs are created as PAUSED — user must explicitly enable them after review. +- New campaigns, asset groups, and RSAs are created as PAUSED — user must explicitly enable them after review. - `draft_campaign` REQUIRES `geo_target_ids` and `language_ids` — campaigns without targeting waste budget. The tool rejects drafts with missing targeting. - `draft_campaign` enforces the `max_daily_budget` safety cap, rejects BROAD match + non-Smart Bidding, and warns if budget is below 5x target CPA. +- `draft_campaign` rejects `channel_type=PERFORMANCE_MAX` — PMax requires the asset_group + assets + signals to be created in the same mutate as the campaign, which the Search-shaped draft cannot produce. Use `draft_pmax_campaign` for PMax. +- `draft_pmax_campaign` enforces Smart Bidding (rejects MANUAL_CPC and TARGET_SPEND) and PMax asset minimums (3+ HEADLINE, 1+ LONG_HEADLINE, 2+ DESCRIPTION, 1+ BUSINESS_NAME, 1+ MARKETING_IMAGE / SQUARE_MARKETING_IMAGE / LOGO). Image and logo assets must be pre-uploaded — pass resource_names, not URLs. - `update_campaign` replaces geo/language targets entirely (not append). Pass the full desired list. - `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", "campaign_asset". Use "negative_keyword" to remove campaign-level negative keywords. Use "campaign_asset" to remove sitelinks and other asset links from a campaign. +- `remove_entity` supports `entity_type` values: "campaign", "ad_group", "ad", "keyword", "negative_keyword", "campaign_asset", "asset_group", "label". Use "negative_keyword" to remove campaign-level negative keywords. Use "campaign_asset" to remove sitelinks and other asset links from a campaign. Use "label" to delete a Label resource (cascade-removes all assignments — to detach a single assignment, use `unapply_label` instead). +- `confirm_and_apply` with `dry_run=true` runs the plan against the Google Ads API with `validate_only=True`. The API performs full validation server-side and returns errors if the plan is malformed (e.g. PMax with `network_settings`, invalid bidding strategy, dangling resource references) — but commits nothing. A passing dry run means the real apply will pass the same validation. A failing dry run returns `status: DRY_RUN_VALIDATION_FAILED` with the actual API error. - `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`. @@ -280,6 +292,40 @@ PMax is structurally different from Search — different tools, different diagno - Enable the campaign via `enable_entity` only after ads and sitelinks are in place 8. Wait for explicit user approval before calling `confirm_and_apply` +### When user wants to create a new Performance Max campaign + +PMax is structurally different — there is no `draft_campaign` path for it. PMax requires the campaign + asset group + assets + signals to be created in the same atomic mutate. + +1. Call `get_pmax_campaigns` to see existing PMax campaigns and avoid name duplicates +2. Confirm the user has uploaded the required image and logo assets to the account already (the API requires resource_names — image/logo binary upload is not supported through this MCP). If not, point them to the Google Ads UI or ask for resource_names of pre-uploaded assets. +3. **Pre-write checks (CRITICAL):** + - **Bidding strategy**: PMax accepts only Smart Bidding — `MAXIMIZE_CONVERSIONS`, `MAXIMIZE_CONVERSION_VALUE`, `TARGET_CPA`, `TARGET_ROAS`. The tool rejects MANUAL_CPC and TARGET_SPEND for PMax. + - **Geo targeting**: ALWAYS ask the user which countries/regions to target. + - **Language targeting**: ALWAYS ask the user which languages. + - **Asset minimums**: 3-5 headlines (≤30 chars), 1-5 long_headlines (≤90 chars), 2-5 descriptions (≤90 chars), 1 business_name (≤25 chars), at least 1 marketing_image, 1 square_marketing_image, 1 logo (resource_names of pre-uploaded assets). + - **Conversion tracking**: PMax depends heavily on Smart Bidding signals. Call `attribution_check` — if zero conversions across the board, WARN that PMax won't optimize without tracking working first. + - **Budget**: ideally ≥ 5x target CPA; the tool warns otherwise. +4. Call `draft_pmax_campaign` with campaign details + the full `asset_group` dict (name, final_urls, headlines, long_headlines, descriptions, business_name, marketing_image_assets, square_marketing_image_assets, logo_assets, and optionally search_themes / audience_resource_names). +5. Present the complete preview to the user — emphasize the campaign will be created as PAUSED. +6. Call `confirm_and_apply(plan_id=..., dry_run=true)` first — this runs `validate_only=True` against Google Ads and surfaces any API rejections (e.g. invalid asset shapes, missing minimums) before applying for real. +7. After dry run passes and user approves, call `confirm_and_apply(plan_id=..., dry_run=false)`. +8. Remind the user to enable the PMax campaign via `enable_entity(entity_type='campaign', entity_id=...)` after reviewing in Google Ads UI. + +### When user wants to add a new asset group, more assets, or signals to an existing PMax campaign + +- **New asset group**: `draft_asset_group(campaign_id, asset_group=...)` — same `asset_group` shape as `draft_pmax_campaign`. Each asset group has independent assets and is its own creative bundle. +- **More assets on an existing asset group**: `draft_asset_group_assets(asset_group_id, headlines=[...], long_headlines=[...], descriptions=[...], business_name=..., marketing_image_assets=[...], square_marketing_image_assets=[...], logo_assets=[...], youtube_video_ids=[...])` — pass only what you want to add. Text and YouTube assets are created inline; images/logos must already exist as Asset resources (pass resource_names). +- **New signal on an existing asset group**: `draft_asset_group_signal(asset_group_id, search_theme="..." OR audience_resource_name="customers/.../audiences/...")`. Pass exactly one. Search themes are immutable — to "edit" one, remove and re-add. + +To pause/enable an asset group, use `pause_entity`/`enable_entity` with `entity_type="asset_group"`. To remove one, use `remove_entity` (irreversible). + +### When user wants to add labels or filter by label + +1. Call `list_labels` to discover existing labels. +2. To create a new label, call `draft_label(name, description=..., background_color="#RRGGBB")`, then `confirm_and_apply`. +3. To attach a label, call `apply_label(entity_type='campaign'|'ad_group'|'ad'|'keyword', entity_id=..., label_id=...)`. The label must already exist — capture the `label_id` from `list_labels` or from the result of `draft_label` + apply. +4. To detach a label from one entity, call `unapply_label` with the same args. To delete the Label resource itself (cascades), use `remove_entity(entity_type='label', entity_id=...)`. + ### When user wants to add an ad group to an existing campaign 1. Call `get_campaign_performance` to identify the target campaign and verify it exists @@ -397,7 +443,7 @@ LIMIT n | `bidding_strategy` | Bidding strategy details | | `customer_client` | List accounts under an MCC (uses login_customer_id) | | `asset_group` | Performance Max asset groups (PMax equivalent of ad groups) | -| `asset_group_asset` | Individual assets in PMax asset groups with field_type + performance_label | +| `asset_group_asset` | Individual assets in PMax asset groups with field_type, status, policy review_status (no `performance_label` in v24) | | `asset_group_signal` | Search themes and audience signals attached to PMax asset groups | | `asset_group_top_combination_view` | Top serving combinations Google has assembled for PMax | | `campaign_search_term_insight` | Aggregated PMax search-term categories (v23.2+, no individual queries) | @@ -437,13 +483,18 @@ LIMIT n **Performance Max fields:** - `campaign.advertising_channel_type = 'PERFORMANCE_MAX'` — filter for PMax campaigns -- `campaign.url_expansion_opt_out` — boolean; when true, PMax only sends traffic to provided final URLs - `campaign.brand_guidelines_enabled` — boolean; when true, business name + logos are at campaign level not asset group - `asset_group.id`, `asset_group.name`, `asset_group.status`, `asset_group.ad_strength` (POOR/AVERAGE/GOOD/EXCELLENT) - `asset_group_asset.field_type` (HEADLINE, DESCRIPTION, MARKETING_IMAGE, LOGO, YOUTUBE_VIDEO, etc.) -- `asset_group_asset.performance_label` (LOW, GOOD, BEST, PENDING) +- `asset_group_asset.policy_summary.review_status`, `asset_group_asset.status` - `asset_group_signal.search_theme.text`, `asset_group_signal.audience.audience` +**Removed in API v24 — DO NOT use:** +- `campaign.url_expansion_opt_out` (and `campaign.url_expansion_optimization`) — replaced by `Campaign.asset_automation_settings` with `AssetAutomationType=FINAL_URL_EXPANSION_TEXT_ASSET_AUTOMATION`. +- `asset_group_asset.performance_label` — the LOW/GOOD/BEST/PENDING per-asset rating is no longer populated. Look at `asset_group_top_combination_view` (which assets actually serve) or query metrics on `asset_field_type_view` for per-field-type performance. +- `metrics.cost_micros`, `metrics.conversions`, `metrics.conversions_value` on `campaign_search_term_insight` — the API returns PROHIBITED_METRIC_IN_SELECT_OR_WHERE_CLAUSE. Only `metrics.clicks` and `metrics.impressions` are selectable on this resource. +- Any `metrics.*` on `asset_group_top_combination_view` — PROHIBITED_METRIC. The view exposes only `asset_group_top_combination_view.asset_group_top_combinations` (a repeated message). + ### Date Ranges ```sql @@ -511,16 +562,21 @@ WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX' ORDER BY metrics.cost_micros DESC ``` -**Performance Max LOW-performing assets (replacement candidates):** +**Performance Max — list all assets in an asset group:** ```sql SELECT asset_group.id, asset_group.name, - asset_group_asset.field_type, asset.text_asset.text, + asset_group_asset.field_type, + asset_group_asset.status, + asset_group_asset.policy_summary.review_status, + asset.text_asset.text, asset.image_asset.full_size.url FROM asset_group_asset -WHERE asset_group_asset.performance_label = 'LOW' +WHERE asset_group.id = 6572147947 AND asset_group_asset.status != 'REMOVED' ``` +(`asset_group_asset.performance_label` was removed in v24 — see "Removed in API v24" above.) + ## Ad Copy Character Limits Google Ads enforces hard character limits. The `draft_responsive_search_ad` tool will reject copy that exceeds them, but you must write copy that fits on the FIRST attempt — do not generate copy and hope it fits. diff --git a/CLAUDE.md b/CLAUDE.md index 09cde24..1d0d325 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,14 +17,14 @@ python scripts/sync-rules.py # Sync rules: .cursor/rules/ -> .claude/rules/ ``` src/adloop/ ├── __init__.py # Entry point — routes 'adloop init' vs MCP server -├── server.py # FastMCP server — 47 tool registrations (incl. 8 PMax) +├── server.py # FastMCP server — 55 tool registrations (incl. 8 PMax read, 4 PMax write, 4 label) ├── config.py # Config loader (~/.adloop/config.yaml) ├── auth.py # OAuth 2.0 + service account + token refresh ├── cli.py # Interactive setup wizard ├── crossref.py # Cross-reference tools (GA4 + Ads, incl. analyze_pmax_performance) ├── tracking.py # Tracking validation + code generation ├── ga4/ # GA4 Data + Admin API (reports, realtime, events) -├── ads/ # Google Ads API (read, write, GAQL, forecasting, pmax_read) +├── ads/ # Google Ads API (read, write, GAQL, forecasting, pmax_read, pmax_write, labels) └── safety/ # Guards, previews, audit logging ``` @@ -34,7 +34,7 @@ All tool usage rules, safety protocols, orchestration patterns, GAQL reference, **Read and follow `.claude/rules/adloop.md` for all AdLoop MCP tool orchestration.** -That file is the complete guide for combining AdLoop's 47 tools (Search + Performance Max). It covers: +That file is the complete guide for combining AdLoop's 55 tools (Search + Performance Max read & write + Labels). It covers: - Tool inventory with parameters and when to use each - 8 safety rules (budget caps, dry-run defaults, Broad Match prevention, pre-write validation) - 16 orchestration patterns (performance review, ad creation, tracking diagnosis, PMax diagnostics, etc.) diff --git a/src/adloop/ads/labels.py b/src/adloop/ads/labels.py new file mode 100644 index 0000000..c051975 --- /dev/null +++ b/src/adloop/ads/labels.py @@ -0,0 +1,398 @@ +"""Google Ads label tools — list, create, apply, unapply. + +Labels are tags you attach to campaigns, ad groups, ads, and keywords for +filtering, reporting, and bulk operations. The API splits them across: + +- ``Label`` — the label definition itself (LabelService). +- ``CampaignLabel`` / ``AdGroupLabel`` / ``AdGroupAdLabel`` / + ``AdGroupCriterionLabel`` — assignments of a label to an entity. + +The draft tools here follow the same draft -> preview -> confirm_and_apply +flow as the rest of the write tools. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from adloop.config import AdLoopConfig + + +# --------------------------------------------------------------------------- +# Read +# --------------------------------------------------------------------------- + + +def list_labels( + config: AdLoopConfig, + *, + customer_id: str = "", +) -> dict: + """List all labels in the account.""" + from adloop.ads.gaql import execute_query + + query = """ + SELECT label.id, label.name, label.status, + label.text_label.description, + label.text_label.background_color + FROM label + WHERE label.status != 'REMOVED' + ORDER BY label.name + """ + + rows = execute_query(config, customer_id, query) + return {"labels": rows, "total_labels": len(rows)} + + +# --------------------------------------------------------------------------- +# Draft tools +# --------------------------------------------------------------------------- + + +def draft_label( + config: AdLoopConfig, + *, + customer_id: str = "", + name: str = "", + description: str = "", + background_color: str = "", +) -> dict: + """Draft creating a new Label — returns preview, does NOT execute. + + background_color: hex color string like "#FF5733" (optional). + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("create_label", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + errors: list[str] = [] + if not name or not name.strip(): + errors.append("name is required") + if background_color and not _is_hex_color(background_color): + errors.append( + f"background_color must be a hex string like '#FF5733', " + f"got '{background_color}'" + ) + if errors: + return {"error": "Validation failed", "details": errors} + + plan = ChangePlan( + operation="create_label", + entity_type="label", + customer_id=customer_id, + changes={ + "name": name, + "description": description, + "background_color": background_color, + }, + ) + store_plan(plan) + return plan.to_preview() + + +def draft_apply_label( + config: AdLoopConfig, + *, + customer_id: str = "", + entity_type: str = "", + entity_id: str = "", + label_id: str = "", +) -> dict: + """Draft attaching a label to a campaign, ad group, ad, or keyword. + + entity_type: "campaign", "ad_group", "ad", or "keyword". + entity_id: bare ID for campaign/ad_group; "adGroupId~adId" for ad; + "adGroupId~criterionId" for keyword. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("apply_label", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + errors = _validate_label_assignment_inputs(entity_type, entity_id, label_id) + if errors: + return {"error": "Validation failed", "details": errors} + + plan = ChangePlan( + operation="apply_label", + entity_type=entity_type, + entity_id=entity_id, + customer_id=customer_id, + changes={ + "entity_type": entity_type, + "entity_id": entity_id, + "label_id": label_id, + }, + ) + store_plan(plan) + return plan.to_preview() + + +def draft_unapply_label( + config: AdLoopConfig, + *, + customer_id: str = "", + entity_type: str = "", + entity_id: str = "", + label_id: str = "", +) -> dict: + """Draft removing a label assignment (does NOT delete the Label itself).""" + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("unapply_label", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + errors = _validate_label_assignment_inputs(entity_type, entity_id, label_id) + if errors: + return {"error": "Validation failed", "details": errors} + + plan = ChangePlan( + operation="unapply_label", + entity_type=entity_type, + entity_id=entity_id, + customer_id=customer_id, + changes={ + "entity_type": entity_type, + "entity_id": entity_id, + "label_id": label_id, + }, + ) + store_plan(plan) + return plan.to_preview() + + +# --------------------------------------------------------------------------- +# Apply helpers — wired into _execute_plan via LABEL_OPERATIONS +# --------------------------------------------------------------------------- + + +def _apply_create_label( + client: object, + cid: str, + changes: dict, + *, + validate_only: bool = False, +) -> dict: + service = client.get_service("LabelService") + operation = client.get_type("LabelOperation") + label = operation.create + label.name = changes["name"] + if changes.get("description"): + label.text_label.description = changes["description"] + if changes.get("background_color"): + label.text_label.background_color = changes["background_color"] + + response = service.mutate_labels( + customer_id=cid, operations=[operation], validate_only=validate_only + ) + if validate_only: + return {"status": "validated"} + return {"resource_name": response.results[0].resource_name} + + +def _apply_apply_label( + client: object, + cid: str, + changes: dict, + *, + validate_only: bool = False, +) -> dict: + """Attach an existing label to a campaign/ad_group/ad/keyword.""" + entity_type = changes["entity_type"] + entity_id = changes["entity_id"] + label_id = changes["label_id"] + label_resource = f"customers/{cid}/labels/{label_id}" + + if entity_type == "campaign": + service = client.get_service("CampaignLabelService") + operation = client.get_type("CampaignLabelOperation") + link = operation.create + link.campaign = client.get_service("CampaignService").campaign_path( + cid, entity_id + ) + link.label = label_resource + response = service.mutate_campaign_labels( + customer_id=cid, operations=[operation], validate_only=validate_only + ) + + elif entity_type == "ad_group": + service = client.get_service("AdGroupLabelService") + operation = client.get_type("AdGroupLabelOperation") + link = operation.create + link.ad_group = client.get_service("AdGroupService").ad_group_path( + cid, entity_id + ) + link.label = label_resource + response = service.mutate_ad_group_labels( + customer_id=cid, operations=[operation], validate_only=validate_only + ) + + elif entity_type == "ad": + from adloop.ads.write import _resolve_ad_entity_id + + resolved_id = _resolve_ad_entity_id(client, cid, entity_id) + service = client.get_service("AdGroupAdLabelService") + operation = client.get_type("AdGroupAdLabelOperation") + link = operation.create + link.ad_group_ad = f"customers/{cid}/adGroupAds/{resolved_id}" + link.label = label_resource + response = service.mutate_ad_group_ad_labels( + customer_id=cid, operations=[operation], validate_only=validate_only + ) + + elif entity_type == "keyword": + service = client.get_service("AdGroupCriterionLabelService") + operation = client.get_type("AdGroupCriterionLabelOperation") + link = operation.create + link.ad_group_criterion = f"customers/{cid}/adGroupCriteria/{entity_id}" + link.label = label_resource + response = service.mutate_ad_group_criterion_labels( + customer_id=cid, operations=[operation], validate_only=validate_only + ) + + else: + raise ValueError( + f"apply_label does not support entity_type '{entity_type}'. " + f"Supported: campaign, ad_group, ad, keyword." + ) + + if validate_only: + return {"status": "validated"} + return {"resource_name": response.results[0].resource_name} + + +def _apply_unapply_label( + client: object, + cid: str, + changes: dict, + *, + validate_only: bool = False, +) -> dict: + """Detach a label from an entity by removing the *Label resource.""" + entity_type = changes["entity_type"] + entity_id = changes["entity_id"] + label_id = changes["label_id"] + + if entity_type == "campaign": + service = client.get_service("CampaignLabelService") + operation = client.get_type("CampaignLabelOperation") + operation.remove = f"customers/{cid}/campaignLabels/{entity_id}~{label_id}" + response = service.mutate_campaign_labels( + customer_id=cid, operations=[operation], validate_only=validate_only + ) + + elif entity_type == "ad_group": + service = client.get_service("AdGroupLabelService") + operation = client.get_type("AdGroupLabelOperation") + operation.remove = f"customers/{cid}/adGroupLabels/{entity_id}~{label_id}" + response = service.mutate_ad_group_labels( + customer_id=cid, operations=[operation], validate_only=validate_only + ) + + elif entity_type == "ad": + from adloop.ads.write import _resolve_ad_entity_id + + resolved_id = _resolve_ad_entity_id(client, cid, entity_id) + service = client.get_service("AdGroupAdLabelService") + operation = client.get_type("AdGroupAdLabelOperation") + operation.remove = ( + f"customers/{cid}/adGroupAdLabels/{resolved_id}~{label_id}" + ) + response = service.mutate_ad_group_ad_labels( + customer_id=cid, operations=[operation], validate_only=validate_only + ) + + elif entity_type == "keyword": + service = client.get_service("AdGroupCriterionLabelService") + operation = client.get_type("AdGroupCriterionLabelOperation") + operation.remove = ( + f"customers/{cid}/adGroupCriterionLabels/{entity_id}~{label_id}" + ) + response = service.mutate_ad_group_criterion_labels( + customer_id=cid, operations=[operation], validate_only=validate_only + ) + + else: + raise ValueError( + f"unapply_label does not support entity_type '{entity_type}'. " + f"Supported: campaign, ad_group, ad, keyword." + ) + + if validate_only: + return {"status": "validated"} + return {"resource_name": response.results[0].resource_name} + + +def _apply_remove_label( + client: object, + cid: str, + entity_id: str, + *, + validate_only: bool = False, +) -> dict: + """Remove a Label resource itself (NOT just an assignment).""" + service = client.get_service("LabelService") + operation = client.get_type("LabelOperation") + operation.remove = f"customers/{cid}/labels/{entity_id}" + response = service.mutate_labels( + customer_id=cid, operations=[operation], validate_only=validate_only + ) + if validate_only: + return {"status": "validated"} + return {"resource_name": response.results[0].resource_name} + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +_LABEL_VALID_ENTITY_TYPES = {"campaign", "ad_group", "ad", "keyword"} + + +def _validate_label_assignment_inputs( + entity_type: str, entity_id: str, label_id: str +) -> list[str]: + errors: list[str] = [] + if entity_type not in _LABEL_VALID_ENTITY_TYPES: + errors.append( + f"entity_type must be one of {sorted(_LABEL_VALID_ENTITY_TYPES)}, " + f"got '{entity_type}'" + ) + if not entity_id: + errors.append("entity_id is required") + if not label_id: + errors.append("label_id is required") + return errors + + +def _is_hex_color(value: str) -> bool: + if not value.startswith("#"): + return False + hex_part = value[1:] + if len(hex_part) not in (3, 6): + return False + return all(c in "0123456789abcdefABCDEF" for c in hex_part) + + +# --------------------------------------------------------------------------- +# Dispatch table +# --------------------------------------------------------------------------- + + +LABEL_OPERATIONS = { + "create_label": _apply_create_label, + "apply_label": _apply_apply_label, + "unapply_label": _apply_unapply_label, +} diff --git a/src/adloop/ads/pmax_read.py b/src/adloop/ads/pmax_read.py index 9013116..4ac75e6 100644 --- a/src/adloop/ads/pmax_read.py +++ b/src/adloop/ads/pmax_read.py @@ -39,7 +39,6 @@ def get_pmax_campaigns( SELECT campaign.id, campaign.name, campaign.status, campaign.advertising_channel_type, campaign.bidding_strategy_type, - campaign.url_expansion_opt_out, campaign.brand_guidelines_enabled, campaign_budget.amount_micros, metrics.impressions, metrics.clicks, metrics.cost_micros, @@ -169,11 +168,13 @@ def get_asset_group_assets( asset_group_id: str = "", campaign_id: str = "", ) -> dict: - """List individual assets in PMax asset groups with field type and performance label. + """List individual assets in PMax asset groups with field type and policy review. Returns asset text/url, the field_type (HEADLINE, DESCRIPTION, MARKETING_IMAGE, - LOGO, YOUTUBE_VIDEO, etc.), and performance_label (LOW, GOOD, BEST, PENDING) - that Google assigns based on actual serving data. + LOGO, YOUTUBE_VIDEO, etc.), status, and policy review_status. The + LOW/GOOD/BEST/PENDING performance_label was removed from this resource in + Google Ads API v24 — to judge per-asset performance, query metrics directly + via asset_field_type_view or asset_group_top_combination_view. """ from adloop.ads.gaql import execute_query @@ -189,8 +190,8 @@ def get_asset_group_assets( query = f""" SELECT asset_group.id, asset_group.name, asset_group_asset.field_type, - asset_group_asset.performance_label, asset_group_asset.status, + asset_group_asset.policy_summary.review_status, asset.id, asset.type, asset.text_asset.text, asset.image_asset.full_size.url, @@ -263,10 +264,12 @@ def get_asset_group_top_combinations( date_range_start: str = "", date_range_end: str = "", ) -> dict: - """Get top-performing asset combinations Google has assembled at serve time. + """Get the asset combinations Google has assembled at serve time. - Each row represents a unique combination of headline + description + image + - (optional) video that has actually served, with its impression count. + Each row's `asset_group_top_combination_view.asset_group_top_combinations` + is a repeated message of the assets that served together. The view does + NOT expose `metrics.*` fields in v24 — combinations come pre-ordered by + Google by serving frequency, so impression-level sorting is not available. """ from adloop.ads.gaql import execute_query @@ -284,13 +287,11 @@ def get_asset_group_top_combinations( query = f""" SELECT asset_group.id, asset_group.name, asset_group_top_combination_view.asset_group_top_combinations, - metrics.impressions, campaign.id, campaign.name FROM asset_group_top_combination_view WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX' {extra_filter} {date_clause} - ORDER BY metrics.impressions DESC LIMIT 50 """ @@ -307,12 +308,14 @@ def get_pmax_search_terms( date_range_start: str = "", date_range_end: str = "", ) -> dict: - """Get aggregated search-category insights for PMax (post-v23.2 surface). - - Queries `campaign_search_term_insight` (the v23.2+ resource). Returns - category labels and metrics — individual search terms are NOT exposed for - PMax campaigns by Google's design. Returns a structured error with a hint - when the resource isn't available on the configured API version. + """Get aggregated search-category insights for PMax. + + Queries `campaign_search_term_insight`. Returns category labels with + impressions and clicks only — `metrics.cost_micros`, `metrics.conversions`, + and `metrics.conversions_value` are not selectable on this resource (the + API rejects them with PROHIBITED_METRIC_IN_SELECT_OR_WHERE_CLAUSE). Google + deliberately does not expose individual search queries for PMax campaigns, + only aggregated category labels. """ from adloop.ads.gaql import execute_query @@ -331,8 +334,7 @@ def get_pmax_search_terms( query = f""" SELECT campaign_search_term_insight.id, campaign_search_term_insight.category_label, - metrics.impressions, metrics.clicks, metrics.cost_micros, - metrics.conversions, metrics.conversions_value + metrics.impressions, metrics.clicks FROM campaign_search_term_insight WHERE campaign_search_term_insight.campaign_id = {cid} {date_clause} @@ -354,10 +356,15 @@ def get_pmax_search_terms( } raise - _enrich_cost_fields(rows) - _enrich_roas(rows) - - return {"search_term_categories": rows, "total_rows": len(rows)} + return { + "search_term_categories": rows, + "total_rows": len(rows), + "note": ( + "metrics.cost, metrics.conversions, and metrics.conversions_value are " + "not exposed by the Google Ads API on campaign_search_term_insight — " + "PMax search-term insights show impression/click volume only." + ), + } # --------------------------------------------------------------------------- diff --git a/src/adloop/ads/pmax_write.py b/src/adloop/ads/pmax_write.py new file mode 100644 index 0000000..3a04d78 --- /dev/null +++ b/src/adloop/ads/pmax_write.py @@ -0,0 +1,985 @@ +"""Google Ads Performance Max write tools — behind the safety layer. + +Performance Max is structurally different from Search: +- No ad groups, no keywords, no individual ads. +- An ``asset_group`` bundles assets (headlines, descriptions, images, logos, + videos) that Google assembles dynamically per impression. +- Channel mix (Search/Display/YouTube/Shopping/Maps/Discover/Gmail) is decided + by Google at serve time. The API REJECTS any ``Campaign.network_settings`` + on PMax — both ``target_search_network`` and ``target_content_network``. +- For non-retail PMax, asset groups + linked assets must be created together + in the SAME bulk mutate as the campaign. + +These write tools follow the same draft -> preview -> confirm_and_apply flow +as the Search tools in ads/write.py. Each draft_* tool creates a ChangePlan +that ``confirm_and_apply`` (in ads/write.py) executes. + +Apply helpers live here too and are wired into ``_execute_plan``'s dispatch +table via ``PMAX_OPERATIONS``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from adloop.config import AdLoopConfig + + +# --------------------------------------------------------------------------- +# Constants — character limits, field-type minimums, allowed enum values +# --------------------------------------------------------------------------- + +# Character limits per Google Ads documentation +_LIMITS = { + "HEADLINE": 30, + "LONG_HEADLINE": 90, + "DESCRIPTION": 90, + "BUSINESS_NAME": 25, + "CALL_TO_ACTION_SELECTION": None, +} + +# Per-field-type minimums for non-retail PMax asset groups (Google's minimums). +# An asset group below ANY minimum will fail Google's "minimum requirements" +# check at serve time even if the API accepts the create. +ASSET_MINIMUMS = { + "HEADLINE": 3, + "LONG_HEADLINE": 1, + "DESCRIPTION": 2, + "BUSINESS_NAME": 1, + "MARKETING_IMAGE": 1, + "SQUARE_MARKETING_IMAGE": 1, + "LOGO": 1, +} + +# Per-field-type maximums (the API rejects more than this). +ASSET_MAXIMUMS = { + "HEADLINE": 5, + "LONG_HEADLINE": 5, + "DESCRIPTION": 5, + "BUSINESS_NAME": 1, + "MARKETING_IMAGE": 20, + "SQUARE_MARKETING_IMAGE": 20, + "LOGO": 5, + "LANDSCAPE_LOGO": 5, + "PORTRAIT_MARKETING_IMAGE": 20, + "YOUTUBE_VIDEO": 5, +} + +# PMax is Smart Bidding only — MANUAL_CPC / TARGET_SPEND are rejected. +_PMAX_VALID_BIDDING = { + "MAXIMIZE_CONVERSIONS", + "MAXIMIZE_CONVERSION_VALUE", + "TARGET_CPA", + "TARGET_ROAS", +} + + +# --------------------------------------------------------------------------- +# Draft tools — return a preview with a plan_id, do NOT execute +# --------------------------------------------------------------------------- + + +def draft_pmax_campaign( + config: AdLoopConfig, + *, + customer_id: str = "", + campaign_name: str = "", + daily_budget: float = 0, + bidding_strategy: str = "", + target_cpa: float = 0, + target_roas: float = 0, + geo_target_ids: list[str] | None = None, + language_ids: list[str] | None = None, + final_url_suffix: str | None = None, + asset_group: dict | None = None, +) -> dict: + """Draft a Performance Max campaign with its first asset group. + + Per Google's PMax structure rules, the campaign and its first asset group + + assets MUST be created in the same API call. This tool produces a + single ChangePlan that, on confirm_and_apply, issues one bulk mutate + containing: CampaignBudget + Campaign (PAUSED) + geo/language targeting + + AssetGroup (PAUSED) + every Asset + every AssetGroupAsset link + every + AssetGroupSignal. + + bidding_strategy: PMax accepts only Smart Bidding strategies — + ``MAXIMIZE_CONVERSIONS``, ``MAXIMIZE_CONVERSION_VALUE``, ``TARGET_CPA``, + ``TARGET_ROAS``. ``MANUAL_CPC`` and ``TARGET_SPEND`` are rejected. + + asset_group: dict with these fields (required unless marked optional): + - ``name`` (str): asset group name. + - ``final_urls`` (list[str]): at least one. These are where ads send users. + - ``path1`` (str, optional, <=15 chars): display URL path component. + - ``path2`` (str, optional, <=15 chars): second display URL path component. + - ``headlines`` (list[str]): 3-5 short headlines, each <=30 chars. + - ``long_headlines`` (list[str]): 1-5 long headlines, each <=90 chars. + - ``descriptions`` (list[str]): 2-5 descriptions, each <=90 chars. + - ``business_name`` (str): your business name, <=25 chars. + - ``marketing_image_assets`` (list[str], optional): resource_names of + existing 1.91:1 marketing image Assets. PMax requires at least one + marketing image — if you have none uploaded yet, do that in Google + Ads UI first and pass the resource names here. + - ``square_marketing_image_assets`` (list[str], optional): resource_names + of existing 1:1 square marketing image Assets. + - ``logo_assets`` (list[str], optional): resource_names of existing logo + Assets. + - ``youtube_video_ids`` (list[str], optional): YouTube video IDs to add + as YOUTUBE_VIDEO assets (these are inline-creatable). + - ``search_themes`` (list[str], optional): search-theme signal phrases. + - ``audience_resource_names`` (list[str], optional): resource_names of + existing Audience resources to use as audience signals. + + NOTE: image and logo Assets cannot be created inline through this tool — + binary upload is out of scope. Pre-upload via Google Ads UI or a separate + AssetService.MutateAssets call, then pass the resource_name strings. + """ + from adloop.safety.guards import ( + SafetyViolation, + check_blocked_operation, + check_budget_cap, + ) + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("create_pmax_campaign", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + errors, warnings = _validate_pmax_campaign( + campaign_name=campaign_name, + daily_budget=daily_budget, + bidding_strategy=bidding_strategy, + target_cpa=target_cpa, + target_roas=target_roas, + geo_target_ids=geo_target_ids, + language_ids=language_ids, + asset_group=asset_group, + ) + if errors: + return {"error": "Validation failed", "details": errors} + + try: + check_budget_cap(daily_budget, config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + plan = ChangePlan( + operation="create_pmax_campaign", + entity_type="campaign", + customer_id=customer_id, + changes={ + "campaign_name": campaign_name, + "daily_budget": daily_budget, + "bidding_strategy": bidding_strategy.upper(), + "target_cpa": target_cpa or None, + "target_roas": target_roas or None, + "geo_target_ids": geo_target_ids or [], + "language_ids": language_ids or [], + "final_url_suffix": final_url_suffix or "", + "asset_group": asset_group, + }, + ) + store_plan(plan) + preview = plan.to_preview() + if warnings: + preview["warnings"] = warnings + return preview + + +def draft_asset_group( + config: AdLoopConfig, + *, + customer_id: str = "", + campaign_id: str = "", + asset_group: dict | None = None, +) -> dict: + """Draft a new asset group inside an existing PMax campaign. + + Creates: AssetGroup (PAUSED) + every text Asset + every YouTube video + Asset + every AssetGroupAsset link + every AssetGroupSignal in one + bulk mutate. + + asset_group: same dict shape as ``draft_pmax_campaign``'s asset_group — + see that tool's docstring for field descriptions. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("create_asset_group", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + errors: list[str] = [] + if not campaign_id: + errors.append("campaign_id is required") + if not asset_group: + errors.append("asset_group is required") + else: + errors.extend(_validate_asset_group(asset_group)) + + if errors: + return {"error": "Validation failed", "details": errors} + + plan = ChangePlan( + operation="create_asset_group", + entity_type="asset_group", + customer_id=customer_id, + changes={"campaign_id": campaign_id, "asset_group": asset_group}, + ) + store_plan(plan) + return plan.to_preview() + + +def draft_asset_group_assets( + config: AdLoopConfig, + *, + customer_id: str = "", + asset_group_id: str = "", + headlines: list[str] | None = None, + long_headlines: list[str] | None = None, + descriptions: list[str] | None = None, + business_name: str = "", + marketing_image_assets: list[str] | None = None, + square_marketing_image_assets: list[str] | None = None, + logo_assets: list[str] | None = None, + youtube_video_ids: list[str] | None = None, +) -> dict: + """Draft adding assets to an existing asset group. + + Use this to extend an asset group with more headlines, descriptions, + images, etc. Each asset gets created (text/video assets inline; image/logo + by resource_name reference) and linked to the asset group via + AssetGroupAsset operations in one bulk mutate. + + Image and logo Assets cannot be created inline (binary upload). Pre-upload + them and pass resource_name strings. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("create_asset_group_assets", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + errors: list[str] = [] + if not asset_group_id: + errors.append("asset_group_id is required") + + new_text = { + "HEADLINE": list(headlines or []), + "LONG_HEADLINE": list(long_headlines or []), + "DESCRIPTION": list(descriptions or []), + } + if business_name: + new_text["BUSINESS_NAME"] = [business_name] + + for ftype, items in new_text.items(): + for i, text in enumerate(items, start=1): + errors.extend(_validate_asset_text(ftype, text, i)) + + new_resource_assets = { + "MARKETING_IMAGE": list(marketing_image_assets or []), + "SQUARE_MARKETING_IMAGE": list(square_marketing_image_assets or []), + "LOGO": list(logo_assets or []), + } + for ftype, items in new_resource_assets.items(): + for rn in items: + if not rn or not rn.startswith("customers/"): + errors.append( + f"{ftype} entries must be Asset resource_names " + f"like 'customers/123/assets/456' — got '{rn}'" + ) + + new_video_ids = list(youtube_video_ids or []) + + has_any = any(new_text.values()) or any(new_resource_assets.values()) or new_video_ids + if not has_any: + errors.append( + "At least one asset must be provided — pass headlines, " + "long_headlines, descriptions, business_name, " + "marketing_image_assets, square_marketing_image_assets, " + "logo_assets, or youtube_video_ids." + ) + + if errors: + return {"error": "Validation failed", "details": errors} + + plan = ChangePlan( + operation="create_asset_group_assets", + entity_type="asset_group", + entity_id=asset_group_id, + customer_id=customer_id, + changes={ + "asset_group_id": asset_group_id, + "text_assets_by_type": new_text, + "resource_assets_by_type": new_resource_assets, + "youtube_video_ids": new_video_ids, + }, + ) + store_plan(plan) + return plan.to_preview() + + +def draft_asset_group_signal( + config: AdLoopConfig, + *, + customer_id: str = "", + asset_group_id: str = "", + search_theme: str = "", + audience_resource_name: str = "", +) -> dict: + """Draft a single new signal (search theme OR audience) on an asset group. + + Search themes are immutable once created — to "edit" one, remove the old + signal and add a new one. Audiences must already exist as Audience + resources; pass the resource_name (``customers/.../audiences/...``). + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("create_asset_group_signal", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + errors: list[str] = [] + if not asset_group_id: + errors.append("asset_group_id is required") + if not search_theme and not audience_resource_name: + errors.append("Either search_theme or audience_resource_name is required") + if search_theme and audience_resource_name: + errors.append( + "Pass only one of search_theme or audience_resource_name " + "per call — Google creates one signal per AssetGroupSignal." + ) + if audience_resource_name and not audience_resource_name.startswith("customers/"): + errors.append( + f"audience_resource_name must look like " + f"'customers/.../audiences/...', got '{audience_resource_name}'" + ) + + if errors: + return {"error": "Validation failed", "details": errors} + + plan = ChangePlan( + operation="create_asset_group_signal", + entity_type="asset_group", + entity_id=asset_group_id, + customer_id=customer_id, + changes={ + "asset_group_id": asset_group_id, + "search_theme": search_theme, + "audience_resource_name": audience_resource_name, + }, + ) + store_plan(plan) + return plan.to_preview() + + +# --------------------------------------------------------------------------- +# Validation helpers +# --------------------------------------------------------------------------- + + +def _validate_pmax_campaign( + *, + campaign_name: str, + daily_budget: float, + bidding_strategy: str, + target_cpa: float, + target_roas: float, + geo_target_ids: list[str] | None, + language_ids: list[str] | None, + asset_group: dict | None, +) -> tuple[list[str], list[str]]: + errors: list[str] = [] + warnings: list[str] = [] + + if not campaign_name or not campaign_name.strip(): + errors.append("campaign_name is required") + if daily_budget <= 0: + errors.append("daily_budget must be greater than 0") + if not geo_target_ids: + errors.append( + "geo_target_ids is required — PMax campaigns must target at " + "least one country/region" + ) + if not language_ids: + errors.append( + "language_ids is required — PMax campaigns must target at " + "least one language" + ) + + bs = bidding_strategy.upper() + if bs not in _PMAX_VALID_BIDDING: + errors.append( + f"bidding_strategy must be one of {sorted(_PMAX_VALID_BIDDING)} " + f"for PMax (no MANUAL_CPC or TARGET_SPEND), got '{bidding_strategy}'" + ) + if bs == "TARGET_CPA" and not target_cpa: + errors.append("target_cpa is required when bidding_strategy is TARGET_CPA") + if bs == "TARGET_ROAS" and not target_roas: + errors.append("target_roas is required when bidding_strategy is TARGET_ROAS") + + if target_cpa > 0 and daily_budget < 5 * target_cpa: + warnings.append( + f"Daily budget {daily_budget:.2f} is less than 5x target CPA " + f"{target_cpa:.2f}. Google recommends at least 5x for PMax " + f"learning to converge." + ) + + if not asset_group: + errors.append("asset_group is required for PMax campaign creation") + else: + errors.extend(_validate_asset_group(asset_group)) + + return errors, warnings + + +def _validate_asset_group(asset_group: dict) -> list[str]: + """Validate the asset_group dict against PMax minimums and char limits.""" + errors: list[str] = [] + + name = (asset_group.get("name") or "").strip() + if not name: + errors.append("asset_group.name is required") + + final_urls = asset_group.get("final_urls") or [] + if not final_urls: + errors.append("asset_group.final_urls must contain at least one URL") + for url in final_urls: + if not isinstance(url, str) or not url.startswith(("http://", "https://")): + errors.append( + f"asset_group.final_urls must be http(s) URLs — got '{url}'" + ) + + path1 = asset_group.get("path1") or "" + if path1 and len(path1) > 15: + errors.append(f"asset_group.path1 exceeds 15 chars ({len(path1)}): '{path1}'") + path2 = asset_group.get("path2") or "" + if path2 and len(path2) > 15: + errors.append(f"asset_group.path2 exceeds 15 chars ({len(path2)}): '{path2}'") + + text_groups = { + "HEADLINE": asset_group.get("headlines") or [], + "LONG_HEADLINE": asset_group.get("long_headlines") or [], + "DESCRIPTION": asset_group.get("descriptions") or [], + } + business_name = asset_group.get("business_name") or "" + if business_name: + text_groups["BUSINESS_NAME"] = [business_name] + + for ftype, items in text_groups.items(): + for i, text in enumerate(items, start=1): + errors.extend(_validate_asset_text(ftype, text, i)) + + minimum = ASSET_MINIMUMS.get(ftype, 0) + if len(items) < minimum: + errors.append( + f"asset_group needs at least {minimum} {ftype} asset(s), " + f"got {len(items)}" + ) + maximum = ASSET_MAXIMUMS.get(ftype, 999) + if len(items) > maximum: + errors.append( + f"asset_group accepts at most {maximum} {ftype} asset(s), " + f"got {len(items)}" + ) + + image_keys = { + "MARKETING_IMAGE": asset_group.get("marketing_image_assets") or [], + "SQUARE_MARKETING_IMAGE": asset_group.get("square_marketing_image_assets") or [], + "LOGO": asset_group.get("logo_assets") or [], + } + for ftype, items in image_keys.items(): + for rn in items: + if not isinstance(rn, str) or not rn.startswith("customers/"): + errors.append( + f"{ftype.lower()}_assets entries must be Asset resource_names " + f"like 'customers/123/assets/456' — got '{rn}'" + ) + minimum = ASSET_MINIMUMS.get(ftype, 0) + if len(items) < minimum: + errors.append( + f"asset_group requires at least {minimum} pre-uploaded " + f"{ftype} asset resource_name(s) — pre-upload images via the " + f"Google Ads UI or AssetService.MutateAssets, then pass the " + f"resource_names. Got {len(items)}." + ) + + return errors + + +def _validate_asset_text(field_type: str, text: str, index: int) -> list[str]: + """Validate a single text asset's char limit and non-emptiness.""" + errors: list[str] = [] + if not text or not str(text).strip(): + errors.append(f"{field_type} #{index} is empty") + return errors + limit = _LIMITS.get(field_type) + if limit is not None and len(text) > limit: + errors.append( + f"{field_type} #{index} exceeds {limit} chars ({len(text)}): '{text}'" + ) + return errors + + +# --------------------------------------------------------------------------- +# Apply helpers — wired into _execute_plan via PMAX_OPERATIONS +# --------------------------------------------------------------------------- + + +def _apply_create_pmax_campaign( + client: object, + cid: str, + changes: dict, + *, + validate_only: bool = False, +) -> dict: + """Create a full PMax campaign in one bulk mutate. + + Order of operations (Google Ads docs say AssetOperations must be + consecutive and precede their AssetGroupAsset links): + + 1. CampaignBudgetOperation.create (temp -1) + 2. CampaignOperation.create (temp -2, references budget -1) + - PMax: NO network_settings, no advertising_channel_sub_type + 3. CampaignCriterionOperation.create x N (geo + language, references -2) + 4. AssetOperation.create x N (text + youtube_video assets, temp -10..) + 5. AssetGroupOperation.create (temp -100, references campaign -2) + 6. AssetGroupAssetOperation.create x N (links assets to asset group) + 7. AssetGroupSignalOperation.create x N (search themes + audiences) + """ + service = client.get_service("GoogleAdsService") + campaign_service = client.get_service("CampaignService") + budget_service = client.get_service("CampaignBudgetService") + asset_service = client.get_service("AssetService") + asset_group_service = client.get_service("AssetGroupService") + + operations: list = [] + asset_group_data = changes["asset_group"] + + # --- 1. CampaignBudget (temp -1) --- + budget_op = client.get_type("MutateOperation") + budget = budget_op.campaign_budget_operation.create + budget.resource_name = budget_service.campaign_budget_path(cid, "-1") + budget.name = f"Budget - {changes['campaign_name']}" + budget.amount_micros = int(changes["daily_budget"] * 1_000_000) + budget.delivery_method = client.enums.BudgetDeliveryMethodEnum.STANDARD + budget.explicitly_shared = False + operations.append(budget_op) + + # --- 2. Campaign (temp -2) — PMax: omit network_settings entirely --- + campaign_op = client.get_type("MutateOperation") + campaign = campaign_op.campaign_operation.create + campaign.resource_name = campaign_service.campaign_path(cid, "-2") + campaign.name = changes["campaign_name"] + campaign.campaign_budget = budget_service.campaign_budget_path(cid, "-1") + campaign.status = client.enums.CampaignStatusEnum.PAUSED + campaign.advertising_channel_type = ( + client.enums.AdvertisingChannelTypeEnum.PERFORMANCE_MAX + ) + + bs = changes["bidding_strategy"] + if bs == "MAXIMIZE_CONVERSIONS": + campaign.maximize_conversions.target_cpa_micros = 0 + if changes.get("target_cpa"): + campaign.maximize_conversions.target_cpa_micros = int( + changes["target_cpa"] * 1_000_000 + ) + elif bs == "TARGET_CPA": + campaign.maximize_conversions.target_cpa_micros = int( + changes["target_cpa"] * 1_000_000 + ) + elif bs == "MAXIMIZE_CONVERSION_VALUE": + campaign.maximize_conversion_value.target_roas = 0 + if changes.get("target_roas"): + campaign.maximize_conversion_value.target_roas = changes["target_roas"] + elif bs == "TARGET_ROAS": + campaign.maximize_conversion_value.target_roas = changes["target_roas"] + + # PMax does NOT accept network_settings — omitted intentionally. + + campaign.contains_eu_political_advertising = ( + client.enums.EuPoliticalAdvertisingStatusEnum.DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING + ) + + if changes.get("final_url_suffix"): + campaign.final_url_suffix = changes["final_url_suffix"] + + operations.append(campaign_op) + + # --- 3. Geo/language targeting (CampaignCriterion, references campaign -2) --- + campaign_path = campaign_service.campaign_path(cid, "-2") + for geo_id in changes.get("geo_target_ids") or []: + geo_op = client.get_type("MutateOperation") + geo = geo_op.campaign_criterion_operation.create + geo.campaign = campaign_path + geo.location.geo_target_constant = f"geoTargetConstants/{geo_id}" + operations.append(geo_op) + + for lang_id in changes.get("language_ids") or []: + lang_op = client.get_type("MutateOperation") + lang = lang_op.campaign_criterion_operation.create + lang.campaign = campaign_path + lang.language.language_constant = f"languageConstants/{lang_id}" + operations.append(lang_op) + + # --- 4-7. Asset group + assets + signals --- + operations.extend( + _build_asset_group_operations( + client=client, + cid=cid, + campaign_resource_name=campaign_path, + asset_group_data=asset_group_data, + asset_temp_id_start=-10, + asset_group_temp_id="-100", + ) + ) + + response = service.mutate( + customer_id=cid, mutate_operations=operations, validate_only=validate_only + ) + + if validate_only: + return {"status": "validated", "operation_count": len(operations)} + + results: dict = { + "campaign_budget": None, + "campaign": None, + "asset_group": None, + "asset_count": 0, + "asset_group_assets": [], + "asset_group_signals": [], + } + for resp in response.mutate_operation_responses: + resp_type = resp.WhichOneof("response") + if not resp_type: + continue + rn = getattr(getattr(resp, resp_type), "resource_name", None) + if not rn: + continue + if resp_type == "campaign_budget_result": + results["campaign_budget"] = rn + elif resp_type == "campaign_result": + results["campaign"] = rn + elif resp_type == "asset_group_result": + results["asset_group"] = rn + elif resp_type == "asset_result": + results["asset_count"] += 1 + elif resp_type == "asset_group_asset_result": + results["asset_group_assets"].append(rn) + elif resp_type == "asset_group_signal_result": + results["asset_group_signals"].append(rn) + + return results + + +def _apply_create_asset_group( + client: object, + cid: str, + changes: dict, + *, + validate_only: bool = False, +) -> dict: + """Add an asset group (with assets + signals) to an existing PMax campaign.""" + service = client.get_service("GoogleAdsService") + campaign_service = client.get_service("CampaignService") + + campaign_path = campaign_service.campaign_path(cid, changes["campaign_id"]) + operations = _build_asset_group_operations( + client=client, + cid=cid, + campaign_resource_name=campaign_path, + asset_group_data=changes["asset_group"], + asset_temp_id_start=-10, + asset_group_temp_id="-100", + ) + + response = service.mutate( + customer_id=cid, mutate_operations=operations, validate_only=validate_only + ) + + if validate_only: + return {"status": "validated", "operation_count": len(operations)} + + results: dict = {"asset_group": None, "asset_count": 0, "links": [], "signals": []} + for resp in response.mutate_operation_responses: + resp_type = resp.WhichOneof("response") + if not resp_type: + continue + rn = getattr(getattr(resp, resp_type), "resource_name", None) + if not rn: + continue + if resp_type == "asset_group_result": + results["asset_group"] = rn + elif resp_type == "asset_result": + results["asset_count"] += 1 + elif resp_type == "asset_group_asset_result": + results["links"].append(rn) + elif resp_type == "asset_group_signal_result": + results["signals"].append(rn) + return results + + +def _apply_create_asset_group_assets( + client: object, + cid: str, + changes: dict, + *, + validate_only: bool = False, +) -> dict: + """Add assets (text/video inline + image refs) to an existing asset group.""" + service = client.get_service("GoogleAdsService") + asset_service = client.get_service("AssetService") + asset_group_service = client.get_service("AssetGroupService") + + asset_group_path = asset_group_service.asset_group_path( + cid, changes["asset_group_id"] + ) + operations: list = [] + next_temp_id = -1 + + text_field_type = client.enums.AssetFieldTypeEnum + + # Inline text assets — Asset.create then AssetGroupAsset.create. + for ftype, texts in (changes.get("text_assets_by_type") or {}).items(): + for text in texts: + asset_op = client.get_type("MutateOperation") + asset = asset_op.asset_operation.create + asset.resource_name = asset_service.asset_path(cid, str(next_temp_id)) + asset.text_asset.text = text + operations.append(asset_op) + + link_op = client.get_type("MutateOperation") + link = link_op.asset_group_asset_operation.create + link.asset = asset.resource_name + link.asset_group = asset_group_path + link.field_type = getattr(text_field_type, ftype) + operations.append(link_op) + + next_temp_id -= 1 + + # YouTube video assets — also inline-creatable. + for video_id in changes.get("youtube_video_ids") or []: + asset_op = client.get_type("MutateOperation") + asset = asset_op.asset_operation.create + asset.resource_name = asset_service.asset_path(cid, str(next_temp_id)) + asset.youtube_video_asset.youtube_video_id = video_id + operations.append(asset_op) + + link_op = client.get_type("MutateOperation") + link = link_op.asset_group_asset_operation.create + link.asset = asset.resource_name + link.asset_group = asset_group_path + link.field_type = text_field_type.YOUTUBE_VIDEO + operations.append(link_op) + + next_temp_id -= 1 + + # Image/logo assets — pre-uploaded, link-only. + for ftype, resource_names in (changes.get("resource_assets_by_type") or {}).items(): + for rn in resource_names: + link_op = client.get_type("MutateOperation") + link = link_op.asset_group_asset_operation.create + link.asset = rn + link.asset_group = asset_group_path + link.field_type = getattr(text_field_type, ftype) + operations.append(link_op) + + if not operations: + return {"message": "No assets to add"} + + response = service.mutate( + customer_id=cid, mutate_operations=operations, validate_only=validate_only + ) + + if validate_only: + return {"status": "validated", "operation_count": len(operations)} + + results: dict = {"assets": [], "links": []} + for resp in response.mutate_operation_responses: + resp_type = resp.WhichOneof("response") + if not resp_type: + continue + rn = getattr(getattr(resp, resp_type), "resource_name", None) + if not rn: + continue + if resp_type == "asset_result": + results["assets"].append(rn) + elif resp_type == "asset_group_asset_result": + results["links"].append(rn) + return results + + +def _apply_create_asset_group_signal( + client: object, + cid: str, + changes: dict, + *, + validate_only: bool = False, +) -> dict: + """Add a single signal (search theme or audience) to an asset group.""" + service = client.get_service("AssetGroupSignalService") + asset_group_service = client.get_service("AssetGroupService") + + operation = client.get_type("AssetGroupSignalOperation") + signal = operation.create + signal.asset_group = asset_group_service.asset_group_path( + cid, changes["asset_group_id"] + ) + + if changes.get("search_theme"): + signal.search_theme.text = changes["search_theme"] + elif changes.get("audience_resource_name"): + signal.audience.audience = changes["audience_resource_name"] + + response = service.mutate_asset_group_signals( + customer_id=cid, operations=[operation], validate_only=validate_only + ) + if validate_only: + return {"status": "validated"} + return {"resource_name": response.results[0].resource_name} + + +# --------------------------------------------------------------------------- +# Internal: build the AssetGroup + Asset + AssetGroupAsset + Signal operations +# --------------------------------------------------------------------------- + + +def _build_asset_group_operations( + *, + client: object, + cid: str, + campaign_resource_name: str, + asset_group_data: dict, + asset_temp_id_start: int, + asset_group_temp_id: str, +) -> list: + """Build the slice of MutateOperations that creates an asset group. + + The order follows Google's "AssetOperations consecutive, before + AssetGroupAssets" requirement: all Asset.create ops first, then + AssetGroup.create, then AssetGroupAsset.create links, then + AssetGroupSignal.create. + """ + asset_service = client.get_service("AssetService") + asset_group_service = client.get_service("AssetGroupService") + + operations: list = [] + field_type_enum = client.enums.AssetFieldTypeEnum + + # Track temp resource names by the field_type they're linked to so we can + # build AssetGroupAsset links after the AssetGroup itself is created. + text_assets: list[tuple[str, str]] = [] # (resource_name, field_type) + video_assets: list[str] = [] # resource_names + next_temp = asset_temp_id_start + + text_groups = { + "HEADLINE": asset_group_data.get("headlines") or [], + "LONG_HEADLINE": asset_group_data.get("long_headlines") or [], + "DESCRIPTION": asset_group_data.get("descriptions") or [], + } + if asset_group_data.get("business_name"): + text_groups["BUSINESS_NAME"] = [asset_group_data["business_name"]] + + # --- 4a. Text Asset operations (one per text) --- + for field_type, texts in text_groups.items(): + for text in texts: + asset_op = client.get_type("MutateOperation") + asset = asset_op.asset_operation.create + asset.resource_name = asset_service.asset_path(cid, str(next_temp)) + asset.text_asset.text = text + operations.append(asset_op) + text_assets.append((asset.resource_name, field_type)) + next_temp -= 1 + + # --- 4b. YouTube video Asset operations --- + for video_id in asset_group_data.get("youtube_video_ids") or []: + asset_op = client.get_type("MutateOperation") + asset = asset_op.asset_operation.create + asset.resource_name = asset_service.asset_path(cid, str(next_temp)) + asset.youtube_video_asset.youtube_video_id = video_id + operations.append(asset_op) + video_assets.append(asset.resource_name) + next_temp -= 1 + + # --- 5. AssetGroup operation --- + ag_resource_name = asset_group_service.asset_group_path(cid, asset_group_temp_id) + ag_op = client.get_type("MutateOperation") + ag = ag_op.asset_group_operation.create + ag.resource_name = ag_resource_name + ag.name = asset_group_data["name"] + ag.campaign = campaign_resource_name + for url in asset_group_data["final_urls"]: + ag.final_urls.append(url) + if asset_group_data.get("path1"): + ag.path1 = asset_group_data["path1"] + if asset_group_data.get("path2"): + ag.path2 = asset_group_data["path2"] + ag.status = client.enums.AssetGroupStatusEnum.PAUSED + operations.append(ag_op) + + # --- 6. AssetGroupAsset link operations --- + for asset_rn, field_type in text_assets: + link_op = client.get_type("MutateOperation") + link = link_op.asset_group_asset_operation.create + link.asset = asset_rn + link.asset_group = ag_resource_name + link.field_type = getattr(field_type_enum, field_type) + operations.append(link_op) + + for video_rn in video_assets: + link_op = client.get_type("MutateOperation") + link = link_op.asset_group_asset_operation.create + link.asset = video_rn + link.asset_group = ag_resource_name + link.field_type = field_type_enum.YOUTUBE_VIDEO + operations.append(link_op) + + image_keys = { + "MARKETING_IMAGE": asset_group_data.get("marketing_image_assets") or [], + "SQUARE_MARKETING_IMAGE": asset_group_data.get("square_marketing_image_assets") or [], + "LOGO": asset_group_data.get("logo_assets") or [], + } + for field_type, resource_names in image_keys.items(): + for rn in resource_names: + link_op = client.get_type("MutateOperation") + link = link_op.asset_group_asset_operation.create + link.asset = rn + link.asset_group = ag_resource_name + link.field_type = getattr(field_type_enum, field_type) + operations.append(link_op) + + # --- 7. AssetGroupSignal operations --- + for theme in asset_group_data.get("search_themes") or []: + sig_op = client.get_type("MutateOperation") + signal = sig_op.asset_group_signal_operation.create + signal.asset_group = ag_resource_name + signal.search_theme.text = theme + operations.append(sig_op) + + for audience_rn in asset_group_data.get("audience_resource_names") or []: + sig_op = client.get_type("MutateOperation") + signal = sig_op.asset_group_signal_operation.create + signal.asset_group = ag_resource_name + signal.audience.audience = audience_rn + operations.append(sig_op) + + return operations + + +# --------------------------------------------------------------------------- +# Dispatch table — imported by ads/write.py's _execute_plan +# --------------------------------------------------------------------------- + + +PMAX_OPERATIONS = { + "create_pmax_campaign": _apply_create_pmax_campaign, + "create_asset_group": _apply_create_asset_group, + "create_asset_group_assets": _apply_create_asset_group_assets, + "create_asset_group_signal": _apply_create_asset_group_signal, +} diff --git a/src/adloop/ads/write.py b/src/adloop/ads/write.py index 3f82746..4a0ed52 100644 --- a/src/adloop/ads/write.py +++ b/src/adloop/ads/write.py @@ -486,12 +486,17 @@ def draft_campaign( language_ids: list[str] | None = None, final_url_suffix: str | None = None, ) -> dict: - """Draft a full campaign structure — returns preview, does NOT execute. + """Draft a full Search campaign structure — returns preview, does NOT execute. Creates: CampaignBudget + Campaign (PAUSED) + AdGroup + optional Keywords + geo targeting + language targeting. Ads are NOT included — use draft_responsive_search_ad separately. + For Performance Max campaigns, use ``draft_pmax_campaign`` instead — the + PMax structure (no ad groups, asset_groups + assets + signals must be + created in the same mutate as the campaign) is incompatible with this + Search-shaped draft. + geo_target_ids: list of geo target constant IDs (e.g. ["2276"] for Germany, ["2840"] for USA). REQUIRED — campaigns must target specific countries. language_ids: list of language constant IDs (e.g. ["1001"] for German, @@ -506,6 +511,16 @@ def draft_campaign( ) from adloop.safety.preview import ChangePlan, store_plan + if channel_type.upper() == "PERFORMANCE_MAX": + return { + "error": ( + "draft_campaign cannot create Performance Max campaigns. PMax " + "campaigns have no ad groups, no keywords, and require an " + "asset_group with assets + signals to be created in the same " + "API call. Use draft_pmax_campaign instead." + ), + } + try: check_blocked_operation("create_campaign", config.safety) except SafetyViolation as e: @@ -870,6 +885,38 @@ def confirm_and_apply( dry_run = True if dry_run: + # Send the operations to the Google Ads API with validate_only=True. + # The API runs full validation (field types, enum values, references, + # PMax-network-settings rules, budget caps, etc.) and commits nothing. + # If validation fails, the API returns the same error shape it would + # return on a real apply — no false DRY_RUN_SUCCESS for a malformed + # mutate. + try: + validation = _execute_plan(config, plan, validate_only=True) + except Exception as e: + log_mutation( + config.safety.log_file, + operation=plan.operation, + customer_id=plan.customer_id, + entity_type=plan.entity_type, + entity_id=plan.entity_id, + changes=plan.changes, + dry_run=True, + result="dry_run_validation_failed", + error=str(e), + ) + return { + "status": "DRY_RUN_VALIDATION_FAILED", + "plan_id": plan.plan_id, + "operation": plan.operation, + "error": str(e), + "message": ( + "Google Ads rejected the plan during validate_only — " + "applying with dry_run=false would fail with the same error. " + "Fix the plan inputs and re-draft." + ), + } + log_mutation( config.safety.log_file, operation=plan.operation, @@ -885,9 +932,11 @@ def confirm_and_apply( "plan_id": plan.plan_id, "operation": plan.operation, "changes": plan.changes, + "validate_only": validation, "message": ( - "Dry run completed — no changes were made to your Google Ads account. " - "To apply for real, call confirm_and_apply again with dry_run=false." + "Google Ads validated the plan with validate_only=True and " + "accepted it. No changes were made. To apply for real, call " + "confirm_and_apply again with dry_run=false." ), } @@ -932,8 +981,12 @@ 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"} +_VALID_ENTITY_TYPES = {"campaign", "ad_group", "ad", "keyword", "asset_group"} +_REMOVABLE_ENTITY_TYPES = _VALID_ENTITY_TYPES | { + "negative_keyword", + "campaign_asset", + "label", +} _DEFAULT_FINAL_URL_SUFFIX = ( "utm_source=google&utm_medium=cpc" @@ -1069,7 +1122,7 @@ def _validate_rsa( "MANUAL_CPC", } -_VALID_CHANNEL_TYPES = {"SEARCH", "DISPLAY", "SHOPPING", "VIDEO", "PERFORMANCE_MAX"} +_VALID_CHANNEL_TYPES = {"SEARCH", "DISPLAY", "SHOPPING", "VIDEO"} def _validate_campaign( @@ -1348,9 +1401,21 @@ def _draft_status_change( # --------------------------------------------------------------------------- -def _execute_plan(config: AdLoopConfig, plan: object) -> dict: - """Dispatch to the right Google Ads mutate call based on plan.operation.""" +def _execute_plan( + config: AdLoopConfig, + plan: object, + *, + validate_only: bool = False, +) -> dict: + """Dispatch to the right Google Ads mutate call based on plan.operation. + + When validate_only=True, every helper passes the flag through to the + underlying Google Ads service mutate, which runs full validation server- + side and returns errors as if it were a real apply, but commits nothing. + """ from adloop.ads.client import get_ads_client, normalize_customer_id + from adloop.ads.labels import LABEL_OPERATIONS + from adloop.ads.pmax_write import PMAX_OPERATIONS client = get_ads_client(config) cid = normalize_customer_id(plan.customer_id) @@ -1367,6 +1432,8 @@ def _execute_plan(config: AdLoopConfig, plan: object) -> dict: "enable_entity": _apply_status_change, "remove_entity": _apply_remove, "create_sitelinks": _apply_create_sitelinks, + **PMAX_OPERATIONS, + **LABEL_OPERATIONS, } handler = dispatch.get(plan.operation) @@ -1380,15 +1447,28 @@ def _execute_plan(config: AdLoopConfig, plan: object) -> dict: plan.entity_type, plan.entity_id, plan.changes["target_status"], + validate_only=validate_only, ) if plan.operation == "remove_entity": - return handler(client, cid, plan.entity_type, plan.entity_id) + return handler( + client, + cid, + plan.entity_type, + plan.entity_id, + validate_only=validate_only, + ) - return handler(client, cid, plan.changes) + return handler(client, cid, plan.changes, validate_only=validate_only) -def _apply_create_campaign(client: object, cid: str, changes: dict) -> dict: +def _apply_create_campaign( + client: object, + cid: str, + changes: dict, + *, + validate_only: bool = False, +) -> dict: """Create campaign + budget + ad group + optional keywords atomically.""" service = client.get_service("GoogleAdsService") campaign_service = client.get_service("CampaignService") @@ -1503,7 +1583,12 @@ def _apply_create_campaign(client: object, cid: str, changes: dict) -> dict: ) operations.append(lang_op) - response = service.mutate(customer_id=cid, mutate_operations=operations) + response = service.mutate( + customer_id=cid, mutate_operations=operations, validate_only=validate_only + ) + + if validate_only: + return {"status": "validated", "operation_count": len(operations)} results = {} num_keywords = len(kw_list) @@ -1530,7 +1615,13 @@ def _apply_create_campaign(client: object, cid: str, changes: dict) -> dict: return results -def _apply_create_ad_group(client: object, cid: str, changes: dict) -> dict: +def _apply_create_ad_group( + client: object, + cid: str, + changes: dict, + *, + validate_only: bool = False, +) -> dict: """Create ad group + optional keywords in an existing campaign atomically.""" service = client.get_service("GoogleAdsService") campaign_service = client.get_service("CampaignService") @@ -1562,7 +1653,12 @@ def _apply_create_ad_group(client: object, cid: str, changes: dict) -> dict: ) operations.append(kw_op) - response = service.mutate(customer_id=cid, mutate_operations=operations) + response = service.mutate( + customer_id=cid, mutate_operations=operations, validate_only=validate_only + ) + + if validate_only: + return {"status": "validated", "operation_count": len(operations)} results: dict = {} for i, resp in enumerate(response.mutate_operation_responses): @@ -1578,7 +1674,13 @@ def _apply_create_ad_group(client: object, cid: str, changes: dict) -> dict: return results -def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict: +def _apply_update_campaign( + client: object, + cid: str, + changes: dict, + *, + validate_only: bool = False, +) -> dict: """Update an existing campaign's settings.""" from google.protobuf import field_mask_pb2 @@ -1719,7 +1821,12 @@ def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict: if not operations: return {"message": "No changes to apply"} - response = service.mutate(customer_id=cid, mutate_operations=operations) + response = service.mutate( + customer_id=cid, mutate_operations=operations, validate_only=validate_only + ) + + if validate_only: + return {"status": "validated", "operation_count": len(operations)} results = {"updated": []} for resp in response.mutate_operation_responses: @@ -1733,7 +1840,13 @@ def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict: return results -def _apply_create_rsa(client: object, cid: str, changes: dict) -> dict: +def _apply_create_rsa( + client: object, + cid: str, + changes: dict, + *, + validate_only: bool = False, +) -> dict: service = client.get_service("AdGroupAdService") operation = client.get_type("AdGroupAdOperation") ad_group_ad = operation.create @@ -1777,12 +1890,20 @@ def _apply_create_rsa(client: object, cid: str, changes: dict) -> dict: ad.responsive_search_ad.path2 = changes["path2"] response = service.mutate_ad_group_ads( - customer_id=cid, operations=[operation] + customer_id=cid, operations=[operation], validate_only=validate_only ) + if validate_only: + return {"status": "validated"} return {"resource_name": response.results[0].resource_name} -def _apply_replace_rsa(client: object, cid: str, changes: dict) -> dict: +def _apply_replace_rsa( + client: object, + cid: str, + changes: dict, + *, + validate_only: bool = False, +) -> dict: """Create a new RSA and pause/remove the old one.""" # Step 1: Create the replacement ad create_changes = { @@ -1793,17 +1914,22 @@ def _apply_replace_rsa(client: object, cid: str, changes: dict) -> dict: "path1": changes.get("path1", ""), "path2": changes.get("path2", ""), } - new_ad_result = _apply_create_rsa(client, cid, create_changes) + new_ad_result = _apply_create_rsa( + client, cid, create_changes, validate_only=validate_only + ) # Step 2: Pause or remove the old ad old_ad_id = changes["old_ad_id"] try: if changes.get("remove_old"): - old_ad_result = _apply_remove(client, cid, "ad", old_ad_id) + old_ad_result = _apply_remove( + client, cid, "ad", old_ad_id, validate_only=validate_only + ) old_action = "REMOVED" else: old_ad_result = _apply_status_change( - client, cid, "ad", old_ad_id, "PAUSED" + client, cid, "ad", old_ad_id, "PAUSED", + validate_only=validate_only, ) old_action = "PAUSED" except Exception as exc: @@ -1832,7 +1958,13 @@ def _apply_replace_rsa(client: object, cid: str, changes: dict) -> dict: } -def _apply_add_keywords(client: object, cid: str, changes: dict) -> dict: +def _apply_add_keywords( + client: object, + cid: str, + changes: dict, + *, + validate_only: bool = False, +) -> dict: service = client.get_service("AdGroupCriterionService") ad_group_path = client.get_service("AdGroupService").ad_group_path( cid, changes["ad_group_id"] @@ -1850,12 +1982,20 @@ def _apply_add_keywords(client: object, cid: str, changes: dict) -> dict: operations.append(operation) response = service.mutate_ad_group_criteria( - customer_id=cid, operations=operations + customer_id=cid, operations=operations, validate_only=validate_only ) + if validate_only: + return {"status": "validated", "operation_count": len(operations)} return {"resource_names": [r.resource_name for r in response.results]} -def _apply_add_negative_keywords(client: object, cid: str, changes: dict) -> dict: +def _apply_add_negative_keywords( + client: object, + cid: str, + changes: dict, + *, + validate_only: bool = False, +) -> dict: service = client.get_service("CampaignCriterionService") campaign_path = client.get_service("CampaignService").campaign_path( cid, changes["campaign_id"] @@ -1874,8 +2014,10 @@ def _apply_add_negative_keywords(client: object, cid: str, changes: dict) -> dic operations.append(operation) response = service.mutate_campaign_criteria( - customer_id=cid, operations=operations + customer_id=cid, operations=operations, validate_only=validate_only ) + if validate_only: + return {"status": "validated", "operation_count": len(operations)} return {"resource_names": [r.resource_name for r in response.results]} @@ -1910,6 +2052,8 @@ def _apply_remove( cid: str, entity_type: str, entity_id: str, + *, + validate_only: bool = False, ) -> dict: """Remove an entity via the REMOVE mutate operation (irreversible).""" if entity_type == "campaign": @@ -1917,7 +2061,7 @@ def _apply_remove( operation = client.get_type("CampaignOperation") operation.remove = service.campaign_path(cid, entity_id) response = service.mutate_campaigns( - customer_id=cid, operations=[operation] + customer_id=cid, operations=[operation], validate_only=validate_only ) elif entity_type == "ad_group": @@ -1925,7 +2069,7 @@ def _apply_remove( operation = client.get_type("AdGroupOperation") operation.remove = service.ad_group_path(cid, entity_id) response = service.mutate_ad_groups( - customer_id=cid, operations=[operation] + customer_id=cid, operations=[operation], validate_only=validate_only ) elif entity_type == "ad": @@ -1934,7 +2078,7 @@ def _apply_remove( operation = client.get_type("AdGroupAdOperation") operation.remove = f"customers/{cid}/adGroupAds/{resolved_id}" response = service.mutate_ad_group_ads( - customer_id=cid, operations=[operation] + customer_id=cid, operations=[operation], validate_only=validate_only ) elif entity_type == "keyword": @@ -1942,7 +2086,7 @@ def _apply_remove( operation = client.get_type("AdGroupCriterionOperation") operation.remove = f"customers/{cid}/adGroupCriteria/{entity_id}" response = service.mutate_ad_group_criteria( - customer_id=cid, operations=[operation] + customer_id=cid, operations=[operation], validate_only=validate_only ) elif entity_type == "negative_keyword": @@ -1950,7 +2094,22 @@ def _apply_remove( operation = client.get_type("CampaignCriterionOperation") operation.remove = f"customers/{cid}/campaignCriteria/{entity_id}" response = service.mutate_campaign_criteria( - customer_id=cid, operations=[operation] + customer_id=cid, operations=[operation], validate_only=validate_only + ) + + elif entity_type == "asset_group": + service = client.get_service("AssetGroupService") + operation = client.get_type("AssetGroupOperation") + operation.remove = service.asset_group_path(cid, entity_id) + response = service.mutate_asset_groups( + customer_id=cid, operations=[operation], validate_only=validate_only + ) + + elif entity_type == "label": + from adloop.ads.labels import _apply_remove_label + + return _apply_remove_label( + client, cid, entity_id, validate_only=validate_only ) elif entity_type == "campaign_asset": @@ -1969,8 +2128,10 @@ def _apply_remove( op = client.get_type("MutateOperation") op.campaign_asset_operation.remove = resource_name response = ga_service.mutate( - customer_id=cid, mutate_operations=[op] + customer_id=cid, mutate_operations=[op], validate_only=validate_only ) + if validate_only: + return {"status": "validated"} resp_inner = response.mutate_operation_responses[0] if resp_inner.campaign_asset_result.resource_name: return {"resource_name": resp_inner.campaign_asset_result.resource_name} @@ -1979,6 +2140,8 @@ def _apply_remove( else: raise ValueError(f"Cannot remove entity_type: {entity_type}") + if validate_only: + return {"status": "validated"} return {"resource_name": response.results[0].resource_name} @@ -1988,8 +2151,10 @@ def _apply_status_change( entity_type: str, entity_id: str, status: str, + *, + validate_only: bool = False, ) -> dict: - """Update the status of a campaign, ad group, ad, or keyword.""" + """Update the status of a campaign, ad group, ad, asset group, or keyword.""" if entity_type == "campaign": service = client.get_service("CampaignService") operation = client.get_type("CampaignOperation") @@ -2025,6 +2190,14 @@ def _apply_status_change( ) mutate = service.mutate_ad_group_criteria + elif entity_type == "asset_group": + service = client.get_service("AssetGroupService") + operation = client.get_type("AssetGroupOperation") + entity = operation.update + entity.resource_name = service.asset_group_path(cid, entity_id) + entity.status = getattr(client.enums.AssetGroupStatusEnum, status) + mutate = service.mutate_asset_groups + else: raise ValueError(f"Unknown entity_type: {entity_type}") @@ -2033,11 +2206,21 @@ def _apply_status_change( operation.update_mask = field_mask_pb2.FieldMask(paths=["status"]) - response = mutate(customer_id=cid, operations=[operation]) + response = mutate( + customer_id=cid, operations=[operation], validate_only=validate_only + ) + if validate_only: + return {"status": "validated"} return {"resource_name": response.results[0].resource_name} -def _apply_create_sitelinks(client: object, cid: str, changes: dict) -> dict: +def _apply_create_sitelinks( + client: object, + cid: str, + changes: dict, + *, + validate_only: bool = False, +) -> dict: """Create sitelink assets and link them to a campaign.""" asset_service = client.get_service("AssetService") campaign_asset_service = client.get_service("CampaignAssetService") @@ -2070,9 +2253,12 @@ def _apply_create_sitelinks(client: object, cid: str, changes: dict) -> dict: operations.append(op) response = googleads_service.mutate( - customer_id=cid, mutate_operations=operations + customer_id=cid, mutate_operations=operations, validate_only=validate_only ) + if validate_only: + return {"status": "validated", "operation_count": len(operations)} + results = {"assets": [], "campaign_assets": []} num_sitelinks = len(sitelinks) for i, resp in enumerate(response.mutate_operation_responses): diff --git a/src/adloop/crossref.py b/src/adloop/crossref.py index 6154caa..3d04fe7 100644 --- a/src/adloop/crossref.py +++ b/src/adloop/crossref.py @@ -534,12 +534,12 @@ def analyze_pmax_performance( Aggregates everything you can see about a PMax campaign in one place so the AI can reason about it as a whole. Pulls campaign metrics, asset group ad - strength, individual asset performance labels, channel breakdown, and (when - a property is configured) GA4 paid sessions/conversions. + strength, asset counts and missing-minimum diagnostics, channel breakdown, + and (when a property is configured) GA4 paid sessions/conversions. Returns auto-generated insights[] flagging: - Asset groups with POOR or AVERAGE ad strength - - Assets labeled LOW that should be replaced + - Asset groups below the documented PMax asset-type minimums - Channel skew (e.g. 90%+ of spend going to a single surface) - Zero-conversion campaigns despite spend - GDPR consent gaps (click-to-session ratio > 2:1) @@ -660,30 +660,41 @@ def analyze_pmax_performance( if ag.get("asset_group.ad_strength") in ("POOR", "AVERAGE") ] + # Asset minimums for non-retail PMax (Google's documented requirements): + # 3+ HEADLINE, 1+ LONG_HEADLINE, 2+ DESCRIPTION, 1+ BUSINESS_NAME, + # 1+ MARKETING_IMAGE, 1+ SQUARE_MARKETING_IMAGE, 1+ LOGO. + _ASSET_MINIMUMS = { + "HEADLINE": 3, + "LONG_HEADLINE": 1, + "DESCRIPTION": 2, + "BUSINESS_NAME": 1, + "MARKETING_IMAGE": 1, + "SQUARE_MARKETING_IMAGE": 1, + "LOGO": 1, + } + group_summaries = [] for ag in cmp_groups: ag_id = str(ag.get("asset_group.id", "")) ag_assets = assets_by_group.get(ag_id, []) counts: dict[str, int] = {} - low_assets: list[dict] = [] for a in ag_assets: ftype = a.get("asset_group_asset.field_type", "UNKNOWN") counts[ftype] = counts.get(ftype, 0) + 1 - if a.get("asset_group_asset.performance_label") == "LOW": - low_assets.append({ - "asset_id": str(a.get("asset.id", "")), - "field_type": ftype, - "text": a.get("asset.text_asset.text"), - "image_url": a.get("asset.image_asset.full_size.url"), - }) + + missing_minimums = [ + f"{ftype} (have {counts.get(ftype, 0)}, need {minimum})" + for ftype, minimum in _ASSET_MINIMUMS.items() + if counts.get(ftype, 0) < minimum + ] group_summaries.append({ "asset_group_id": ag_id, "asset_group_name": ag.get("asset_group.name", ""), "ad_strength": ag.get("asset_group.ad_strength", ""), "asset_counts_by_type": counts, - "low_performing_assets": low_assets, + "missing_asset_minimums": missing_minimums, "metrics": { "cost": _safe_float(ag.get("metrics.cost", 0)), "clicks": _safe_int(ag.get("metrics.clicks", 0)), @@ -691,11 +702,11 @@ def analyze_pmax_performance( }, }) - if low_assets: + if missing_minimums: insights.append( f"{cmp_name} / {ag.get('asset_group.name', '')}: " - f"{len(low_assets)} LOW-performing asset(s) — " - f"review the low_performing_assets list and replace these in Google Ads" + f"asset group is below minimums for {', '.join(missing_minimums)} " + f"— add the missing assets via draft_asset_group_assets" ) if ag.get("asset_group.ad_strength") in ("POOR", "AVERAGE"): @@ -748,7 +759,6 @@ def analyze_pmax_performance( "campaign_name": cmp_name, "campaign_status": camp.get("campaign.status", ""), "bidding_strategy_type": camp.get("campaign.bidding_strategy_type", ""), - "url_expansion_opt_out": camp.get("campaign.url_expansion_opt_out"), "brand_guidelines_enabled": camp.get("campaign.brand_guidelines_enabled"), "daily_budget": camp.get("campaign_budget.amount"), "metrics": { diff --git a/src/adloop/server.py b/src/adloop/server.py index 2109947..f499cfb 100644 --- a/src/adloop/server.py +++ b/src/adloop/server.py @@ -714,9 +714,9 @@ def get_pmax_campaigns( ) -> dict: """Get Performance Max campaigns with PMax-specific settings and metrics. - Returns: campaign id/name/status, bidding strategy, URL expansion setting, - brand guidelines flag, daily budget, impressions, clicks, cost, conversions, - conversions_value, CPA, and ROAS for each PMax campaign. + Returns: campaign id/name/status, bidding strategy, brand guidelines flag, + daily budget, impressions, clicks, cost, conversions, conversions_value, + CPA, and ROAS for each PMax campaign. Date format: "YYYY-MM-DD". Empty = last 30 days. """ @@ -794,13 +794,17 @@ def get_asset_group_assets( asset_group_id: str = "", campaign_id: str = "", ) -> dict: - """List individual assets in PMax asset groups with field type and performance label. + """List individual assets in PMax asset groups with field type and policy review. Returns asset id/type, field_type (HEADLINE, DESCRIPTION, MARKETING_IMAGE, - LOGO, YOUTUBE_VIDEO, etc.), performance_label (LOW, GOOD, BEST, PENDING), + LOGO, YOUTUBE_VIDEO, etc.), status, policy_summary.review_status, and the text content, image URL, or YouTube video id/title/url depending on type. - Use this to identify LOW-performing assets that should be replaced. + Note: the LOW/GOOD/BEST/PENDING performance_label was removed from + asset_group_asset in Google Ads API v24. To judge per-asset performance + now, query metrics directly via asset_field_type_view, or use + get_asset_group_top_combinations to see which combinations actually serve. + Provide either asset_group_id (single group) or campaign_id (all groups in the campaign). With both empty, returns all assets across all PMax campaigns. """ @@ -849,14 +853,15 @@ def get_asset_group_top_combinations( date_range_start: str = "", date_range_end: str = "", ) -> dict: - """Get top-performing asset combinations Google has assembled at serve time. + """Get the asset combinations Google has assembled at serve time for PMax. - Each row represents a unique combination (headline + description + image + - optional video) that has actually served, with its impression count. - Use this to understand which message/creative pairings work best. + Each row's asset_group_top_combinations field is a repeated message of + the assets that served together (headlines, descriptions, images, optional + video). The view does NOT expose metrics in v24 — the API rejects any + metrics.* field on this resource. Combinations come pre-ordered by Google + by serving frequency. - Provide either asset_group_id or campaign_id. Returns up to 50 rows ordered - by impressions DESC. + Provide either asset_group_id or campaign_id. Returns up to 50 rows. Date format: "YYYY-MM-DD". Empty = last 30 days. """ from adloop.ads.pmax_read import get_asset_group_top_combinations as _impl @@ -883,8 +888,10 @@ def get_pmax_search_terms( Note: PMax does NOT expose individual search terms (Google's design choice). This returns category labels (e.g. "Buy women's running shoes") aggregated - across many real queries, with metrics. Useful for understanding what - search themes the campaign is matching. + across many real queries, with impression and click counts. The Google Ads + API does NOT expose cost, conversions, or conversions_value on + campaign_search_term_insight (PROHIBITED_METRIC_IN_SELECT_OR_WHERE_CLAUSE), + so per-category cost is not available. campaign_id is REQUIRED — these insights are queried per-campaign. Date format: "YYYY-MM-DD". Empty = last 30 days. @@ -912,14 +919,13 @@ def analyze_pmax_performance( """Comprehensive PMax diagnostic — campaign + asset groups + assets + channels + GA4. Pulls everything you can inspect about Performance Max in one call: - campaign metrics + bidding/URL-expansion/brand-guidelines settings, every - asset group with its ad strength, individual asset performance labels, - channel-mix breakdown, and (when property_id is configured) GA4 paid - sessions/conversions per campaign. + campaign metrics + bidding/brand-guidelines settings, every asset group + with its ad strength and asset counts, channel-mix breakdown, and (when + property_id is configured) GA4 paid sessions/conversions per campaign. Returns auto-generated insights[] flagging: - Asset groups with POOR or AVERAGE ad strength - - Individual assets labeled LOW that should be replaced + - Asset groups below the documented PMax asset-type minimums - Channel skew (e.g. >90% of spend on a single surface) - Zero-conversion campaigns despite spend - GDPR consent gaps (click-to-session ratio > 2:1) @@ -940,6 +946,267 @@ def analyze_pmax_performance( ) +# --------------------------------------------------------------------------- +# Performance Max Write Tools +# --------------------------------------------------------------------------- + + +@mcp.tool(annotations=_WRITE) +@_safe +def draft_pmax_campaign( + campaign_name: str, + daily_budget: float, + bidding_strategy: str, + geo_target_ids: list[str], + language_ids: list[str], + asset_group: dict, + customer_id: str = "", + target_cpa: float = 0, + target_roas: float = 0, + final_url_suffix: str | None = None, +) -> dict: + """Draft a Performance Max campaign with its first asset group — returns PREVIEW. + + Creates: CampaignBudget + Campaign (PAUSED, no network_settings) + geo + + language + AssetGroup (PAUSED) + Assets + AssetGroupAsset links + Signals + in one atomic mutate. PMax requires this all-in-one shape. + + bidding_strategy: PMax accepts only Smart Bidding — + MAXIMIZE_CONVERSIONS | MAXIMIZE_CONVERSION_VALUE | TARGET_CPA | TARGET_ROAS + target_cpa / target_roas: required when bidding_strategy is the matching name. + geo_target_ids / language_ids: REQUIRED — same constants as draft_campaign. + + asset_group dict: see draft_pmax_campaign in pmax_write.py. Keys: + - name (str): asset group name + - final_urls (list[str]): at least one + - path1, path2 (str, optional, <=15 chars) + - headlines (list[str], 3-5, <=30 chars) + - long_headlines (list[str], 1-5, <=90 chars) + - descriptions (list[str], 2-5, <=90 chars) + - business_name (str, <=25 chars) + - marketing_image_assets (list[str]): resource_names of pre-uploaded + 1.91:1 images. PMax requires at least one. + - square_marketing_image_assets (list[str]): resource_names of pre- + uploaded 1:1 images. At least one required. + - logo_assets (list[str]): resource_names of pre-uploaded logos. At + least one required. + - youtube_video_ids (list[str], optional) + - search_themes (list[str], optional): SearchTheme signal phrases + - audience_resource_names (list[str], optional): Audience resource_names + + NOTE: image/logo assets cannot be created inline through this MCP — pre- + upload via Google Ads UI or AssetService.MutateAssets, then pass the + resource_name strings. + + Call confirm_and_apply with the returned plan_id to execute. The new + campaign is created as PAUSED — enable_entity it after review. + """ + from adloop.ads.pmax_write import draft_pmax_campaign as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + campaign_name=campaign_name, + daily_budget=daily_budget, + bidding_strategy=bidding_strategy, + target_cpa=target_cpa, + target_roas=target_roas, + geo_target_ids=geo_target_ids, + language_ids=language_ids, + final_url_suffix=final_url_suffix, + asset_group=asset_group, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def draft_asset_group( + campaign_id: str, + asset_group: dict, + customer_id: str = "", +) -> dict: + """Draft a new asset group inside an existing PMax campaign — returns PREVIEW. + + asset_group has the same shape as draft_pmax_campaign's asset_group field. + See that tool's docstring for the full schema. + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.pmax_write import draft_asset_group as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + campaign_id=campaign_id, + asset_group=asset_group, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def draft_asset_group_assets( + asset_group_id: str, + customer_id: str = "", + headlines: list[str] = [], + long_headlines: list[str] = [], + descriptions: list[str] = [], + business_name: str = "", + marketing_image_assets: list[str] = [], + square_marketing_image_assets: list[str] = [], + logo_assets: list[str] = [], + youtube_video_ids: list[str] = [], +) -> dict: + """Draft attaching new assets to an existing asset group — returns PREVIEW. + + Use this to add more headlines, descriptions, images, etc. to an asset + group that already exists. Each text/youtube asset is created inline (one + Asset.create + one AssetGroupAsset.create per item). Image and logo assets + must already exist in the account — pass their resource_names. + + Char limits: headlines <=30, long_headlines <=90, descriptions <=90, + business_name <=25. + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.pmax_write import draft_asset_group_assets as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + asset_group_id=asset_group_id, + headlines=headlines, + long_headlines=long_headlines, + descriptions=descriptions, + business_name=business_name, + marketing_image_assets=marketing_image_assets, + square_marketing_image_assets=square_marketing_image_assets, + logo_assets=logo_assets, + youtube_video_ids=youtube_video_ids, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def list_labels(customer_id: str = "") -> dict: + """List all labels in the Google Ads account. + + Returns each label's id, name, status, description, and background_color. + Use the IDs returned here with apply_label / unapply_label / remove_entity. + """ + from adloop.ads.labels import list_labels as _impl + + return _impl(_config, customer_id=customer_id or _config.ads.customer_id) + + +@mcp.tool(annotations=_WRITE) +@_safe +def draft_label( + name: str, + customer_id: str = "", + description: str = "", + background_color: str = "", +) -> dict: + """Draft creating a new Label — returns PREVIEW. + + name: required. Must be unique in the account. + description: optional human description. + background_color: optional hex color string like '#FF5733'. + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.labels import draft_label as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + name=name, + description=description, + background_color=background_color, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def apply_label( + entity_type: str, + entity_id: str, + label_id: str, + customer_id: str = "", +) -> dict: + """Draft attaching a label to a campaign/ad_group/ad/keyword — returns PREVIEW. + + entity_type: 'campaign', 'ad_group', 'ad', or 'keyword'. + entity_id: bare ID for campaign/ad_group, 'adGroupId~adId' for ad, + 'adGroupId~criterionId' for keyword. + label_id: the ID of an existing Label (use list_labels to discover them). + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.labels import draft_apply_label as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + entity_type=entity_type, + entity_id=entity_id, + label_id=label_id, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def unapply_label( + entity_type: str, + entity_id: str, + label_id: str, + customer_id: str = "", +) -> dict: + """Draft detaching a label from an entity (does NOT delete the Label itself). + + To delete the Label resource itself, use remove_entity with + entity_type='label'. + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.labels import draft_unapply_label as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + entity_type=entity_type, + entity_id=entity_id, + label_id=label_id, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def draft_asset_group_signal( + asset_group_id: str, + customer_id: str = "", + search_theme: str = "", + audience_resource_name: str = "", +) -> dict: + """Draft a new signal (search theme OR audience) on an asset group — returns PREVIEW. + + Pass exactly one of search_theme (a phrase) or audience_resource_name + (a 'customers/.../audiences/...' resource name). Search themes are + immutable once created — to "edit", remove the old signal and add a new + one. + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.pmax_write import draft_asset_group_signal as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + asset_group_id=asset_group_id, + search_theme=search_theme, + audience_resource_name=audience_resource_name, + ) + + # --------------------------------------------------------------------------- # Custom GAQL # --------------------------------------------------------------------------- @@ -1250,12 +1517,13 @@ def pause_entity( ) -> dict: """Draft pausing a campaign, ad group, ad, or keyword — returns a PREVIEW. - entity_type: "campaign", "ad_group", "ad", or "keyword" + entity_type: "campaign", "ad_group", "ad", "keyword", or "asset_group" entity_id format by type: - campaign: campaign ID (e.g. "12345678") - ad_group: ad group ID (e.g. "12345678") - ad: "adGroupId~adId" (e.g. "12345678~987654") - keyword: "adGroupId~criterionId" (e.g. "12345678~987654") + - asset_group: asset group ID (e.g. "6572147947") Call confirm_and_apply with the returned plan_id to execute. """ @@ -1278,12 +1546,13 @@ def enable_entity( ) -> dict: """Draft enabling a paused campaign, ad group, ad, or keyword — returns a PREVIEW. - entity_type: "campaign", "ad_group", "ad", or "keyword" + entity_type: "campaign", "ad_group", "ad", "keyword", or "asset_group" entity_id format by type: - campaign: campaign ID (e.g. "12345678") - ad_group: ad group ID (e.g. "12345678") - ad: "adGroupId~adId" (e.g. "12345678~987654") - keyword: "adGroupId~criterionId" (e.g. "12345678~987654") + - asset_group: asset group ID (e.g. "6572147947") Call confirm_and_apply with the returned plan_id to execute. """ @@ -1306,12 +1575,17 @@ def remove_entity( ) -> dict: """Draft REMOVING an entity — returns a PREVIEW. This is IRREVERSIBLE. - entity_type: "campaign", "ad_group", "ad", "keyword", or "negative_keyword" + entity_type: "campaign", "ad_group", "ad", "keyword", "negative_keyword", + "asset_group", "campaign_asset", or "label" entity_id: The resource ID. For keywords use "adGroupId~criterionId". For negative_keywords use the campaign criterion ID. + For campaign_assets use "campaignId~assetId~fieldType". + For asset_groups use the asset group ID. + For labels use the label ID (cascades to all assignments). WARNING: Removed entities cannot be re-enabled. Use pause_entity instead - if you just want to temporarily disable something. + if you just want to temporarily disable something. To detach a label from + a single entity (without deleting the Label itself), use unapply_label. Call confirm_and_apply with the returned plan_id to execute. """ diff --git a/tests/test_labels.py b/tests/test_labels.py new file mode 100644 index 0000000..742c961 --- /dev/null +++ b/tests/test_labels.py @@ -0,0 +1,166 @@ +"""Tests for label list / draft / apply / unapply tools.""" + +from unittest.mock import patch + +import pytest + +from adloop.ads.labels import ( + draft_apply_label, + draft_label, + draft_unapply_label, + list_labels, +) +from adloop.config import AdLoopConfig, AdsConfig, GA4Config, SafetyConfig + + +@pytest.fixture +def config(): + return AdLoopConfig( + ads=AdsConfig(customer_id="1234567890", developer_token="test"), + ga4=GA4Config(property_id="properties/123456"), + safety=SafetyConfig(), + ) + + +# --------------------------------------------------------------------------- +# list_labels +# --------------------------------------------------------------------------- + + +class TestListLabels: + @patch("adloop.ads.gaql.execute_query") + def test_returns_labels(self, mock_query, config): + mock_query.return_value = [ + { + "label.id": 1, + "label.name": "Test Run", + "label.status": "ENABLED", + "label.text_label.description": "", + "label.text_label.background_color": "#FF0000", + } + ] + + result = list_labels(config, customer_id="1234567890") + + assert result["total_labels"] == 1 + assert result["labels"][0]["label.name"] == "Test Run" + + @patch("adloop.ads.gaql.execute_query") + def test_filters_removed(self, mock_query, config): + mock_query.return_value = [] + + list_labels(config, customer_id="1234567890") + + call_query = mock_query.call_args[0][2] + assert "label.status != 'REMOVED'" in call_query + + +# --------------------------------------------------------------------------- +# draft_label +# --------------------------------------------------------------------------- + + +class TestDraftLabel: + def test_accepts_valid(self, config): + result = draft_label(config, customer_id="1234567890", name="Q2 Tests") + + assert "error" not in result + assert result["operation"] == "create_label" + + def test_requires_name(self, config): + result = draft_label(config, customer_id="1234567890", name="") + + assert "error" in result + details = " ".join(result["details"]) + assert "name is required" in details + + def test_validates_hex_color(self, config): + result = draft_label( + config, + customer_id="1234567890", + name="x", + background_color="not-hex", + ) + + assert "error" in result + + def test_accepts_valid_hex_color(self, config): + result = draft_label( + config, + customer_id="1234567890", + name="x", + background_color="#FF5733", + ) + + assert "error" not in result + + +# --------------------------------------------------------------------------- +# draft_apply_label +# --------------------------------------------------------------------------- + + +class TestDraftApplyLabel: + def test_accepts_campaign(self, config): + result = draft_apply_label( + config, + customer_id="1234567890", + entity_type="campaign", + entity_id="22488112473", + label_id="42", + ) + + assert "error" not in result + assert result["operation"] == "apply_label" + + def test_rejects_unknown_entity_type(self, config): + result = draft_apply_label( + config, + customer_id="1234567890", + entity_type="asset_group", + entity_id="6572147947", + label_id="42", + ) + + assert "error" in result + + def test_requires_label_id(self, config): + result = draft_apply_label( + config, + customer_id="1234567890", + entity_type="campaign", + entity_id="22488112473", + label_id="", + ) + + assert "error" in result + + +# --------------------------------------------------------------------------- +# draft_unapply_label +# --------------------------------------------------------------------------- + + +class TestDraftUnapplyLabel: + def test_accepts_keyword(self, config): + result = draft_unapply_label( + config, + customer_id="1234567890", + entity_type="keyword", + entity_id="111~222", + label_id="42", + ) + + assert "error" not in result + assert result["operation"] == "unapply_label" + + def test_rejects_unknown_entity_type(self, config): + result = draft_unapply_label( + config, + customer_id="1234567890", + entity_type="campaign_asset", + entity_id="x", + label_id="42", + ) + + assert "error" in result diff --git a/tests/test_pmax_read.py b/tests/test_pmax_read.py index c3b28af..0f53fdd 100644 --- a/tests/test_pmax_read.py +++ b/tests/test_pmax_read.py @@ -50,7 +50,6 @@ def test_enriches_cost_budget_roas(self, mock_query, config): "campaign.status": "ENABLED", "campaign.advertising_channel_type": "PERFORMANCE_MAX", "campaign.bidding_strategy_type": "MAXIMIZE_CONVERSIONS", - "campaign.url_expansion_opt_out": False, "campaign.brand_guidelines_enabled": True, "campaign_budget.amount_micros": 25_000_000, "metrics.impressions": 10_000, @@ -263,8 +262,8 @@ def test_builds_youtube_url(self, mock_query, config): "asset_group.id": 555, "asset_group.name": "Group 1", "asset_group_asset.field_type": "YOUTUBE_VIDEO", - "asset_group_asset.performance_label": "GOOD", "asset_group_asset.status": "ENABLED", + "asset_group_asset.policy_summary.review_status": "REVIEWED", "asset.id": 999, "asset.type": "YOUTUBE_VIDEO", "asset.text_asset.text": None, @@ -284,6 +283,16 @@ def test_builds_youtube_url(self, mock_query, config): == "https://www.youtube.com/watch?v=dQw4w9WgXcQ" ) + @patch("adloop.ads.gaql.execute_query") + def test_does_not_select_dropped_v24_fields(self, mock_query, config): + """Verify the query no longer references fields removed in API v24.""" + mock_query.return_value = [] + + get_asset_group_assets(config, customer_id="1234567890") + + call_query = mock_query.call_args[0][2] + assert "performance_label" not in call_query + @patch("adloop.ads.gaql.execute_query") def test_asset_group_id_filter(self, mock_query, config): mock_query.return_value = [] @@ -370,7 +379,6 @@ def test_basic_query(self, mock_query, config): "asset_group.id": 555, "asset_group.name": "Group 1", "asset_group_top_combination_view.asset_group_top_combinations": "...", - "metrics.impressions": 1500, "campaign.id": 111, "campaign.name": "PMax A", } @@ -382,6 +390,18 @@ def test_basic_query(self, mock_query, config): assert result["total_rows"] == 1 + @patch("adloop.ads.gaql.execute_query") + def test_query_excludes_metrics(self, mock_query, config): + """asset_group_top_combination_view does not expose metrics.* in v24.""" + mock_query.return_value = [] + + get_asset_group_top_combinations(config, customer_id="1234567890") + + call_query = mock_query.call_args[0][2] + assert "metrics." not in call_query + # And cannot ORDER BY a metric we don't select. + assert "ORDER BY metrics" not in call_query + @patch("adloop.ads.gaql.execute_query") def test_includes_limit(self, mock_query, config): mock_query.return_value = [] @@ -412,9 +432,6 @@ def test_returns_categories(self, mock_query, config): "campaign_search_term_insight.category_label": "buy running shoes", "metrics.impressions": 500, "metrics.clicks": 30, - "metrics.cost_micros": 15_000_000, - "metrics.conversions": 2, - "metrics.conversions_value": 200.0, } ] @@ -424,8 +441,25 @@ def test_returns_categories(self, mock_query, config): assert result["total_rows"] == 1 row = result["search_term_categories"][0] - assert row["metrics.cost"] == 15.0 - assert row["metrics.cpa"] == 7.5 + assert row["metrics.impressions"] == 500 + assert row["metrics.clicks"] == 30 + # Per Google Ads API v24, cost/conversion metrics are not selectable + # on campaign_search_term_insight. The tool surfaces a `note` field + # so callers know cost is not available. + assert "note" in result + + @patch("adloop.ads.gaql.execute_query") + def test_query_excludes_prohibited_metrics(self, mock_query, config): + """The API rejects cost_micros/conversions on campaign_search_term_insight.""" + mock_query.return_value = [] + + get_pmax_search_terms( + config, customer_id="1234567890", campaign_id="111" + ) + + call_query = mock_query.call_args[0][2] + assert "cost_micros" not in call_query + assert "conversions" not in call_query @patch("adloop.ads.gaql.execute_query") def test_handles_unsupported_api_version(self, mock_query, config): @@ -468,7 +502,6 @@ def test_aggregates_full_diagnostic( "campaign.name": "PMax A", "campaign.status": "ENABLED", "campaign.bidding_strategy_type": "MAXIMIZE_CONVERSIONS", - "campaign.url_expansion_opt_out": False, "campaign.brand_guidelines_enabled": True, "campaign_budget.amount": 25.0, "metrics.clicks": 200, @@ -499,16 +532,14 @@ def test_aggregates_full_diagnostic( "asset.id": 999, "asset_group.id": 555, "asset_group_asset.field_type": "HEADLINE", - "asset_group_asset.performance_label": "LOW", - "asset.text_asset.text": "Bad headline", + "asset.text_asset.text": "Headline 1", "asset.image_asset.full_size.url": None, }, { "asset.id": 1000, "asset_group.id": 555, "asset_group_asset.field_type": "HEADLINE", - "asset_group_asset.performance_label": "GOOD", - "asset.text_asset.text": "Good headline", + "asset.text_asset.text": "Headline 2", "asset.image_asset.full_size.url": None, }, ] @@ -558,17 +589,21 @@ def test_aggregates_full_diagnostic( assert len(camp["asset_groups"]) == 1 ag = camp["asset_groups"][0] assert ag["ad_strength"] == "POOR" - assert len(ag["low_performing_assets"]) == 1 assert ag["asset_counts_by_type"]["HEADLINE"] == 2 + # Asset group has only HEADLINEs — every other required type is missing. + # missing_asset_minimums lists each one with current vs needed counts. + assert len(ag["missing_asset_minimums"]) >= 5 # Channel skew check: YouTube is ~94% of spend (75/80) skew_insights = [i for i in result["insights"] if "skewed" in i] assert len(skew_insights) == 1 # POOR ad strength insight ad_strength_insights = [i for i in result["insights"] if "ad strength is POOR" in i] assert len(ad_strength_insights) == 1 - # LOW asset insight - low_insights = [i for i in result["insights"] if "LOW-performing" in i] - assert len(low_insights) == 1 + # Missing-asset-minimums insight (replaces the old LOW-performing-asset check) + minimums_insights = [ + i for i in result["insights"] if "below minimums" in i + ] + assert len(minimums_insights) == 1 # GA4 paid attached assert camp["ga4_paid"]["sessions"] == 60 assert camp["ga4_paid"]["conversions"] == 5 diff --git a/tests/test_pmax_write.py b/tests/test_pmax_write.py new file mode 100644 index 0000000..a6ed56e --- /dev/null +++ b/tests/test_pmax_write.py @@ -0,0 +1,377 @@ +"""Tests for Performance Max draft tools — input validation behavior. + +These tests cover validation paths (no Google Ads API calls). The actual +mutate roundtrip is exercised at runtime via confirm_and_apply with +validate_only=True. +""" + +import pytest + +from adloop.ads.pmax_write import ( + draft_asset_group, + draft_asset_group_assets, + draft_asset_group_signal, + draft_pmax_campaign, +) +from adloop.ads.write import draft_campaign +from adloop.config import AdLoopConfig, AdsConfig, GA4Config, SafetyConfig + + +@pytest.fixture +def config(): + return AdLoopConfig( + ads=AdsConfig(customer_id="1234567890", developer_token="test"), + ga4=GA4Config(property_id="properties/123456"), + safety=SafetyConfig(max_daily_budget=100.0), + ) + + +def _valid_asset_group(): + return { + "name": "Group A", + "final_urls": ["https://example.com/"], + "path1": "products", + "path2": "shoes", + "headlines": [ + "Buy running shoes", + "Free shipping today", + "Top brands on sale", + ], + "long_headlines": ["Find the perfect running shoes for your stride"], + "descriptions": [ + "Browse 100+ models from top brands.", + "Free returns within 30 days.", + ], + "business_name": "Acme Sports", + "marketing_image_assets": ["customers/1234567890/assets/1001"], + "square_marketing_image_assets": ["customers/1234567890/assets/1002"], + "logo_assets": ["customers/1234567890/assets/1003"], + } + + +# --------------------------------------------------------------------------- +# draft_campaign should reject channel_type=PERFORMANCE_MAX +# --------------------------------------------------------------------------- + + +class TestDraftCampaignRejectsPMax: + def test_rejects_performance_max_channel(self, config): + result = draft_campaign( + config, + customer_id="1234567890", + campaign_name="Test", + daily_budget=10.0, + bidding_strategy="MAXIMIZE_CONVERSIONS", + channel_type="PERFORMANCE_MAX", + geo_target_ids=["2840"], + language_ids=["1000"], + ) + + assert "error" in result + assert "draft_pmax_campaign" in result["error"] + + +# --------------------------------------------------------------------------- +# draft_pmax_campaign +# --------------------------------------------------------------------------- + + +class TestDraftPmaxCampaign: + def test_accepts_valid_campaign(self, config): + result = draft_pmax_campaign( + config, + customer_id="1234567890", + campaign_name="PMax Test", + daily_budget=20.0, + bidding_strategy="MAXIMIZE_CONVERSIONS", + geo_target_ids=["2840"], + language_ids=["1000"], + asset_group=_valid_asset_group(), + ) + + assert "error" not in result + assert result["status"] == "PENDING_CONFIRMATION" + assert result["operation"] == "create_pmax_campaign" + assert result["plan_id"] + + def test_rejects_manual_cpc(self, config): + result = draft_pmax_campaign( + config, + customer_id="1234567890", + campaign_name="PMax Test", + daily_budget=20.0, + bidding_strategy="MANUAL_CPC", + geo_target_ids=["2840"], + language_ids=["1000"], + asset_group=_valid_asset_group(), + ) + + assert "error" in result + details = " ".join(result["details"]) + assert "MANUAL_CPC" not in details or "PMax" in details + assert "MAXIMIZE_CONVERSIONS" in details + + def test_rejects_target_spend(self, config): + result = draft_pmax_campaign( + config, + customer_id="1234567890", + campaign_name="PMax Test", + daily_budget=20.0, + bidding_strategy="TARGET_SPEND", + geo_target_ids=["2840"], + language_ids=["1000"], + asset_group=_valid_asset_group(), + ) + + assert "error" in result + + def test_requires_asset_group(self, config): + result = draft_pmax_campaign( + config, + customer_id="1234567890", + campaign_name="PMax Test", + daily_budget=20.0, + bidding_strategy="MAXIMIZE_CONVERSIONS", + geo_target_ids=["2840"], + language_ids=["1000"], + asset_group=None, + ) + + assert "error" in result + details = " ".join(result["details"]) + assert "asset_group is required" in details + + def test_requires_geo_targets(self, config): + result = draft_pmax_campaign( + config, + customer_id="1234567890", + campaign_name="PMax Test", + daily_budget=20.0, + bidding_strategy="MAXIMIZE_CONVERSIONS", + geo_target_ids=[], + language_ids=["1000"], + asset_group=_valid_asset_group(), + ) + + assert "error" in result + assert any("geo_target_ids" in d for d in result["details"]) + + def test_rejects_budget_above_cap(self, config): + # Cap is 100 in fixture; 200 exceeds it + result = draft_pmax_campaign( + config, + customer_id="1234567890", + campaign_name="PMax Test", + daily_budget=200.0, + bidding_strategy="MAXIMIZE_CONVERSIONS", + geo_target_ids=["2840"], + language_ids=["1000"], + asset_group=_valid_asset_group(), + ) + + assert "error" in result + + def test_validates_text_char_limits(self, config): + bad = _valid_asset_group() + bad["headlines"] = [ + "This headline is way more than thirty characters long for sure", + "Short", + "Also short", + ] + result = draft_pmax_campaign( + config, + customer_id="1234567890", + campaign_name="PMax Test", + daily_budget=20.0, + bidding_strategy="MAXIMIZE_CONVERSIONS", + geo_target_ids=["2840"], + language_ids=["1000"], + asset_group=bad, + ) + + assert "error" in result + details = " ".join(result["details"]) + assert "30 chars" in details or "exceeds" in details + + def test_enforces_min_headlines(self, config): + bad = _valid_asset_group() + bad["headlines"] = ["only one"] + result = draft_pmax_campaign( + config, + customer_id="1234567890", + campaign_name="PMax Test", + daily_budget=20.0, + bidding_strategy="MAXIMIZE_CONVERSIONS", + geo_target_ids=["2840"], + language_ids=["1000"], + asset_group=bad, + ) + + assert "error" in result + details = " ".join(result["details"]) + assert "HEADLINE" in details + + def test_requires_image_resource_names_not_urls(self, config): + bad = _valid_asset_group() + bad["marketing_image_assets"] = ["https://example.com/img.png"] + result = draft_pmax_campaign( + config, + customer_id="1234567890", + campaign_name="PMax Test", + daily_budget=20.0, + bidding_strategy="MAXIMIZE_CONVERSIONS", + geo_target_ids=["2840"], + language_ids=["1000"], + asset_group=bad, + ) + + assert "error" in result + details = " ".join(result["details"]) + assert "resource_names" in details or "customers/" in details + + +# --------------------------------------------------------------------------- +# draft_asset_group +# --------------------------------------------------------------------------- + + +class TestDraftAssetGroup: + def test_accepts_valid(self, config): + result = draft_asset_group( + config, + customer_id="1234567890", + campaign_id="22488112473", + asset_group=_valid_asset_group(), + ) + + assert "error" not in result + assert result["operation"] == "create_asset_group" + + def test_requires_campaign_id(self, config): + result = draft_asset_group( + config, + customer_id="1234567890", + campaign_id="", + asset_group=_valid_asset_group(), + ) + + assert "error" in result + details = " ".join(result["details"]) + assert "campaign_id" in details + + def test_requires_asset_group(self, config): + result = draft_asset_group( + config, + customer_id="1234567890", + campaign_id="22488112473", + asset_group=None, + ) + + assert "error" in result + + +# --------------------------------------------------------------------------- +# draft_asset_group_assets +# --------------------------------------------------------------------------- + + +class TestDraftAssetGroupAssets: + def test_accepts_text_only(self, config): + result = draft_asset_group_assets( + config, + customer_id="1234567890", + asset_group_id="6572147947", + headlines=["New headline 1", "New headline 2"], + ) + + assert "error" not in result + assert result["operation"] == "create_asset_group_assets" + + def test_requires_at_least_one_asset(self, config): + result = draft_asset_group_assets( + config, + customer_id="1234567890", + asset_group_id="6572147947", + ) + + assert "error" in result + details = " ".join(result["details"]) + assert "At least one asset" in details + + def test_validates_headline_length(self, config): + result = draft_asset_group_assets( + config, + customer_id="1234567890", + asset_group_id="6572147947", + headlines=["a" * 31], + ) + + assert "error" in result + + def test_image_resource_names_must_be_resource_format(self, config): + result = draft_asset_group_assets( + config, + customer_id="1234567890", + asset_group_id="6572147947", + marketing_image_assets=["not-a-resource-name"], + ) + + assert "error" in result + + +# --------------------------------------------------------------------------- +# draft_asset_group_signal +# --------------------------------------------------------------------------- + + +class TestDraftAssetGroupSignal: + def test_accepts_search_theme(self, config): + result = draft_asset_group_signal( + config, + customer_id="1234567890", + asset_group_id="6572147947", + search_theme="buy women's running shoes", + ) + + assert "error" not in result + assert result["operation"] == "create_asset_group_signal" + + def test_accepts_audience(self, config): + result = draft_asset_group_signal( + config, + customer_id="1234567890", + asset_group_id="6572147947", + audience_resource_name="customers/1234567890/audiences/abc", + ) + + assert "error" not in result + + def test_rejects_both_signal_types_at_once(self, config): + result = draft_asset_group_signal( + config, + customer_id="1234567890", + asset_group_id="6572147947", + search_theme="buy shoes", + audience_resource_name="customers/1234567890/audiences/abc", + ) + + assert "error" in result + + def test_rejects_missing_signal_content(self, config): + result = draft_asset_group_signal( + config, + customer_id="1234567890", + asset_group_id="6572147947", + ) + + assert "error" in result + + def test_rejects_bad_audience_format(self, config): + result = draft_asset_group_signal( + config, + customer_id="1234567890", + asset_group_id="6572147947", + audience_resource_name="not-a-resource-name", + ) + + assert "error" in result From 25fe39d3e5dad87aba6492e6e8f44faa04f48d4a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 23:42:46 +0000 Subject: [PATCH 28/36] Self-review cleanup: dead code, naming, op order, brand_guidelines gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from a self-review and an independent reviewer pass over the PMax + label changes from 2779ede. Code: - pmax_write.py: drop the `_LIMITS["CALL_TO_ACTION_SELECTION"]` entry (never read) and the unreachable `ASSET_MAXIMUMS` keys for LANDSCAPE_LOGO / PORTRAIT_MARKETING_IMAGE / YOUTUBE_VIDEO (the validator never iterates over them). - pmax_write.py: drop unused `asset_service` and `asset_group_service` locals from `_apply_create_pmax_campaign` — `_build_asset_group_operations` fetches its own. - pmax_write.py: rename `text_field_type` to `field_type_enum` in `_apply_create_asset_group_assets` — it's the same `AssetFieldTypeEnum` used for text, video, image, and logo field types. - pmax_write.py: restructure `_apply_create_asset_group_assets` to emit all `Asset.create` operations first and all `AssetGroupAsset.create` links after, matching `_build_asset_group_operations` and Google's documented "AssetOperations consecutive, before AssetGroupAssets" rule. Image/logo links are now built alongside text/video links instead of in a separate trailing loop. - crossref.py: replace the in-loop `_ASSET_MINIMUMS` literal in `analyze_pmax_performance` with an import from `pmax_write.ASSET_MINIMUMS` — single source of truth, no per-iteration redeclaration. - crossref.py: when `campaign.brand_guidelines_enabled` is true, skip the BUSINESS_NAME and LOGO minimum checks at the asset-group level (those assets live on the campaign in that mode, so flagging them as "missing" is a false positive). - server.py: move the four label tools out from between `draft_asset_group_assets` and `draft_asset_group_signal`. PMax write block now stays contiguous; labels live in their own section header. Tests: - test_pmax_write.py: replace the convoluted `"MANUAL_CPC" not in details or "PMax" in details` assertion with two direct checks on the rejection message. Rules: - .cursor/rules/adloop.mdc (synced to .claude/rules/adloop.md): drop the stale "drill into LOW-labeled assets" instruction (those arrays no longer exist after `performance_label` was removed in v24) and replace with guidance pointing at `asset_counts_by_type`, `missing_asset_minimums`, and `get_asset_group_top_combinations`. - Same file: revise the Performance Max marketing best-practice bullet to drop the stale LOW-asset language, mention the asset-type minimums flagged by `analyze_pmax_performance`, and document explicitly that image/logo upload happens in the Google Ads UI (the MCP only accepts pre-uploaded resource_names). Tests still 192 passing. https://claude.ai/code/session_018uRgkMdVJKZcfCV6NE2MSP --- .claude/rules/adloop.md | 4 +- .cursor/rules/adloop.mdc | 4 +- src/adloop/ads/pmax_write.py | 78 ++++++++++++++++-------------------- src/adloop/crossref.py | 22 +++++----- src/adloop/server.py | 61 +++++++++++++++------------- tests/test_pmax_write.py | 4 +- 6 files changed, 85 insertions(+), 88 deletions(-) diff --git a/.claude/rules/adloop.md b/.claude/rules/adloop.md index c455d2f..7ffb96a 100644 --- a/.claude/rules/adloop.md +++ b/.claude/rules/adloop.md @@ -205,7 +205,7 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has PMax is structurally different from Search — different tools, different diagnostics, different levers. 1. **Default to `analyze_pmax_performance`.** It pulls campaign + asset groups + assets + channel breakdown + GA4 in one call and returns auto-generated `insights[]`. Don't manually chain `get_pmax_campaigns` + `get_asset_groups` + `get_asset_group_assets` unless you need data the cross-ref tool doesn't surface. -2. If the user asks specifically about creative quality: present the asset groups sorted by `ad_strength`, then drill into LOW-labeled assets via the `low_performing_assets` arrays. +2. If the user asks specifically about creative quality: present the asset groups sorted by `ad_strength`, then look at `asset_counts_by_type` and `missing_asset_minimums` to identify groups under-supplied with assets. The per-asset `performance_label` was removed in v24, so individual LOW asset diagnosis is no longer available — `get_asset_group_top_combinations` (which assets actually serve together) is the closest replacement. 3. If the user asks "where is my budget going?": use the `channel_breakdown` array and emphasize that PMax decides channel mix at serve time. If one channel dominates (>90%), that's worth flagging — Google may be suppressing other surfaces due to weak creative for those formats. 4. **Do NOT compare PMax CPA to Search CPA directly.** PMax includes Display, YouTube, and Discovery surfaces that have intrinsically different conversion dynamics. Compare PMax CPA to the campaign's `target_cpa` (if set) or to historical PMax CPA, not to a Search benchmark. 5. **Search terms work differently for PMax.** When users ask "what are people searching for" — explain that PMax does NOT expose individual queries (Google's design), only aggregated category labels via `get_pmax_search_terms`. Don't promise data the API doesn't return. @@ -647,4 +647,4 @@ When advising on Google Ads: - **Display paths**: Always set `path1` and `path2` on RSAs. They cost nothing, improve ad relevance, and make the display URL informative (e.g. `example.com/Features/Pricing` instead of bare `example.com`). Derive them from the landing page path or the ad's core message. Max 15 chars each. - **Sitelinks**: Every campaign should have at least 4 sitelinks. They increase ad real estate (more screen space = higher CTR), direct users to key pages, and are free. Good candidates: pricing, features, signup/trial, about, key product pages. Use `draft_sitelinks` to create them. Link text max 25 chars, descriptions max 35 chars each. - **Clicks vs sessions gap**: Never report a clicks > sessions discrepancy as a tracking bug without first accounting for GDPR consent. In the EU, 30-70% of users may reject analytics cookies. This is normal, not broken. -- **Performance Max specifics**: PMax campaigns are Smart Bidding only (`MAXIMIZE_CONVERSIONS` or `MAXIMIZE_CONVERSION_VALUE`) — no MANUAL_CPC option, so the BROAD-match-without-Smart-Bidding rule doesn't apply. Channel mix is decided by Google at serve time; you cannot directly target Search-only or YouTube-only. Asset groups should ideally have `ad_strength = GOOD` or better and zero `LOW`-labeled assets — replace LOW assets first before touching budget. PMax search-term insights expose categories only, not individual queries. +- **Performance Max specifics**: PMax campaigns are Smart Bidding only (`MAXIMIZE_CONVERSIONS` or `MAXIMIZE_CONVERSION_VALUE`) — no MANUAL_CPC option, so the BROAD-match-without-Smart-Bidding rule doesn't apply. Channel mix is decided by Google at serve time; you cannot directly target Search-only or YouTube-only. Asset groups should ideally have `ad_strength = GOOD` or better, and they should meet Google's documented asset-type minimums (3+ HEADLINE, 1+ LONG_HEADLINE, 2+ DESCRIPTION, etc. — `analyze_pmax_performance` flags any below-minimum groups). PMax search-term insights expose categories only, not individual queries. **Use the Google Ads UI to upload images and logos** — the MCP doesn't do binary upload; pass pre-uploaded Asset resource_names to `draft_pmax_campaign` / `draft_asset_group_assets`. diff --git a/.cursor/rules/adloop.mdc b/.cursor/rules/adloop.mdc index c7adfe9..d270002 100644 --- a/.cursor/rules/adloop.mdc +++ b/.cursor/rules/adloop.mdc @@ -207,7 +207,7 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has PMax is structurally different from Search — different tools, different diagnostics, different levers. 1. **Default to `analyze_pmax_performance`.** It pulls campaign + asset groups + assets + channel breakdown + GA4 in one call and returns auto-generated `insights[]`. Don't manually chain `get_pmax_campaigns` + `get_asset_groups` + `get_asset_group_assets` unless you need data the cross-ref tool doesn't surface. -2. If the user asks specifically about creative quality: present the asset groups sorted by `ad_strength`, then drill into LOW-labeled assets via the `low_performing_assets` arrays. +2. If the user asks specifically about creative quality: present the asset groups sorted by `ad_strength`, then look at `asset_counts_by_type` and `missing_asset_minimums` to identify groups under-supplied with assets. The per-asset `performance_label` was removed in v24, so individual LOW asset diagnosis is no longer available — `get_asset_group_top_combinations` (which assets actually serve together) is the closest replacement. 3. If the user asks "where is my budget going?": use the `channel_breakdown` array and emphasize that PMax decides channel mix at serve time. If one channel dominates (>90%), that's worth flagging — Google may be suppressing other surfaces due to weak creative for those formats. 4. **Do NOT compare PMax CPA to Search CPA directly.** PMax includes Display, YouTube, and Discovery surfaces that have intrinsically different conversion dynamics. Compare PMax CPA to the campaign's `target_cpa` (if set) or to historical PMax CPA, not to a Search benchmark. 5. **Search terms work differently for PMax.** When users ask "what are people searching for" — explain that PMax does NOT expose individual queries (Google's design), only aggregated category labels via `get_pmax_search_terms`. Don't promise data the API doesn't return. @@ -649,4 +649,4 @@ When advising on Google Ads: - **Display paths**: Always set `path1` and `path2` on RSAs. They cost nothing, improve ad relevance, and make the display URL informative (e.g. `example.com/Features/Pricing` instead of bare `example.com`). Derive them from the landing page path or the ad's core message. Max 15 chars each. - **Sitelinks**: Every campaign should have at least 4 sitelinks. They increase ad real estate (more screen space = higher CTR), direct users to key pages, and are free. Good candidates: pricing, features, signup/trial, about, key product pages. Use `draft_sitelinks` to create them. Link text max 25 chars, descriptions max 35 chars each. - **Clicks vs sessions gap**: Never report a clicks > sessions discrepancy as a tracking bug without first accounting for GDPR consent. In the EU, 30-70% of users may reject analytics cookies. This is normal, not broken. -- **Performance Max specifics**: PMax campaigns are Smart Bidding only (`MAXIMIZE_CONVERSIONS` or `MAXIMIZE_CONVERSION_VALUE`) — no MANUAL_CPC option, so the BROAD-match-without-Smart-Bidding rule doesn't apply. Channel mix is decided by Google at serve time; you cannot directly target Search-only or YouTube-only. Asset groups should ideally have `ad_strength = GOOD` or better and zero `LOW`-labeled assets — replace LOW assets first before touching budget. PMax search-term insights expose categories only, not individual queries. +- **Performance Max specifics**: PMax campaigns are Smart Bidding only (`MAXIMIZE_CONVERSIONS` or `MAXIMIZE_CONVERSION_VALUE`) — no MANUAL_CPC option, so the BROAD-match-without-Smart-Bidding rule doesn't apply. Channel mix is decided by Google at serve time; you cannot directly target Search-only or YouTube-only. Asset groups should ideally have `ad_strength = GOOD` or better, and they should meet Google's documented asset-type minimums (3+ HEADLINE, 1+ LONG_HEADLINE, 2+ DESCRIPTION, etc. — `analyze_pmax_performance` flags any below-minimum groups). PMax search-term insights expose categories only, not individual queries. **Use the Google Ads UI to upload images and logos** — the MCP doesn't do binary upload; pass pre-uploaded Asset resource_names to `draft_pmax_campaign` / `draft_asset_group_assets`. diff --git a/src/adloop/ads/pmax_write.py b/src/adloop/ads/pmax_write.py index 3a04d78..76fc606 100644 --- a/src/adloop/ads/pmax_write.py +++ b/src/adloop/ads/pmax_write.py @@ -36,7 +36,6 @@ "LONG_HEADLINE": 90, "DESCRIPTION": 90, "BUSINESS_NAME": 25, - "CALL_TO_ACTION_SELECTION": None, } # Per-field-type minimums for non-retail PMax asset groups (Google's minimums). @@ -52,7 +51,7 @@ "LOGO": 1, } -# Per-field-type maximums (the API rejects more than this). +# Per-field-type maximums for the inputs this module accepts. ASSET_MAXIMUMS = { "HEADLINE": 5, "LONG_HEADLINE": 5, @@ -61,9 +60,6 @@ "MARKETING_IMAGE": 20, "SQUARE_MARKETING_IMAGE": 20, "LOGO": 5, - "LANDSCAPE_LOGO": 5, - "PORTRAIT_MARKETING_IMAGE": 20, - "YOUTUBE_VIDEO": 5, } # PMax is Smart Bidding only — MANUAL_CPC / TARGET_SPEND are rejected. @@ -556,8 +552,6 @@ def _apply_create_pmax_campaign( service = client.get_service("GoogleAdsService") campaign_service = client.get_service("CampaignService") budget_service = client.get_service("CampaignBudgetService") - asset_service = client.get_service("AssetService") - asset_group_service = client.get_service("AssetGroupService") operations: list = [] asset_group_data = changes["asset_group"] @@ -732,7 +726,11 @@ def _apply_create_asset_group_assets( *, validate_only: bool = False, ) -> dict: - """Add assets (text/video inline + image refs) to an existing asset group.""" + """Add assets (text/video inline + image refs) to an existing asset group. + + Per Google's bulk-mutate ordering rules, all Asset.create operations are + emitted first (consecutive), then all AssetGroupAsset.create links. + """ service = client.get_service("GoogleAdsService") asset_service = client.get_service("AssetService") asset_group_service = client.get_service("AssetGroupService") @@ -740,59 +738,53 @@ def _apply_create_asset_group_assets( asset_group_path = asset_group_service.asset_group_path( cid, changes["asset_group_id"] ) - operations: list = [] - next_temp_id = -1 + field_type_enum = client.enums.AssetFieldTypeEnum - text_field_type = client.enums.AssetFieldTypeEnum + # Plan all the new Asset.create operations first; record the resulting + # temp resource_names so AssetGroupAsset.create operations can reference + # them after all Asset operations are emitted. + asset_ops: list = [] + link_specs: list[tuple[str, str]] = [] # (asset_resource_name, field_type) + next_temp_id = -1 - # Inline text assets — Asset.create then AssetGroupAsset.create. + # Inline text assets. for ftype, texts in (changes.get("text_assets_by_type") or {}).items(): for text in texts: - asset_op = client.get_type("MutateOperation") - asset = asset_op.asset_operation.create + op = client.get_type("MutateOperation") + asset = op.asset_operation.create asset.resource_name = asset_service.asset_path(cid, str(next_temp_id)) asset.text_asset.text = text - operations.append(asset_op) - - link_op = client.get_type("MutateOperation") - link = link_op.asset_group_asset_operation.create - link.asset = asset.resource_name - link.asset_group = asset_group_path - link.field_type = getattr(text_field_type, ftype) - operations.append(link_op) - + asset_ops.append(op) + link_specs.append((asset.resource_name, ftype)) next_temp_id -= 1 - # YouTube video assets — also inline-creatable. + # Inline YouTube video assets. for video_id in changes.get("youtube_video_ids") or []: - asset_op = client.get_type("MutateOperation") - asset = asset_op.asset_operation.create + op = client.get_type("MutateOperation") + asset = op.asset_operation.create asset.resource_name = asset_service.asset_path(cid, str(next_temp_id)) asset.youtube_video_asset.youtube_video_id = video_id - operations.append(asset_op) - - link_op = client.get_type("MutateOperation") - link = link_op.asset_group_asset_operation.create - link.asset = asset.resource_name - link.asset_group = asset_group_path - link.field_type = text_field_type.YOUTUBE_VIDEO - operations.append(link_op) - + asset_ops.append(op) + link_specs.append((asset.resource_name, "YOUTUBE_VIDEO")) next_temp_id -= 1 - # Image/logo assets — pre-uploaded, link-only. + # Pre-uploaded image/logo assets — link-only. for ftype, resource_names in (changes.get("resource_assets_by_type") or {}).items(): for rn in resource_names: - link_op = client.get_type("MutateOperation") - link = link_op.asset_group_asset_operation.create - link.asset = rn - link.asset_group = asset_group_path - link.field_type = getattr(text_field_type, ftype) - operations.append(link_op) + link_specs.append((rn, ftype)) - if not operations: + if not asset_ops and not link_specs: return {"message": "No assets to add"} + operations: list = list(asset_ops) + for asset_rn, ftype in link_specs: + op = client.get_type("MutateOperation") + link = op.asset_group_asset_operation.create + link.asset = asset_rn + link.asset_group = asset_group_path + link.field_type = getattr(field_type_enum, ftype) + operations.append(op) + response = service.mutate( customer_id=cid, mutate_operations=operations, validate_only=validate_only ) diff --git a/src/adloop/crossref.py b/src/adloop/crossref.py index 3d04fe7..a5f2635 100644 --- a/src/adloop/crossref.py +++ b/src/adloop/crossref.py @@ -551,6 +551,7 @@ def analyze_pmax_performance( get_pmax_campaigns, get_pmax_channel_breakdown, ) + from adloop.ads.pmax_write import ASSET_MINIMUMS from adloop.ga4.reports import run_ga4_report start, end = _default_date_range(date_range_start, date_range_end) @@ -660,17 +661,14 @@ def analyze_pmax_performance( if ag.get("asset_group.ad_strength") in ("POOR", "AVERAGE") ] - # Asset minimums for non-retail PMax (Google's documented requirements): - # 3+ HEADLINE, 1+ LONG_HEADLINE, 2+ DESCRIPTION, 1+ BUSINESS_NAME, - # 1+ MARKETING_IMAGE, 1+ SQUARE_MARKETING_IMAGE, 1+ LOGO. - _ASSET_MINIMUMS = { - "HEADLINE": 3, - "LONG_HEADLINE": 1, - "DESCRIPTION": 2, - "BUSINESS_NAME": 1, - "MARKETING_IMAGE": 1, - "SQUARE_MARKETING_IMAGE": 1, - "LOGO": 1, + # When brand_guidelines_enabled is on, BUSINESS_NAME and LOGO assets + # live at the campaign level rather than the asset group, so checking + # the asset group for them produces false-positive "missing" warnings. + brand_guidelines = bool(camp.get("campaign.brand_guidelines_enabled")) + applicable_minimums = { + ftype: minimum + for ftype, minimum in ASSET_MINIMUMS.items() + if not (brand_guidelines and ftype in ("BUSINESS_NAME", "LOGO")) } group_summaries = [] @@ -685,7 +683,7 @@ def analyze_pmax_performance( missing_minimums = [ f"{ftype} (have {counts.get(ftype, 0)}, need {minimum})" - for ftype, minimum in _ASSET_MINIMUMS.items() + for ftype, minimum in applicable_minimums.items() if counts.get(ftype, 0) < minimum ] diff --git a/src/adloop/server.py b/src/adloop/server.py index f499cfb..c184562 100644 --- a/src/adloop/server.py +++ b/src/adloop/server.py @@ -1085,6 +1085,39 @@ def draft_asset_group_assets( ) +@mcp.tool(annotations=_WRITE) +@_safe +def draft_asset_group_signal( + asset_group_id: str, + customer_id: str = "", + search_theme: str = "", + audience_resource_name: str = "", +) -> dict: + """Draft a new signal (search theme OR audience) on an asset group — returns PREVIEW. + + Pass exactly one of search_theme (a phrase) or audience_resource_name + (a 'customers/.../audiences/...' resource name). Search themes are + immutable once created — to "edit", remove the old signal and add a new + one. + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.pmax_write import draft_asset_group_signal as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + asset_group_id=asset_group_id, + search_theme=search_theme, + audience_resource_name=audience_resource_name, + ) + + +# --------------------------------------------------------------------------- +# Label Tools +# --------------------------------------------------------------------------- + + @mcp.tool(annotations=_READONLY) @_safe def list_labels(customer_id: str = "") -> dict: @@ -1179,34 +1212,6 @@ def unapply_label( ) -@mcp.tool(annotations=_WRITE) -@_safe -def draft_asset_group_signal( - asset_group_id: str, - customer_id: str = "", - search_theme: str = "", - audience_resource_name: str = "", -) -> dict: - """Draft a new signal (search theme OR audience) on an asset group — returns PREVIEW. - - Pass exactly one of search_theme (a phrase) or audience_resource_name - (a 'customers/.../audiences/...' resource name). Search themes are - immutable once created — to "edit", remove the old signal and add a new - one. - - Call confirm_and_apply with the returned plan_id to execute. - """ - from adloop.ads.pmax_write import draft_asset_group_signal as _impl - - return _impl( - _config, - customer_id=customer_id or _config.ads.customer_id, - asset_group_id=asset_group_id, - search_theme=search_theme, - audience_resource_name=audience_resource_name, - ) - - # --------------------------------------------------------------------------- # Custom GAQL # --------------------------------------------------------------------------- diff --git a/tests/test_pmax_write.py b/tests/test_pmax_write.py index a6ed56e..eb375eb 100644 --- a/tests/test_pmax_write.py +++ b/tests/test_pmax_write.py @@ -108,7 +108,9 @@ def test_rejects_manual_cpc(self, config): assert "error" in result details = " ".join(result["details"]) - assert "MANUAL_CPC" not in details or "PMax" in details + # The validator names the actual rejected value and points at the + # allowed Smart Bidding strategies. + assert "MANUAL_CPC" in details assert "MAXIMIZE_CONVERSIONS" in details def test_rejects_target_spend(self, config): From 2d31ae98eba38d3f82ad4249345e66b4b244ad34 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 14:55:32 +0000 Subject: [PATCH 29/36] Address PR #11 review: BUSINESS_NAME minimum + image/logo maximum checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two validation gaps surfaced by the PR review. P1: BUSINESS_NAME was added to text_groups only when business_name was non-empty, so an omitted business_name silently skipped its minimum check (declared as 1 in ASSET_MINIMUMS). The asset group passed draft validation and the failure was deferred to apply-time API validation. Fix: always include BUSINESS_NAME in text_groups with an empty list when business_name is empty so the minimum check fires. P2: Image/logo lists (marketing_image_assets, square_marketing_image_assets, logo_assets) were validated against ASSET_MINIMUMS but never against ASSET_MAXIMUMS. Drafts could accept oversized lists that the API would later reject. Fix: mirror the text-group pattern — also enforce the per-field-type maximum and emit a clear error when exceeded. Tests: two new regression tests cover both paths (test_requires_business_name_when_omitted, test_rejects_too_many_marketing_images). Full suite 194 passing. https://claude.ai/code/session_018uRgkMdVJKZcfCV6NE2MSP --- src/adloop/ads/pmax_write.py | 14 +++++++++--- tests/test_pmax_write.py | 44 ++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/adloop/ads/pmax_write.py b/src/adloop/ads/pmax_write.py index 76fc606..7e8e931 100644 --- a/src/adloop/ads/pmax_write.py +++ b/src/adloop/ads/pmax_write.py @@ -459,14 +459,16 @@ def _validate_asset_group(asset_group: dict) -> list[str]: if path2 and len(path2) > 15: errors.append(f"asset_group.path2 exceeds 15 chars ({len(path2)}): '{path2}'") + business_name = asset_group.get("business_name") or "" + # Always include BUSINESS_NAME — even when omitted — so the minimum check + # fires. Otherwise an empty business_name skips validation and the + # asset group fails apply-time API validation instead of draft-time. text_groups = { "HEADLINE": asset_group.get("headlines") or [], "LONG_HEADLINE": asset_group.get("long_headlines") or [], "DESCRIPTION": asset_group.get("descriptions") or [], + "BUSINESS_NAME": [business_name] if business_name else [], } - business_name = asset_group.get("business_name") or "" - if business_name: - text_groups["BUSINESS_NAME"] = [business_name] for ftype, items in text_groups.items(): for i, text in enumerate(items, start=1): @@ -505,6 +507,12 @@ def _validate_asset_group(asset_group: dict) -> list[str]: f"Google Ads UI or AssetService.MutateAssets, then pass the " f"resource_names. Got {len(items)}." ) + maximum = ASSET_MAXIMUMS.get(ftype, 999) + if len(items) > maximum: + errors.append( + f"asset_group accepts at most {maximum} {ftype} " + f"asset resource_name(s), got {len(items)}." + ) return errors diff --git a/tests/test_pmax_write.py b/tests/test_pmax_write.py index eb375eb..4fca76a 100644 --- a/tests/test_pmax_write.py +++ b/tests/test_pmax_write.py @@ -231,6 +231,50 @@ def test_requires_image_resource_names_not_urls(self, config): details = " ".join(result["details"]) assert "resource_names" in details or "customers/" in details + def test_requires_business_name_when_omitted(self, config): + # Regression: omitting business_name silently skipped the minimum + # check, so the asset group passed draft validation and only failed + # at apply-time API validation. Now caught at draft. + bad = _valid_asset_group() + bad["business_name"] = "" + result = draft_pmax_campaign( + config, + customer_id="1234567890", + campaign_name="PMax Test", + daily_budget=20.0, + bidding_strategy="MAXIMIZE_CONVERSIONS", + geo_target_ids=["2840"], + language_ids=["1000"], + asset_group=bad, + ) + + assert "error" in result + details = " ".join(result["details"]) + assert "BUSINESS_NAME" in details + + def test_rejects_too_many_marketing_images(self, config): + # Regression: image lists were checked against minimums but not + # maximums. ASSET_MAXIMUMS["MARKETING_IMAGE"] = 20. + bad = _valid_asset_group() + bad["marketing_image_assets"] = [ + f"customers/1234567890/assets/{i}" for i in range(1, 22) + ] + result = draft_pmax_campaign( + config, + customer_id="1234567890", + campaign_name="PMax Test", + daily_budget=20.0, + bidding_strategy="MAXIMIZE_CONVERSIONS", + geo_target_ids=["2840"], + language_ids=["1000"], + asset_group=bad, + ) + + assert "error" in result + details = " ".join(result["details"]) + assert "at most 20" in details + assert "MARKETING_IMAGE" in details + # --------------------------------------------------------------------------- # draft_asset_group From 3d2865427418327d1ba4f2abf1ef9c1d0ab5e48f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 22:49:13 +0000 Subject: [PATCH 30/36] Fix validate_only TypeError that broke every PMax + label write path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Google Ads service-client mutate method was being called with validate_only as a Python kwarg (e.g. service.mutate_labels(customer_id=cid, operations=[...], validate_only=True)). The gapic-generated methods do not accept validate_only as a kwarg — it is a field on the request proto. This raised a TypeError before any network round-trip, blocking the entire write surface (draft_pmax_campaign, draft_asset_group, draft_asset_group_assets, draft_asset_group_signal, draft_label, apply_label, unapply_label, plus the older Search write paths and remove/status-change calls). Fix: rewrite all 29 mutate call sites across write.py, pmax_write.py, and labels.py to use the request={...} dict form, which the gapic client coerces into the appropriate request proto with validate_only set as a field. Also tighten the DRY_RUN_VALIDATION_FAILED message in confirm_and_apply to distinguish a real Google Ads API rejection from an internal Python error that never reached the API — the previous message ("Google Ads rejected the plan") was misleading when the failure was a server-side bug. https://claude.ai/code/session_013NS9Ru7b2RSFVpA8vKxhed --- src/adloop/ads/labels.py | 60 +++++++++++++++--- src/adloop/ads/pmax_write.py | 24 ++++++-- src/adloop/ads/write.py | 116 +++++++++++++++++++++++++++++------ 3 files changed, 166 insertions(+), 34 deletions(-) diff --git a/src/adloop/ads/labels.py b/src/adloop/ads/labels.py index c051975..8ae7d2f 100644 --- a/src/adloop/ads/labels.py +++ b/src/adloop/ads/labels.py @@ -194,7 +194,11 @@ def _apply_create_label( label.text_label.background_color = changes["background_color"] response = service.mutate_labels( - customer_id=cid, operations=[operation], validate_only=validate_only + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } ) if validate_only: return {"status": "validated"} @@ -223,7 +227,11 @@ def _apply_apply_label( ) link.label = label_resource response = service.mutate_campaign_labels( - customer_id=cid, operations=[operation], validate_only=validate_only + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } ) elif entity_type == "ad_group": @@ -235,7 +243,11 @@ def _apply_apply_label( ) link.label = label_resource response = service.mutate_ad_group_labels( - customer_id=cid, operations=[operation], validate_only=validate_only + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } ) elif entity_type == "ad": @@ -248,7 +260,11 @@ def _apply_apply_label( link.ad_group_ad = f"customers/{cid}/adGroupAds/{resolved_id}" link.label = label_resource response = service.mutate_ad_group_ad_labels( - customer_id=cid, operations=[operation], validate_only=validate_only + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } ) elif entity_type == "keyword": @@ -258,7 +274,11 @@ def _apply_apply_label( link.ad_group_criterion = f"customers/{cid}/adGroupCriteria/{entity_id}" link.label = label_resource response = service.mutate_ad_group_criterion_labels( - customer_id=cid, operations=[operation], validate_only=validate_only + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } ) else: @@ -289,7 +309,11 @@ def _apply_unapply_label( operation = client.get_type("CampaignLabelOperation") operation.remove = f"customers/{cid}/campaignLabels/{entity_id}~{label_id}" response = service.mutate_campaign_labels( - customer_id=cid, operations=[operation], validate_only=validate_only + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } ) elif entity_type == "ad_group": @@ -297,7 +321,11 @@ def _apply_unapply_label( operation = client.get_type("AdGroupLabelOperation") operation.remove = f"customers/{cid}/adGroupLabels/{entity_id}~{label_id}" response = service.mutate_ad_group_labels( - customer_id=cid, operations=[operation], validate_only=validate_only + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } ) elif entity_type == "ad": @@ -310,7 +338,11 @@ def _apply_unapply_label( f"customers/{cid}/adGroupAdLabels/{resolved_id}~{label_id}" ) response = service.mutate_ad_group_ad_labels( - customer_id=cid, operations=[operation], validate_only=validate_only + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } ) elif entity_type == "keyword": @@ -320,7 +352,11 @@ def _apply_unapply_label( f"customers/{cid}/adGroupCriterionLabels/{entity_id}~{label_id}" ) response = service.mutate_ad_group_criterion_labels( - customer_id=cid, operations=[operation], validate_only=validate_only + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } ) else: @@ -346,7 +382,11 @@ def _apply_remove_label( operation = client.get_type("LabelOperation") operation.remove = f"customers/{cid}/labels/{entity_id}" response = service.mutate_labels( - customer_id=cid, operations=[operation], validate_only=validate_only + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } ) if validate_only: return {"status": "validated"} diff --git a/src/adloop/ads/pmax_write.py b/src/adloop/ads/pmax_write.py index 7e8e931..89cc42b 100644 --- a/src/adloop/ads/pmax_write.py +++ b/src/adloop/ads/pmax_write.py @@ -643,7 +643,11 @@ def _apply_create_pmax_campaign( ) response = service.mutate( - customer_id=cid, mutate_operations=operations, validate_only=validate_only + request={ + "customer_id": cid, + "mutate_operations": operations, + "validate_only": validate_only, + } ) if validate_only: @@ -702,7 +706,11 @@ def _apply_create_asset_group( ) response = service.mutate( - customer_id=cid, mutate_operations=operations, validate_only=validate_only + request={ + "customer_id": cid, + "mutate_operations": operations, + "validate_only": validate_only, + } ) if validate_only: @@ -794,7 +802,11 @@ def _apply_create_asset_group_assets( operations.append(op) response = service.mutate( - customer_id=cid, mutate_operations=operations, validate_only=validate_only + request={ + "customer_id": cid, + "mutate_operations": operations, + "validate_only": validate_only, + } ) if validate_only: @@ -838,7 +850,11 @@ def _apply_create_asset_group_signal( signal.audience.audience = changes["audience_resource_name"] response = service.mutate_asset_group_signals( - customer_id=cid, operations=[operation], validate_only=validate_only + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } ) if validate_only: return {"status": "validated"} diff --git a/src/adloop/ads/write.py b/src/adloop/ads/write.py index 4a0ed52..72b82ab 100644 --- a/src/adloop/ads/write.py +++ b/src/adloop/ads/write.py @@ -905,16 +905,32 @@ def confirm_and_apply( result="dry_run_validation_failed", error=str(e), ) + # Distinguish a Google Ads API rejection (request reached the API + # and was rejected) from an internal Python error before any + # network round-trip (TypeError, AttributeError, etc.). + is_api_rejection = type(e).__name__ == "GoogleAdsException" or hasattr( + e, "failure" + ) + if is_api_rejection: + message = ( + "Google Ads rejected the plan during validate_only — " + "applying with dry_run=false would fail with the same error. " + "Fix the plan inputs and re-draft." + ) + else: + message = ( + "The validate_only call failed before reaching Google Ads " + "(internal error in the apply pipeline, not an API " + "rejection). The plan inputs may be fine — this is " + "usually a bug in the MCP server. See 'error' for the " + "exception." + ) return { "status": "DRY_RUN_VALIDATION_FAILED", "plan_id": plan.plan_id, "operation": plan.operation, "error": str(e), - "message": ( - "Google Ads rejected the plan during validate_only — " - "applying with dry_run=false would fail with the same error. " - "Fix the plan inputs and re-draft." - ), + "message": message, } log_mutation( @@ -1584,7 +1600,11 @@ def _apply_create_campaign( operations.append(lang_op) response = service.mutate( - customer_id=cid, mutate_operations=operations, validate_only=validate_only + request={ + "customer_id": cid, + "mutate_operations": operations, + "validate_only": validate_only, + } ) if validate_only: @@ -1654,7 +1674,11 @@ def _apply_create_ad_group( operations.append(kw_op) response = service.mutate( - customer_id=cid, mutate_operations=operations, validate_only=validate_only + request={ + "customer_id": cid, + "mutate_operations": operations, + "validate_only": validate_only, + } ) if validate_only: @@ -1822,7 +1846,11 @@ def _apply_update_campaign( return {"message": "No changes to apply"} response = service.mutate( - customer_id=cid, mutate_operations=operations, validate_only=validate_only + request={ + "customer_id": cid, + "mutate_operations": operations, + "validate_only": validate_only, + } ) if validate_only: @@ -1890,7 +1918,11 @@ def _apply_create_rsa( ad.responsive_search_ad.path2 = changes["path2"] response = service.mutate_ad_group_ads( - customer_id=cid, operations=[operation], validate_only=validate_only + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } ) if validate_only: return {"status": "validated"} @@ -1982,7 +2014,11 @@ def _apply_add_keywords( operations.append(operation) response = service.mutate_ad_group_criteria( - customer_id=cid, operations=operations, validate_only=validate_only + request={ + "customer_id": cid, + "operations": operations, + "validate_only": validate_only, + } ) if validate_only: return {"status": "validated", "operation_count": len(operations)} @@ -2014,7 +2050,11 @@ def _apply_add_negative_keywords( operations.append(operation) response = service.mutate_campaign_criteria( - customer_id=cid, operations=operations, validate_only=validate_only + request={ + "customer_id": cid, + "operations": operations, + "validate_only": validate_only, + } ) if validate_only: return {"status": "validated", "operation_count": len(operations)} @@ -2061,7 +2101,11 @@ def _apply_remove( operation = client.get_type("CampaignOperation") operation.remove = service.campaign_path(cid, entity_id) response = service.mutate_campaigns( - customer_id=cid, operations=[operation], validate_only=validate_only + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } ) elif entity_type == "ad_group": @@ -2069,7 +2113,11 @@ def _apply_remove( operation = client.get_type("AdGroupOperation") operation.remove = service.ad_group_path(cid, entity_id) response = service.mutate_ad_groups( - customer_id=cid, operations=[operation], validate_only=validate_only + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } ) elif entity_type == "ad": @@ -2078,7 +2126,11 @@ def _apply_remove( operation = client.get_type("AdGroupAdOperation") operation.remove = f"customers/{cid}/adGroupAds/{resolved_id}" response = service.mutate_ad_group_ads( - customer_id=cid, operations=[operation], validate_only=validate_only + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } ) elif entity_type == "keyword": @@ -2086,7 +2138,11 @@ def _apply_remove( operation = client.get_type("AdGroupCriterionOperation") operation.remove = f"customers/{cid}/adGroupCriteria/{entity_id}" response = service.mutate_ad_group_criteria( - customer_id=cid, operations=[operation], validate_only=validate_only + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } ) elif entity_type == "negative_keyword": @@ -2094,7 +2150,11 @@ def _apply_remove( operation = client.get_type("CampaignCriterionOperation") operation.remove = f"customers/{cid}/campaignCriteria/{entity_id}" response = service.mutate_campaign_criteria( - customer_id=cid, operations=[operation], validate_only=validate_only + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } ) elif entity_type == "asset_group": @@ -2102,7 +2162,11 @@ def _apply_remove( operation = client.get_type("AssetGroupOperation") operation.remove = service.asset_group_path(cid, entity_id) response = service.mutate_asset_groups( - customer_id=cid, operations=[operation], validate_only=validate_only + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } ) elif entity_type == "label": @@ -2128,7 +2192,11 @@ def _apply_remove( op = client.get_type("MutateOperation") op.campaign_asset_operation.remove = resource_name response = ga_service.mutate( - customer_id=cid, mutate_operations=[op], validate_only=validate_only + request={ + "customer_id": cid, + "mutate_operations": [op], + "validate_only": validate_only, + } ) if validate_only: return {"status": "validated"} @@ -2207,7 +2275,11 @@ def _apply_status_change( operation.update_mask = field_mask_pb2.FieldMask(paths=["status"]) response = mutate( - customer_id=cid, operations=[operation], validate_only=validate_only + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } ) if validate_only: return {"status": "validated"} @@ -2253,7 +2325,11 @@ def _apply_create_sitelinks( operations.append(op) response = googleads_service.mutate( - customer_id=cid, mutate_operations=operations, validate_only=validate_only + request={ + "customer_id": cid, + "mutate_operations": operations, + "validate_only": validate_only, + } ) if validate_only: From 63987fff1a7310d0ce108bbf47af39d11bef7ae3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 04:02:53 +0000 Subject: [PATCH 31/36] Add draft_image_asset so PMax creation no longer requires the Ads UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Google Ads API rejects asset_group creates that are missing any of MARKETING_IMAGE / SQUARE_MARKETING_IMAGE / LOGO with ASSET_GROUP_NOT_ENOUGH_*_ASSET, so PMax campaigns cannot be drafted text-only and patched later. draft_image_asset closes the gap by letting users upload local JPG/PNG/GIF files (≤5 MB, magic-byte checked, batch upload supported) through the same draft → confirm_and_apply flow as every other write tool. The apply helper reads bytes fresh at apply time and feeds AssetService.MutateAssets, returning resource_names ready for draft_pmax_campaign / draft_asset_group / draft_asset_group_assets. Also tightens the missing-asset error messages in the existing PMax draft tools to name draft_image_asset as the upload path, and updates the orchestration rules with a dedicated upload pattern. https://claude.ai/code/session_01WjDCCdg7BRfa6HZR4uUi2X --- .claude/rules/adloop.md | 24 +++- .cursor/rules/adloop.mdc | 24 +++- CLAUDE.md | 4 +- src/adloop/ads/pmax_write.py | 245 +++++++++++++++++++++++++++++++++-- src/adloop/server.py | 39 ++++++ tests/test_pmax_write.py | 142 ++++++++++++++++++++ 6 files changed, 454 insertions(+), 24 deletions(-) diff --git a/.claude/rules/adloop.md b/.claude/rules/adloop.md index 7ffb96a..1690edd 100644 --- a/.claude/rules/adloop.md +++ b/.claude/rules/adloop.md @@ -114,6 +114,7 @@ These tools call both APIs internally and return unified results with computed ` | `draft_asset_group` | Add a new asset group (with assets + signals) to an existing PMax campaign | `campaign_id` (REQUIRED), `asset_group` dict | | `draft_asset_group_assets` | Add headlines / long_headlines / descriptions / business_name / image refs / YouTube videos to an existing asset group | `asset_group_id` (REQUIRED), plus any of the asset arrays | | `draft_asset_group_signal` | Add a single signal (search theme OR audience) to an asset group | `asset_group_id` (REQUIRED), plus exactly one of `search_theme` / `audience_resource_name` | +| `draft_image_asset` | Upload one or more local JPG/PNG/GIF images to the account as Google Ads Assets. Returns resource_names you can pass into `draft_pmax_campaign` / `draft_asset_group` / `draft_asset_group_assets`. | `images` list of `{file_path, name}` dicts. Absolute paths, ≤5 MB each, JPG/PNG/GIF only. Bytes are read at apply time. | | `draft_ad_group` | Create a new ad group within an existing SEARCH campaign (does NOT publish) | `campaign_id` (REQUIRED), `ad_group_name` (REQUIRED), `keywords` (optional list of {text, match_type}), `cpc_bid_micros` (optional) | | `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, Final URL suffix | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `final_url_suffix` | | `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each). Headlines/descriptions support optional **pinning**: pass `{"text": "...", "pinned_to": "HEADLINE_1"}` instead of a plain string. Valid pins: HEADLINE_1/2/3 for headlines, DESCRIPTION_1/2 for descriptions. Plain strings are unpinned. | @@ -141,7 +142,7 @@ These tools call both APIs internally and return unified results with computed ` - `draft_campaign` REQUIRES `geo_target_ids` and `language_ids` — campaigns without targeting waste budget. The tool rejects drafts with missing targeting. - `draft_campaign` enforces the `max_daily_budget` safety cap, rejects BROAD match + non-Smart Bidding, and warns if budget is below 5x target CPA. - `draft_campaign` rejects `channel_type=PERFORMANCE_MAX` — PMax requires the asset_group + assets + signals to be created in the same mutate as the campaign, which the Search-shaped draft cannot produce. Use `draft_pmax_campaign` for PMax. -- `draft_pmax_campaign` enforces Smart Bidding (rejects MANUAL_CPC and TARGET_SPEND) and PMax asset minimums (3+ HEADLINE, 1+ LONG_HEADLINE, 2+ DESCRIPTION, 1+ BUSINESS_NAME, 1+ MARKETING_IMAGE / SQUARE_MARKETING_IMAGE / LOGO). Image and logo assets must be pre-uploaded — pass resource_names, not URLs. +- `draft_pmax_campaign` enforces Smart Bidding (rejects MANUAL_CPC and TARGET_SPEND) and PMax asset minimums (3+ HEADLINE, 1+ LONG_HEADLINE, 2+ DESCRIPTION, 1+ BUSINESS_NAME, 1+ MARKETING_IMAGE / SQUARE_MARKETING_IMAGE / LOGO). Image and logo assets must be uploaded before the campaign mutate — either via `draft_image_asset` (point at local JPG/PNG/GIF paths) or via the Google Ads UI — then pass the resulting resource_names. The Google Ads API rejects an asset_group create that's missing any image / logo minimum (`ASSET_GROUP_NOT_ENOUGH_MARKETING_IMAGE_ASSET` etc.), so there is no "text-only PMax draft" workflow. - `update_campaign` replaces geo/language targets entirely (not append). Pass the full desired list. - `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", "campaign_asset", "asset_group", "label". Use "negative_keyword" to remove campaign-level negative keywords. Use "campaign_asset" to remove sitelinks and other asset links from a campaign. Use "label" to delete a Label resource (cascade-removes all assignments — to detach a single assignment, use `unapply_label` instead). @@ -292,15 +293,17 @@ PMax is structurally different from Search — different tools, different diagno ### When user wants to create a new Performance Max campaign -PMax is structurally different — there is no `draft_campaign` path for it. PMax requires the campaign + asset group + assets + signals to be created in the same atomic mutate. +PMax is structurally different — there is no `draft_campaign` path for it. PMax requires the campaign + asset group + assets + signals to be created in the same atomic mutate. There is no "text-only" or "draft-and-add-images-later" path: the Google Ads API rejects an asset_group create with any missing image/logo minimum (`ASSET_GROUP_NOT_ENOUGH_MARKETING_IMAGE_ASSET`, `...SQUARE_MARKETING_IMAGE_ASSET`, `...LOGO_ASSET`). Images and logos MUST exist as Asset resources before `draft_pmax_campaign` is called. 1. Call `get_pmax_campaigns` to see existing PMax campaigns and avoid name duplicates -2. Confirm the user has uploaded the required image and logo assets to the account already (the API requires resource_names — image/logo binary upload is not supported through this MCP). If not, point them to the Google Ads UI or ask for resource_names of pre-uploaded assets. +2. **Get the image/logo resource_names ready.** PMax needs at least one MARKETING_IMAGE (1.91:1, ≥600x314), one SQUARE_MARKETING_IMAGE (1:1, ≥300x300), and one LOGO (1:1, ≥128x128) before the campaign can be created. Two options: + - **Upload via the MCP**: call `draft_image_asset(images=[{file_path, name}, ...])`, confirm, and use the returned resource_names. JPG/PNG/GIF, ≤5 MB each, absolute paths. + - **Upload via the Google Ads UI**: ask the user for the resource_names of assets they've already uploaded (format `customers/.../assets/...`). 3. **Pre-write checks (CRITICAL):** - **Bidding strategy**: PMax accepts only Smart Bidding — `MAXIMIZE_CONVERSIONS`, `MAXIMIZE_CONVERSION_VALUE`, `TARGET_CPA`, `TARGET_ROAS`. The tool rejects MANUAL_CPC and TARGET_SPEND for PMax. - **Geo targeting**: ALWAYS ask the user which countries/regions to target. - **Language targeting**: ALWAYS ask the user which languages. - - **Asset minimums**: 3-5 headlines (≤30 chars), 1-5 long_headlines (≤90 chars), 2-5 descriptions (≤90 chars), 1 business_name (≤25 chars), at least 1 marketing_image, 1 square_marketing_image, 1 logo (resource_names of pre-uploaded assets). + - **Asset minimums**: 3-5 headlines (≤30 chars), 1-5 long_headlines (≤90 chars), 2-5 descriptions (≤90 chars), 1 business_name (≤25 chars), at least 1 marketing_image, 1 square_marketing_image, 1 logo (resource_names from step 2). - **Conversion tracking**: PMax depends heavily on Smart Bidding signals. Call `attribution_check` — if zero conversions across the board, WARN that PMax won't optimize without tracking working first. - **Budget**: ideally ≥ 5x target CPA; the tool warns otherwise. 4. Call `draft_pmax_campaign` with campaign details + the full `asset_group` dict (name, final_urls, headlines, long_headlines, descriptions, business_name, marketing_image_assets, square_marketing_image_assets, logo_assets, and optionally search_themes / audience_resource_names). @@ -309,10 +312,19 @@ PMax is structurally different — there is no `draft_campaign` path for it. PMa 7. After dry run passes and user approves, call `confirm_and_apply(plan_id=..., dry_run=false)`. 8. Remind the user to enable the PMax campaign via `enable_entity(entity_type='campaign', entity_id=...)` after reviewing in Google Ads UI. +### When user wants to upload images / logos for PMax + +1. Confirm the user has the files locally and knows the absolute paths. +2. Group the files into one `draft_image_asset(images=[{file_path, name}, ...])` call — a single batch is cheaper than one upload per file. Names are display names that show up in the Ads UI Asset Library; pick descriptive ones the user will recognize (e.g. `"Acme Logo - Square"`). +3. Present the preview. Each image has its file_path, mime_type, and file_size; on confirm the bytes are read fresh from disk and uploaded via `AssetService.MutateAssets`. +4. Call `confirm_and_apply(plan_id=..., dry_run=true)` to let Google validate dimensions and policy. A dry run failure here usually means wrong aspect ratio for the slot the user wants to fill — surface the exact Google Ads error. +5. Then call `confirm_and_apply(plan_id=..., dry_run=false)`. The result contains `uploaded` — a list of `{name, resource_name}`. Use those resource_names in `draft_pmax_campaign` / `draft_asset_group` / `draft_asset_group_assets`. +6. The same uploaded Asset can be linked as MARKETING_IMAGE, SQUARE_MARKETING_IMAGE, or LOGO at link time as long as its real pixel dimensions fit the slot — Google enforces the aspect ratio when the AssetGroupAsset link is created, not at upload time. + ### When user wants to add a new asset group, more assets, or signals to an existing PMax campaign - **New asset group**: `draft_asset_group(campaign_id, asset_group=...)` — same `asset_group` shape as `draft_pmax_campaign`. Each asset group has independent assets and is its own creative bundle. -- **More assets on an existing asset group**: `draft_asset_group_assets(asset_group_id, headlines=[...], long_headlines=[...], descriptions=[...], business_name=..., marketing_image_assets=[...], square_marketing_image_assets=[...], logo_assets=[...], youtube_video_ids=[...])` — pass only what you want to add. Text and YouTube assets are created inline; images/logos must already exist as Asset resources (pass resource_names). +- **More assets on an existing asset group**: `draft_asset_group_assets(asset_group_id, headlines=[...], long_headlines=[...], descriptions=[...], business_name=..., marketing_image_assets=[...], square_marketing_image_assets=[...], logo_assets=[...], youtube_video_ids=[...])` — pass only what you want to add. Text and YouTube assets are created inline; images/logos must already exist as Asset resources (upload them via `draft_image_asset` first, or paste resource_names from the Google Ads UI). - **New signal on an existing asset group**: `draft_asset_group_signal(asset_group_id, search_theme="..." OR audience_resource_name="customers/.../audiences/...")`. Pass exactly one. Search themes are immutable — to "edit" one, remove and re-add. To pause/enable an asset group, use `pause_entity`/`enable_entity` with `entity_type="asset_group"`. To remove one, use `remove_entity` (irreversible). @@ -647,4 +659,4 @@ When advising on Google Ads: - **Display paths**: Always set `path1` and `path2` on RSAs. They cost nothing, improve ad relevance, and make the display URL informative (e.g. `example.com/Features/Pricing` instead of bare `example.com`). Derive them from the landing page path or the ad's core message. Max 15 chars each. - **Sitelinks**: Every campaign should have at least 4 sitelinks. They increase ad real estate (more screen space = higher CTR), direct users to key pages, and are free. Good candidates: pricing, features, signup/trial, about, key product pages. Use `draft_sitelinks` to create them. Link text max 25 chars, descriptions max 35 chars each. - **Clicks vs sessions gap**: Never report a clicks > sessions discrepancy as a tracking bug without first accounting for GDPR consent. In the EU, 30-70% of users may reject analytics cookies. This is normal, not broken. -- **Performance Max specifics**: PMax campaigns are Smart Bidding only (`MAXIMIZE_CONVERSIONS` or `MAXIMIZE_CONVERSION_VALUE`) — no MANUAL_CPC option, so the BROAD-match-without-Smart-Bidding rule doesn't apply. Channel mix is decided by Google at serve time; you cannot directly target Search-only or YouTube-only. Asset groups should ideally have `ad_strength = GOOD` or better, and they should meet Google's documented asset-type minimums (3+ HEADLINE, 1+ LONG_HEADLINE, 2+ DESCRIPTION, etc. — `analyze_pmax_performance` flags any below-minimum groups). PMax search-term insights expose categories only, not individual queries. **Use the Google Ads UI to upload images and logos** — the MCP doesn't do binary upload; pass pre-uploaded Asset resource_names to `draft_pmax_campaign` / `draft_asset_group_assets`. +- **Performance Max specifics**: PMax campaigns are Smart Bidding only (`MAXIMIZE_CONVERSIONS` or `MAXIMIZE_CONVERSION_VALUE`) — no MANUAL_CPC option, so the BROAD-match-without-Smart-Bidding rule doesn't apply. Channel mix is decided by Google at serve time; you cannot directly target Search-only or YouTube-only. Asset groups should ideally have `ad_strength = GOOD` or better, and they should meet Google's documented asset-type minimums (3+ HEADLINE, 1+ LONG_HEADLINE, 2+ DESCRIPTION, etc. — `analyze_pmax_performance` flags any below-minimum groups). PMax search-term insights expose categories only, not individual queries. **Image and logo assets must exist before the campaign mutate.** Use `draft_image_asset` to upload local JPG/PNG/GIF files (≤5 MB each) directly from the MCP, or upload via the Google Ads UI and paste the resource_names — there is no "text-only PMax" path (the API rejects asset_group creates that are missing any image / logo minimum). diff --git a/.cursor/rules/adloop.mdc b/.cursor/rules/adloop.mdc index d270002..61f3ffd 100644 --- a/.cursor/rules/adloop.mdc +++ b/.cursor/rules/adloop.mdc @@ -116,6 +116,7 @@ These tools call both APIs internally and return unified results with computed ` | `draft_asset_group` | Add a new asset group (with assets + signals) to an existing PMax campaign | `campaign_id` (REQUIRED), `asset_group` dict | | `draft_asset_group_assets` | Add headlines / long_headlines / descriptions / business_name / image refs / YouTube videos to an existing asset group | `asset_group_id` (REQUIRED), plus any of the asset arrays | | `draft_asset_group_signal` | Add a single signal (search theme OR audience) to an asset group | `asset_group_id` (REQUIRED), plus exactly one of `search_theme` / `audience_resource_name` | +| `draft_image_asset` | Upload one or more local JPG/PNG/GIF images to the account as Google Ads Assets. Returns resource_names you can pass into `draft_pmax_campaign` / `draft_asset_group` / `draft_asset_group_assets`. | `images` list of `{file_path, name}` dicts. Absolute paths, ≤5 MB each, JPG/PNG/GIF only. Bytes are read at apply time. | | `draft_ad_group` | Create a new ad group within an existing SEARCH campaign (does NOT publish) | `campaign_id` (REQUIRED), `ad_group_name` (REQUIRED), `keywords` (optional list of {text, match_type}), `cpc_bid_micros` (optional) | | `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, Final URL suffix | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `final_url_suffix` | | `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each). Headlines/descriptions support optional **pinning**: pass `{"text": "...", "pinned_to": "HEADLINE_1"}` instead of a plain string. Valid pins: HEADLINE_1/2/3 for headlines, DESCRIPTION_1/2 for descriptions. Plain strings are unpinned. | @@ -143,7 +144,7 @@ These tools call both APIs internally and return unified results with computed ` - `draft_campaign` REQUIRES `geo_target_ids` and `language_ids` — campaigns without targeting waste budget. The tool rejects drafts with missing targeting. - `draft_campaign` enforces the `max_daily_budget` safety cap, rejects BROAD match + non-Smart Bidding, and warns if budget is below 5x target CPA. - `draft_campaign` rejects `channel_type=PERFORMANCE_MAX` — PMax requires the asset_group + assets + signals to be created in the same mutate as the campaign, which the Search-shaped draft cannot produce. Use `draft_pmax_campaign` for PMax. -- `draft_pmax_campaign` enforces Smart Bidding (rejects MANUAL_CPC and TARGET_SPEND) and PMax asset minimums (3+ HEADLINE, 1+ LONG_HEADLINE, 2+ DESCRIPTION, 1+ BUSINESS_NAME, 1+ MARKETING_IMAGE / SQUARE_MARKETING_IMAGE / LOGO). Image and logo assets must be pre-uploaded — pass resource_names, not URLs. +- `draft_pmax_campaign` enforces Smart Bidding (rejects MANUAL_CPC and TARGET_SPEND) and PMax asset minimums (3+ HEADLINE, 1+ LONG_HEADLINE, 2+ DESCRIPTION, 1+ BUSINESS_NAME, 1+ MARKETING_IMAGE / SQUARE_MARKETING_IMAGE / LOGO). Image and logo assets must be uploaded before the campaign mutate — either via `draft_image_asset` (point at local JPG/PNG/GIF paths) or via the Google Ads UI — then pass the resulting resource_names. The Google Ads API rejects an asset_group create that's missing any image / logo minimum (`ASSET_GROUP_NOT_ENOUGH_MARKETING_IMAGE_ASSET` etc.), so there is no "text-only PMax draft" workflow. - `update_campaign` replaces geo/language targets entirely (not append). Pass the full desired list. - `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", "campaign_asset", "asset_group", "label". Use "negative_keyword" to remove campaign-level negative keywords. Use "campaign_asset" to remove sitelinks and other asset links from a campaign. Use "label" to delete a Label resource (cascade-removes all assignments — to detach a single assignment, use `unapply_label` instead). @@ -294,15 +295,17 @@ PMax is structurally different from Search — different tools, different diagno ### When user wants to create a new Performance Max campaign -PMax is structurally different — there is no `draft_campaign` path for it. PMax requires the campaign + asset group + assets + signals to be created in the same atomic mutate. +PMax is structurally different — there is no `draft_campaign` path for it. PMax requires the campaign + asset group + assets + signals to be created in the same atomic mutate. There is no "text-only" or "draft-and-add-images-later" path: the Google Ads API rejects an asset_group create with any missing image/logo minimum (`ASSET_GROUP_NOT_ENOUGH_MARKETING_IMAGE_ASSET`, `...SQUARE_MARKETING_IMAGE_ASSET`, `...LOGO_ASSET`). Images and logos MUST exist as Asset resources before `draft_pmax_campaign` is called. 1. Call `get_pmax_campaigns` to see existing PMax campaigns and avoid name duplicates -2. Confirm the user has uploaded the required image and logo assets to the account already (the API requires resource_names — image/logo binary upload is not supported through this MCP). If not, point them to the Google Ads UI or ask for resource_names of pre-uploaded assets. +2. **Get the image/logo resource_names ready.** PMax needs at least one MARKETING_IMAGE (1.91:1, ≥600x314), one SQUARE_MARKETING_IMAGE (1:1, ≥300x300), and one LOGO (1:1, ≥128x128) before the campaign can be created. Two options: + - **Upload via the MCP**: call `draft_image_asset(images=[{file_path, name}, ...])`, confirm, and use the returned resource_names. JPG/PNG/GIF, ≤5 MB each, absolute paths. + - **Upload via the Google Ads UI**: ask the user for the resource_names of assets they've already uploaded (format `customers/.../assets/...`). 3. **Pre-write checks (CRITICAL):** - **Bidding strategy**: PMax accepts only Smart Bidding — `MAXIMIZE_CONVERSIONS`, `MAXIMIZE_CONVERSION_VALUE`, `TARGET_CPA`, `TARGET_ROAS`. The tool rejects MANUAL_CPC and TARGET_SPEND for PMax. - **Geo targeting**: ALWAYS ask the user which countries/regions to target. - **Language targeting**: ALWAYS ask the user which languages. - - **Asset minimums**: 3-5 headlines (≤30 chars), 1-5 long_headlines (≤90 chars), 2-5 descriptions (≤90 chars), 1 business_name (≤25 chars), at least 1 marketing_image, 1 square_marketing_image, 1 logo (resource_names of pre-uploaded assets). + - **Asset minimums**: 3-5 headlines (≤30 chars), 1-5 long_headlines (≤90 chars), 2-5 descriptions (≤90 chars), 1 business_name (≤25 chars), at least 1 marketing_image, 1 square_marketing_image, 1 logo (resource_names from step 2). - **Conversion tracking**: PMax depends heavily on Smart Bidding signals. Call `attribution_check` — if zero conversions across the board, WARN that PMax won't optimize without tracking working first. - **Budget**: ideally ≥ 5x target CPA; the tool warns otherwise. 4. Call `draft_pmax_campaign` with campaign details + the full `asset_group` dict (name, final_urls, headlines, long_headlines, descriptions, business_name, marketing_image_assets, square_marketing_image_assets, logo_assets, and optionally search_themes / audience_resource_names). @@ -311,10 +314,19 @@ PMax is structurally different — there is no `draft_campaign` path for it. PMa 7. After dry run passes and user approves, call `confirm_and_apply(plan_id=..., dry_run=false)`. 8. Remind the user to enable the PMax campaign via `enable_entity(entity_type='campaign', entity_id=...)` after reviewing in Google Ads UI. +### When user wants to upload images / logos for PMax + +1. Confirm the user has the files locally and knows the absolute paths. +2. Group the files into one `draft_image_asset(images=[{file_path, name}, ...])` call — a single batch is cheaper than one upload per file. Names are display names that show up in the Ads UI Asset Library; pick descriptive ones the user will recognize (e.g. `"Acme Logo - Square"`). +3. Present the preview. Each image has its file_path, mime_type, and file_size; on confirm the bytes are read fresh from disk and uploaded via `AssetService.MutateAssets`. +4. Call `confirm_and_apply(plan_id=..., dry_run=true)` to let Google validate dimensions and policy. A dry run failure here usually means wrong aspect ratio for the slot the user wants to fill — surface the exact Google Ads error. +5. Then call `confirm_and_apply(plan_id=..., dry_run=false)`. The result contains `uploaded` — a list of `{name, resource_name}`. Use those resource_names in `draft_pmax_campaign` / `draft_asset_group` / `draft_asset_group_assets`. +6. The same uploaded Asset can be linked as MARKETING_IMAGE, SQUARE_MARKETING_IMAGE, or LOGO at link time as long as its real pixel dimensions fit the slot — Google enforces the aspect ratio when the AssetGroupAsset link is created, not at upload time. + ### When user wants to add a new asset group, more assets, or signals to an existing PMax campaign - **New asset group**: `draft_asset_group(campaign_id, asset_group=...)` — same `asset_group` shape as `draft_pmax_campaign`. Each asset group has independent assets and is its own creative bundle. -- **More assets on an existing asset group**: `draft_asset_group_assets(asset_group_id, headlines=[...], long_headlines=[...], descriptions=[...], business_name=..., marketing_image_assets=[...], square_marketing_image_assets=[...], logo_assets=[...], youtube_video_ids=[...])` — pass only what you want to add. Text and YouTube assets are created inline; images/logos must already exist as Asset resources (pass resource_names). +- **More assets on an existing asset group**: `draft_asset_group_assets(asset_group_id, headlines=[...], long_headlines=[...], descriptions=[...], business_name=..., marketing_image_assets=[...], square_marketing_image_assets=[...], logo_assets=[...], youtube_video_ids=[...])` — pass only what you want to add. Text and YouTube assets are created inline; images/logos must already exist as Asset resources (upload them via `draft_image_asset` first, or paste resource_names from the Google Ads UI). - **New signal on an existing asset group**: `draft_asset_group_signal(asset_group_id, search_theme="..." OR audience_resource_name="customers/.../audiences/...")`. Pass exactly one. Search themes are immutable — to "edit" one, remove and re-add. To pause/enable an asset group, use `pause_entity`/`enable_entity` with `entity_type="asset_group"`. To remove one, use `remove_entity` (irreversible). @@ -649,4 +661,4 @@ When advising on Google Ads: - **Display paths**: Always set `path1` and `path2` on RSAs. They cost nothing, improve ad relevance, and make the display URL informative (e.g. `example.com/Features/Pricing` instead of bare `example.com`). Derive them from the landing page path or the ad's core message. Max 15 chars each. - **Sitelinks**: Every campaign should have at least 4 sitelinks. They increase ad real estate (more screen space = higher CTR), direct users to key pages, and are free. Good candidates: pricing, features, signup/trial, about, key product pages. Use `draft_sitelinks` to create them. Link text max 25 chars, descriptions max 35 chars each. - **Clicks vs sessions gap**: Never report a clicks > sessions discrepancy as a tracking bug without first accounting for GDPR consent. In the EU, 30-70% of users may reject analytics cookies. This is normal, not broken. -- **Performance Max specifics**: PMax campaigns are Smart Bidding only (`MAXIMIZE_CONVERSIONS` or `MAXIMIZE_CONVERSION_VALUE`) — no MANUAL_CPC option, so the BROAD-match-without-Smart-Bidding rule doesn't apply. Channel mix is decided by Google at serve time; you cannot directly target Search-only or YouTube-only. Asset groups should ideally have `ad_strength = GOOD` or better, and they should meet Google's documented asset-type minimums (3+ HEADLINE, 1+ LONG_HEADLINE, 2+ DESCRIPTION, etc. — `analyze_pmax_performance` flags any below-minimum groups). PMax search-term insights expose categories only, not individual queries. **Use the Google Ads UI to upload images and logos** — the MCP doesn't do binary upload; pass pre-uploaded Asset resource_names to `draft_pmax_campaign` / `draft_asset_group_assets`. +- **Performance Max specifics**: PMax campaigns are Smart Bidding only (`MAXIMIZE_CONVERSIONS` or `MAXIMIZE_CONVERSION_VALUE`) — no MANUAL_CPC option, so the BROAD-match-without-Smart-Bidding rule doesn't apply. Channel mix is decided by Google at serve time; you cannot directly target Search-only or YouTube-only. Asset groups should ideally have `ad_strength = GOOD` or better, and they should meet Google's documented asset-type minimums (3+ HEADLINE, 1+ LONG_HEADLINE, 2+ DESCRIPTION, etc. — `analyze_pmax_performance` flags any below-minimum groups). PMax search-term insights expose categories only, not individual queries. **Image and logo assets must exist before the campaign mutate.** Use `draft_image_asset` to upload local JPG/PNG/GIF files (≤5 MB each) directly from the MCP, or upload via the Google Ads UI and paste the resource_names — there is no "text-only PMax" path (the API rejects asset_group creates that are missing any image / logo minimum). diff --git a/CLAUDE.md b/CLAUDE.md index 1d0d325..336c806 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ python scripts/sync-rules.py # Sync rules: .cursor/rules/ -> .claude/rules/ ``` src/adloop/ ├── __init__.py # Entry point — routes 'adloop init' vs MCP server -├── server.py # FastMCP server — 55 tool registrations (incl. 8 PMax read, 4 PMax write, 4 label) +├── server.py # FastMCP server — 56 tool registrations (incl. 8 PMax read, 5 PMax write, 4 label) ├── config.py # Config loader (~/.adloop/config.yaml) ├── auth.py # OAuth 2.0 + service account + token refresh ├── cli.py # Interactive setup wizard @@ -34,7 +34,7 @@ All tool usage rules, safety protocols, orchestration patterns, GAQL reference, **Read and follow `.claude/rules/adloop.md` for all AdLoop MCP tool orchestration.** -That file is the complete guide for combining AdLoop's 55 tools (Search + Performance Max read & write + Labels). It covers: +That file is the complete guide for combining AdLoop's 56 tools (Search + Performance Max read & write + Image upload + Labels). It covers: - Tool inventory with parameters and when to use each - 8 safety rules (budget caps, dry-run defaults, Broad Match prevention, pre-write validation) - 16 orchestration patterns (performance review, ad creation, tracking diagnosis, PMax diagnostics, etc.) diff --git a/src/adloop/ads/pmax_write.py b/src/adloop/ads/pmax_write.py index 89cc42b..72954cb 100644 --- a/src/adloop/ads/pmax_write.py +++ b/src/adloop/ads/pmax_write.py @@ -20,6 +20,7 @@ from __future__ import annotations +import os from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -38,6 +39,20 @@ "BUSINESS_NAME": 25, } +# Image upload constraints (Google Ads ImageAsset). +_IMAGE_MAX_BYTES = 5 * 1024 * 1024 # 5 MB +_IMAGE_EXT_TO_MIME = { + ".jpg": "IMAGE_JPEG", + ".jpeg": "IMAGE_JPEG", + ".png": "IMAGE_PNG", + ".gif": "IMAGE_GIF", +} +_IMAGE_MAGIC_BYTES = { + "IMAGE_JPEG": (b"\xff\xd8\xff",), + "IMAGE_PNG": (b"\x89PNG\r\n\x1a\n",), + "IMAGE_GIF": (b"GIF87a", b"GIF89a"), +} + # Per-field-type minimums for non-retail PMax asset groups (Google's minimums). # An asset group below ANY minimum will fail Google's "minimum requirements" # check at serve time even if the API accepts the create. @@ -126,9 +141,10 @@ def draft_pmax_campaign( - ``audience_resource_names`` (list[str], optional): resource_names of existing Audience resources to use as audience signals. - NOTE: image and logo Assets cannot be created inline through this tool — - binary upload is out of scope. Pre-upload via Google Ads UI or a separate - AssetService.MutateAssets call, then pass the resource_name strings. + NOTE: image and logo Assets are not inline-creatable inside this mutate. + Upload them first via ``draft_image_asset`` (point at local JPG/PNG/GIF + paths), then pass the returned resource_names here. Resource_names from + assets you've already uploaded via the Google Ads UI work too. """ from adloop.safety.guards import ( SafetyViolation, @@ -249,8 +265,9 @@ def draft_asset_group_assets( by resource_name reference) and linked to the asset group via AssetGroupAsset operations in one bulk mutate. - Image and logo Assets cannot be created inline (binary upload). Pre-upload - them and pass resource_name strings. + Image and logo Assets are not inline-creatable. Upload local files first + via ``draft_image_asset`` and pass the resulting resource_names here, or + paste resource_names of assets already uploaded via the Google Ads UI. """ from adloop.safety.guards import SafetyViolation, check_blocked_operation from adloop.safety.preview import ChangePlan, store_plan @@ -286,7 +303,8 @@ def draft_asset_group_assets( if not rn or not rn.startswith("customers/"): errors.append( f"{ftype} entries must be Asset resource_names " - f"like 'customers/123/assets/456' — got '{rn}'" + f"like 'customers/123/assets/456' — got '{rn}'. Upload " + f"local files via draft_image_asset to obtain resource_names." ) new_video_ids = list(youtube_video_ids or []) @@ -319,6 +337,82 @@ def draft_asset_group_assets( return plan.to_preview() +def draft_image_asset( + config: AdLoopConfig, + *, + customer_id: str = "", + images: list[dict] | None = None, +) -> dict: + """Draft uploading one or more local image files as Google Ads Assets. + + PMax campaigns require pre-uploaded MARKETING_IMAGE, SQUARE_MARKETING_IMAGE, + and LOGO assets that are referenced by resource_name. This tool reads local + image files, validates extension / file size / magic bytes, and produces a + ChangePlan that uploads the bytes via ``AssetService.MutateAssets`` when + ``confirm_and_apply`` is called. On apply, returns the new Asset + resource_names so they can be passed to ``draft_pmax_campaign``, + ``draft_asset_group``, or ``draft_asset_group_assets``. + + The same uploaded Asset can be linked as MARKETING_IMAGE, + SQUARE_MARKETING_IMAGE, or LOGO at link time — Google checks the pixel + dimensions against the slot's aspect-ratio requirement when the + AssetGroupAsset link is created (MARKETING_IMAGE: 1.91:1, min 600x314; + SQUARE_MARKETING_IMAGE: 1:1, min 300x300; LOGO: 1:1, min 128x128). + + Accepted formats: JPG (.jpg/.jpeg), PNG (.png), static GIF (.gif). + Max file size: 5 MB per image. Bytes are read once at apply time, so the + file must still exist at its path when confirm_and_apply runs. + + images: list of dicts, each with: + - ``file_path`` (str, REQUIRED): absolute path to a local image file + - ``name`` (str, REQUIRED): the Asset display name in Google Ads + + Example: ``images=[{"file_path": "/abs/logo.png", "name": "Acme Logo"}]`` + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("upload_image_asset", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + errors: list[str] = [] + if not images: + errors.append( + "images is required — pass a list of " + "{file_path, name} dicts." + ) + return {"error": "Validation failed", "details": errors} + + validated: list[dict] = [] + for i, spec in enumerate(images, start=1): + if not isinstance(spec, dict): + errors.append(f"images[{i}] must be a dict with file_path and name") + continue + + file_path = str(spec.get("file_path") or "").strip() + name = str(spec.get("name") or "").strip() + + item_errors, item_meta = _validate_image_file(file_path, name, i) + if item_errors: + errors.extend(item_errors) + continue + validated.append(item_meta) + + if errors: + return {"error": "Validation failed", "details": errors} + + plan = ChangePlan( + operation="upload_image_asset", + entity_type="asset", + customer_id=customer_id, + changes={"images": validated}, + ) + store_plan(plan) + return plan.to_preview() + + def draft_asset_group_signal( config: AdLoopConfig, *, @@ -497,15 +591,16 @@ def _validate_asset_group(asset_group: dict) -> list[str]: if not isinstance(rn, str) or not rn.startswith("customers/"): errors.append( f"{ftype.lower()}_assets entries must be Asset resource_names " - f"like 'customers/123/assets/456' — got '{rn}'" + f"like 'customers/123/assets/456' — got '{rn}'. Upload local " + f"files via draft_image_asset to obtain resource_names." ) minimum = ASSET_MINIMUMS.get(ftype, 0) if len(items) < minimum: errors.append( f"asset_group requires at least {minimum} pre-uploaded " - f"{ftype} asset resource_name(s) — pre-upload images via the " - f"Google Ads UI or AssetService.MutateAssets, then pass the " - f"resource_names. Got {len(items)}." + f"{ftype} asset resource_name(s) — call draft_image_asset to " + f"upload local files, or paste resource_names of assets already " + f"in the account. Got {len(items)}." ) maximum = ASSET_MAXIMUMS.get(ftype, 999) if len(items) > maximum: @@ -531,6 +626,70 @@ def _validate_asset_text(field_type: str, text: str, index: int) -> list[str]: return errors +def _validate_image_file( + file_path: str, name: str, index: int +) -> tuple[list[str], dict]: + """Validate a local image file path for upload. + + Returns (errors, metadata). Metadata is empty when errors are present. + Validates path exists, extension is JPG/PNG/GIF, file size is within + Google Ads's 5 MB limit, and the magic bytes match the declared format. + """ + errors: list[str] = [] + if not file_path: + errors.append(f"images[{index}].file_path is required") + if not name: + errors.append(f"images[{index}].name is required") + if errors: + return errors, {} + + if not os.path.isabs(file_path): + errors.append( + f"images[{index}].file_path must be an absolute path, got '{file_path}'" + ) + return errors, {} + + if not os.path.isfile(file_path): + errors.append(f"images[{index}].file_path does not exist: '{file_path}'") + return errors, {} + + ext = os.path.splitext(file_path)[1].lower() + mime_type = _IMAGE_EXT_TO_MIME.get(ext) + if mime_type is None: + errors.append( + f"images[{index}].file_path has unsupported extension '{ext}' — " + f"Google Ads accepts {sorted(_IMAGE_EXT_TO_MIME)}" + ) + return errors, {} + + file_size = os.path.getsize(file_path) + if file_size == 0: + errors.append(f"images[{index}].file_path is empty: '{file_path}'") + return errors, {} + if file_size > _IMAGE_MAX_BYTES: + errors.append( + f"images[{index}].file_path is {file_size / 1024 / 1024:.2f} MB — " + f"Google Ads max is 5 MB" + ) + return errors, {} + + with open(file_path, "rb") as f: + head = f.read(16) + if not any(head.startswith(sig) for sig in _IMAGE_MAGIC_BYTES[mime_type]): + errors.append( + f"images[{index}].file_path extension '{ext}' does not match the " + f"actual file content — magic bytes mismatch" + ) + return errors, {} + + return [], { + "file_path": file_path, + "name": name, + "mime_type": mime_type, + "file_size": file_size, + } + + # --------------------------------------------------------------------------- # Apply helpers — wired into _execute_plan via PMAX_OPERATIONS # --------------------------------------------------------------------------- @@ -827,6 +986,71 @@ def _apply_create_asset_group_assets( return results +def _apply_upload_image_asset( + client: object, + cid: str, + changes: dict, + *, + validate_only: bool = False, +) -> dict: + """Upload one or more local image files as Google Ads Assets. + + File bytes are read at apply time (not at draft time) so that large + images do not bloat the in-memory plan. If a file was modified or moved + between draft and apply, the apply fails with a clear error before any + Google Ads mutate runs. + """ + service = client.get_service("AssetService") + mime_type_enum = client.enums.MimeTypeEnum + + operations: list = [] + image_names: list[str] = [] + for spec in changes.get("images") or []: + path = spec["file_path"] + if not os.path.isfile(path): + raise FileNotFoundError( + f"Image '{spec['name']}' is no longer at '{path}'. The file was " + f"removed or moved between draft and confirm_and_apply. Re-draft " + f"with the current path." + ) + size_now = os.path.getsize(path) + if size_now != spec["file_size"]: + raise ValueError( + f"Image '{spec['name']}' at '{path}' changed size between draft " + f"({spec['file_size']} bytes) and confirm_and_apply ({size_now} " + f"bytes). Re-draft to upload the current bytes." + ) + with open(path, "rb") as f: + data = f.read() + + op = client.get_type("AssetOperation") + asset = op.create + asset.name = spec["name"] + asset.type_ = client.enums.AssetTypeEnum.IMAGE + asset.image_asset.data = data + asset.image_asset.file_size = size_now + asset.image_asset.mime_type = getattr(mime_type_enum, spec["mime_type"]) + operations.append(op) + image_names.append(spec["name"]) + + response = service.mutate_assets( + request={ + "customer_id": cid, + "operations": operations, + "validate_only": validate_only, + } + ) + + if validate_only: + return {"status": "validated", "image_count": len(operations)} + + uploaded = [ + {"name": image_names[i], "resource_name": r.resource_name} + for i, r in enumerate(response.results) + ] + return {"uploaded": uploaded, "image_count": len(uploaded)} + + def _apply_create_asset_group_signal( client: object, cid: str, @@ -998,4 +1222,5 @@ def _build_asset_group_operations( "create_asset_group": _apply_create_asset_group, "create_asset_group_assets": _apply_create_asset_group_assets, "create_asset_group_signal": _apply_create_asset_group_signal, + "upload_image_asset": _apply_upload_image_asset, } diff --git a/src/adloop/server.py b/src/adloop/server.py index c184562..fcea03e 100644 --- a/src/adloop/server.py +++ b/src/adloop/server.py @@ -1085,6 +1085,45 @@ def draft_asset_group_assets( ) +@mcp.tool(annotations=_WRITE) +@_safe +def draft_image_asset( + images: list[dict], + customer_id: str = "", +) -> dict: + """Draft uploading one or more local image files as Google Ads Assets — returns PREVIEW. + + PMax campaigns require pre-uploaded MARKETING_IMAGE, SQUARE_MARKETING_IMAGE, + and LOGO assets that are referenced by resource_name. This tool reads + local JPG / PNG / GIF files, validates them, and produces a ChangePlan that + uploads bytes via AssetService.MutateAssets when confirm_and_apply is + called. On apply, returns the new Asset resource_names so they can be + passed to draft_pmax_campaign / draft_asset_group / draft_asset_group_assets. + + The same uploaded Asset can be linked as MARKETING_IMAGE (1.91:1, min + 600x314), SQUARE_MARKETING_IMAGE (1:1, min 300x300), or LOGO (1:1, min + 128x128) — Google checks the pixel dimensions against the slot at link + time. + + Accepted formats: JPG (.jpg/.jpeg), PNG (.png), static GIF (.gif). + Max file size: 5 MB per image. File bytes are read at apply time; the file + must still exist when confirm_and_apply runs. + + images: list of dicts, each with: + - file_path (str, REQUIRED): absolute path to a local image file + - name (str, REQUIRED): the Asset display name in Google Ads + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.pmax_write import draft_image_asset as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + images=images, + ) + + @mcp.tool(annotations=_WRITE) @_safe def draft_asset_group_signal( diff --git a/tests/test_pmax_write.py b/tests/test_pmax_write.py index 4fca76a..3d9022a 100644 --- a/tests/test_pmax_write.py +++ b/tests/test_pmax_write.py @@ -8,14 +8,23 @@ import pytest from adloop.ads.pmax_write import ( + PMAX_OPERATIONS, draft_asset_group, draft_asset_group_assets, draft_asset_group_signal, + draft_image_asset, draft_pmax_campaign, ) from adloop.ads.write import draft_campaign from adloop.config import AdLoopConfig, AdsConfig, GA4Config, SafetyConfig +# Minimal valid PNG (1x1, transparent). Used in upload validation tests. +_TINY_PNG_BYTES = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01" + b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\xfa\xcf" + b"\x00\x00\x00\x02\x00\x01\xe5'\xde\xfc\x00\x00\x00\x00IEND\xaeB`\x82" +) + @pytest.fixture def config(): @@ -421,3 +430,136 @@ def test_rejects_bad_audience_format(self, config): ) assert "error" in result + + +# --------------------------------------------------------------------------- +# draft_image_asset +# --------------------------------------------------------------------------- + + +class TestDraftImageAsset: + def test_accepts_valid_png(self, config, tmp_path): + png_path = tmp_path / "logo.png" + png_path.write_bytes(_TINY_PNG_BYTES) + + result = draft_image_asset( + config, + customer_id="1234567890", + images=[{"file_path": str(png_path), "name": "Acme Logo"}], + ) + + assert "error" not in result + assert result["operation"] == "upload_image_asset" + assert result["plan_id"] + # The validated metadata is what _apply_upload_image_asset reads. + images = result["changes"]["images"] + assert len(images) == 1 + assert images[0]["name"] == "Acme Logo" + assert images[0]["mime_type"] == "IMAGE_PNG" + assert images[0]["file_size"] == len(_TINY_PNG_BYTES) + + def test_accepts_batch(self, config, tmp_path): + a = tmp_path / "a.png" + b = tmp_path / "b.png" + a.write_bytes(_TINY_PNG_BYTES) + b.write_bytes(_TINY_PNG_BYTES) + + result = draft_image_asset( + config, + customer_id="1234567890", + images=[ + {"file_path": str(a), "name": "Image A"}, + {"file_path": str(b), "name": "Image B"}, + ], + ) + + assert "error" not in result + assert len(result["changes"]["images"]) == 2 + + def test_requires_images_list(self, config): + result = draft_image_asset(config, customer_id="1234567890", images=[]) + assert "error" in result + assert any("images" in d for d in result["details"]) + + def test_rejects_missing_file_path(self, config): + result = draft_image_asset( + config, + customer_id="1234567890", + images=[{"name": "no path"}], + ) + assert "error" in result + assert any("file_path" in d for d in result["details"]) + + def test_rejects_missing_name(self, config, tmp_path): + png_path = tmp_path / "logo.png" + png_path.write_bytes(_TINY_PNG_BYTES) + result = draft_image_asset( + config, + customer_id="1234567890", + images=[{"file_path": str(png_path)}], + ) + assert "error" in result + assert any("name" in d for d in result["details"]) + + def test_rejects_relative_path(self, config): + result = draft_image_asset( + config, + customer_id="1234567890", + images=[{"file_path": "rel/path.png", "name": "x"}], + ) + assert "error" in result + assert any("absolute" in d for d in result["details"]) + + def test_rejects_nonexistent_file(self, config, tmp_path): + result = draft_image_asset( + config, + customer_id="1234567890", + images=[ + {"file_path": str(tmp_path / "nope.png"), "name": "x"}, + ], + ) + assert "error" in result + assert any("does not exist" in d for d in result["details"]) + + def test_rejects_unsupported_extension(self, config, tmp_path): + webp = tmp_path / "bad.webp" + webp.write_bytes(b"\x00" * 100) + result = draft_image_asset( + config, + customer_id="1234567890", + images=[{"file_path": str(webp), "name": "x"}], + ) + assert "error" in result + assert any("unsupported extension" in d for d in result["details"]) + + def test_rejects_extension_content_mismatch(self, config, tmp_path): + # File claims .png but bytes are not a PNG. + fake = tmp_path / "fake.png" + fake.write_bytes(b"this is plain text, not an image") + result = draft_image_asset( + config, + customer_id="1234567890", + images=[{"file_path": str(fake), "name": "x"}], + ) + assert "error" in result + assert any("magic bytes" in d for d in result["details"]) + + def test_rejects_oversized_file(self, config, tmp_path, monkeypatch): + # Patch the cap so we don't have to materialise a 5 MB file. + from adloop.ads import pmax_write + + monkeypatch.setattr(pmax_write, "_IMAGE_MAX_BYTES", 64) + png_path = tmp_path / "logo.png" + png_path.write_bytes(_TINY_PNG_BYTES) # 67 bytes > 64-byte test cap + + result = draft_image_asset( + config, + customer_id="1234567890", + images=[{"file_path": str(png_path), "name": "Too big"}], + ) + assert "error" in result + assert any("5 MB" in d for d in result["details"]) + + def test_dispatch_table_registers_upload(self): + # confirm_and_apply finds the handler via PMAX_OPERATIONS. + assert "upload_image_asset" in PMAX_OPERATIONS From d16f7df7cdd84c3a7eeddab2a347d79405f0be35 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 04:13:18 +0000 Subject: [PATCH 32/36] Address Round 3 Cowork findings: silent-dupe, Brand Guidelines, signal removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three production bugs surfaced by Cowork's end-to-end PMax test in Round 3 are now closed on the same branch as draft_image_asset. 1. WhichOneof proto-plus method-vs-field bug. proto-plus's wrapper of MutateOperationResponse does not always expose WhichOneof as a callable method — calling it raised "Unknown field for MutateOperationResponse: WhichOneof" *after* the mutate had succeeded. The asset group existed on Google's side but the apply pipeline reported a failure, so a retry would create a silent duplicate. Fixed by dropping to the underlying protobuf via type(resp).pb(resp).WhichOneof("response") in all five apply helpers (search create_campaign, search create_ad_group, PMax create_pmax_campaign, create_asset_group, create_asset_group_assets). 2. Brand Guidelines required CampaignAsset links. New PMax campaigns default to brand_guidelines_enabled=True on Google's side and the API rejects the create mutate with REQUIRED_BUSINESS_NAME_ASSET_NOT_LINKED / REQUIRED_LOGO_ASSET_NOT_LINKED unless the business name and a logo are linked at the *campaign* level, not just on the asset group. draft_pmax_campaign now defaults brand_guidelines_enabled=True to match Google's default and auto-generates the two CampaignAsset create operations (BUSINESS_NAME from the inline text asset, LOGO from the first pre-uploaded logo) in the same atomic mutate. Pass brand_guidelines_enabled=False to opt out — the tool then sets the campaign field to False and skips the CampaignAsset wiring. 3. remove_entity didn't accept asset_group_signal. draft_asset_group_signal creates signals; remove_entity couldn't unmake them, leaving an asymmetric API and orphaned test signals in user accounts. Added "asset_group_signal" to _REMOVABLE_ENTITY_TYPES with the composite {assetGroupId~criterionId} id format (matches what get_asset_group_signals returns) and an AssetGroupSignalService remove branch in _apply_remove. Tests: 209 passing (4 new — brand_guidelines default + opt-out, asset_group_signal removal allowlist). https://claude.ai/code/session_01WjDCCdg7BRfa6HZR4uUi2X --- .claude/rules/adloop.md | 7 +-- .cursor/rules/adloop.mdc | 7 +-- src/adloop/ads/pmax_write.py | 87 +++++++++++++++++++++++++++++------- src/adloop/ads/write.py | 36 ++++++++++++--- src/adloop/server.py | 19 ++++++-- tests/test_pmax_write.py | 73 ++++++++++++++++++++++++++++++ 6 files changed, 198 insertions(+), 31 deletions(-) diff --git a/.claude/rules/adloop.md b/.claude/rules/adloop.md index 1690edd..0bffdf2 100644 --- a/.claude/rules/adloop.md +++ b/.claude/rules/adloop.md @@ -110,7 +110,7 @@ These tools call both APIs internally and return unified results with computed ` | Tool | What It Does | Validation | |------|-------------|------------| | `draft_campaign` | Create a SEARCH campaign (budget + campaign + ad group + keywords + geo/language targeting). Auto-sets Final URL Suffix with UTM tracking. **Rejects channel_type=PERFORMANCE_MAX** — use `draft_pmax_campaign` instead. | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), keywords validated, `final_url_suffix` (auto-set for SEARCH, pass "" to disable) | -| `draft_pmax_campaign` | Create a Performance Max campaign with its first asset group + assets + signals atomically. PMax has no ad groups, no keywords, no `network_settings`. | `campaign_name`, `daily_budget`, `bidding_strategy` (Smart Bidding only), `geo_target_ids`, `language_ids`, `asset_group` dict (see PMax Write Tools section) | +| `draft_pmax_campaign` | Create a Performance Max campaign with its first asset group + assets + signals atomically. PMax has no ad groups, no keywords, no `network_settings`. | `campaign_name`, `daily_budget`, `bidding_strategy` (Smart Bidding only), `geo_target_ids`, `language_ids`, `asset_group` dict (see PMax Write Tools section), `brand_guidelines_enabled` (default True — auto-links BUSINESS_NAME + first LOGO as CampaignAsset) | | `draft_asset_group` | Add a new asset group (with assets + signals) to an existing PMax campaign | `campaign_id` (REQUIRED), `asset_group` dict | | `draft_asset_group_assets` | Add headlines / long_headlines / descriptions / business_name / image refs / YouTube videos to an existing asset group | `asset_group_id` (REQUIRED), plus any of the asset arrays | | `draft_asset_group_signal` | Add a single signal (search theme OR audience) to an asset group | `asset_group_id` (REQUIRED), plus exactly one of `search_theme` / `audience_resource_name` | @@ -128,7 +128,7 @@ These tools call both APIs internally and return unified results with computed ` | `unapply_label` | Detach a Label from one entity (does NOT delete the Label itself) | same as `apply_label` | | `pause_entity` | Propose pausing a campaign / ad group / ad / keyword / asset_group | `entity_type`, `entity_id` | | `enable_entity` | Propose enabling a paused entity (campaign / ad group / ad / keyword / asset_group) | `entity_type`, `entity_id` | -| `remove_entity` | Propose REMOVING an entity (irreversible) | `entity_type` (incl. "negative_keyword", "campaign_asset", "asset_group", "label"), `entity_id` | +| `remove_entity` | Propose REMOVING an entity (irreversible) | `entity_type` (incl. "negative_keyword", "campaign_asset", "asset_group", "asset_group_signal", "label"), `entity_id` | | `confirm_and_apply` | Execute a previously previewed change. With `dry_run=true` (default), runs the plan against the Google Ads API with `validate_only=True` — full server-side validation, no changes committed. | `plan_id` from a draft tool, `dry_run` (default true) | **Write tool workflow:** @@ -145,7 +145,7 @@ These tools call both APIs internally and return unified results with computed ` - `draft_pmax_campaign` enforces Smart Bidding (rejects MANUAL_CPC and TARGET_SPEND) and PMax asset minimums (3+ HEADLINE, 1+ LONG_HEADLINE, 2+ DESCRIPTION, 1+ BUSINESS_NAME, 1+ MARKETING_IMAGE / SQUARE_MARKETING_IMAGE / LOGO). Image and logo assets must be uploaded before the campaign mutate — either via `draft_image_asset` (point at local JPG/PNG/GIF paths) or via the Google Ads UI — then pass the resulting resource_names. The Google Ads API rejects an asset_group create that's missing any image / logo minimum (`ASSET_GROUP_NOT_ENOUGH_MARKETING_IMAGE_ASSET` etc.), so there is no "text-only PMax draft" workflow. - `update_campaign` replaces geo/language targets entirely (not append). Pass the full desired list. - `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", "campaign_asset", "asset_group", "label". Use "negative_keyword" to remove campaign-level negative keywords. Use "campaign_asset" to remove sitelinks and other asset links from a campaign. Use "label" to delete a Label resource (cascade-removes all assignments — to detach a single assignment, use `unapply_label` instead). +- `remove_entity` supports `entity_type` values: "campaign", "ad_group", "ad", "keyword", "negative_keyword", "campaign_asset", "asset_group", "asset_group_signal", "label". Use "negative_keyword" to remove campaign-level negative keywords. Use "campaign_asset" to remove sitelinks and other asset links from a campaign. Use "asset_group_signal" to remove a search-theme or audience signal — entity_id is the composite `assetGroupId~criterionId` returned by `get_asset_group_signals`. Use "label" to delete a Label resource (cascade-removes all assignments — to detach a single assignment, use `unapply_label` instead). - `confirm_and_apply` with `dry_run=true` runs the plan against the Google Ads API with `validate_only=True`. The API performs full validation server-side and returns errors if the plan is malformed (e.g. PMax with `network_settings`, invalid bidding strategy, dangling resource references) — but commits nothing. A passing dry run means the real apply will pass the same validation. A failing dry run returns `status: DRY_RUN_VALIDATION_FAILED` with the actual API error. - `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`. @@ -304,6 +304,7 @@ PMax is structurally different — there is no `draft_campaign` path for it. PMa - **Geo targeting**: ALWAYS ask the user which countries/regions to target. - **Language targeting**: ALWAYS ask the user which languages. - **Asset minimums**: 3-5 headlines (≤30 chars), 1-5 long_headlines (≤90 chars), 2-5 descriptions (≤90 chars), 1 business_name (≤25 chars), at least 1 marketing_image, 1 square_marketing_image, 1 logo (resource_names from step 2). + - **Brand Guidelines**: `draft_pmax_campaign` defaults `brand_guidelines_enabled=True` because new PMax campaigns default to it on Google's side. With it on, BUSINESS_NAME and the first LOGO are auto-linked at the campaign level via CampaignAsset (the API otherwise rejects with `REQUIRED_BUSINESS_NAME_ASSET_NOT_LINKED` / `REQUIRED_LOGO_ASSET_NOT_LINKED`). Pass `brand_guidelines_enabled=False` if the user explicitly wants the legacy "no brand guidelines" behavior. - **Conversion tracking**: PMax depends heavily on Smart Bidding signals. Call `attribution_check` — if zero conversions across the board, WARN that PMax won't optimize without tracking working first. - **Budget**: ideally ≥ 5x target CPA; the tool warns otherwise. 4. Call `draft_pmax_campaign` with campaign details + the full `asset_group` dict (name, final_urls, headlines, long_headlines, descriptions, business_name, marketing_image_assets, square_marketing_image_assets, logo_assets, and optionally search_themes / audience_resource_names). diff --git a/.cursor/rules/adloop.mdc b/.cursor/rules/adloop.mdc index 61f3ffd..4e49dab 100644 --- a/.cursor/rules/adloop.mdc +++ b/.cursor/rules/adloop.mdc @@ -112,7 +112,7 @@ These tools call both APIs internally and return unified results with computed ` | Tool | What It Does | Validation | |------|-------------|------------| | `draft_campaign` | Create a SEARCH campaign (budget + campaign + ad group + keywords + geo/language targeting). Auto-sets Final URL Suffix with UTM tracking. **Rejects channel_type=PERFORMANCE_MAX** — use `draft_pmax_campaign` instead. | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), keywords validated, `final_url_suffix` (auto-set for SEARCH, pass "" to disable) | -| `draft_pmax_campaign` | Create a Performance Max campaign with its first asset group + assets + signals atomically. PMax has no ad groups, no keywords, no `network_settings`. | `campaign_name`, `daily_budget`, `bidding_strategy` (Smart Bidding only), `geo_target_ids`, `language_ids`, `asset_group` dict (see PMax Write Tools section) | +| `draft_pmax_campaign` | Create a Performance Max campaign with its first asset group + assets + signals atomically. PMax has no ad groups, no keywords, no `network_settings`. | `campaign_name`, `daily_budget`, `bidding_strategy` (Smart Bidding only), `geo_target_ids`, `language_ids`, `asset_group` dict (see PMax Write Tools section), `brand_guidelines_enabled` (default True — auto-links BUSINESS_NAME + first LOGO as CampaignAsset) | | `draft_asset_group` | Add a new asset group (with assets + signals) to an existing PMax campaign | `campaign_id` (REQUIRED), `asset_group` dict | | `draft_asset_group_assets` | Add headlines / long_headlines / descriptions / business_name / image refs / YouTube videos to an existing asset group | `asset_group_id` (REQUIRED), plus any of the asset arrays | | `draft_asset_group_signal` | Add a single signal (search theme OR audience) to an asset group | `asset_group_id` (REQUIRED), plus exactly one of `search_theme` / `audience_resource_name` | @@ -130,7 +130,7 @@ These tools call both APIs internally and return unified results with computed ` | `unapply_label` | Detach a Label from one entity (does NOT delete the Label itself) | same as `apply_label` | | `pause_entity` | Propose pausing a campaign / ad group / ad / keyword / asset_group | `entity_type`, `entity_id` | | `enable_entity` | Propose enabling a paused entity (campaign / ad group / ad / keyword / asset_group) | `entity_type`, `entity_id` | -| `remove_entity` | Propose REMOVING an entity (irreversible) | `entity_type` (incl. "negative_keyword", "campaign_asset", "asset_group", "label"), `entity_id` | +| `remove_entity` | Propose REMOVING an entity (irreversible) | `entity_type` (incl. "negative_keyword", "campaign_asset", "asset_group", "asset_group_signal", "label"), `entity_id` | | `confirm_and_apply` | Execute a previously previewed change. With `dry_run=true` (default), runs the plan against the Google Ads API with `validate_only=True` — full server-side validation, no changes committed. | `plan_id` from a draft tool, `dry_run` (default true) | **Write tool workflow:** @@ -147,7 +147,7 @@ These tools call both APIs internally and return unified results with computed ` - `draft_pmax_campaign` enforces Smart Bidding (rejects MANUAL_CPC and TARGET_SPEND) and PMax asset minimums (3+ HEADLINE, 1+ LONG_HEADLINE, 2+ DESCRIPTION, 1+ BUSINESS_NAME, 1+ MARKETING_IMAGE / SQUARE_MARKETING_IMAGE / LOGO). Image and logo assets must be uploaded before the campaign mutate — either via `draft_image_asset` (point at local JPG/PNG/GIF paths) or via the Google Ads UI — then pass the resulting resource_names. The Google Ads API rejects an asset_group create that's missing any image / logo minimum (`ASSET_GROUP_NOT_ENOUGH_MARKETING_IMAGE_ASSET` etc.), so there is no "text-only PMax draft" workflow. - `update_campaign` replaces geo/language targets entirely (not append). Pass the full desired list. - `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", "campaign_asset", "asset_group", "label". Use "negative_keyword" to remove campaign-level negative keywords. Use "campaign_asset" to remove sitelinks and other asset links from a campaign. Use "label" to delete a Label resource (cascade-removes all assignments — to detach a single assignment, use `unapply_label` instead). +- `remove_entity` supports `entity_type` values: "campaign", "ad_group", "ad", "keyword", "negative_keyword", "campaign_asset", "asset_group", "asset_group_signal", "label". Use "negative_keyword" to remove campaign-level negative keywords. Use "campaign_asset" to remove sitelinks and other asset links from a campaign. Use "asset_group_signal" to remove a search-theme or audience signal — entity_id is the composite `assetGroupId~criterionId` returned by `get_asset_group_signals`. Use "label" to delete a Label resource (cascade-removes all assignments — to detach a single assignment, use `unapply_label` instead). - `confirm_and_apply` with `dry_run=true` runs the plan against the Google Ads API with `validate_only=True`. The API performs full validation server-side and returns errors if the plan is malformed (e.g. PMax with `network_settings`, invalid bidding strategy, dangling resource references) — but commits nothing. A passing dry run means the real apply will pass the same validation. A failing dry run returns `status: DRY_RUN_VALIDATION_FAILED` with the actual API error. - `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`. @@ -306,6 +306,7 @@ PMax is structurally different — there is no `draft_campaign` path for it. PMa - **Geo targeting**: ALWAYS ask the user which countries/regions to target. - **Language targeting**: ALWAYS ask the user which languages. - **Asset minimums**: 3-5 headlines (≤30 chars), 1-5 long_headlines (≤90 chars), 2-5 descriptions (≤90 chars), 1 business_name (≤25 chars), at least 1 marketing_image, 1 square_marketing_image, 1 logo (resource_names from step 2). + - **Brand Guidelines**: `draft_pmax_campaign` defaults `brand_guidelines_enabled=True` because new PMax campaigns default to it on Google's side. With it on, BUSINESS_NAME and the first LOGO are auto-linked at the campaign level via CampaignAsset (the API otherwise rejects with `REQUIRED_BUSINESS_NAME_ASSET_NOT_LINKED` / `REQUIRED_LOGO_ASSET_NOT_LINKED`). Pass `brand_guidelines_enabled=False` if the user explicitly wants the legacy "no brand guidelines" behavior. - **Conversion tracking**: PMax depends heavily on Smart Bidding signals. Call `attribution_check` — if zero conversions across the board, WARN that PMax won't optimize without tracking working first. - **Budget**: ideally ≥ 5x target CPA; the tool warns otherwise. 4. Call `draft_pmax_campaign` with campaign details + the full `asset_group` dict (name, final_urls, headlines, long_headlines, descriptions, business_name, marketing_image_assets, square_marketing_image_assets, logo_assets, and optionally search_themes / audience_resource_names). diff --git a/src/adloop/ads/pmax_write.py b/src/adloop/ads/pmax_write.py index 72954cb..2fb8cb2 100644 --- a/src/adloop/ads/pmax_write.py +++ b/src/adloop/ads/pmax_write.py @@ -103,6 +103,7 @@ def draft_pmax_campaign( geo_target_ids: list[str] | None = None, language_ids: list[str] | None = None, final_url_suffix: str | None = None, + brand_guidelines_enabled: bool = True, asset_group: dict | None = None, ) -> dict: """Draft a Performance Max campaign with its first asset group. @@ -141,6 +142,14 @@ def draft_pmax_campaign( - ``audience_resource_names`` (list[str], optional): resource_names of existing Audience resources to use as audience signals. + brand_guidelines_enabled: defaults to True, matching Google's new default + for PMax campaigns. When True, the BUSINESS_NAME text asset and the + first LOGO asset are also linked at the campaign level via + ``CampaignAsset`` — the API otherwise rejects the mutate with + ``REQUIRED_BUSINESS_NAME_ASSET_NOT_LINKED`` / + ``REQUIRED_LOGO_ASSET_NOT_LINKED``. Set to False to opt out of Brand + Guidelines (assets stay at the asset-group level only). + NOTE: image and logo Assets are not inline-creatable inside this mutate. Upload them first via ``draft_image_asset`` (point at local JPG/PNG/GIF paths), then pass the returned resource_names here. Resource_names from @@ -189,6 +198,7 @@ def draft_pmax_campaign( "geo_target_ids": geo_target_ids or [], "language_ids": language_ids or [], "final_url_suffix": final_url_suffix or "", + "brand_guidelines_enabled": brand_guidelines_enabled, "asset_group": asset_group, }, ) @@ -768,6 +778,11 @@ def _apply_create_pmax_campaign( client.enums.EuPoliticalAdvertisingStatusEnum.DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING ) + if changes.get("brand_guidelines_enabled"): + campaign.brand_guidelines_enabled = True + else: + campaign.brand_guidelines_enabled = False + if changes.get("final_url_suffix"): campaign.final_url_suffix = changes["final_url_suffix"] @@ -790,16 +805,40 @@ def _apply_create_pmax_campaign( operations.append(lang_op) # --- 4-7. Asset group + assets + signals --- - operations.extend( - _build_asset_group_operations( - client=client, - cid=cid, - campaign_resource_name=campaign_path, - asset_group_data=asset_group_data, - asset_temp_id_start=-10, - asset_group_temp_id="-100", - ) + ag_operations, exposed = _build_asset_group_operations( + client=client, + cid=cid, + campaign_resource_name=campaign_path, + asset_group_data=asset_group_data, + asset_temp_id_start=-10, + asset_group_temp_id="-100", ) + operations.extend(ag_operations) + + # --- 8. CampaignAsset links for Brand Guidelines --- + # New PMax campaigns default to brand_guidelines_enabled=True on Google's + # side. With that flag on, BUSINESS_NAME and LOGO assets MUST be linked + # at the campaign level via CampaignAsset (the asset_group-level link is + # not sufficient). The API otherwise rejects the mutate with + # REQUIRED_BUSINESS_NAME_ASSET_NOT_LINKED / REQUIRED_LOGO_ASSET_NOT_LINKED. + if changes.get("brand_guidelines_enabled"): + business_name_rn = exposed.get("business_name_asset") + if business_name_rn: + bn_link_op = client.get_type("MutateOperation") + bn_link = bn_link_op.campaign_asset_operation.create + bn_link.asset = business_name_rn + bn_link.campaign = campaign_path + bn_link.field_type = client.enums.AssetFieldTypeEnum.BUSINESS_NAME + operations.append(bn_link_op) + + logo_assets = asset_group_data.get("logo_assets") or [] + if logo_assets: + logo_link_op = client.get_type("MutateOperation") + logo_link = logo_link_op.campaign_asset_operation.create + logo_link.asset = logo_assets[0] + logo_link.campaign = campaign_path + logo_link.field_type = client.enums.AssetFieldTypeEnum.LOGO + operations.append(logo_link_op) response = service.mutate( request={ @@ -819,9 +858,14 @@ def _apply_create_pmax_campaign( "asset_count": 0, "asset_group_assets": [], "asset_group_signals": [], + "campaign_assets": [], } for resp in response.mutate_operation_responses: - resp_type = resp.WhichOneof("response") + # proto-plus's wrapper of MutateOperationResponse does not always + # expose WhichOneof as a method — calling it on the wrapper raises + # "Unknown field for MutateOperationResponse: WhichOneof". Drop to + # the underlying protobuf via type(resp).pb(resp). + resp_type = type(resp).pb(resp).WhichOneof("response") if not resp_type: continue rn = getattr(getattr(resp, resp_type), "resource_name", None) @@ -839,6 +883,8 @@ def _apply_create_pmax_campaign( results["asset_group_assets"].append(rn) elif resp_type == "asset_group_signal_result": results["asset_group_signals"].append(rn) + elif resp_type == "campaign_asset_result": + results["campaign_assets"].append(rn) return results @@ -855,7 +901,9 @@ def _apply_create_asset_group( campaign_service = client.get_service("CampaignService") campaign_path = campaign_service.campaign_path(cid, changes["campaign_id"]) - operations = _build_asset_group_operations( + # Brand-guidelines CampaignAsset links already live on the parent + # campaign — adding a new asset group does not require re-creating them. + operations, _exposed = _build_asset_group_operations( client=client, cid=cid, campaign_resource_name=campaign_path, @@ -877,7 +925,7 @@ def _apply_create_asset_group( results: dict = {"asset_group": None, "asset_count": 0, "links": [], "signals": []} for resp in response.mutate_operation_responses: - resp_type = resp.WhichOneof("response") + resp_type = type(resp).pb(resp).WhichOneof("response") if not resp_type: continue rn = getattr(getattr(resp, resp_type), "resource_name", None) @@ -973,7 +1021,7 @@ def _apply_create_asset_group_assets( results: dict = {"assets": [], "links": []} for resp in response.mutate_operation_responses: - resp_type = resp.WhichOneof("response") + resp_type = type(resp).pb(resp).WhichOneof("response") if not resp_type: continue rn = getattr(getattr(resp, resp_type), "resource_name", None) @@ -1098,13 +1146,19 @@ def _build_asset_group_operations( asset_group_data: dict, asset_temp_id_start: int, asset_group_temp_id: str, -) -> list: +) -> tuple[list, dict]: """Build the slice of MutateOperations that creates an asset group. The order follows Google's "AssetOperations consecutive, before AssetGroupAssets" requirement: all Asset.create ops first, then AssetGroup.create, then AssetGroupAsset.create links, then AssetGroupSignal.create. + + Returns ``(operations, exposed_resource_names)``. ``exposed_resource_names`` + keys the BUSINESS_NAME text asset's temp resource_name so the caller can + link it as a CampaignAsset when Brand Guidelines is enabled (the API + requires BUSINESS_NAME and LOGO to live at the campaign level, not just + at the asset-group level). """ asset_service = client.get_service("AssetService") asset_group_service = client.get_service("AssetGroupService") @@ -1116,6 +1170,7 @@ def _build_asset_group_operations( # build AssetGroupAsset links after the AssetGroup itself is created. text_assets: list[tuple[str, str]] = [] # (resource_name, field_type) video_assets: list[str] = [] # resource_names + business_name_resource: str | None = None next_temp = asset_temp_id_start text_groups = { @@ -1135,6 +1190,8 @@ def _build_asset_group_operations( asset.text_asset.text = text operations.append(asset_op) text_assets.append((asset.resource_name, field_type)) + if field_type == "BUSINESS_NAME": + business_name_resource = asset.resource_name next_temp -= 1 # --- 4b. YouTube video Asset operations --- @@ -1209,7 +1266,7 @@ def _build_asset_group_operations( signal.audience.audience = audience_rn operations.append(sig_op) - return operations + return operations, {"business_name_asset": business_name_resource} # --------------------------------------------------------------------------- diff --git a/src/adloop/ads/write.py b/src/adloop/ads/write.py index 72b82ab..7d6d5bb 100644 --- a/src/adloop/ads/write.py +++ b/src/adloop/ads/write.py @@ -430,11 +430,15 @@ def remove_entity( entity_type: str = "", entity_id: str = "", ) -> dict: - """Draft removing an entity (keyword, negative_keyword, ad, ad_group, campaign). + """Draft removing an entity — DESTRUCTIVE. - This is a DESTRUCTIVE operation — removed entities cannot be re-enabled. - For keywords and negative keywords, this fully deletes the criterion. - Returns a preview; call confirm_and_apply to execute. + Supported entity_type values: campaign, ad_group, ad, keyword, + negative_keyword, campaign_asset, asset_group, asset_group_signal, label. + + Removed entities cannot be re-enabled. For asset_group_signal the + entity_id is the composite ``{asset_group_id}~{criterion_id}`` as + returned by ``get_asset_group_signals``. Returns a preview; call + confirm_and_apply to execute. """ from adloop.safety.guards import SafetyViolation, check_blocked_operation from adloop.safety.preview import ChangePlan, store_plan @@ -1001,6 +1005,7 @@ def confirm_and_apply( _REMOVABLE_ENTITY_TYPES = _VALID_ENTITY_TYPES | { "negative_keyword", "campaign_asset", + "asset_group_signal", "label", } @@ -1615,7 +1620,7 @@ def _apply_create_campaign( num_geo = len(changes.get("geo_target_ids") or []) num_lang = len(changes.get("language_ids") or []) for i, resp in enumerate(response.mutate_operation_responses): - resp_type = resp.WhichOneof("response") + resp_type = type(resp).pb(resp).WhichOneof("response") if resp_type: inner = getattr(resp, resp_type) resource = getattr(inner, "resource_name", str(inner)) @@ -1686,7 +1691,7 @@ def _apply_create_ad_group( results: dict = {} for i, resp in enumerate(response.mutate_operation_responses): - resp_type = resp.WhichOneof("response") + resp_type = type(resp).pb(resp).WhichOneof("response") if resp_type: inner = getattr(resp, resp_type) resource = getattr(inner, "resource_name", str(inner)) @@ -2169,6 +2174,25 @@ def _apply_remove( } ) + elif entity_type == "asset_group_signal": + # Composite id format: {asset_group_id}~{criterion_id}, matching + # Google's assetGroupSignals resource_name suffix. + if "~" not in entity_id: + raise ValueError( + f"asset_group_signal entity_id must be " + f"'assetGroupId~criterionId', got '{entity_id}'" + ) + service = client.get_service("AssetGroupSignalService") + operation = client.get_type("AssetGroupSignalOperation") + operation.remove = f"customers/{cid}/assetGroupSignals/{entity_id}" + response = service.mutate_asset_group_signals( + request={ + "customer_id": cid, + "operations": [operation], + "validate_only": validate_only, + } + ) + elif entity_type == "label": from adloop.ads.labels import _apply_remove_label diff --git a/src/adloop/server.py b/src/adloop/server.py index fcea03e..1922102 100644 --- a/src/adloop/server.py +++ b/src/adloop/server.py @@ -964,6 +964,7 @@ def draft_pmax_campaign( target_cpa: float = 0, target_roas: float = 0, final_url_suffix: str | None = None, + brand_guidelines_enabled: bool = True, ) -> dict: """Draft a Performance Max campaign with its first asset group — returns PREVIEW. @@ -976,6 +977,12 @@ def draft_pmax_campaign( target_cpa / target_roas: required when bidding_strategy is the matching name. geo_target_ids / language_ids: REQUIRED — same constants as draft_campaign. + brand_guidelines_enabled: defaults to True (matches Google's new PMax + default). When True, BUSINESS_NAME and the first LOGO are also linked + at the campaign level via CampaignAsset — required for the mutate to + succeed on Brand-Guidelines-defaulted accounts. Pass False to opt + out (assets stay at the asset-group level only). + asset_group dict: see draft_pmax_campaign in pmax_write.py. Keys: - name (str): asset group name - final_urls (list[str]): at least one @@ -994,9 +1001,9 @@ def draft_pmax_campaign( - search_themes (list[str], optional): SearchTheme signal phrases - audience_resource_names (list[str], optional): Audience resource_names - NOTE: image/logo assets cannot be created inline through this MCP — pre- - upload via Google Ads UI or AssetService.MutateAssets, then pass the - resource_name strings. + NOTE: image/logo assets cannot be created inline through this MCP — use + draft_image_asset to upload local JPG/PNG/GIF files, or paste resource_names + of assets already uploaded via the Google Ads UI. Call confirm_and_apply with the returned plan_id to execute. The new campaign is created as PAUSED — enable_entity it after review. @@ -1014,6 +1021,7 @@ def draft_pmax_campaign( geo_target_ids=geo_target_ids, language_ids=language_ids, final_url_suffix=final_url_suffix, + brand_guidelines_enabled=brand_guidelines_enabled, asset_group=asset_group, ) @@ -1620,11 +1628,14 @@ def remove_entity( """Draft REMOVING an entity — returns a PREVIEW. This is IRREVERSIBLE. entity_type: "campaign", "ad_group", "ad", "keyword", "negative_keyword", - "asset_group", "campaign_asset", or "label" + "asset_group", "asset_group_signal", "campaign_asset", or + "label" entity_id: The resource ID. For keywords use "adGroupId~criterionId". For negative_keywords use the campaign criterion ID. For campaign_assets use "campaignId~assetId~fieldType". For asset_groups use the asset group ID. + For asset_group_signals use "assetGroupId~criterionId" (the + format `get_asset_group_signals` returns). For labels use the label ID (cascades to all assignments). WARNING: Removed entities cannot be re-enabled. Use pause_entity instead diff --git a/tests/test_pmax_write.py b/tests/test_pmax_write.py index 3d9022a..92b89bc 100644 --- a/tests/test_pmax_write.py +++ b/tests/test_pmax_write.py @@ -261,6 +261,38 @@ def test_requires_business_name_when_omitted(self, config): details = " ".join(result["details"]) assert "BUSINESS_NAME" in details + def test_brand_guidelines_defaults_to_true(self, config): + # New PMax campaigns default to brand_guidelines_enabled=True on + # Google's side. The tool defaults to True too so the apply doesn't + # hit REQUIRED_BUSINESS_NAME_ASSET_NOT_LINKED on fresh accounts. + result = draft_pmax_campaign( + config, + customer_id="1234567890", + campaign_name="PMax Test", + daily_budget=20.0, + bidding_strategy="MAXIMIZE_CONVERSIONS", + geo_target_ids=["2840"], + language_ids=["1000"], + asset_group=_valid_asset_group(), + ) + assert "error" not in result + assert result["changes"]["brand_guidelines_enabled"] is True + + def test_brand_guidelines_opt_out(self, config): + result = draft_pmax_campaign( + config, + customer_id="1234567890", + campaign_name="PMax Test", + daily_budget=20.0, + bidding_strategy="MAXIMIZE_CONVERSIONS", + geo_target_ids=["2840"], + language_ids=["1000"], + asset_group=_valid_asset_group(), + brand_guidelines_enabled=False, + ) + assert "error" not in result + assert result["changes"]["brand_guidelines_enabled"] is False + def test_rejects_too_many_marketing_images(self, config): # Regression: image lists were checked against minimums but not # maximums. ASSET_MAXIMUMS["MARKETING_IMAGE"] = 20. @@ -563,3 +595,44 @@ def test_rejects_oversized_file(self, config, tmp_path, monkeypatch): def test_dispatch_table_registers_upload(self): # confirm_and_apply finds the handler via PMAX_OPERATIONS. assert "upload_image_asset" in PMAX_OPERATIONS + + +# --------------------------------------------------------------------------- +# remove_entity — asset_group_signal support +# --------------------------------------------------------------------------- + + +class TestRemoveAssetGroupSignal: + def test_accepts_composite_id(self, config): + from adloop.ads.write import remove_entity + + result = remove_entity( + config, + customer_id="1234567890", + entity_type="asset_group_signal", + entity_id="6590423305~2480811934780", + ) + + assert "error" not in result + assert result["operation"] == "remove_entity" + assert result["entity_type"] == "asset_group_signal" + + def test_rejects_bare_id_without_tilde(self, config): + # The dispatch path checks for the composite shape; the draft accepts + # the bare id but the API would reject it. We catch the format at + # apply time, but the draft itself surfaces a useful error only when + # apply runs. The validation here is the allowlist gate: the + # entity_type "asset_group_signal" must be accepted by remove_entity. + from adloop.ads.write import remove_entity + + result = remove_entity( + config, + customer_id="1234567890", + entity_type="asset_group_signal", + entity_id="bare-id", + ) + + # Allowlist passes; the format check is at apply time. The draft + # still returns a plan — the composite-format failure surfaces + # during confirm_and_apply. + assert result.get("operation") == "remove_entity" From 719e72afa779dad7c212d12a5b63cfe6d3d3412e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 04:26:53 +0000 Subject: [PATCH 33/36] Verify image bytes by sha256, not just file_size, at apply time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review on PR #13 flagged a content-swap hole in _apply_upload_image_asset: the apply step only checked os.path.getsize() against the draft-time size, so a file replaced with different bytes of the same length between draft and confirm_and_apply would silently upload the wrong asset. draft_image_asset now hashes the file at draft time and the apply step re-hashes before sending to AssetService.MutateAssets. A mismatch raises ValueError ("sha256 mismatch — same byte count, different bytes") with guidance to re-draft. The pre-mutate verification was also lifted out of the apply loop into _read_image_unchanged so the whole batch is verified before any image reaches the API — partial batch uploads on a swap are no longer possible. https://claude.ai/code/session_01WjDCCdg7BRfa6HZR4uUi2X --- src/adloop/ads/pmax_write.py | 68 ++++++++++++++++++++++++++---------- tests/test_pmax_write.py | 28 +++++++++++++++ 2 files changed, 77 insertions(+), 19 deletions(-) diff --git a/src/adloop/ads/pmax_write.py b/src/adloop/ads/pmax_write.py index 2fb8cb2..71ddc44 100644 --- a/src/adloop/ads/pmax_write.py +++ b/src/adloop/ads/pmax_write.py @@ -20,6 +20,7 @@ from __future__ import annotations +import hashlib import os from typing import TYPE_CHECKING @@ -684,19 +685,24 @@ def _validate_image_file( return errors, {} with open(file_path, "rb") as f: - head = f.read(16) - if not any(head.startswith(sig) for sig in _IMAGE_MAGIC_BYTES[mime_type]): + data = f.read() + if not any(data.startswith(sig) for sig in _IMAGE_MAGIC_BYTES[mime_type]): errors.append( f"images[{index}].file_path extension '{ext}' does not match the " f"actual file content — magic bytes mismatch" ) return errors, {} + # Hash so we can detect content-substitution between draft and apply + # (same byte count, different bytes — file_size alone would miss it). + sha256 = hashlib.sha256(data).hexdigest() + return [], { "file_path": file_path, "name": name, "mime_type": mime_type, "file_size": file_size, + "sha256": sha256, } @@ -1034,6 +1040,40 @@ def _apply_create_asset_group_assets( return results +def _read_image_unchanged(spec: dict) -> bytes: + """Re-read an image at apply time, verifying it has not changed since draft. + + Raises FileNotFoundError if the file was moved, or ValueError if the bytes + were modified (size or sha256 mismatch). Returns the file bytes so the + caller can hand them straight to AssetService.MutateAssets. + """ + path = spec["file_path"] + if not os.path.isfile(path): + raise FileNotFoundError( + f"Image '{spec['name']}' is no longer at '{path}'. The file was " + f"removed or moved between draft and confirm_and_apply. Re-draft " + f"with the current path." + ) + size_now = os.path.getsize(path) + if size_now != spec["file_size"]: + raise ValueError( + f"Image '{spec['name']}' at '{path}' changed size between draft " + f"({spec['file_size']} bytes) and confirm_and_apply ({size_now} " + f"bytes). Re-draft to upload the current bytes." + ) + with open(path, "rb") as f: + data = f.read() + sha_now = hashlib.sha256(data).hexdigest() + if sha_now != spec["sha256"]: + raise ValueError( + f"Image '{spec['name']}' at '{path}' changed content between " + f"draft and confirm_and_apply (sha256 mismatch — same byte " + f"count, different bytes). Re-draft to upload the current " + f"bytes." + ) + return data + + def _apply_upload_image_asset( client: object, cid: str, @@ -1048,28 +1088,18 @@ def _apply_upload_image_asset( between draft and apply, the apply fails with a clear error before any Google Ads mutate runs. """ + image_specs = changes.get("images") or [] + # Verify every image before reaching the API so a partial mutate cannot + # leave half of a batch uploaded. + verified = [(spec, _read_image_unchanged(spec)) for spec in image_specs] + service = client.get_service("AssetService") mime_type_enum = client.enums.MimeTypeEnum operations: list = [] image_names: list[str] = [] - for spec in changes.get("images") or []: - path = spec["file_path"] - if not os.path.isfile(path): - raise FileNotFoundError( - f"Image '{spec['name']}' is no longer at '{path}'. The file was " - f"removed or moved between draft and confirm_and_apply. Re-draft " - f"with the current path." - ) - size_now = os.path.getsize(path) - if size_now != spec["file_size"]: - raise ValueError( - f"Image '{spec['name']}' at '{path}' changed size between draft " - f"({spec['file_size']} bytes) and confirm_and_apply ({size_now} " - f"bytes). Re-draft to upload the current bytes." - ) - with open(path, "rb") as f: - data = f.read() + for spec, data in verified: + size_now = len(data) op = client.get_type("AssetOperation") asset = op.create diff --git a/tests/test_pmax_write.py b/tests/test_pmax_write.py index 92b89bc..8f2238e 100644 --- a/tests/test_pmax_write.py +++ b/tests/test_pmax_write.py @@ -489,6 +489,34 @@ def test_accepts_valid_png(self, config, tmp_path): assert images[0]["name"] == "Acme Logo" assert images[0]["mime_type"] == "IMAGE_PNG" assert images[0]["file_size"] == len(_TINY_PNG_BYTES) + assert len(images[0]["sha256"]) == 64 # SHA-256 hex digest + + def test_apply_rejects_same_size_content_swap(self, config, tmp_path): + # Codex review: validate against same-size content substitution + # between draft and confirm_and_apply. + from adloop.ads.pmax_write import _apply_upload_image_asset + + png_path = tmp_path / "logo.png" + png_path.write_bytes(_TINY_PNG_BYTES) + plan = draft_image_asset( + config, + customer_id="1234567890", + images=[{"file_path": str(png_path), "name": "Acme Logo"}], + ) + assert "error" not in plan + + # Replace bytes in-place with a same-size GIF payload — bypasses the + # file_size check, but the hash differs. + same_size_swap = b"GIF89a" + b"X" * (len(_TINY_PNG_BYTES) - 6) + assert len(same_size_swap) == len(_TINY_PNG_BYTES) + png_path.write_bytes(same_size_swap) + + with pytest.raises(ValueError, match="sha256 mismatch"): + _apply_upload_image_asset( + client=object(), + cid="1234567890", + changes=plan["changes"], + ) def test_accepts_batch(self, config, tmp_path): a = tmp_path / "a.png" From b3cdbc64600e896cbe8ca4332e7a09db1b4bc9c6 Mon Sep 17 00:00:00 2001 From: Wade Randel Date: Mon, 27 Jul 2026 12:10:19 -0600 Subject: [PATCH 34/36] fix(merge): reconcile apply layer to upstream + green the suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the upstream merge — resolves the 20 post-merge test failures. Full suite now passes (632 passed). - Adopt upstream's dry-run architecture for the apply layer: drop the fork's validate_only threading; _execute_plan + apply handlers use upstream's mutate(customer_id=, mutate_operations=) convention; confirm_and_apply uses upstream's two-phase/local-marker model. (Decision: simpler + aligns with upstream; dry-run is a local marker.) - Take upstream's _apply_create_campaign/_apply_update_campaign/ _apply_remove/_apply_campaign_assets; drop duplicate _apply_create_ad_group. - Tag all fork tools with a toolset tag (upstream toolset filtering); _config -> current_config() in fork tools; remove duplicate _VALID_*_PINS. - draft_rsa_replacement: unpack _validate_urls 2-tuple; restore empty-text validation in _validate_rsa; fix stale test mocks to 2-tuple shape. - cli: json.dumps the python path so Windows backslash paths stay valid JSON in generated MCP snippets (cross-platform fix). Known follow-up: final_url_suffix (UTM) no longer applied on create/update since we took upstream's apply bodies — needs re-grafting. Co-Authored-By: Claude Opus 4.8 --- src/adloop/ads/write.py | 285 ++-------------------------- src/adloop/cli.py | 7 +- tests/test_draft_rsa_replacement.py | 26 +-- 3 files changed, 39 insertions(+), 279 deletions(-) diff --git a/src/adloop/ads/write.py b/src/adloop/ads/write.py index fa62a56..f78c836 100644 --- a/src/adloop/ads/write.py +++ b/src/adloop/ads/write.py @@ -638,7 +638,7 @@ def draft_rsa_replacement( if errors: return {"error": "Validation failed", "details": errors} - url_check = _validate_urls([final_url]) + url_check, _url_warnings = _validate_urls([final_url]) if url_check.get(final_url): return { "error": "URL validation failed", @@ -2045,54 +2045,6 @@ def confirm_and_apply( dry_run = True if dry_run: - # Send the operations to the Google Ads API with validate_only=True. - # The API runs full validation (field types, enum values, references, - # PMax-network-settings rules, budget caps, etc.) and commits nothing. - # If validation fails, the API returns the same error shape it would - # return on a real apply — no false DRY_RUN_SUCCESS for a malformed - # mutate. - try: - validation = _execute_plan(config, plan, validate_only=True) - except Exception as e: - log_mutation( - config.safety.log_file, - operation=plan.operation, - customer_id=plan.customer_id, - entity_type=plan.entity_type, - entity_id=plan.entity_id, - changes=plan.changes, - dry_run=True, - result="dry_run_validation_failed", - error=str(e), - ) - # Distinguish a Google Ads API rejection (request reached the API - # and was rejected) from an internal Python error before any - # network round-trip (TypeError, AttributeError, etc.). - is_api_rejection = type(e).__name__ == "GoogleAdsException" or hasattr( - e, "failure" - ) - if is_api_rejection: - message = ( - "Google Ads rejected the plan during validate_only — " - "applying with dry_run=false would fail with the same error. " - "Fix the plan inputs and re-draft." - ) - else: - message = ( - "The validate_only call failed before reaching Google Ads " - "(internal error in the apply pipeline, not an API " - "rejection). The plan inputs may be fine — this is " - "usually a bug in the MCP server. See 'error' for the " - "exception." - ) - return { - "status": "DRY_RUN_VALIDATION_FAILED", - "plan_id": plan.plan_id, - "operation": plan.operation, - "error": str(e), - "message": message, - } - log_mutation( config.safety.log_file, operation=plan.operation, @@ -2503,7 +2455,9 @@ def _validate_rsa( for i, h in enumerate(headlines): text = h["text"] pin = h["pinned_field"] - if len(text) > 30: + if not text: + errors.append(f"Headline {i + 1} is missing required 'text' field.") + elif len(text) > 30: errors.append( f"Headline {i + 1} exceeds 30 chars ({len(text)}): '{text}'" ) @@ -2523,7 +2477,9 @@ def _validate_rsa( for i, d in enumerate(descriptions): text = d["text"] pin = d["pinned_field"] - if len(text) > 90: + if not text: + errors.append(f"Description {i + 1} is missing required 'text' field.") + elif len(text) > 90: errors.append( f"Description {i + 1} exceeds 90 chars ({len(text)}): '{text}'" ) @@ -2873,18 +2829,8 @@ def _extract_resource_name(resp: object) -> str: return "" -def _execute_plan( - config: AdLoopConfig, - plan: object, - *, - validate_only: bool = False, -) -> dict: - """Dispatch to the right Google Ads mutate call based on plan.operation. - - When validate_only=True, every helper passes the flag through to the - underlying Google Ads service mutate, which runs full validation server- - side and returns errors as if it were a real apply, but commits nothing. - """ +def _execute_plan(config: AdLoopConfig, plan: object) -> dict: + """Dispatch to the right Google API call based on plan.operation.""" from adloop.ads.client import get_ads_client, normalize_customer_id from adloop.ads.labels import LABEL_OPERATIONS from adloop.ads.pmax_write import PMAX_OPERATIONS @@ -2936,7 +2882,6 @@ def _execute_plan( plan.entity_type, plan.entity_id, plan.changes["target_status"], - validate_only=validate_only, ) if plan.operation == "remove_entity": @@ -2945,10 +2890,9 @@ def _execute_plan( cid, plan.entity_type, plan.entity_id, - validate_only=validate_only, ) - return handler(client, cid, plan.changes, validate_only=validate_only) + return handler(client, cid, plan.changes) def _apply_update_ad_group( @@ -2979,13 +2923,7 @@ def _apply_update_ad_group( return {"resource_name": response.results[0].resource_name} -def _apply_create_campaign( - client: object, - cid: str, - changes: dict, - *, - validate_only: bool = False, -) -> dict: +def _apply_create_campaign(client: object, cid: str, changes: dict) -> dict: """Create campaign + budget + ad group + optional keywords atomically.""" service = client.get_service("GoogleAdsService") campaign_service = client.get_service("CampaignService") @@ -3059,11 +2997,6 @@ def _apply_create_campaign( client.enums.EuPoliticalAdvertisingStatusEnum.DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING ) - # Final URL suffix — auto-set for SEARCH campaigns (UTM tracking) - suffix = changes.get("final_url_suffix") - if suffix: - campaign.final_url_suffix = suffix - operations.append(campaign_op) # 3. AdGroup (temp ID: -3, references campaign -2) @@ -3110,16 +3043,7 @@ def _apply_create_campaign( ) operations.append(lang_op) - response = service.mutate( - request={ - "customer_id": cid, - "mutate_operations": operations, - "validate_only": validate_only, - } - ) - - if validate_only: - return {"status": "validated", "operation_count": len(operations)} + response = service.mutate(customer_id=cid, mutate_operations=operations) results = {} num_keywords = len(kw_list) @@ -3190,76 +3114,7 @@ def _apply_create_ad_group(client: object, cid: str, changes: dict) -> dict: return results -def _apply_create_ad_group( - client: object, - cid: str, - changes: dict, - *, - validate_only: bool = False, -) -> dict: - """Create ad group + optional keywords in an existing campaign atomically.""" - service = client.get_service("GoogleAdsService") - campaign_service = client.get_service("CampaignService") - ad_group_service = client.get_service("AdGroupService") - - operations: list = [] - - # 1. AdGroup (temp ID: -1, references existing campaign) - ag_op = client.get_type("MutateOperation") - ad_group = ag_op.ad_group_operation.create - ad_group.resource_name = ad_group_service.ad_group_path(cid, "-1") - ad_group.name = changes["ad_group_name"] - ad_group.campaign = campaign_service.campaign_path(cid, changes["campaign_id"]) - ad_group.status = client.enums.AdGroupStatusEnum.ENABLED - ad_group.type_ = client.enums.AdGroupTypeEnum.SEARCH_STANDARD - if changes.get("cpc_bid_micros"): - ad_group.cpc_bid_micros = changes["cpc_bid_micros"] - operations.append(ag_op) - - # 2. Keywords (reference ad_group -1) - kw_list = changes.get("keywords") or [] - for kw in kw_list: - kw_op = client.get_type("MutateOperation") - criterion = kw_op.ad_group_criterion_operation.create - criterion.ad_group = ad_group_service.ad_group_path(cid, "-1") - criterion.keyword.text = kw["text"] - criterion.keyword.match_type = getattr( - client.enums.KeywordMatchTypeEnum, kw["match_type"].upper() - ) - operations.append(kw_op) - - response = service.mutate( - request={ - "customer_id": cid, - "mutate_operations": operations, - "validate_only": validate_only, - } - ) - - if validate_only: - return {"status": "validated", "operation_count": len(operations)} - - results: dict = {} - for i, resp in enumerate(response.mutate_operation_responses): - resp_type = type(resp).pb(resp).WhichOneof("response") - if resp_type: - inner = getattr(resp, resp_type) - resource = getattr(inner, "resource_name", str(inner)) - if i == 0: - results["ad_group"] = resource - else: - results.setdefault("keywords", []).append(resource) - - return results - - -def _apply_update_campaign( - client: object, - cid: str, - changes: dict, - *, - validate_only: bool = False, -) -> dict: +def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict: """Update an existing campaign's settings.""" from google.protobuf import field_mask_pb2 @@ -3355,18 +3210,6 @@ def _apply_update_campaign( ) operations.append(budget_op) - # Final URL suffix change - new_suffix = changes.get("final_url_suffix") - if new_suffix is not None: - suffix_op = client.get_type("MutateOperation") - suffix_campaign = suffix_op.campaign_operation.update - suffix_campaign.resource_name = resource_name - suffix_campaign.final_url_suffix = new_suffix - suffix_op.campaign_operation.update_mask.CopyFrom( - field_mask_pb2.FieldMask(paths=["final_url_suffix"]) - ) - operations.append(suffix_op) - # Geo targeting — replace POSITIVE location criteria, preserve NEGATIVE # location exclusions. The previous implementation filtered on # campaign_criterion.type = 'LOCATION' alone, which swept up negative @@ -3429,16 +3272,7 @@ def _apply_update_campaign( if not operations: return {"message": "No changes to apply"} - response = service.mutate( - request={ - "customer_id": cid, - "mutate_operations": operations, - "validate_only": validate_only, - } - ) - - if validate_only: - return {"status": "validated", "operation_count": len(operations)} + response = service.mutate(customer_id=cid, mutate_operations=operations) results = {"updated": []} for resp in response.mutate_operation_responses: @@ -3781,8 +3615,6 @@ def _apply_remove( cid: str, entity_type: str, entity_id: str, - *, - validate_only: bool = False, ) -> dict: """Remove an entity via the REMOVE mutate operation (irreversible).""" if entity_type == "campaign": @@ -3790,11 +3622,7 @@ def _apply_remove( operation = client.get_type("CampaignOperation") operation.remove = service.campaign_path(cid, entity_id) response = service.mutate_campaigns( - request={ - "customer_id": cid, - "operations": [operation], - "validate_only": validate_only, - } + customer_id=cid, operations=[operation] ) elif entity_type == "ad_group": @@ -3802,11 +3630,7 @@ def _apply_remove( operation = client.get_type("AdGroupOperation") operation.remove = service.ad_group_path(cid, entity_id) response = service.mutate_ad_groups( - request={ - "customer_id": cid, - "operations": [operation], - "validate_only": validate_only, - } + customer_id=cid, operations=[operation] ) elif entity_type == "ad": @@ -3815,11 +3639,7 @@ def _apply_remove( operation = client.get_type("AdGroupAdOperation") operation.remove = f"customers/{cid}/adGroupAds/{resolved_id}" response = service.mutate_ad_group_ads( - request={ - "customer_id": cid, - "operations": [operation], - "validate_only": validate_only, - } + customer_id=cid, operations=[operation] ) elif entity_type in ("keyword", "ad_group_criterion"): @@ -3827,11 +3647,7 @@ def _apply_remove( operation = client.get_type("AdGroupCriterionOperation") operation.remove = f"customers/{cid}/adGroupCriteria/{entity_id}" response = service.mutate_ad_group_criteria( - request={ - "customer_id": cid, - "operations": [operation], - "validate_only": validate_only, - } + customer_id=cid, operations=[operation] ) elif entity_type in ("negative_keyword", "campaign_criterion"): @@ -3839,49 +3655,7 @@ def _apply_remove( operation = client.get_type("CampaignCriterionOperation") operation.remove = f"customers/{cid}/campaignCriteria/{entity_id}" response = service.mutate_campaign_criteria( - request={ - "customer_id": cid, - "operations": [operation], - "validate_only": validate_only, - } - ) - - elif entity_type == "asset_group": - service = client.get_service("AssetGroupService") - operation = client.get_type("AssetGroupOperation") - operation.remove = service.asset_group_path(cid, entity_id) - response = service.mutate_asset_groups( - request={ - "customer_id": cid, - "operations": [operation], - "validate_only": validate_only, - } - ) - - elif entity_type == "asset_group_signal": - # Composite id format: {asset_group_id}~{criterion_id}, matching - # Google's assetGroupSignals resource_name suffix. - if "~" not in entity_id: - raise ValueError( - f"asset_group_signal entity_id must be " - f"'assetGroupId~criterionId', got '{entity_id}'" - ) - service = client.get_service("AssetGroupSignalService") - operation = client.get_type("AssetGroupSignalOperation") - operation.remove = f"customers/{cid}/assetGroupSignals/{entity_id}" - response = service.mutate_asset_group_signals( - request={ - "customer_id": cid, - "operations": [operation], - "validate_only": validate_only, - } - ) - - elif entity_type == "label": - from adloop.ads.labels import _apply_remove_label - - return _apply_remove_label( - client, cid, entity_id, validate_only=validate_only + customer_id=cid, operations=[operation] ) elif entity_type == "shared_criterion": @@ -3909,14 +3683,8 @@ def _apply_remove( op = client.get_type("MutateOperation") op.campaign_asset_operation.remove = resource_name response = ga_service.mutate( - request={ - "customer_id": cid, - "mutate_operations": [op], - "validate_only": validate_only, - } + customer_id=cid, mutate_operations=[op] ) - if validate_only: - return {"status": "validated"} resp_inner = response.mutate_operation_responses[0] if resp_inner.campaign_asset_result.resource_name: return {"resource_name": resp_inner.campaign_asset_result.resource_name} @@ -3952,8 +3720,6 @@ def _apply_remove( else: raise ValueError(f"Cannot remove entity_type: {entity_type}") - if validate_only: - return {"status": "validated"} return {"resource_name": response.results[0].resource_name} @@ -4037,8 +3803,6 @@ def _apply_campaign_assets( assets: list[dict], field_type: object, populate_asset: object, - *, - validate_only: bool = False, ) -> dict: """Create assets and link them to a campaign via CampaignAsset.""" asset_service = client.get_service("AssetService") @@ -4061,16 +3825,9 @@ def _apply_campaign_assets( operations.append(op) response = googleads_service.mutate( - request={ - "customer_id": cid, - "mutate_operations": operations, - "validate_only": validate_only, - } + customer_id=cid, mutate_operations=operations ) - if validate_only: - return {"status": "validated", "operation_count": len(operations)} - results = {"assets": [], "campaign_assets": []} num_assets = len(assets) for i, resp in enumerate(response.mutate_operation_responses): diff --git a/src/adloop/cli.py b/src/adloop/cli.py index fc4c1ae..a7ab387 100644 --- a/src/adloop/cli.py +++ b/src/adloop/cli.py @@ -214,7 +214,10 @@ def _generate_config_yaml( def _mcp_json_snippet(toolsets: str = "") -> str: """mcpServers JSON block (Cursor and Claude Code use the same shape).""" - python_path = sys.executable + import json + + # json.dumps escapes the path so Windows backslashes stay valid JSON. + python_path = json.dumps(sys.executable) env_line = ( f'\n "env": {{ "ADLOOP_TOOLSETS": "{toolsets}" }},' if toolsets else "" ) @@ -222,7 +225,7 @@ def _mcp_json_snippet(toolsets: str = "") -> str: {{ "mcpServers": {{ "adloop": {{{env_line} - "command": "{python_path}", + "command": {python_path}, "args": ["-m", "adloop"] }} }} diff --git a/tests/test_draft_rsa_replacement.py b/tests/test_draft_rsa_replacement.py index 3ef5979..1072360 100644 --- a/tests/test_draft_rsa_replacement.py +++ b/tests/test_draft_rsa_replacement.py @@ -60,7 +60,7 @@ def config(): class TestDraftRsaReplacement: - @patch("adloop.ads.write._validate_urls", return_value={}) + @patch("adloop.ads.write._validate_urls", return_value=({}, {})) @patch("adloop.ads.write._fetch_existing_rsa") def test_happy_path_returns_preview_with_diff( self, mock_fetch, mock_urls, config @@ -137,7 +137,7 @@ def test_ad_already_removed(self, mock_fetch, config): assert "error" in result assert "removed" in result["error"].lower() - @patch("adloop.ads.write._validate_urls", return_value={}) + @patch("adloop.ads.write._validate_urls", return_value=({}, {})) @patch("adloop.ads.write._fetch_existing_rsa") def test_too_few_headlines(self, mock_fetch, mock_urls, config): mock_fetch.return_value = EXISTING_RSA @@ -151,7 +151,7 @@ def test_too_few_headlines(self, mock_fetch, mock_urls, config): ) assert "error" in result - @patch("adloop.ads.write._validate_urls", return_value={}) + @patch("adloop.ads.write._validate_urls", return_value=({}, {})) @patch("adloop.ads.write._fetch_existing_rsa") def test_headline_over_30_chars(self, mock_fetch, mock_urls, config): mock_fetch.return_value = EXISTING_RSA @@ -170,7 +170,7 @@ def test_headline_over_30_chars(self, mock_fetch, mock_urls, config): ) assert "error" in result - @patch("adloop.ads.write._validate_urls", return_value={}) + @patch("adloop.ads.write._validate_urls", return_value=({}, {})) @patch("adloop.ads.write._fetch_existing_rsa") def test_inherits_final_url_from_old_ad(self, mock_fetch, mock_urls, config): mock_fetch.return_value = EXISTING_RSA @@ -187,7 +187,7 @@ def test_inherits_final_url_from_old_ad(self, mock_fetch, mock_urls, config): remove_plan(result["plan_id"]) - @patch("adloop.ads.write._validate_urls", return_value={}) + @patch("adloop.ads.write._validate_urls", return_value=({}, {})) @patch("adloop.ads.write._fetch_existing_rsa") def test_default_removes_old_with_double_confirm( self, mock_fetch, mock_urls, config @@ -208,7 +208,7 @@ def test_default_removes_old_with_double_confirm( assert plan.requires_double_confirm is True remove_plan(result["plan_id"]) - @patch("adloop.ads.write._validate_urls", return_value={}) + @patch("adloop.ads.write._validate_urls", return_value=({}, {})) @patch("adloop.ads.write._fetch_existing_rsa") def test_keep_old_paused_no_double_confirm( self, mock_fetch, mock_urls, config @@ -242,7 +242,7 @@ def test_blocked_operation(self, config): @patch( "adloop.ads.write._validate_urls", - return_value={"https://broken.example.com": "Connection refused"}, + return_value=({"https://broken.example.com": "Connection refused"}, {}), ) @patch("adloop.ads.write._fetch_existing_rsa") def test_url_validation_failure(self, mock_fetch, mock_urls, config): @@ -258,7 +258,7 @@ def test_url_validation_failure(self, mock_fetch, mock_urls, config): assert "error" in result assert "not reachable" in result.get("details", [""])[0] - @patch("adloop.ads.write._validate_urls", return_value={}) + @patch("adloop.ads.write._validate_urls", return_value=({}, {})) @patch("adloop.ads.write._fetch_existing_rsa") def test_old_copy_in_changes(self, mock_fetch, mock_urls, config): """The plan changes should include old_copy for audit/diff purposes.""" @@ -408,7 +408,7 @@ def test_missing_text_rejected(self): class TestDraftRsaWithPinning: """Tests for pinning in draft_responsive_search_ad.""" - @patch("adloop.ads.write._validate_urls", return_value={}) + @patch("adloop.ads.write._validate_urls", return_value=({}, {})) def test_pinned_headlines_stored_in_plan(self, mock_urls, config): headlines = [ {"text": "Pinned Headline", "pinned_field": "HEADLINE_1"}, @@ -431,7 +431,7 @@ def test_pinned_headlines_stored_in_plan(self, mock_urls, config): assert stored[1] == {"text": "Unpinned Headline Two", "pinned_field": None} remove_plan(result["plan_id"]) - @patch("adloop.ads.write._validate_urls", return_value={}) + @patch("adloop.ads.write._validate_urls", return_value=({}, {})) def test_pinned_descriptions_stored_in_plan(self, mock_urls, config): headlines = VALID_HEADLINES descs = [ @@ -453,7 +453,7 @@ def test_pinned_descriptions_stored_in_plan(self, mock_urls, config): assert stored[1]["pinned_field"] is None remove_plan(result["plan_id"]) - @patch("adloop.ads.write._validate_urls", return_value={}) + @patch("adloop.ads.write._validate_urls", return_value=({}, {})) def test_invalid_pin_rejected(self, mock_urls, config): headlines = [ {"text": "Bad Pin", "pinned_field": "HEADLINE_99"}, @@ -476,7 +476,7 @@ def test_invalid_pin_rejected(self, mock_urls, config): class TestDraftRsaReplacementWithPinning: """Tests for pinning in draft_rsa_replacement.""" - @patch("adloop.ads.write._validate_urls", return_value={}) + @patch("adloop.ads.write._validate_urls", return_value=({}, {})) @patch("adloop.ads.write._fetch_existing_rsa") def test_pinned_new_headlines_in_diff(self, mock_fetch, mock_urls, config): mock_fetch.return_value = EXISTING_RSA @@ -498,7 +498,7 @@ def test_pinned_new_headlines_in_diff(self, mock_fetch, mock_urls, config): assert new_h[1] == {"text": "Replacement Two", "pinned_field": None} remove_plan(result["plan_id"]) - @patch("adloop.ads.write._validate_urls", return_value={}) + @patch("adloop.ads.write._validate_urls", return_value=({}, {})) @patch("adloop.ads.write._fetch_existing_rsa") def test_pinned_old_headlines_preserved(self, mock_fetch, mock_urls, config): """When the existing RSA has pinned assets (returned as dicts from GAQL), From fd57e170a6b4dd9a7f2153f3d49579d622e0cf9a Mon Sep 17 00:00:00 2001 From: Wade Randel Date: Mon, 27 Jul 2026 12:38:31 -0600 Subject: [PATCH 35/36] fix(write): re-graft final_url_suffix (UTM) onto upstream apply layer Taking upstream's _apply_create_campaign/_apply_update_campaign in the merge dropped the fork's auto-UTM handling. Re-add it on top of upstream's bodies: set campaign.final_url_suffix on create when non-empty, and on update add it to the field mask (empty string clears, None = no change). draft_campaign already records it in plan.changes. Suite still green (632). Co-Authored-By: Claude Opus 4.8 --- src/adloop/ads/write.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/adloop/ads/write.py b/src/adloop/ads/write.py index f78c836..570f09d 100644 --- a/src/adloop/ads/write.py +++ b/src/adloop/ads/write.py @@ -2997,6 +2997,12 @@ def _apply_create_campaign(client: object, cid: str, changes: dict) -> dict: client.enums.EuPoliticalAdvertisingStatusEnum.DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING ) + # Final URL suffix (UTM tracking) — auto-set for SEARCH by draft_campaign; + # an empty string means the caller explicitly disabled it, so only apply + # when non-empty. + if changes.get("final_url_suffix"): + campaign.final_url_suffix = changes["final_url_suffix"] + operations.append(campaign_op) # 3. AdGroup (temp ID: -3, references campaign -2) @@ -3135,6 +3141,7 @@ def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict: or search_partners_enabled is not None or display_network_enabled is not None or changes.get("max_cpc") + or changes.get("final_url_suffix") is not None ): campaign_op = client.get_type("MutateOperation") campaign = campaign_op.campaign_operation.update @@ -3182,6 +3189,12 @@ def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict: campaign.network_settings.target_content_network = display_network_enabled field_paths.append("network_settings.target_content_network") + # Final URL suffix (UTM) — empty string clears it; None = no change. + new_suffix = changes.get("final_url_suffix") + if new_suffix is not None: + campaign.final_url_suffix = new_suffix + field_paths.append("final_url_suffix") + if field_paths: campaign_op.campaign_operation.update_mask.CopyFrom( field_mask_pb2.FieldMask(paths=field_paths) From c345606701d5c71074bd7dc0a01d3ca3f5b62632 Mon Sep 17 00:00:00 2001 From: Wade Randel Date: Mon, 27 Jul 2026 12:54:20 -0600 Subject: [PATCH 36/36] chore(merge): clean up post-merge follow-ups - rules doc: drop duplicate GAQL "Common Resources" rows for asset_group / asset_group_asset / asset_group_top_combination_view left by the union merge (kept the detailed, v24-accurate rows); regenerated derived docs. - pmax_read: rename metrics.average_cpc_eur -> metrics.average_cpc_amount to match read.py's currency-agnostic convention; update its test. Suite green (632 passed). Co-Authored-By: Claude Opus 4.8 --- .claude/rules/adloop.md | 3 --- .cursor/rules/adloop.mdc | 3 --- src/adloop/ads/pmax_read.py | 4 ++-- src/adloop/rules/adloop.md | 3 --- tests/test_pmax_read.py | 2 +- 5 files changed, 3 insertions(+), 12 deletions(-) diff --git a/.claude/rules/adloop.md b/.claude/rules/adloop.md index 290138a..280ee63 100644 --- a/.claude/rules/adloop.md +++ b/.claude/rules/adloop.md @@ -655,9 +655,6 @@ LIMIT n | `asset_group_signal` | Search themes and audience signals attached to PMax asset groups | | `asset_group_top_combination_view` | Top serving combinations Google has assembled for PMax | | `campaign_search_term_insight` | Aggregated PMax search-term categories (v23.2+, no individual queries) | -| `asset_group` | PMax asset group data (ad strength, status) | -| `asset_group_asset` | PMax per-asset performance labels | -| `asset_group_top_combination_view` | PMax top asset combinations | | `recommendation` | Google's auto-generated recommendations | | `ad_group_audience_view` | Audience segment performance | diff --git a/.cursor/rules/adloop.mdc b/.cursor/rules/adloop.mdc index 809dcc0..7adf03f 100644 --- a/.cursor/rules/adloop.mdc +++ b/.cursor/rules/adloop.mdc @@ -657,9 +657,6 @@ LIMIT n | `asset_group_signal` | Search themes and audience signals attached to PMax asset groups | | `asset_group_top_combination_view` | Top serving combinations Google has assembled for PMax | | `campaign_search_term_insight` | Aggregated PMax search-term categories (v23.2+, no individual queries) | -| `asset_group` | PMax asset group data (ad strength, status) | -| `asset_group_asset` | PMax per-asset performance labels | -| `asset_group_top_combination_view` | PMax top asset combinations | | `recommendation` | Google's auto-generated recommendations | | `ad_group_audience_view` | Audience segment performance | diff --git a/src/adloop/ads/pmax_read.py b/src/adloop/ads/pmax_read.py index 4ac75e6..e41f534 100644 --- a/src/adloop/ads/pmax_read.py +++ b/src/adloop/ads/pmax_read.py @@ -388,7 +388,7 @@ def _validate_numeric_id(value: str, name: str) -> str: def _enrich_cost_fields(rows: list[dict]) -> None: - """Add metrics.cost (EUR) and metrics.cpa from cost_micros.""" + """Add metrics.cost and metrics.cpa (account currency) from cost_micros.""" for row in rows: cost_micros = row.get("metrics.cost_micros", 0) or 0 row["metrics.cost"] = round(cost_micros / 1_000_000, 2) @@ -399,7 +399,7 @@ def _enrich_cost_fields(rows: list[dict]) -> None: avg_cpc_micros = row.get("metrics.average_cpc", 0) or 0 if avg_cpc_micros: - row["metrics.average_cpc_eur"] = round(avg_cpc_micros / 1_000_000, 2) + row["metrics.average_cpc_amount"] = round(avg_cpc_micros / 1_000_000, 2) def _enrich_budget_fields(rows: list[dict]) -> None: diff --git a/src/adloop/rules/adloop.md b/src/adloop/rules/adloop.md index 290138a..280ee63 100644 --- a/src/adloop/rules/adloop.md +++ b/src/adloop/rules/adloop.md @@ -655,9 +655,6 @@ LIMIT n | `asset_group_signal` | Search themes and audience signals attached to PMax asset groups | | `asset_group_top_combination_view` | Top serving combinations Google has assembled for PMax | | `campaign_search_term_insight` | Aggregated PMax search-term categories (v23.2+, no individual queries) | -| `asset_group` | PMax asset group data (ad strength, status) | -| `asset_group_asset` | PMax per-asset performance labels | -| `asset_group_top_combination_view` | PMax top asset combinations | | `recommendation` | Google's auto-generated recommendations | | `ad_group_audience_view` | Audience segment performance | diff --git a/tests/test_pmax_read.py b/tests/test_pmax_read.py index 0f53fdd..072f7ed 100644 --- a/tests/test_pmax_read.py +++ b/tests/test_pmax_read.py @@ -70,7 +70,7 @@ def test_enriches_cost_budget_roas(self, mock_query, config): assert row["metrics.cpa"] == 10.0 assert row["metrics.roas"] == 10.0 # 800 / 80 assert row["campaign_budget.amount"] == 25.0 - assert row["metrics.average_cpc_eur"] == 0.4 + assert row["metrics.average_cpc_amount"] == 0.4 @patch("adloop.ads.gaql.execute_query") def test_with_date_range(self, mock_query, config):