@topy-ai/maggie 0.7.48 → 0.7.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README-zh-TW.md +2 -2
  2. package/README.md +31 -4
  3. package/bin/maggie.js +17 -4
  4. package/bundled-contracts/google-integrations/capability-report-v2.schema.json +2 -1
  5. package/bundled-contracts/maggie-content/email-localization-v1.schema.json +89 -0
  6. package/bundled-contracts/maggie-content/lifecycle-email-v1.schema.json +76 -0
  7. package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +6 -1
  8. package/bundled-contracts/maggie-ops/marketing-run-v1.schema.json +89 -0
  9. package/bundled-contracts/maggie-ops/notification-catalogue-example-v1.json +941 -0
  10. package/bundled-contracts/maggie-ops/notification-catalogue-v1.schema.json +273 -0
  11. package/bundled-contracts/maggie-service-booking/fact-provenance-v1.schema.json +128 -0
  12. package/bundled-contracts/maggie-service-booking/import-stages-v1.schema.json +191 -0
  13. package/bundled-references/google-integrations-runbook.md +54 -0
  14. package/bundled-skills/maggie-content-localization/SKILL.md +17 -1
  15. package/bundled-skills/maggie-content-localization/references/email-and-lifecycle.md +83 -0
  16. package/bundled-skills/maggie-deployment/SKILL.md +89 -7
  17. package/bundled-skills/maggie-deployment/references/vps.md +38 -6
  18. package/bundled-skills/maggie-design/SKILL.md +55 -0
  19. package/bundled-skills/maggie-feedback/SKILL.md +29 -0
  20. package/bundled-skills/maggie-ops/SKILL.md +37 -0
  21. package/bundled-skills/maggie-ops/references/notifications-operations.md +154 -0
  22. package/bundled-skills/maggie-service-booking/SKILL.md +59 -0
  23. package/bundled-skills/maggie-social-share/SKILL.md +19 -2
  24. package/bundled-skills/maggie-social-share/references/marketing-run.md +57 -0
  25. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +51 -0
  26. package/bundled-tools/clis/maggie_deployment.py +58 -17
  27. package/bundled-tools/clis/maggie_deployment_auth_canary.py +216 -0
  28. package/bundled-tools/clis/maggie_deployment_collect.py +225 -0
  29. package/bundled-tools/clis/maggie_deployment_readiness.py +53 -3
  30. package/bundled-tools/clis/maggie_design.py +8 -0
  31. package/bundled-tools/clis/maggie_design_verify.py +359 -0
  32. package/bundled-tools/clis/maggie_docs.py +82 -4
  33. package/bundled-tools/clis/maggie_email_localization.py +382 -0
  34. package/bundled-tools/clis/maggie_feedback.py +9 -2
  35. package/bundled-tools/clis/maggie_icon_glyph_check.py +382 -0
  36. package/bundled-tools/clis/maggie_icon_release_gate.py +28 -1
  37. package/bundled-tools/clis/maggie_localization.py +5 -0
  38. package/bundled-tools/clis/maggie_marketing_run.py +272 -0
  39. package/bundled-tools/clis/maggie_notifications.py +528 -0
  40. package/bundled-tools/clis/maggie_ops.py +87 -0
  41. package/bundled-tools/clis/maggie_schedule.py +12 -2
  42. package/bundled-tools/clis/maggie_service_import.py +520 -0
  43. package/bundled-tools/clis/maggie_social_queue.py +104 -0
  44. package/bundled-tools/clis/maggie_social_share.py +6 -2
  45. package/bundled-tools/runtime/google_ads.py +306 -0
  46. package/bundled-tools/runtime/google_capabilities.py +18 -2
  47. package/bundled-tools/runtime/google_remediation.py +152 -0
  48. package/package.json +1 -1
  49. package/references/google-integrations-runbook.md +54 -0
@@ -98,6 +98,8 @@ def validate_adapters(manifest: dict) -> dict:
98
98
  if shared:
99
99
  warnings.append(f"{label} OAuth client is shared with {len(shared)} other product(s); their sign-ins can "
100
100
  "revoke this product's tokens unless they request the identical scope set — prefer a dedicated client")
101
+ if adapter.get("publishes"):
102
+ errors.extend(f"{label} {problem}" for problem in publish_contract_errors(adapter))
101
103
  hosts = sorted({urlsplit(uri).hostname or "" for uri in adapter.get("redirect_uris") or [] if isinstance(uri, str)} - {""})
