adloop 0.24.0__tar.gz → 0.26.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. {adloop-0.24.0 → adloop-0.26.0}/PKG-INFO +5 -3
  2. {adloop-0.24.0 → adloop-0.26.0}/README.md +4 -2
  3. {adloop-0.24.0 → adloop-0.26.0}/pyproject.toml +1 -1
  4. {adloop-0.24.0 → adloop-0.26.0}/pyproject.toml.orig +1 -1
  5. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ads/conversion_actions.py +295 -16
  6. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ads/write.py +38 -7
  7. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/reddit/read.py +123 -1
  8. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/reddit/write.py +446 -18
  9. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/rules/adloop.md +29 -4
  10. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/server.py +284 -27
  11. adloop-0.26.0/src/adloop/upload_card.html +247 -0
  12. adloop-0.26.0/src/adloop/uploads.py +286 -0
  13. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/__init__.py +0 -0
  14. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/__main__.py +0 -0
  15. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/_mcp_patches.py +0 -0
  16. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ads/__init__.py +0 -0
  17. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ads/ai_max.py +0 -0
  18. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ads/brands.py +0 -0
  19. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ads/client.py +0 -0
  20. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ads/currency.py +0 -0
  21. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ads/custom_conversion_goals.py +0 -0
  22. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ads/enums.py +0 -0
  23. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ads/forecast.py +0 -0
  24. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ads/gaql.py +0 -0
  25. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ads/image_fetch.py +0 -0
  26. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ads/pmax.py +0 -0
  27. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ads/read.py +0 -0
  28. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ads/validate_only.py +0 -0
  29. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/auth.py +0 -0
  30. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/cli.py +0 -0
  31. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/config.py +0 -0
  32. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/crossref.py +0 -0
  33. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/diagnostics.py +0 -0
  34. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ga4/__init__.py +0 -0
  35. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ga4/client.py +0 -0
  36. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ga4/reports.py +0 -0
  37. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ga4/tracking.py +0 -0
  38. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/ga4/write.py +0 -0
  39. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/gsc/__init__.py +0 -0
  40. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/gsc/client.py +0 -0
  41. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/gsc/reports.py +0 -0
  42. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/gtm/__init__.py +0 -0
  43. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/gtm/client.py +0 -0
  44. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/gtm/entities.py +0 -0
  45. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/gtm/read.py +0 -0
  46. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/gtm/write.py +0 -0
  47. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/merchant/__init__.py +0 -0
  48. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/merchant/client.py +0 -0
  49. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/merchant/read.py +0 -0
  50. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/pagespeed.py +0 -0
  51. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/reddit/__init__.py +0 -0
  52. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/reddit/auth.py +0 -0
  53. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/reddit/client.py +0 -0
  54. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/reddit/schedule.py +0 -0
  55. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/rules/__init__.py +0 -0
  56. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
  57. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/rules/commands/budget-plan.md +0 -0
  58. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/rules/commands/create-ad.md +0 -0
  59. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/rules/commands/create-campaign.md +0 -0
  60. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
  61. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
  62. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/rules_install.py +0 -0
  63. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/runtime.py +0 -0
  64. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/safety/__init__.py +0 -0
  65. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/safety/approval.py +0 -0
  66. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/safety/approval_card.html +0 -0
  67. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/safety/audit.py +0 -0
  68. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/safety/guards.py +0 -0
  69. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/safety/preview.py +0 -0
  70. {adloop-0.24.0 → adloop-0.26.0}/src/adloop/tracking.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.24.0
3
+ Version: 0.26.0
4
4
  Summary: The AI command center for Google Ads, Reddit Ads, GA4, and tracking code.
5
5
  Keywords: mcp,google-ads,reddit-ads,google-analytics,ga4,cursor,marketing
6
6
  Author: Daniel Klose
@@ -251,12 +251,14 @@ A second ad platform, same safety model. Reddit is a separate connection: its ow
251
251
  | `get_reddit_performance` | Spend, clicks, CTR, CPC, conversions, CPA, ROAS per account/campaign/ad group/ad, optional breakdown (date, country, community, keyword, placement, …), compact mode with insights |
252
252
  | `run_reddit_report` | Raw reports endpoint for any metric (REACH, video, per-event conversions) |
253
253
  | `get_reddit_pixels` | Pixels and when each event last fired — flags ad groups optimizing for events the pixel never sent |
254
+ | `get_reddit_audiences` | Custom and saved audiences with status, size and source, plus the ad groups that target or exclude each |
254
255
  | `search_reddit_targeting` | Communities, interests, geolocations, languages, keyword suggestions, and Reddit's related-community suggestions (by seed communities or website) |
255
256
  | `get_reddit_account_history` | Who changed what and when: field, before/after, member |