102
104
  if len(hosts) > 1 and not shared:
103
105
  warnings.append(f"{label} redirect URIs span {len(hosts)} hosts ({', '.join(hosts)}); confirm they belong to "
@@ -105,6 +107,108 @@ def validate_adapters(manifest: dict) -> dict:
105
107
  return {"valid": not errors, "errors": errors, "warnings": warnings}
106
108
 
107
109
 
110
+ # --- D33: publish contract and reconciliation ----------------------------------------------------
111
+
112
+
113
+ RETRY_CLASSES = ("retryable", "terminal", "unknown_outcome")
114
+ READBACK_LOOKUPS = {"by_idempotency_key", "by_marker", "by_recent_posts"}
115
+
116
+
117
+ def publish_contract_errors(adapter: dict) -> list[str]:
118
+ """A publishing adapter declares limits, media rules, pending behaviour, retry classes, idempotency, and readback."""
119
+ problems: list[str] = []
120
+ limits = adapter.get("limits")
121
+ if not isinstance(limits, dict) or not isinstance(limits.get("text_max"), int) or limits["text_max"] <= 0:
122
+ problems.append("publish contract needs limits.text_max")
123
+ media = adapter.get("media")
124
+ if not isinstance(media, dict) or not isinstance(media.get("types"), list) or not isinstance(media.get("max_bytes"), int):
125
+ problems.append("publish contract needs media.types and media.max_bytes")
126
+ pending = adapter.get("pending")
127
+ if not isinstance(pending, dict) or not isinstance(pending.get("max_pending_seconds"), int) or not pending.get("states"):
128
+ problems.append("publish contract needs pending.states and pending.max_pending_seconds")
129
+ retry = adapter.get("retry_classes")
130
+ if not isinstance(retry, dict) or any(not isinstance(retry.get(name), list) for name in RETRY_CLASSES):
131
+ problems.append("publish contract needs retry_classes.retryable, .terminal and .unknown_outcome lists")
132
+ else:
133
+ overlap = (set(retry["retryable"]) & set(retry["terminal"])) | (set(retry["retryable"]) & set(retry["unknown_outcome"]))
134
+ if overlap:
135
+ problems.append(f"retry classes overlap: {', '.join(sorted(overlap))}")
136
+ if "timeout" in retry["retryable"]:
137
+ problems.append("timeout must be classed unknown_outcome, not retryable: the post may already exist")
138
+ idem = adapter.get("idempotency")
139
+ if not isinstance(idem, dict) or not idem.get("key_source"):
140
+ problems.append("publish contract needs idempotency.key_source")
141
+ idem = {}
142
+ readback = adapter.get("readback")
143
+ if not isinstance(readback, dict) or readback.get("lookup") not in READBACK_LOOKUPS:
144
+ problems.append(f"publish contract needs readback.lookup ({', '.join(sorted(READBACK_LOOKUPS))})")
145
+ elif not idem.get("provider_supported") and readback["lookup"] == "by_idempotency_key":
146
+ problems.append("provider has no idempotency support; readback must look up by_marker or by_recent_posts")
147
+ return problems
148
+
149
+
150
+ def _matches(post: dict, key: str) -> bool:
151
+ return post.get("idempotency_key") == key or (isinstance(post.get("marker"), str) and key in post["marker"])
152
+
153
+
154
+ def reconcile(document: dict) -> dict:
155
+ """Decide the next safe action per queue item from the attempt log and provider readback.
156
+
157
+ Timeout-after-success never produces a duplicate: an unknown outcome waits
158
+ for readback, a readback post carrying the idempotency key is adopted, and
159
+ a retry always reuses the original key.
160
+ """
161
+ errors: list[str] = []
162
+ warnings: list[str] = []
163
+ attempts = document.get("attempts")
164
+ readback = document.get("readback") or {}
165
+ posts = readback.get("posts") if isinstance(readback, dict) else None
166
+ if not isinstance(attempts, list) or not isinstance(posts, list):
167
+ return {"valid": False, "errors": ["attempts and readback.posts must be lists"], "warnings": [], "items": []}
168
+ complete = bool(readback.get("complete"))
169
+ by_item: dict[str, list[dict]] = {}
170
+ for attempt in attempts:
171
+ if not isinstance(attempt, dict) or not attempt.get("queue_item_id") or not attempt.get("idempotency_key"):
172
+ errors.append("each attempt needs queue_item_id and idempotency_key")
173
+ continue
174
+ by_item.setdefault(attempt["queue_item_id"], []).append(attempt)
175
+ items = []
176
+ for item_id, history in by_item.items():
177
+ keys = {attempt["idempotency_key"] for attempt in history}
178
+ if len(keys) > 1:
179
+ errors.append(f"queue item {item_id} used {len(keys)} idempotency keys; retries must reuse the original key")
180
+ key = history[0]["idempotency_key"]
181
+ matched = [post for post in posts if isinstance(post, dict) and _matches(post, key)]
182
+ claimed = sorted({attempt["provider_post_id"] for attempt in history if attempt.get("provider_post_id")})
183
+ ids = sorted({post.get("id") for post in matched} | set(claimed))
184
+ if len(matched) > 1 or len(ids) > 1:
185
+ errors.append(f"queue item {item_id} has duplicate provider posts ({', '.join(map(str, ids))}); delete extras and keep the first")
186
+ items.append({"queue_item_id": item_id, "action": "resolve_duplicate", "provider_post_ids": ids, "retry_allowed": False})
187
+ continue
188
+ last = history[-1].get("status")
189
+ if matched:
190
+ action, status = "adopt", "published"
191
+ elif claimed:
192
+ action, status = "verify_readback", "published"
193
+ warnings.append(f"queue item {item_id} reports {claimed[0]} but readback did not find it")
194
+ elif last in {"timeout", "pending", "unknown"}:
195
+ if complete:
196
+ action, status = "retry_same_key", "retrying"
197
+ else:
198
+ action, status = "await_readback", "pending"
199
+ elif last == "failed_retryable":
200
+ action, status = "retry_same_key", "retrying"
201
+ elif last == "failed_terminal":
202
+ action, status = "fail", "failed"
203
+ else:
204
+ action, status = "await_readback", "pending"
205
+ warnings.append(f"queue item {item_id} has unclassified last status {last!r}")
206
+ items.append({"queue_item_id": item_id, "idempotency_key": key, "action": action, "status": status,
207
+ "provider_post_id": (matched[0].get("id") if matched else (claimed[0] if claimed else None)),
208
+ "retry_allowed": action == "retry_same_key"})
209
+ return {"valid": not errors, "errors": errors, "warnings": warnings, "readback_complete": complete, "items": items}
210
+
211
+
108
212
  # --- T02: connection health --------------------------------------------------------------------
109
213
 
110
214
 
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """Create and validate deterministic Maggie Social Share drafts, adapters, connections, campaigns, and the slot queue."""
2
+ """Create and validate deterministic Maggie Social Share drafts, adapters, connections, campaigns, the slot queue, and publish reconciliation."""
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
@@ -10,7 +10,7 @@ from pathlib import Path
10
10
  from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
11
11
 
12
12
  sys.path.insert(0, str(Path(__file__).resolve().parent))
13
- from maggie_social_queue import check_campaigns, connection_health, parse_instant, plan_queue, validate_adapters # noqa: E402
13
+ from maggie_social_queue import check_campaigns, connection_health, parse_instant, plan_queue, reconcile, validate_adapters # noqa: E402
14
14
 
15
15
 
16
16
  LIMITS = {"linkedin": 3000, "facebook": 63206, "x": 280}
@@ -82,6 +82,8 @@ def main() -> int:
82
82
  health.add_argument("--now", help="ISO 8601 instant used for expiry checks (default: current time)")
83
83
  campaigns = sub.add_parser("campaigns", help="check the campaign idea stage and Start gate")
84
84
  campaigns.add_argument("input")
85
+ rec = sub.add_parser("reconcile", help="decide adopt/await/retry per queue item from attempts and provider readback")
86
+ rec.add_argument("input")
85
87
  queue = sub.add_parser("queue", help="assign queued and campaign posts to the next posting slots")
86
88
  queue.add_argument("input")
87
89
  queue.add_argument("--from", dest="start", help="ISO 8601 instant to plan from (default: current time)")
@@ -95,6 +97,8 @@ def main() -> int:
95
97
  result = validate_adapters(value)
96
98
  elif args.command == "connections":
97
99
  result = connection_health(value, parse_instant(args.now, "--now") if args.now else now)
100
+ elif args.command == "reconcile":
101
+ result = reconcile(value)
98
102
  elif args.command == "campaigns":
99
103
  result = check_campaigns(value)
100
104
  elif args.command == "queue":
@@ -0,0 +1,306 @@
1
+ """Read-only Google Ads preflight and Keyword Planner adapter.
2
+
3
+ Every request goes through an injectable transport so tests run offline. The
4
+ default transport uses urllib and is only exercised when an operator runs the
5
+ CLI with credentials. The adapter refuses any request that is not one of the
6
+ three read-only calls below, so it cannot mutate Ads state.
7
+
8
+ Credential values are read from environment variables named by the caller and
9
+ are never returned, logged, or written to a report.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import re
17
+ import urllib.error
18
+ import urllib.parse
19
+ import urllib.request
20
+ from typing import Any, Callable
21
+
22
+ from google_remediation import classify
23
+
24
+
25
+ ADS_SCOPE = "https://www.googleapis.com/auth/adwords"
26
+ ADS_HOST = "googleads.googleapis.com"
27
+ TOKENINFO_URL = "https://oauth2.googleapis.com/tokeninfo"
28
+ DEFAULT_API_VERSION = os.environ.get("MAGGIE_GOOGLE_ADS_API_VERSION", "v21")
29
+ COMPETITION = {"UNSPECIFIED": "unknown", "UNKNOWN": "unknown", "LOW": "low", "MEDIUM": "medium", "HIGH": "high"}
30
+
31
+ # (method, path regex relative to https://googleads.googleapis.com/<version>/)
32
+ READ_ONLY_ROUTES = (
33
+ ("GET", r"customers:listAccessibleCustomers"),
34
+ ("POST", r"customers/\d{10}/googleAds:search"),
35
+ ("POST", r"customers/\d{10}:generateKeywordIdeas"),
36
+ )
37
+
38
+ Transport = Callable[[str, str, dict[str, str], Any], tuple[int, Any]]
39
+
40
+
41
+ class AdsRequestRefused(ValueError):
42
+ """Raised when a request is not on the read-only allowlist."""
43
+
44
+
45
+ def normalize_customer_id(value: Any) -> str | None:
46
+ """Return a 10-digit customer ID without hyphens, or None when invalid."""
47
+ if not isinstance(value, str):
48
+ return None
49
+ digits = value.replace("-", "").strip()
50
+ return digits if re.fullmatch(r"\d{10}", digits) else None
51
+
52
+
53
+ def urllib_transport(method: str, url: str, headers: dict[str, str], body: Any) -> tuple[int, Any]:
54
+ data = None
55
+ if body is not None:
56
+ if headers.get("content-type") == "application/x-www-form-urlencoded":
57
+ data = urllib.parse.urlencode(body).encode()
58
+ else:
59
+ data = json.dumps(body).encode()
60
+ headers = {**headers, "content-type": "application/json"}
61
+ request = urllib.request.Request(url, data=data, headers=headers, method=method)
62
+ try:
63
+ with urllib.request.urlopen(request, timeout=30) as response:
64
+ return response.status, json.loads(response.read() or b"{}")
65
+ except urllib.error.HTTPError as error:
66
+ try:
67
+ payload = json.loads(error.read() or b"{}")
68
+ except json.JSONDecodeError:
69
+ payload = {}
70
+ return error.code, payload
71
+
72
+
73
+ def _error_signal(payload: Any) -> dict[str, Any]:
74
+ """Extract only error codes/reasons from a provider payload for classification."""
75
+ signal: list[str] = []
76
+ if isinstance(payload, dict):
77
+ error = payload.get("error")
78
+ if isinstance(error, str):
79
+ signal.append(error)
80
+ if isinstance(error, dict):
81
+ signal.append(str(error.get("status") or ""))
82
+ signal.append(str(error.get("message") or "")[:300])
83
+ for detail in error.get("details") or []:
84
+ if not isinstance(detail, dict):
85
+ continue
86
+ signal.append(str(detail.get("reason") or ""))
87
+ for item in detail.get("errors") or []:
88
+ if isinstance(item, dict) and isinstance(item.get("errorCode"), dict):
89
+ signal.extend(f"{value}" for value in item["errorCode"].values())
90
+ if isinstance(payload.get("error_description"), str):
91
+ signal.append(payload["error_description"][:300])
92
+ return {"reason": " ".join(part for part in signal if part)[:300]}
93
+
94
+
95
+ class AdsClient:
96
+ def __init__(self, *, access_token: str, developer_token: str | None, login_customer_id: str | None = None,
97
+ api_version: str = DEFAULT_API_VERSION, transport: Transport | None = None) -> None:
98
+ self._access_token = access_token
99
+ self._developer_token = developer_token
100
+ self._login_customer_id = login_customer_id
101
+ self.api_version = api_version
102
+ self._transport = transport or urllib_transport
103
+
104
+ def _headers(self) -> dict[str, str]:
105
+ headers = {"authorization": f"Bearer {self._access_token}"}
106
+ if self._developer_token:
107
+ headers["developer-token"] = self._developer_token
108
+ if self._login_customer_id:
109
+ headers["login-customer-id"] = self._login_customer_id
110
+ return headers
111
+
112
+ def request(self, method: str, path: str, body: Any = None) -> tuple[int, Any]:
113
+ if not any(method == allowed and re.fullmatch(pattern, path) for allowed, pattern in READ_ONLY_ROUTES):
114
+ raise AdsRequestRefused(f"refused non-allowlisted Google Ads request: {method} {path.split('/')[-1].split(':')[-1]}")
115
+ return self._transport(method, f"https://{ADS_HOST}/{self.api_version}/{path}", self._headers(), body)
116
+
117
+ def token_scopes(self) -> tuple[int, list[str]]:
118
+ # POST body keeps the token out of URLs and access logs.
119
+ status, payload = self._transport("POST", TOKENINFO_URL, {"content-type": "application/x-www-form-urlencoded"}, {"access_token": self._access_token})
120
+ scopes = str(payload.get("scope") or "").split() if isinstance(payload, dict) else []
121
+ return status, scopes
122
+
123
+
124
+ def _check(checks: list[dict[str, Any]], name: str, status: str, detail: str, remediation: dict[str, Any] | None = None) -> None:
125
+ item = {"check": name, "status": status, "detail": detail}
126
+ if remediation:
127
+ item["remediation"] = {key: remediation[key] for key in ("code", "prerequisite", "nextAction", "httpStatus")}
128
+ checks.append(item)
129
+
130
+
131
+ def _failure(status: int, payload: Any) -> dict[str, Any]:
132
+ return classify(_error_signal(payload), status=status, provider="google-ads")
133
+
134
+
135
+ def preflight(*, customer_id: str | None, login_customer_id: str | None, access_token: str | None,
136
+ developer_token: str | None, api_version: str = DEFAULT_API_VERSION,
137
+ transport: Transport | None = None, probe_keyword_planner: bool = False) -> dict[str, Any]:
138
+ """Run ordered, independent read-only checks and return a redacted report."""
139
+ checks: list[dict[str, Any]] = []
140
+ customer = normalize_customer_id(customer_id)
141
+ login = normalize_customer_id(login_customer_id) if login_customer_id else None
142
+ if customer:
143
+ _check(checks, "customer_id_format", "passed", "customer ID is 10 digits")
144
+ else:
145
+ _check(checks, "customer_id_format", "failed", "customer ID must be 10 digits (hyphens allowed in input)",
146
+ {"code": "customer_id_format", "prerequisite": "ads-customer-id", "nextAction": "Pass the target Ads customer ID as 10 digits, e.g. 123-456-7890.", "httpStatus": None})
147
+ if login_customer_id is None:
148
+ _check(checks, "login_customer_id_format", "skipped", "no manager login-customer-id supplied")
149
+ elif login:
150
+ _check(checks, "login_customer_id_format", "passed", "login-customer-id is 10 digits")
151
+ else:
152
+ _check(checks, "login_customer_id_format", "failed", "login-customer-id must be 10 digits",
153
+ {"code": "login_customer_mismatch", "prerequisite": "ads-login-customer-id", "nextAction": "Pass the manager account ID as 10 digits, or omit it for direct access.", "httpStatus": None})
154
+ if developer_token:
155
+ _check(checks, "developer_token_present", "passed", "developer token is present (value not recorded)")
156
+ else:
157
+ _check(checks, "developer_token_present", "failed", "developer token environment variable is empty", classify("DEVELOPER_TOKEN_PARAMETER_MISSING", provider="google-ads"))
158
+ if not access_token:
159
+ _check(checks, "oauth_scope", "failed", "access token environment variable is empty", classify("invalid_grant", provider="google-ads"))
160
+ return _report(checks, customer, login, api_version)
161
+
162
+ client = AdsClient(access_token=access_token, developer_token=developer_token, login_customer_id=login, api_version=api_version, transport=transport)
163
+ status, scopes = client.token_scopes()
164
+ if status != 200:
165
+ _check(checks, "oauth_scope", "failed", "token introspection failed", classify("invalid_grant", status=status, provider="google-ads"))
166
+ return _report(checks, customer, login, api_version)
167
+ if ADS_SCOPE in scopes:
168
+ _check(checks, "oauth_scope", "passed", "token carries the adwords scope")
169
+ else:
170
+ _check(checks, "oauth_scope", "failed", "token lacks the adwords scope", classify("ACCESS_TOKEN_SCOPE_INSUFFICIENT", provider="google-ads"))
171
+ return _report(checks, customer, login, api_version)
172
+ if not developer_token or not customer:
173
+ return _report(checks, customer, login, api_version)
174
+
175
+ status, payload = client.request("GET", "customers:listAccessibleCustomers")
176
+ accessible = sorted(name.split("/")[-1] for name in (payload.get("resourceNames") or [])) if status == 200 and isinstance(payload, dict) else []
177
+ if status != 200:
178
+ _check(checks, "accessible_customers", "failed", "listAccessibleCustomers was rejected", _failure(status, payload))
179
+ return _report(checks, customer, login, api_version)
180
+ direct = customer in accessible
181
+ via_manager = bool(login and login in accessible)
182
+ if direct or via_manager:
183
+ _check(checks, "accessible_customers", "passed", f"{len(accessible)} accessible customer(s); target reachable {'directly' if direct else 'via manager'}")
184
+ else:
185
+ _check(checks, "accessible_customers", "failed", f"{len(accessible)} accessible customer(s); target not among them",
186
+ classify("USER_PERMISSION_DENIED" if not login else "USER_PERMISSION_DENIED manager", provider="google-ads"))
187
+ return _report(checks, customer, login, api_version)
188
+
189
+ currency = None
190
+ if via_manager and not direct:
191
+ query = f"SELECT customer_client.id, customer_client.manager, customer_client.status FROM customer_client WHERE customer_client.id = {customer}"
192
+ status, payload = client.request("POST", f"customers/{login}/googleAds:search", {"query": query})
193
+ rows = (payload.get("results") or []) if status == 200 and isinstance(payload, dict) else []
194
+ if status == 200 and rows:
195
+ _check(checks, "account_hierarchy", "passed", "target is linked under the manager account")
196
+ else:
197
+ _check(checks, "account_hierarchy", "failed", "target is not linked under the manager account",
198
+ _failure(status, payload) if status != 200 else classify("login-customer-id", provider="google-ads"))
199
+ return _report(checks, customer, login, api_version)
200
+ else:
201
+ _check(checks, "account_hierarchy", "skipped", "direct access; no manager hierarchy needed")
202
+ status, payload = client.request("POST", f"customers/{customer}/googleAds:search",
203
+ {"query": "SELECT customer.id, customer.currency_code, customer.manager, customer.test_account FROM customer LIMIT 1"})
204
+ if status == 200 and isinstance(payload, dict) and payload.get("results"):
205
+ info = payload["results"][0].get("customer") or {}
206
+ currency = info.get("currencyCode")
207
+ if info.get("manager"):
208
+ _check(checks, "customer_readable", "failed", "target is a manager account; Keyword Planner needs a client account",
209
+ {"code": "manager_account_target", "prerequisite": "ads-client-account", "nextAction": "Select a client (non-manager) customer ID as the Keyword Planner target.", "httpStatus": None})
210
+ return _report(checks, customer, login, api_version, currency)
211
+ _check(checks, "customer_readable", "passed", "customer row readable" + (" (test account)" if info.get("testAccount") else ""))
212
+ else:
213
+ _check(checks, "customer_readable", "failed", "customer query was rejected", _failure(status, payload))
214
+ return _report(checks, customer, login, api_version)
215
+
216
+ if probe_keyword_planner:
217
+ status, payload = client.request("POST", f"customers/{customer}:generateKeywordIdeas",
218
+ {"keywordSeed": {"keywords": ["test"]}, "pageSize": 1, "includeAdultKeywords": False})
219
+ if status == 200:
220
+ _check(checks, "keyword_planner", "passed", "one-result KeywordPlanIdeaService probe succeeded")
221
+ else:
222
+ _check(checks, "keyword_planner", "failed", "KeywordPlanIdeaService probe was rejected", _failure(status, payload))
223
+ else:
224
+ _check(checks, "keyword_planner", "ready", "all prerequisites passed; pass --probe-keyword-planner to spend one read-only request")
225
+ return _report(checks, customer, login, api_version, currency)
226
+
227
+
228
+ def _report(checks: list[dict[str, Any]], customer: str | None, login: str | None, api_version: str, currency: str | None = None) -> dict[str, Any]:
229
+ failed = [item for item in checks if item["status"] == "failed"]
230
+ return {
231
+ "schemaVersion": "maggie-google-ads-preflight.v1",
232
+ "passed": not failed,
233
+ "apiVersion": api_version,
234
+ "customerId": customer,
235
+ "loginCustomerId": login,
236
+ "currency": currency,
237
+ "checks": checks,
238
+ "firstFailure": failed[0]["check"] if failed else None,
239
+ "nextAction": failed[0]["remediation"]["nextAction"] if failed and failed[0].get("remediation") else None,
240
+ "mutationsAllowed": False,
241
+ }
242
+
243
+
244
+ def _micros(value: Any) -> float | None:
245
+ try:
246
+ return round(int(value) / 1_000_000, 2)
247
+ except (TypeError, ValueError):
248
+ return None
249
+
250
+
251
+ def normalize_ideas(results: list[Any], currency: str | None) -> list[dict[str, Any]]:
252
+ ideas = []
253
+ for item in results:
254
+ if not isinstance(item, dict) or not isinstance(item.get("text"), str):
255
+ continue
256
+ metrics = item.get("keywordIdeaMetrics") or {}
257
+ searches = metrics.get("avgMonthlySearches")
258
+ ideas.append({
259
+ "text": item["text"],
260
+ "avgMonthlySearches": int(searches) if str(searches or "").isdigit() else None,
261
+ "competition": COMPETITION.get(str(metrics.get("competition") or "UNKNOWN"), "unknown"),
262
+ "competitionIndex": int(metrics["competitionIndex"]) if str(metrics.get("competitionIndex") or "").isdigit() else None,
263
+ "lowTopOfPageBid": _micros(metrics.get("lowTopOfPageBidMicros")),
264
+ "highTopOfPageBid": _micros(metrics.get("highTopOfPageBidMicros")),
265
+ "currency": currency,
266
+ })
267
+ return ideas
268
+
269
+
270
+ def keyword_ideas(*, customer_id: str, login_customer_id: str | None, access_token: str, developer_token: str,
271
+ keywords: list[str], url: str | None = None, language: str | None = None, geo_targets: list[str] | None = None,
272
+ currency: str | None = None, max_pages: int = 1, page_size: int = 100,
273
+ api_version: str = DEFAULT_API_VERSION, transport: Transport | None = None) -> dict[str, Any]:
274
+ customer = normalize_customer_id(customer_id)
275
+ if not customer:
276
+ raise ValueError("customer ID must be 10 digits")
277
+ if not keywords and not url:
278
+ raise ValueError("provide at least one seed keyword or a seed URL")
279
+ login = normalize_customer_id(login_customer_id) if login_customer_id else None
280
+ client = AdsClient(access_token=access_token, developer_token=developer_token, login_customer_id=login, api_version=api_version, transport=transport)
281
+ body: dict[str, Any] = {"pageSize": max(1, min(page_size, 1000)), "includeAdultKeywords": False, "keywordPlanNetwork": "GOOGLE_SEARCH"}
282
+ if keywords and url:
283
+ body["keywordAndUrlSeed"] = {"keywords": keywords, "url": url}
284
+ elif keywords:
285
+ body["keywordSeed"] = {"keywords": keywords}
286
+ else:
287
+ body["urlSeed"] = {"url": url}
288
+ if language:
289
+ body["language"] = language if language.startswith("languageConstants/") else f"languageConstants/{language}"
290
+ if geo_targets:
291
+ body["geoTargetConstants"] = [item if item.startswith("geoTargetConstants/") else f"geoTargetConstants/{item}" for item in geo_targets]
292
+ results: list[Any] = []
293
+ pages = 0
294
+ while pages < max(1, max_pages):
295
+ status, payload = client.request("POST", f"customers/{customer}:generateKeywordIdeas", body)
296
+ pages += 1
297
+ if status != 200:
298
+ return {"schemaVersion": "maggie-google-ads-keyword-ideas.v1", "passed": False, "customerId": customer, "ideas": normalize_ideas(results, currency),
299
+ "pages": pages, "remediation": {key: value for key, value in _failure(status, payload).items() if key != "provider"}, "mutationsAllowed": False}
300
+ results.extend(payload.get("results") or [])
301
+ token = payload.get("nextPageToken")
302
+ if not token:
303
+ break
304
+ body = {**body, "pageToken": token}
305
+ return {"schemaVersion": "maggie-google-ads-keyword-ideas.v1", "passed": True, "customerId": customer, "pages": pages,
306
+ "ideas": normalize_ideas(results, currency), "mutationsAllowed": False}
@@ -6,6 +6,8 @@ import re
6
6
  from typing import Any
7
7
  from urllib.parse import urlsplit
8
8
 
9
+ from google_remediation import classify as classify_failure
10
+
9
11
 
10
12
  SCHEMA_VERSION = "maggie-google-capability-report.v2"
11
13
  CAPABILITY_NAMES = ("read", "report", "edit", "publish")
@@ -172,7 +174,7 @@ def validate_report(report: Any) -> dict[str, Any]:
172
174
  errors.append(f"{prefix} must be an object")
173
175
  continue
174
176
  required = {"provider", "resource", "activeAccount", "target", "authMode", "scopes", "productRole", "evidence", "capabilities", "nextAction"}
175
- unknown = sorted(set(row) - required)
177
+ unknown = sorted(set(row) - required - {"failure"})
176
178
  if unknown:
177
179
  errors.append(f"{prefix} contains unsupported fields")
178
180
  provider = row.get("provider")
@@ -253,6 +255,15 @@ def validate_report(report: Any) -> dict[str, Any]:
253
255
  errors.append(f"{prefix}.evidence.adsPrerequisites requires developer token and customer ID presence")
254
256
  elif prerequisites is not None:
255
257
  errors.append(f"{prefix}.evidence.adsPrerequisites is only valid for google-ads")
258
+ remediation = None
259
+ failure = row.get("failure")
260
+ if failure is not None:
261
+ if not isinstance(failure, dict) or set(failure) - {"code", "reason", "httpStatus"} or not any(failure.get(key) for key in ("code", "reason", "httpStatus")):
262
+ errors.append(f"{prefix}.failure must contain only code, reason, and httpStatus")
263
+ elif any(not isinstance(failure.get(key), str) or len(failure[key]) > 300 for key in ("code", "reason") if key in failure):
264
+ errors.append(f"{prefix}.failure.code and reason must be bounded strings")
265
+ else:
266
+ remediation = classify_failure(failure, provider=provider)
256
267
  normalized.append({
257
268
  "provider": provider,
258
269
  "resource": resource,
@@ -269,7 +280,8 @@ def validate_report(report: Any) -> dict[str, Any]:
269
280
  "writeTested": isinstance(evidence.get("writeTest"), dict),
270
281
  },
271
282
  "capabilities": safe_capabilities,
272
- "nextAction": row.get("nextAction"),
283
+ "nextAction": remediation["nextAction"] if remediation else row.get("nextAction"),
284
+ **({"remediation": remediation} if remediation else {}),
273
285
  })
274
286
 
275
287
  return {
@@ -277,5 +289,9 @@ def validate_report(report: Any) -> dict[str, Any]:
277
289
  "passed": not errors,
278
290
  "errors": sorted(set(errors)),
279
291
  "providerResults": normalized,
292
+ "remediation": [
293
+ {"provider": item["provider"], "resource": item["resource"], **{key: item["remediation"][key] for key in ("code", "prerequisite", "nextAction", "httpStatus")}}
294
+ for item in normalized if item.get("remediation")
295
+ ],
280
296
  "mutationsAllowed": False,
281
297
  }