256
257
  | `estimate_reddit_ad_group` | Audience size, delivery estimate and suggested bid range for a planned ad group (Reddit's counterpart of `estimate_budget`) |
257
258
  | `pause_reddit_entity` / `enable_reddit_entity` / `remove_reddit_entity` | Status changes through the preview gate (remove = ARCHIVE, irreversible, double-confirmed) |
258
- | `update_reddit_campaign` / `update_reddit_ad_group` / `update_reddit_ad` | Budget, bid, run dates, weekly schedule (day names, viewer-local hours), targeting and placements (validated with Reddit at draft time), landing URL and comment changes with old → new previews, budget cap and bid-increase guards |
259
- | `draft_reddit_campaign` / `draft_reddit_ad_group` / `draft_reddit_ad` | Create campaign → ad group (pixel + targeting required) → post + ad, or promote an existing post with `post_id`. Everything is created **PAUSED**. |
259
+ | `update_reddit_campaign` / `update_reddit_ad_group` / `update_reddit_ad` | Budget, bid, run dates, weekly schedule (day names, viewer-local hours), targeting, placements and audiences to target or exclude (validated with Reddit at draft time), landing URL and comment changes with old → new previews, budget cap and bid-increase guards |
260
+ | `draft_reddit_campaign` / `draft_reddit_ad_group` / `draft_reddit_ad` | Create campaign → ad group (pixel + targeting required) → post + ad (text, image, video or a 2–6 card carousel, media by URL), or promote an existing post with `post_id`. Everything is created **PAUSED**. |
261
+ | `draft_reddit_custom_audience` | Retargeting audiences from website visitors (pixel events) or people who engaged with your campaigns. No customer-list uploads. |
260
262
 
261
263
  > **Setup for Reddit Ads tools** — In Reddit Ads Manager open **Business Manager → Developer Application → Create app** (business admins only; no approval wait). Register the redirect URL exactly as `http://localhost:8765/callback`, then run `adloop init` and complete the Reddit Ads step: it opens Reddit's consent page (scopes `adsread` + `adsedit`, permanent grant), stores the refresh token at `~/.adloop/reddit_token.json`, and lets you pick the default ad account. Reddit rate-limits per user (reporting: 60 requests/min) and requires a descriptive User-Agent, which AdLoop builds from your app id and Reddit username. Reddit has no validate-only mode, so `confirm_and_apply(dry_run=true)` re-reads the target and re-checks the safety caps instead.
262
264
 
@@ -223,12 +223,14 @@ A second ad platform, same safety model. Reddit is a separate connection: its ow
223
223
  | `get_reddit_performance` | Spend, clicks, CTR, CPC, conversions, CPA, ROAS per account/campaign/ad group/ad, optional breakdown (date, country, community, keyword, placement, …), compact mode with insights |
224
224
  | `run_reddit_report` | Raw reports endpoint for any metric (REACH, video, per-event conversions) |
225
225
  | `get_reddit_pixels` | Pixels and when each event last fired — flags ad groups optimizing for events the pixel never sent |
226
+ | `get_reddit_audiences` | Custom and saved audiences with status, size and source, plus the ad groups that target or exclude each |
226
227
  | `search_reddit_targeting` | Communities, interests, geolocations, languages, keyword suggestions, and Reddit's related-community suggestions (by seed communities or website) |
227
228
  | `get_reddit_account_history` | Who changed what and when: field, before/after, member |
228
229
  | `estimate_reddit_ad_group` | Audience size, delivery estimate and suggested bid range for a planned ad group (Reddit's counterpart of `estimate_budget`) |
229
230
  | `pause_reddit_entity` / `enable_reddit_entity` / `remove_reddit_entity` | Status changes through the preview gate (remove = ARCHIVE, irreversible, double-confirmed) |
230
- | `update_reddit_campaign` / `update_reddit_ad_group` / `update_reddit_ad` | Budget, bid, run dates, weekly schedule (day names, viewer-local hours), targeting and placements (validated with Reddit at draft time), landing URL and comment changes with old → new previews, budget cap and bid-increase guards |
231
- | `draft_reddit_campaign` / `draft_reddit_ad_group` / `draft_reddit_ad` | Create campaign → ad group (pixel + targeting required) → post + ad, or promote an existing post with `post_id`. Everything is created **PAUSED**. |
231
+ | `update_reddit_campaign` / `update_reddit_ad_group` / `update_reddit_ad` | Budget, bid, run dates, weekly schedule (day names, viewer-local hours), targeting, placements and audiences to target or exclude (validated with Reddit at draft time), landing URL and comment changes with old → new previews, budget cap and bid-increase guards |
232
+ | `draft_reddit_campaign` / `draft_reddit_ad_group` / `draft_reddit_ad` | Create campaign → ad group (pixel + targeting required) → post + ad (text, image, video or a 2–6 card carousel, media by URL), or promote an existing post with `post_id`. Everything is created **PAUSED**. |
233
+ | `draft_reddit_custom_audience` | Retargeting audiences from website visitors (pixel events) or people who engaged with your campaigns. No customer-list uploads. |
232
234
 
233
235
  > **Setup for Reddit Ads tools** — In Reddit Ads Manager open **Business Manager → Developer Application → Create app** (business admins only; no approval wait). Register the redirect URL exactly as `http://localhost:8765/callback`, then run `adloop init` and complete the Reddit Ads step: it opens Reddit's consent page (scopes `adsread` + `adsedit`, permanent grant), stores the refresh token at `~/.adloop/reddit_token.json`, and lets you pick the default ad account. Reddit rate-limits per user (reporting: 60 requests/min) and requires a descriptive User-Agent, which AdLoop builds from your app id and Reddit username. Reddit has no validate-only mode, so `confirm_and_apply(dry_run=true)` re-reads the target and re-checks the safety caps instead.
234
236
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.24.0"
3
+ version = "0.26.0"
4
4
  description = "The AI command center for Google Ads, Reddit Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.24.0"
3
+ version = "0.26.0"
4
4
  description = "The AI command center for Google Ads, Reddit Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -26,7 +26,7 @@ import math
26
26
  import re
27
27
  import threading
28
28
  from datetime import datetime, timezone as _tz
29
- from typing import TYPE_CHECKING
29
+ from typing import TYPE_CHECKING, Callable
30
30
 
31
31
  from adloop.ads.enums import enum_names
32
32
 
@@ -685,13 +685,21 @@ def _gaql_escape(s: str) -> str:
685
685
  return s.replace("\\", "\\\\").replace("'", "\\'")
686
686
 
687
687
 
688
+ # The consent values a caller may send. Google's ConsentStatus enum also has
689
+ # UNKNOWN, but that one reports a state the API determined, not one a caller
690
+ # declares, so it is not accepted here. Shared with the per-row columns so the
691
+ # two cannot drift apart.
692
+ _CONSENT_VALUES = frozenset({"GRANTED", "DENIED", "UNSPECIFIED"})
693
+
694
+
688
695
  def _consent_from_param(consent: dict | None) -> dict | None:
689
696
  """Validate + normalize the ``consent`` tool parameter.
690
697
 
691
698
  Accepts ``{"ad_user_data": "GRANTED"|"DENIED"|"UNSPECIFIED",
692
- "ad_personalization": ...}``. ``UNKNOWN`` is a legal enum value too.
693
- Missing keys default to UNSPECIFIED. Returns a plain dict stored in the
694
- plan (JSON-safe, non-PII), or None if no consent was supplied at all.
699
+ "ad_personalization": ...}``. Missing keys default to UNSPECIFIED. Returns
700
+ a plain dict stored in the plan (JSON-safe, non-PII), or None if no consent
701
+ was supplied at all. ``UNKNOWN`` is refused: the enum has it, but it
702
+ reports what Google determined, not something a caller declares.
695
703
 
696
704
  An unknown key is an error rather than a silent no-op: ``adPersonalization``
697
705
  (camelCase) would otherwise leave the field at UNSPECIFIED, which for EEA
@@ -711,19 +719,117 @@ def _consent_from_param(consent: dict | None) -> dict | None:
711
719
  f"consent has unknown key(s): {', '.join(unknown)}. Use only "
712
720
  "ad_user_data and ad_personalization."
713
721
  )
714
- valid = {"UNSPECIFIED", "UNKNOWN", "GRANTED", "DENIED"}
715
722
  out: dict[str, str] = {}
716
723
  for key in known:
717
724
  raw = str(consent.get(key, "UNSPECIFIED") or "UNSPECIFIED").upper()
718
- if raw not in valid:
725
+ if raw not in _CONSENT_VALUES:
719
726
  raise ValueError(
720
727
  f"consent.{key}='{raw}' is invalid. Use one of: "
721
- "GRANTED, DENIED, UNSPECIFIED, UNKNOWN."
728
+ "GRANTED, DENIED, UNSPECIFIED."
722
729
  )
723
730
  out[key] = raw
724
731
  return out
725
732
 
726
733
 
734
+ # Google's own template carries consent per person, not per file: Consent is a
735
+ # field on each Conversion, so a CRM export that mixes people who opted in with
736
+ # people who did not can say so row by row. The spelling is Google's.
737
+ _CONSENT_HEADERS = {
738
+ "Ad User Data": "ad_user_data",
739
+ "Ad Personalization": "ad_personalization",
740
+ }
741
+ def _row_consent(
742
+ raw: list[str], optional_col: dict[str, int]
743
+ ) -> tuple[dict[str, str] | None, str]:
744
+ """The consent one CSV row specifies: ``(consent, problem)``.
745
+
746
+ The dict holds only the fields that row actually filled in, so the
747
+ file-wide ``consent`` argument stays in charge of the rest. An unusable
748
+ value is reported without echoing the cell — errors never carry file
749
+ content.
750
+ """
751
+ out: dict[str, str] = {}
752
+ for header, key in _CONSENT_HEADERS.items():
753
+ index = optional_col.get(header)
754
+ if index is None:
755
+ continue
756
+ value = (raw[index] or "").strip().upper()
757
+ if not value:
758
+ continue
759
+ if value not in _CONSENT_VALUES:
760
+ return None, (
761
+ f"{header} is not one of GRANTED, DENIED or UNSPECIFIED"
762
+ )
763
+ out[key] = value
764
+ return (out or None), ""
765
+
766
+
767
+ def _effective_consent(
768
+ row_consent: dict | None, default: dict | None
769
+ ) -> dict | None:
770
+ """What one row uploads with: its own cells over the file-wide default.
771
+
772
+ Filling the remaining field with UNSPECIFIED keeps each stored row readable
773
+ on its own; ``_apply_consent`` would default it that way anyway.
774
+ """
775
+ if not row_consent and not default:
776
+ return None
777
+ merged = dict(default or {})
778
+ merged.update(row_consent or {})
779
+ for key in ("ad_user_data", "ad_personalization"):
780
+ merged.setdefault(key, "UNSPECIFIED")
781
+ return merged
782
+
783
+
784
+ def _consent_summary(
785
+ rows: list[dict], default: dict | None
786
+ ) -> dict[str, dict[str, int]]:
787
+ """How many uploaded rows carry each consent value, per field.
788
+
789
+ The preview is where an upload gets approved, and the plan-level ``consent``
790
+ is only the fallback for rows without their own: a file whose rows say
791
+ DENIED must not preview as GRANTED because that is the argument.
792
+ """
793
+ summary: dict[str, dict[str, int]] = {
794
+ "ad_user_data": {}, "ad_personalization": {},
795
+ }
796
+ for row in rows:
797
+ values = _effective_consent(row.get("consent"), default) or {}
798
+ for key in summary:
799
+ value = values.get(key, "UNSPECIFIED")
800
+ summary[key][value] = summary[key].get(value, 0) + 1
801
+ return summary
802
+
803
+
804
+ def _consent_warnings(
805
+ rows: list[dict], *, columns_present: bool, default: dict | None
806
+ ) -> list[str]:
807
+ """Rows that end up UNSPECIFIED even though the file tried to carry consent.
808
+
809
+ Google uses UNSPECIFIED conversions only in a limited way for EEA traffic,
810
+ and empty consent cells still look like the file says something. A file
811
+ without consent columns stays quiet: that is today's behaviour, and a
812
+ warning on every such file would only train callers to ignore it.
813
+ """
814
+ if not columns_present:
815
+ return []
816
+ unspecified = 0
817
+ for row in rows:
818
+ values = _effective_consent(row.get("consent"), default) or {}
819
+ if any(
820
+ values.get(key, "UNSPECIFIED") == "UNSPECIFIED"
821
+ for key in ("ad_user_data", "ad_personalization")
822
+ ):
823
+ unspecified += 1
824
+ if not unspecified:
825
+ return []
826
+ return [
827
+ f"{unspecified} of {len(rows)} row(s) upload as UNSPECIFIED for "
828
+ "consent: their consent cells are empty or only partly filled. Google "
829
+ "uses UNSPECIFIED conversions only in a limited way for EEA traffic."
830
+ ]
831
+
832
+
727
833
  def _apply_consent(client: object, conversion: object, consent: dict | None) -> None:
728
834
  """Set conversion.consent.{ad_user_data,ad_personalization} from a plan dict.
729
835
 
@@ -1032,6 +1138,24 @@ def _short_row(raw: list[str], col: dict[str, int]) -> bool:
1032
1138
  return len(raw) <= max(col.values())
1033
1139
 
1034
1140
 
1141
+ def _optional_column_indexes(
1142
+ header: list[str], names: list[str]
1143
+ ) -> dict[str, int]:
1144
+ """Indexes of optional columns, matched case-insensitively.
1145
+
1146
+ The required columns come from Google's template verbatim. Optional ones
1147
+ are typed by hand into exports often enough that "Order ID" versus
1148
+ "order id" should not decide whether the file has a dedup key.
1149
+ """
1150
+ folded = [cell.casefold() for cell in header]
1151
+ found: dict[str, int] = {}
1152
+ for name in names:
1153
+ key = name.casefold()
1154
+ if key in folded:
1155
+ found[name] = folded.index(key)
1156
+ return found
1157
+
1158
+
1035
1159
  def _pad_to_header(raw: list[str], columns: int) -> list[str]:
1036
1160
  """Fill missing trailing cells with "" so every header index exists."""
1037
1161
  if len(raw) >= columns:
@@ -1067,6 +1191,9 @@ def _parse_call_conversion_csv(
1067
1191
  col, errors = _column_map(header, _EXPECTED_CALL_HEADERS)
1068
1192
  if errors:
1069
1193
  return [], errors, [], []
1194
+ optional_col = _optional_column_indexes(
1195
+ [cell.strip() for cell in header], list(_CONSENT_HEADERS)
1196
+ )
1070
1197
 
1071
1198
  now = datetime.now(_tz.utc)
1072
1199
  out: list[dict] = []
@@ -1083,6 +1210,10 @@ def _parse_call_conversion_csv(
1083
1210
  })
1084
1211
  continue
1085
1212
  raw = _pad_to_header(raw, len(header))
1213
+ row_consent, problem = _row_consent(raw, optional_col)
1214
+ if problem:
1215
+ skipped.append({"row": source_line, "reason": problem})
1216
+ continue
1086
1217
  value, currency, problem = _parse_amount(
1087
1218
  raw[col["Conversion Value"]], raw[col["Conversion Currency"]]
1088
1219
  )
@@ -1137,6 +1268,8 @@ def _parse_call_conversion_csv(
1137
1268
  "conversion_time": converted,
1138
1269
  "conversion_value": value,
1139
1270
  "currency_code": currency,
1271
+ "consent": row_consent,
1272
+ "consent_columns_present": bool(optional_col),
1140
1273
  })
1141
1274
  return out, errors, advisories, skipped
1142
1275
 
@@ -1203,7 +1336,9 @@ def draft_upload_call_conversions(
1203
1336
  ``consent`` (GDPR/EEA): a dict like
1204
1337
  ``{"ad_user_data": "GRANTED", "ad_personalization": "DENIED"}``. Values:
1205
1338
  GRANTED / DENIED / UNSPECIFIED. Required for EEA traffic. Defaults to
1206
- UNSPECIFIED when omitted.
1339
+ UNSPECIFIED when omitted. Optional ``Ad User Data`` /
1340
+ ``Ad Personalization`` CSV columns say it per row and override this value
1341
+ for the row that fills them in.
1207
1342
 
1208
1343
  PII note: the caller phone number is required raw by Google for matching,
1209
1344
  so the rows live in the plan's ``apply_only_payload`` — apply needs them,
@@ -1257,6 +1392,12 @@ def draft_upload_call_conversions(
1257
1392
  }
1258
1393
  rows = usable
1259
1394
 
1395
+ consent_warnings = _consent_warnings(
1396
+ rows,
1397
+ columns_present=any(r.get("consent_columns_present") for r in rows),
1398
+ default=consent_norm,
1399
+ )
1400
+ dedup_warnings = _duplicate_row_warnings(rows, _call_duplicate_key, "call")
1260
1401
  distinct_actions = sorted({r["conversion_name"] for r in rows})
1261
1402
  total_value, totals_by_currency, currency_hint, value_warnings = (
1262
1403
  _value_summary(rows)
@@ -1293,6 +1434,7 @@ def draft_upload_call_conversions(
1293
1434
  "conversion_time": r["conversion_time"],
1294
1435
  "conversion_value": r["conversion_value"],
1295
1436
  "currency_code": r["currency_code"],
1437
+ "consent": _effective_consent(r.get("consent"), consent_norm),
1296
1438
  }
1297
1439
  for r in rows
1298
1440
  ]
@@ -1329,7 +1471,11 @@ def draft_upload_call_conversions(
1329
1471
  # Resolved at draft time; apply reads them instead of querying again.
1330
1472
  "conversion_actions": action_resources,
1331
1473
  "consent": consent_norm,
1332
- "parse_warnings": parse_advisories,
1474
+ # What the rows actually upload with; ``consent`` alone is only the
1475
+ # fallback and would misrepresent a file that sets it per row.
1476
+ "consent_summary": _consent_summary(rows, consent_norm),
1477
+ "parse_warnings": [*parse_advisories, *consent_warnings],
1478
+ "dedup_warnings": dedup_warnings,
1333
1479
  # Display sample uses REDACTED caller ids only.
1334
1480
  "sample_rows": [
1335
1481
  {
@@ -1337,6 +1483,7 @@ def draft_upload_call_conversions(
1337
1483
  "call_start_time": r["call_start_time"],
1338
1484
  "conversion_name": r["conversion_name"],
1339
1485
  "conversion_value": r["conversion_value"],
1486
+ "consent": _effective_consent(r.get("consent"), consent_norm),
1340
1487
  }
1341
1488
  for r in rows[:3]
1342
1489
  ],
@@ -1461,6 +1608,106 @@ def _value_summary(
1461
1608
  return total, by_currency, hint, warnings
1462
1609
 
1463
1610
 
1611
+ def _join_source_lines(lines: list[object]) -> str:
1612
+ """``4``, ``4 and 9``, ``4, 9 and 12`` — in reading order."""
1613
+
1614
+ def order(line: object) -> tuple:
1615
+ # Line numbers are ints; a hand-built plan may carry none. Sorting the
1616
+ # mixed list by ``str`` alone would put line 10 in front of line 9.
1617
+ return (0, line, "") if isinstance(line, int) else (1, 0, str(line))
1618
+
1619
+ parts = [str(line) for line in sorted(lines, key=order)]
1620
+ if len(parts) == 1:
1621
+ return parts[0]
1622
+ return f"{', '.join(parts[:-1])} and {parts[-1]}"
1623
+
1624
+
1625
+ def _duplicate_row_warnings(
1626
+ rows: list[dict],
1627
+ key_of: Callable[[dict], tuple | None],
1628
+ what: str,
1629
+ order_id_of: Callable[[dict], str] | None = None,
1630
+ ) -> list[str]:
1631
+ """Rows that repeat inside one CSV — reported, not refused.
1632
+
1633
+ Google dedups a re-upload on the Order ID, and a call upload has no dedup
1634
+ key at all, so the same record twice in one file goes out twice and nothing
1635
+ in the result says so. Legitimate repetition exists (two calls from the
1636
+ same number, the same second), so the rows are named and the caller decides
1637
+ in the preview.
1638
+
1639
+ ``order_id_of`` splits the wording: rows that share an Order ID collapse
1640
+ into one conversion at Google, the others add up.
1641
+ """
1642
+ seen: dict[tuple, list[tuple[object, dict]]] = {}
1643
+ for index, row in enumerate(rows, start=1):
1644
+ key = key_of(row)
1645
+ if key:
1646
+ seen.setdefault(key, []).append((_source_line(row, index), row))
1647
+ warnings: list[str] = []
1648
+ for group in seen.values():
1649
+ if len(group) < 2:
1650
+ continue
1651
+ lines = [line for line, _ in group]
1652
+ order_id = order_id_of(group[0][1]) if order_id_of else ""
1653
+ if order_id:
1654
+ warnings.append(
1655
+ f"{len(group)} rows look like the same {what} and carry the "
1656
+ f"same Order ID: lines {_join_source_lines(lines)}. Google "
1657
+ "dedups on the conversion action and that Order ID, so they "
1658
+ "count once."
1659
+ )
1660
+ continue
1661
+ warnings.append(
1662
+ f"{len(group)} rows look like the same {what}: lines "
1663
+ f"{_join_source_lines(lines)}. Every row is uploaded as its own "
1664
+ f"conversion, so the record is counted {len(group)} times — delete "
1665
+ "the extra rows if it is one copy-paste accident."
1666
+ )
1667
+ return warnings
1668
+
1669
+
1670
+ def _call_duplicate_key(row: dict) -> tuple | None:
1671
+ """What identifies a call: the number and the start of the call.
1672
+
1673
+ The value stays out of the key on purpose: two rows for the same call with
1674
+ different values are still almost certainly one call exported twice.
1675
+ """
1676
+ caller = row.get("caller_id") or ""
1677
+ if not caller:
1678
+ return None
1679
+ return (caller, row.get("call_start_time") or "")
1680
+
1681
+
1682
+ def _ec_duplicate_key(row: dict) -> tuple | None:
1683
+ """What identifies a lead: the strongest identifier the row carries.
1684
+
1685
+ A row that only matched on its address is compared by that address, not
1686
+ reported as a duplicate of every other row without an email. The Order ID
1687
+ is part of the key whenever the row has one: two orders for the same lead
1688
+ at the same (often date-only) time are two real orders, not one.
1689
+ """
1690
+ identifier: object = row.get("email_sha256") or row.get("phone_sha256")
1691
+ if not identifier and _has_complete_address(row):
1692
+ identifier = (
1693
+ row["first_name_sha256"],
1694
+ row["last_name_sha256"],
1695
+ row["country_code"],
1696
+ row["postal_code"],
1697
+ )
1698
+ if not identifier:
1699
+ return None
1700
+ return (
1701
+ identifier,
1702
+ row.get("conversion_time") or "",
1703
+ row.get("order_id") or "",
1704
+ )
1705
+
1706
+
1707
+ def _row_order_id(row: dict) -> str:
1708
+ return str(row.get("order_id") or "")
1709
+
1710
+
1464
1711
  def _upload_draft_preflight(
1465
1712
  config: AdLoopConfig,
1466
1713
  *,
@@ -1954,7 +2201,10 @@ def _apply_upload_call_conversions(
1954
2201
  cc.conversion_value = float(r["conversion_value"])
1955
2202
  if r.get("currency_code"):
1956
2203
  cc.currency_code = r["currency_code"]
1957
- _apply_consent(client, cc, consent)
2204
+ # The row's own value wins; the file-wide argument is the fallback
2205
+ # for rows that left the consent columns empty (and for plans
2206
+ # drafted before the columns existed).
2207
+ _apply_consent(client, cc, r.get("consent") or consent)
1958
2208
  payload.append(cc)
1959
2209
  return payload
1960
2210
 
@@ -2020,8 +2270,10 @@ def _parse_ec_for_leads_csv(
2020
2270
  Conversion Name, Conversion Time, Conversion Value, Conversion Currency.
2021
2271
  Optional: Order ID (Google's dedup key — strongly recommended so
2022
2272
  re-uploads of the same source row don't double-count), Country Code,
2023
- Postal Code. Returns ``(rows, errors, advisories, skipped)``: ``errors``
2024
- are file-level problems, ``advisories`` describe rows that still upload, and
2273
+ Postal Code, and Google's per-person consent columns ``Ad User Data`` /
2274
+ ``Ad Personalization``. Returns ``(rows, errors, advisories, skipped)``:
2275
+ ``errors`` are file-level problems, ``advisories`` describe rows that still
2276
+ upload, and
2025
2277
  ``skipped`` lists every row that will not be uploaded with its
2026
2278
  ``source_line``. A record that ends before the last required column is
2027
2279
  skipped instead of aborting the draft.
@@ -2041,7 +2293,9 @@ def _parse_ec_for_leads_csv(
2041
2293
  if errors:
2042
2294
  return [], errors, [], []
2043
2295
  header = [cell.strip() for cell in raw_header]
2044
- optional_col = {n: header.index(n) for n in _OPTIONAL_EC_HEADERS if n in header}
2296
+ optional_col = _optional_column_indexes(
2297
+ header, [*_OPTIONAL_EC_HEADERS, *_CONSENT_HEADERS]
2298
+ )
2045
2299
 
2046
2300
  out: list[dict] = []
2047
2301
  skipped: list[dict] = []
@@ -2060,6 +2314,10 @@ def _parse_ec_for_leads_csv(
2060
2314
  # the header promises exists (Country Code and Postal Code used to
2061
2315
  # raise IndexError here).
2062
2316
  raw = _pad_to_header(raw, len(header))
2317
+ row_consent, problem = _row_consent(raw, optional_col)
2318
+ if problem:
2319
+ skipped.append({"row": source_line, "reason": problem})
2320
+ continue
2063
2321
  value, currency, problem = _parse_amount(
2064
2322
  raw[col["Conversion Value"]], raw[col["Conversion Currency"]]
2065
2323
  )
@@ -2136,6 +2394,10 @@ def _parse_ec_for_leads_csv(
2136
2394
  "conversion_value": value,
2137
2395
  "currency_code": currency,
2138
2396
  "order_id": order_id,
2397
+ "consent": row_consent,
2398
+ "consent_columns_present": any(
2399
+ header in optional_col for header in _CONSENT_HEADERS
2400
+ ),
2139
2401
  })
2140
2402
  return out, errors, advisories, skipped
2141
2403
 
@@ -2226,7 +2488,9 @@ def draft_upload_enhanced_conversions_for_leads(
2226
2488
  ``consent`` (GDPR/EEA): a dict like
2227
2489
  ``{"ad_user_data": "GRANTED", "ad_personalization": "DENIED"}``. Values:
2228
2490
  GRANTED / DENIED / UNSPECIFIED. Required for EEA traffic. Defaults to
2229
- UNSPECIFIED when omitted.
2491
+ UNSPECIFIED when omitted. Optional ``Ad User Data`` /
2492
+ ``Ad Personalization`` CSV columns say it per row and override this value
2493
+ for the row that fills them in.
2230
2494
 
2231
2495
  Call confirm_and_apply with the returned plan_id to execute.
2232
2496
  """
@@ -2304,6 +2568,11 @@ def draft_upload_enhanced_conversions_for_leads(
2304
2568
  }
2305
2569
  rows = usable
2306
2570
 
2571
+ consent_warnings = _consent_warnings(
2572
+ rows,
2573
+ columns_present=any(r.get("consent_columns_present") for r in rows),
2574
+ default=consent_norm,
2575
+ )
2307
2576
  distinct_actions = sorted({r["conversion_name"] for r in rows})
2308
2577
  total_value, totals_by_currency, currency_hint, value_warnings = (
2309
2578
  _value_summary(rows)
@@ -2369,6 +2638,11 @@ def draft_upload_enhanced_conversions_for_leads(
2369
2638
  f"Only {with_order_id} of {len(rows)} rows have an Order ID. "
2370
2639
  "Rows without one will double-count on re-upload."
2371
2640
  )
2641
+ dedup_warnings.extend(
2642
+ _duplicate_row_warnings(
2643
+ rows, _ec_duplicate_key, "lead", order_id_of=_row_order_id
2644
+ )
2645
+ )
2372
2646
 
2373
2647
  # Freeze the hashed rows apply will upload. These are already SHA-256
2374
2648
  # hashes — NO raw PII. Safe to persist in the plan and (order_id/value/
@@ -2387,6 +2661,7 @@ def draft_upload_enhanced_conversions_for_leads(
2387
2661
  "conversion_value": r["conversion_value"],
2388
2662
  "currency_code": r["currency_code"],
2389
2663
  "order_id": r.get("order_id", ""),
2664
+ "consent": _effective_consent(r.get("consent"), consent_norm),
2390
2665
  }
2391
2666
  for r in rows
2392
2667
  ]
@@ -2425,7 +2700,10 @@ def draft_upload_enhanced_conversions_for_leads(
2425
2700
  # Resolved at draft time; apply reads them instead of querying again.
2426
2701
  "conversion_actions": action_resources,
2427
2702
  "consent": consent_norm,
2428
- "parse_warnings": parse_advisories,
2703
+ # What the rows actually upload with; ``consent`` alone is only the
2704
+ # fallback and would misrepresent a file that sets it per row.
2705
+ "consent_summary": _consent_summary(rows, consent_norm),
2706
+ "parse_warnings": [*parse_advisories, *consent_warnings],
2429
2707
  "dedup_warnings": dedup_warnings,
2430
2708
  "match_warnings": _match_warnings(
2431
2709
  names_without_address,
@@ -2447,6 +2725,7 @@ def draft_upload_enhanced_conversions_for_leads(
2447
2725
  "conversion_value": r["conversion_value"],
2448
2726
  "conversion_time": r["conversion_time"],
2449
2727
  "order_id": r.get("order_id", ""),
2728
+ "consent": _effective_consent(r.get("consent"), consent_norm),
2450
2729
  }
2451
2730
  for r in rows[:3]
2452
2731
  ],
@@ -2484,7 +2763,7 @@ def _apply_upload_enhanced_conversions_for_leads(
2484
2763
  cc.currency_code = r["currency_code"]
2485
2764
  if r.get("order_id"):
2486
2765
  cc.order_id = r["order_id"]
2487
- _apply_consent(client, cc, consent)
2766
+ _apply_consent(client, cc, r.get("consent") or consent)
2488
2767
 
2489
2768
  # Hashed identifiers only; Google matches them to logged-in users
2490
2769
  # who clicked the ads. A row with no usable identifier never gets
@@ -432,7 +432,7 @@ def _size_kb(data: bytes) -> float:
432
432
 
433
433
  def _describe_image(image: dict[str, object]) -> str:
434
434
  """One preview line: source, type, dimensions and size."""
435
- source = image.get("url") or image.get("path")
435
+ source = image.get("url") or image.get("path") or f"upload {image.get('upload_id')}"
436
436
  kind = str(image.get("mime_type", "")).removeprefix("image/").upper()
437
437
  return (
438
438
  f"{source} — {kind} {image.get('width')}x{image.get('height')}, "
@@ -2457,12 +2457,14 @@ def draft_image_assets(
2457
2457
  campaign_id: str = "",
2458
2458
  image_paths: list[str] | None = None,
2459
2459
  image_urls: list[str] | None = None,
2460
+ upload_ids: list[str] | None = None,
2460
2461
  ) -> dict:
2461
- """Draft campaign image assets from local files and/or public image URLs.
2462
+ """Draft campaign image assets from local files, public image URLs and/or uploads.
2462
2463
 
2463
2464
  URL images are fetched now (public addresses only, pinned against DNS
2464
2465
  rebinding, capped at Google's 5120 KB limit) and their sha256 is kept
2465
- in the plan; apply re-fetches and refuses if the bytes changed.
2466
+ in the plan; apply re-fetches and refuses if the bytes changed. Uploads
2467
+ (``up_...`` ids from the upload card or link) are pinned the same way.
2466
2468
  """
2467
2469
  from adloop.runtime import deployment_mode
2468
2470
  from adloop.safety.guards import SafetyViolation, check_blocked_operation
@@ -2486,7 +2488,7 @@ def draft_image_assets(
2486
2488
  return {"error": str(e)}
2487
2489
 
2488
2490
  validated_images, errors = _validate_image_assets(
2489
- campaign_id, image_paths, image_urls
2491
+ campaign_id, image_paths, image_urls, list(upload_ids or [])
2490
2492
  )
2491
2493
  if errors:
2492
2494
  return {"error": "Validation failed", "details": errors}
@@ -3300,16 +3302,38 @@ def _validate_structured_snippets(
3300
3302
 
3301
3303
 
3302
3304
  def _validate_image_assets(
3303
- campaign_id: str, image_paths: list[str], image_urls: list[str] | None = None
3305
+ campaign_id: str,
3306
+ image_paths: list[str],
3307
+ image_urls: list[str] | None = None,
3308
+ upload_ids: list[str] | None = None,
3304
3309
  ) -> tuple[list[dict[str, object]], list[str]]:
3305
3310
  errors = []
3306
3311
  validated = []
3307
3312
  image_urls = image_urls or []
3313
+ upload_ids = upload_ids or []
3308
3314
 
3309
3315
  if not campaign_id:
3310
3316
  errors.append("campaign_id is required")
3311
- if not image_paths and not image_urls:
3312
- errors.append("At least one image (image_paths or image_urls) is required")
3317
+ if not image_paths and not image_urls and not upload_ids:
3318
+ errors.append("At least one image (image_paths, image_urls or upload_ids) is required")
3319
+
3320
+ for index, upload_id in enumerate(upload_ids):
3321
+ from adloop.uploads import UploadError, current_tenant, get_upload_store
3322
+
3323
+ try:
3324
+ upload, _data = get_upload_store().get(current_tenant(), upload_id)
3325
+ except UploadError as exc:
3326
+ errors.append(f"Upload {index + 1}: {exc}")
3327
+ continue
3328
+ validated.append({
3329
+ "upload_id": upload.upload_id,
3330
+ "sha256": upload.sha256,
3331
+ "name": _build_image_asset_name(PurePosixPath(upload.name), _data),
3332
+ "mime_type": upload.mime_type,
3333
+ "width": upload.width,
3334
+ "height": upload.height,
3335
+ "size_kb": round(upload.size_bytes / 1024, 1),
3336
+ })
3313
3337
 
3314
3338
  for index, image_path in enumerate(image_paths):
3315
3339
  try:
@@ -4916,6 +4940,13 @@ def _apply_create_image_assets(client: object, cid: str, changes: dict) -> dict:
4916
4940
  if payload.get("url"):
4917
4941
  image_bytes = _fetch_approved_image_url(payload)
4918
4942
  name_source = _url_path(str(payload["url"]))
4943
+ elif payload.get("upload_id"):
4944
+ from adloop.uploads import current_tenant, get_upload_store
4945
+
4946
+ upload, image_bytes = get_upload_store().get(current_tenant(), str(payload["upload_id"]))
4947
+ if upload.sha256 != payload.get("sha256"):
4948
+ raise ValueError(f"upload {upload.upload_id} changed since the preview; draft again.")
4949
+ name_source = PurePosixPath(upload.name)
4919
4950
  elif deployment_mode() == "server":
4920
4951
  # Drafts refuse paths on a server; never read server files even
4921
4952
  # if a path-based plan reached apply some other way.