adloop 0.32.1__tar.gz → 0.33.1__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.
- {adloop-0.32.1 → adloop-0.33.1}/PKG-INFO +1 -1
- {adloop-0.32.1 → adloop-0.33.1}/pyproject.toml +1 -1
- {adloop-0.32.1 → adloop-0.33.1}/pyproject.toml.orig +1 -1
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/conversion_actions.py +97 -25
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/write.py +2 -2
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/rules/adloop.md +16 -1
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/safety/approval.py +69 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/safety/preview.py +5 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/server.py +76 -24
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/upload_card.html +26 -5
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/uploads.py +132 -34
- {adloop-0.32.1 → adloop-0.33.1}/README.md +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/__init__.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/__main__.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/_mcp_patches.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/ai_max.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/audiences.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/brands.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/client.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/currency.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/custom_conversion_goals.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/enums.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/forecast.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/image_fetch.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/read.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/shopping.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/validate_only.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/auth.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/cli.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/config.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/crossref.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/diagnostics.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ga4/client.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ga4/write.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/gsc/__init__.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/gsc/client.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/gsc/reports.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/gtm/__init__.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/gtm/client.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/gtm/entities.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/gtm/read.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/gtm/write.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/merchant/__init__.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/merchant/client.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/merchant/read.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/pagespeed.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/reddit/__init__.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/reddit/auth.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/reddit/client.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/reddit/read.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/reddit/schedule.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/reddit/write.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/rules/__init__.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/rules/commands/analyze-performance.md +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/rules/commands/budget-plan.md +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/rules/commands/create-ad.md +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/rules/commands/create-campaign.md +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/rules/commands/optimize-campaign.md +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/rules_install.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/runtime.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/safety/approval_card.html +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/safety/audit.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/safety/guards.py +0 -0
- {adloop-0.32.1 → adloop-0.33.1}/src/adloop/tracking.py +0 -0
|
@@ -25,6 +25,7 @@ import hashlib
|
|
|
25
25
|
import math
|
|
26
26
|
import re
|
|
27
27
|
import threading
|
|
28
|
+
from dataclasses import dataclass
|
|
28
29
|
from datetime import datetime, timezone as _tz
|
|
29
30
|
from typing import TYPE_CHECKING, Callable
|
|
30
31
|
|
|
@@ -1009,8 +1010,16 @@ _CSV_COMMENT_PREFIX = "#"
|
|
|
1009
1010
|
_MAX_CSV_BYTES = 5 * 1024 * 1024
|
|
1010
1011
|
|
|
1011
1012
|
|
|
1013
|
+
@dataclass(frozen=True)
|
|
1014
|
+
class UploadedCsv:
|
|
1015
|
+
"""A conversion CSV that arrived as an upload instead of a local path."""
|
|
1016
|
+
|
|
1017
|
+
name: str
|
|
1018
|
+
data: bytes
|
|
1019
|
+
|
|
1020
|
+
|
|
1012
1021
|
def _read_upload_csv(
|
|
1013
|
-
csv_path: str,
|
|
1022
|
+
csv_path: "str | UploadedCsv",
|
|
1014
1023
|
) -> tuple[list[tuple[int, list[str]]], list[str], str]:
|
|
1015
1024
|
"""Read an upload CSV into ``(source_line, cells)`` records (header first).
|
|
1016
1025
|
|
|
@@ -1020,8 +1029,9 @@ def _read_upload_csv(
|
|
|
1020
1029
|
Counting records instead would drift as soon as a comment or a bad row is
|
|
1021
1030
|
dropped.
|
|
1022
1031
|
|
|
1023
|
-
|
|
1024
|
-
|
|
1032
|
+
``csv_path`` is a path on the machine running AdLoop (refused in server
|
|
1033
|
+
mode before it gets here) or an :class:`UploadedCsv` from the upload
|
|
1034
|
+
store, which a hosted server reads instead. Errors name the file
|
|
1025
1035
|
and the schema, never file *content* — a hosted runtime can read files the
|
|
1026
1036
|
caller is not allowed to see.
|
|
1027
1037
|
"""
|
|
@@ -1036,15 +1046,21 @@ def _read_upload_csv(
|
|
|
1036
1046
|
# which is what timestamps without an offset are resolved against.
|
|
1037
1047
|
timezone = ""
|
|
1038
1048
|
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1049
|
+
import io
|
|
1050
|
+
|
|
1051
|
+
if isinstance(csv_path, UploadedCsv):
|
|
1052
|
+
path = None
|
|
1053
|
+
size = len(csv_path.data)
|
|
1054
|
+
else:
|
|
1055
|
+
path = Path(csv_path).expanduser()
|
|
1056
|
+
if not path.is_file():
|
|
1057
|
+
return [], [f"CSV not found or not a regular file: {path}"], ""
|
|
1058
|
+
if path.suffix.lower() != ".csv":
|
|
1059
|
+
return [], [f"CSV must be a .csv file, got: {path.name}"], ""
|
|
1060
|
+
try:
|
|
1061
|
+
size = path.stat().st_size
|
|
1062
|
+
except OSError as exc:
|
|
1063
|
+
return [], [f"CSV could not be read: {exc.strerror or exc}"], ""
|
|
1048
1064
|
if size > _MAX_CSV_BYTES:
|
|
1049
1065
|
return [], [
|
|
1050
1066
|
f"CSV is {size / 1_048_576:.1f} MB; the limit is "
|
|
@@ -1053,7 +1069,11 @@ def _read_upload_csv(
|
|
|
1053
1069
|
], ""
|
|
1054
1070
|
|
|
1055
1071
|
try:
|
|
1056
|
-
with
|
|
1072
|
+
with (
|
|
1073
|
+
io.StringIO(csv_path.data.decode("utf-8-sig"), newline="")
|
|
1074
|
+
if path is None
|
|
1075
|
+
else path.open("r", newline="", encoding="utf-8-sig")
|
|
1076
|
+
) as handle:
|
|
1057
1077
|
position = {"line": 0}
|
|
1058
1078
|
|
|
1059
1079
|
def _lines():
|
|
@@ -1164,7 +1184,7 @@ def _pad_to_header(raw: list[str], columns: int) -> list[str]:
|
|
|
1164
1184
|
|
|
1165
1185
|
|
|
1166
1186
|
def _parse_call_conversion_csv(
|
|
1167
|
-
csv_path: str, default_region: str = ""
|
|
1187
|
+
csv_path: "str | UploadedCsv", default_region: str = ""
|
|
1168
1188
|
) -> tuple[list[dict], list[str], list[str], list[dict]]:
|
|
1169
1189
|
"""Read the call-conversions CSV (local file) and normalize each row.
|
|
1170
1190
|
|
|
@@ -1310,7 +1330,8 @@ def draft_upload_call_conversions(
|
|
|
1310
1330
|
config: AdLoopConfig,
|
|
1311
1331
|
*,
|
|
1312
1332
|
customer_id: str = "",
|
|
1313
|
-
csv_path: str,
|
|
1333
|
+
csv_path: str = "",
|
|
1334
|
+
upload_id: str = "",
|
|
1314
1335
|
default_region: str = "",
|
|
1315
1336
|
consent: dict | None = None,
|
|
1316
1337
|
) -> dict:
|
|
@@ -1353,12 +1374,17 @@ def draft_upload_call_conversions(
|
|
|
1353
1374
|
operation="upload_call_conversions",
|
|
1354
1375
|
default_region=default_region,
|
|
1355
1376
|
consent=consent,
|
|
1377
|
+
csv_path=csv_path,
|
|
1378
|
+
upload_id=upload_id,
|
|
1356
1379
|
)
|
|
1380
|
+
if error:
|
|
1381
|
+
return error
|
|
1382
|
+
source, error = _csv_source(csv_path, upload_id)
|
|
1357
1383
|
if error:
|
|
1358
1384
|
return error
|
|
1359
1385
|
|
|
1360
1386
|
rows, parse_advisories, skipped, error = _upload_parse_result(
|
|
1361
|
-
*_parse_call_conversion_csv(
|
|
1387
|
+
*_parse_call_conversion_csv(source, default_region),
|
|
1362
1388
|
empty_message="CSV contained zero conversion rows",
|
|
1363
1389
|
)
|
|
1364
1390
|
if error:
|
|
@@ -1493,7 +1519,7 @@ def draft_upload_call_conversions(
|
|
|
1493
1519
|
apply_only_payload={"rows": frozen_rows},
|
|
1494
1520
|
)
|
|
1495
1521
|
store_plan(plan)
|
|
1496
|
-
return plan.to_preview()
|
|
1522
|
+
return _consume_upload(plan.to_preview(), upload_id)
|
|
1497
1523
|
|
|
1498
1524
|
|
|
1499
1525
|
def _resolve_upload_action(
|
|
@@ -1714,6 +1740,8 @@ def _upload_draft_preflight(
|
|
|
1714
1740
|
operation: str,
|
|
1715
1741
|
default_region: str,
|
|
1716
1742
|
consent: dict | None,
|
|
1743
|
+
csv_path: str = "",
|
|
1744
|
+
upload_id: str = "",
|
|
1717
1745
|
) -> tuple[str, dict | None, dict | None]:
|
|
1718
1746
|
"""The guards both upload drafts run before they touch the CSV.
|
|
1719
1747
|
|
|
@@ -1724,12 +1752,19 @@ def _upload_draft_preflight(
|
|
|
1724
1752
|
from adloop.runtime import deployment_mode
|
|
1725
1753
|
from adloop.safety.guards import SafetyViolation, check_blocked_operation
|
|
1726
1754
|
|
|
1727
|
-
if
|
|
1755
|
+
if bool(csv_path) == bool(upload_id):
|
|
1756
|
+
return "", None, {
|
|
1757
|
+
"error": (
|
|
1758
|
+
"Pass exactly one of csv_path (a file on the machine running "
|
|
1759
|
+
"AdLoop) or upload_id (a CSV uploaded with upload_conversion_csv)."
|
|
1760
|
+
)
|
|
1761
|
+
}
|
|
1762
|
+
if csv_path and deployment_mode() == "server":
|
|
1728
1763
|
return "", None, {
|
|
1729
1764
|
"error": (
|
|
1730
|
-
"
|
|
1731
|
-
"
|
|
1732
|
-
"
|
|
1765
|
+
"csv_path names a file on the machine running AdLoop, which on "
|
|
1766
|
+
"the hosted server is not yours. Upload the CSV with "
|
|
1767
|
+
"upload_conversion_csv and pass its upload_id instead."
|
|
1733
1768
|
)
|
|
1734
1769
|
}
|
|
1735
1770
|
|
|
@@ -1754,6 +1789,37 @@ def _upload_draft_preflight(
|
|
|
1754
1789
|
return region, consent_norm, None
|
|
1755
1790
|
|
|
1756
1791
|
|
|
1792
|
+
def _csv_source(csv_path: str, upload_id: str) -> "tuple[str | UploadedCsv | None, dict | None]":
|
|
1793
|
+
"""The CSV to parse: the local path, or the uploaded file's bytes."""
|
|
1794
|
+
if not upload_id:
|
|
1795
|
+
return csv_path, None
|
|
1796
|
+
from adloop.uploads import UploadError, current_tenant, get_upload_store
|
|
1797
|
+
|
|
1798
|
+
try:
|
|
1799
|
+
upload, data = get_upload_store().get(current_tenant(), upload_id, kind="csv")
|
|
1800
|
+
except UploadError as exc:
|
|
1801
|
+
return None, {"error": f"{exc}. Upload the CSV again with upload_conversion_csv."}
|
|
1802
|
+
return UploadedCsv(name=upload.name, data=data), None
|
|
1803
|
+
|
|
1804
|
+
|
|
1805
|
+
def _consume_upload(preview: dict, upload_id: str) -> dict:
|
|
1806
|
+
"""Delete an uploaded CSV once its plan holds the rows.
|
|
1807
|
+
|
|
1808
|
+
The plan carries everything apply needs, so the raw file (phone numbers,
|
|
1809
|
+
emails) has no reason to stay. A draft that failed keeps the file, so a
|
|
1810
|
+
retry with another default_region needs no new upload.
|
|
1811
|
+
"""
|
|
1812
|
+
if upload_id:
|
|
1813
|
+
from adloop.uploads import current_tenant, get_upload_store
|
|
1814
|
+
|
|
1815
|
+
get_upload_store().delete(current_tenant(), upload_id)
|
|
1816
|
+
preview["source_upload"] = (
|
|
1817
|
+
f"{upload_id} was read and deleted; the plan holds the rows. A new "
|
|
1818
|
+
"draft needs a new upload."
|
|
1819
|
+
)
|
|
1820
|
+
return preview
|
|
1821
|
+
|
|
1822
|
+
|
|
1757
1823
|
def _upload_parse_result(
|
|
1758
1824
|
rows: list[dict],
|
|
1759
1825
|
errors: list[str],
|
|
@@ -2262,7 +2328,7 @@ _OPTIONAL_EC_HEADERS = [
|
|
|
2262
2328
|
|
|
2263
2329
|
|
|
2264
2330
|
def _parse_ec_for_leads_csv(
|
|
2265
|
-
csv_path: str, default_region: str = ""
|
|
2331
|
+
csv_path: "str | UploadedCsv", default_region: str = ""
|
|
2266
2332
|
) -> tuple[list[dict], list[str], list[str], list[dict]]:
|
|
2267
2333
|
"""Parse the EC-for-Leads CSV (local file) and hash PII at parse time.
|
|
2268
2334
|
|
|
@@ -2463,7 +2529,8 @@ def draft_upload_enhanced_conversions_for_leads(
|
|
|
2463
2529
|
config: AdLoopConfig,
|
|
2464
2530
|
*,
|
|
2465
2531
|
customer_id: str = "",
|
|
2466
|
-
csv_path: str,
|
|
2532
|
+
csv_path: str = "",
|
|
2533
|
+
upload_id: str = "",
|
|
2467
2534
|
default_region: str = "",
|
|
2468
2535
|
consent: dict | None = None,
|
|
2469
2536
|
) -> dict:
|
|
@@ -2501,12 +2568,17 @@ def draft_upload_enhanced_conversions_for_leads(
|
|
|
2501
2568
|
operation="upload_enhanced_conversions_for_leads",
|
|
2502
2569
|
default_region=default_region,
|
|
2503
2570
|
consent=consent,
|
|
2571
|
+
csv_path=csv_path,
|
|
2572
|
+
upload_id=upload_id,
|
|
2504
2573
|
)
|
|
2574
|
+
if error:
|
|
2575
|
+
return error
|
|
2576
|
+
source, error = _csv_source(csv_path, upload_id)
|
|
2505
2577
|
if error:
|
|
2506
2578
|
return error
|
|
2507
2579
|
|
|
2508
2580
|
rows, parse_advisories, skipped, error = _upload_parse_result(
|
|
2509
|
-
*_parse_ec_for_leads_csv(
|
|
2581
|
+
*_parse_ec_for_leads_csv(source, default_region),
|
|
2510
2582
|
empty_message="CSV contained zero conversion rows",
|
|
2511
2583
|
)
|
|
2512
2584
|
if error:
|
|
@@ -2735,7 +2807,7 @@ def draft_upload_enhanced_conversions_for_leads(
|
|
|
2735
2807
|
apply_only_payload={"rows": frozen_rows},
|
|
2736
2808
|
)
|
|
2737
2809
|
store_plan(plan)
|
|
2738
|
-
return plan.to_preview()
|
|
2810
|
+
return _consume_upload(plan.to_preview(), upload_id)
|
|
2739
2811
|
|
|
2740
2812
|
|
|
2741
2813
|
def _apply_upload_enhanced_conversions_for_leads(
|
|
@@ -3351,7 +3351,7 @@ def image_payloads(
|
|
|
3351
3351
|
from adloop.uploads import UploadError, current_tenant, get_upload_store
|
|
3352
3352
|
|
|
3353
3353
|
try:
|
|
3354
|
-
upload, _data = get_upload_store().get(current_tenant(), upload_id)
|
|
3354
|
+
upload, _data = get_upload_store().get(current_tenant(), upload_id, kind="image")
|
|
3355
3355
|
except UploadError as exc:
|
|
3356
3356
|
errors.append(f"Upload {index + 1}: {exc}")
|
|
3357
3357
|
continue
|
|
@@ -5135,7 +5135,7 @@ def load_image_bytes(payload: dict) -> tuple[bytes, PurePath]:
|
|
|
5135
5135
|
if payload.get("upload_id"):
|
|
5136
5136
|
from adloop.uploads import current_tenant, get_upload_store
|
|
5137
5137
|
|
|
5138
|
-
upload, image_bytes = get_upload_store().get(current_tenant(), str(payload["upload_id"]))
|
|
5138
|
+
upload, image_bytes = get_upload_store().get(current_tenant(), str(payload["upload_id"]), kind="image")
|
|
5139
5139
|
if upload.sha256 != payload.get("sha256"):
|
|
5140
5140
|
raise ValueError(f"upload {upload.upload_id} changed since the preview; draft again.")
|
|
5141
5141
|
return image_bytes, PurePosixPath(upload.name)
|
|
@@ -249,6 +249,21 @@ For images that have no public URL: a file on the user's computer, or an image t
|
|
|
249
249
|
- **Using an upload:** `draft_image_assets(upload_ids=["up_..."])` for Google Ads; `"upload:up_..."` as `image_url`, `thumbnail_url` or a carousel card's `image_url` in `draft_reddit_ad` (AdLoop Cloud only, because Reddit downloads the image from a short-lived public link). Videos cannot be uploaded.
|
|
250
250
|
- Uploads expire after 24 hours. Self-hosted without a card, pass local files directly (`image_paths`).
|
|
251
251
|
|
|
252
|
+
### Offline Conversion Uploads
|
|
253
|
+
|
|
254
|
+
Conversions that happen offline (a sales call, a lead that turned into a deal) go back to Google Ads from a CSV in Google's upload template.
|
|
255
|
+
|
|
256
|
+
| Tool | When to Use | Key Parameters |
|
|
257
|
+
|------|-------------|----------------|
|
|
258
|
+
| `upload_conversion_csv` | The CSV is not on the machine running AdLoop (always on AdLoop Cloud). Shows the upload card, or returns an upload link | (none) |
|
|
259
|
+
| `draft_upload_call_conversions` | Calls from call assets or call-tracking numbers that became a conversion; matched by the caller's phone number | exactly one of `csv_path` (local file) or `upload_id`; `default_region` for numbers without a country code; `consent` |
|
|
260
|
+
| `draft_upload_enhanced_conversions_for_leads` | Form leads that became a conversion; matched by hashed email, phone or name and address | exactly one of `csv_path` or `upload_id`; `default_region`; `consent` |
|
|
261
|
+
|
|
262
|
+
- The conversion action named in the CSV's `Conversion Name` column must exist and be of type UPLOAD_CALLS (calls) or UPLOAD_CLICKS (leads); the draft checks it against the account.
|
|
263
|
+
- **The CSV holds personal data** (phone numbers, emails, names). An uploaded file lasts two hours at most and is deleted as soon as a draft has read it, so a second draft needs a new upload; a failed draft keeps it for a retry (for example with `default_region`). Lead identifiers are hashed while drafting. Never ask the user to paste rows into the chat.
|
|
264
|
+
- EEA traffic needs consent signals: pass `consent` or fill the CSV's `Ad User Data` / `Ad Personalization` columns. Present the preview's `consent_summary`, `skipped_rows` and `dedup_warnings`.
|
|
265
|
+
- Apply sends batches of 2,000 rows. `PARTIAL_UPLOAD` means some batches are already in the account: the plan is retired, and a new draft continues at `resume_from_line`. Don't re-upload rows that went through.
|
|
266
|
+
|
|
252
267
|
### Planning Tools
|
|
253
268
|
|
|
254
269
|
| Tool | When to Use | Key Parameters |
|
|
@@ -330,7 +345,7 @@ Same draft → preview → `confirm_and_apply` gate; the plan carries `platform:
|
|
|
330
345
|
|
|
331
346
|
**Approval cards (Claude, ChatGPT, VS Code and other apps with MCP Apps):** when the preview comes back with `approval: "card"`, the user sees it as an AdLoop approval card with Test run, Apply and Discard buttons, and approves there. Summarize the change and its warnings, then let the user decide on the card instead of asking for a "yes" in the chat. In those apps `confirm_and_apply(dry_run=false)` is refused with status `APPROVE_ON_CARD` (a dry run still works), because only the click applies; the AI cannot press the button. Do not retry. If the user says the card is not visible, tell them the workspace owner can allow approval in chat (AdLoop Cloud: Settings → Safety; self-hosted: `safety.allow_chat_approval`). After a click the card posts a message such as "Applied on the AdLoop approval card: ..."; continue from there (e.g. remind them to enable a new paused ad).
|
|
332
347
|
|
|
333
|
-
**Confirmation dialogs (apps without cards that support elicitation):** when you call `confirm_and_apply(dry_run=false)` after the user's go-ahead, AdLoop itself asks the user in the app's confirmation dialog before anything changes; irreversible changes need an extra "cannot be undone" tick there. You do not answer that dialog. `NOT_CONFIRMED` means the user declined or closed it: nothing was changed, the preview stays pending, so ask what they want instead of retrying. Apps with neither cards nor dialogs keep the chat flow above.
|
|
348
|
+
**Confirmation dialogs (apps without cards that support elicitation):** when you call `confirm_and_apply(dry_run=false)` after the user's go-ahead, AdLoop itself asks the user in the app's confirmation dialog before anything changes; irreversible changes need an extra "cannot be undone" tick there. You do not answer that dialog. `NOT_CONFIRMED` means the user declined or closed it: nothing was changed, the preview stays pending, so ask what they want instead of retrying. A dry run's `next_step` says when a dialog will follow, so tell the user to look out for it. `CONFIRMATION_UNANSWERED` means the app received the dialog but never returned an answer (some headless agents and IDE setups declare dialogs they cannot show): nothing was changed; ask the user whether a dialog appeared, and if their app cannot show one, relay the message's ways out (approval in chat, or an app with the approval card). Apps with neither cards nor dialogs keep the chat flow above.
|
|
334
349
|
|
|
335
350
|
**Safety behaviors:**
|
|
336
351
|
- New campaigns and RSAs (and every Reddit campaign, ad group and ad) are created as PAUSED — user must explicitly enable them after review.
|
|
@@ -335,6 +335,64 @@ def _not_confirmed(plan: ChangePlan) -> dict:
|
|
|
335
335
|
}
|
|
336
336
|
|
|
337
337
|
|
|
338
|
+
def _dialog_unanswered(plan: ChangePlan) -> dict:
|
|
339
|
+
return {
|
|
340
|
+
"status": "CONFIRMATION_UNANSWERED",
|
|
341
|
+
"plan_id": plan.plan_id,
|
|
342
|
+
"operation": plan.operation,
|
|
343
|
+
"message": (
|
|
344
|
+
"AdLoop asked the user to confirm this change in a dialog of their app, "
|
|
345
|
+
"but the app never returned an answer, so nothing was changed and the "
|
|
346
|
+
"plan stays pending. Ask the user whether a confirmation dialog appeared. "
|
|
347
|
+
"If it did, call confirm_and_apply again and have them answer it. If their "
|
|
348
|
+
"app cannot show dialogs (for example a headless agent or a script), the "
|
|
349
|
+
"workspace owner can allow approval in chat (AdLoop Cloud: Settings -> "
|
|
350
|
+
"Safety -> 'Also allow approval in chat'; self-hosted: "
|
|
351
|
+
"safety.allow_chat_approval: true), or the change can be applied from an "
|
|
352
|
+
"app that shows AdLoop's approval card, such as Claude or ChatGPT. Calling "
|
|
353
|
+
"confirm_and_apply again sends the dialog once more."
|
|
354
|
+
),
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _mark_dialog(plan: ChangePlan, open_: bool) -> None:
|
|
359
|
+
"""Record (or clear) that a dialog for this plan awaits an answer."""
|
|
360
|
+
from datetime import datetime, timezone
|
|
361
|
+
|
|
362
|
+
from adloop.safety.preview import store_plan
|
|
363
|
+
|
|
364
|
+
plan.dialog_requested_at = datetime.now(timezone.utc).isoformat() if open_ else ""
|
|
365
|
+
store_plan(plan)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def dialog_expected(plan_id: str) -> bool:
|
|
369
|
+
"""Whether a real apply of this plan will ask the user in a dialog.
|
|
370
|
+
|
|
371
|
+
Used after a successful dry run, so the AI can tell the user to look out
|
|
372
|
+
for it instead of letting an unanswered dialog look like a hang.
|
|
373
|
+
"""
|
|
374
|
+
if _dialog_applies(plan_id) is None:
|
|
375
|
+
return False
|
|
376
|
+
try:
|
|
377
|
+
from fastmcp.server.dependencies import get_context
|
|
378
|
+
|
|
379
|
+
if get_context()._is_modern_protocol():
|
|
380
|
+
return True
|
|
381
|
+
except Exception:
|
|
382
|
+
return False
|
|
383
|
+
from adloop.runtime import deployment_mode
|
|
384
|
+
|
|
385
|
+
# Older protocols get the dialog only self-hosted (see confirm_in_dialog).
|
|
386
|
+
return deployment_mode() != "server"
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
DIALOG_NOTE = (
|
|
390
|
+
"The real apply (dry_run=false) asks the user to confirm in a dialog shown by "
|
|
391
|
+
"their app; tell them to look out for it and answer it. If no dialog appears, "
|
|
392
|
+
"the call returns CONFIRMATION_UNANSWERED on the next attempt with the ways out."
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
338
396
|
def _dialog_applies(plan_id: str) -> ChangePlan | None:
|
|
339
397
|
"""The plan when a real apply must be confirmed in a dialog, else None."""
|
|
340
398
|
from adloop.runtime import current_config
|
|
@@ -375,6 +433,14 @@ async def confirm_in_dialog(context: Any, plan_id: str) -> Any | None:
|
|
|
375
433
|
if context._is_modern_protocol():
|
|
376
434
|
responses = context.input_responses
|
|
377
435
|
if responses is None:
|
|
436
|
+
if plan.dialog_requested_at:
|
|
437
|
+
# The last dialog for this plan was sent and this is a fresh
|
|
438
|
+
# call, not its answer: the app received the dialog and never
|
|
439
|
+
# returned one. Say so instead of sending another dialog the
|
|
440
|
+
# app will sit on until its timeout. Cleared, so asking again
|
|
441
|
+
# sends a new dialog.
|
|
442
|
+
_mark_dialog(plan, False)
|
|
443
|
+
return ToolResult(structured_content=_dialog_unanswered(plan))
|
|
378
444
|
import mcp_types
|
|
379
445
|
from fastmcp.tools.base import InputRequiredToolResult
|
|
380
446
|
|
|
@@ -385,11 +451,14 @@ async def confirm_in_dialog(context: Any, plan_id: str) -> Any | None:
|
|
|
385
451
|
)
|
|
386
452
|
)
|
|
387
453
|
state = json.dumps({"plan_id": plan.plan_id, "code": approval_code(plan)})
|
|
454
|
+
_mark_dialog(plan, True)
|
|
388
455
|
return InputRequiredToolResult(
|
|
389
456
|
mcp_types.InputRequiredResult(
|
|
390
457
|
input_requests={DIALOG_KEY: request}, request_state=state
|
|
391
458
|
)
|
|
392
459
|
)
|
|
460
|
+
if plan.dialog_requested_at:
|
|
461
|
+
_mark_dialog(plan, False) # answered, whatever the answer is
|
|
393
462
|
try:
|
|
394
463
|
state = json.loads(context.request_state or "{}")
|
|
395
464
|
except ValueError:
|
|
@@ -25,6 +25,11 @@ class ChangePlan:
|
|
|
25
25
|
# Ad platform the plan targets: "" (Google, the default so stored plans
|
|
26
26
|
# from before the field existed still load) or "reddit".
|
|
27
27
|
platform: str = ""
|
|
28
|
+
# When a confirmation dialog for this plan was last sent and not yet
|
|
29
|
+
# answered (ISO timestamp, "" when none is open). An app that receives
|
|
30
|
+
# the dialog but never answers it would otherwise leave every retry
|
|
31
|
+
# waiting until its own timeout; see approval.confirm_in_dialog.
|
|
32
|
+
dialog_requested_at: str = ""
|
|
28
33
|
|
|
29
34
|
# Payload the applier needs but a preview must never show: an upload's rows
|
|
30
35
|
# (the caller id cannot be hashed, so call uploads carry raw phone numbers),
|
|
@@ -4482,6 +4482,11 @@ def confirm_and_apply(
|
|
|
4482
4482
|
only the user's click on the card applies the change. Dry runs still
|
|
4483
4483
|
work. The workspace owner can allow approval in chat as well.
|
|
4484
4484
|
|
|
4485
|
+
Confirmation dialogs: in apps that show dialogs instead, dry_run=false
|
|
4486
|
+
first asks the user in a dialog. A declined dialog returns NOT_CONFIRMED;
|
|
4487
|
+
an app that receives the dialog but never answers it gets
|
|
4488
|
+
CONFIRMATION_UNANSWERED on the next call, with the ways out.
|
|
4489
|
+
|
|
4485
4490
|
Args:
|
|
4486
4491
|
plan_id: The plan_id returned by a prior draft_*, update_*, add_*,
|
|
4487
4492
|
pause/enable/remove or other preview tool call. An unknown or
|
|
@@ -4490,13 +4495,17 @@ def confirm_and_apply(
|
|
|
4490
4495
|
applies the change for real.
|
|
4491
4496
|
"""
|
|
4492
4497
|
from adloop.ads.write import confirm_and_apply as _impl
|
|
4493
|
-
from adloop.safety.approval import chat_apply_refusal
|
|
4498
|
+
from adloop.safety.approval import DIALOG_NOTE, chat_apply_refusal, dialog_expected
|
|
4494
4499
|
|
|
4495
4500
|
if not dry_run:
|
|
4496
4501
|
refusal = chat_apply_refusal(plan_id)
|
|
4497
4502
|
if refusal is not None:
|
|
4498
4503
|
return refusal
|
|
4499
|
-
|
|
4504
|
+
result = _impl(current_config(), plan_id=plan_id, dry_run=dry_run)
|
|
4505
|
+
if (dry_run and isinstance(result, dict) and result.get("status") == "DRY_RUN_SUCCESS"
|
|
4506
|
+
and dialog_expected(plan_id)):
|
|
4507
|
+
result["next_step"] = DIALOG_NOTE
|
|
4508
|
+
return result
|
|
4500
4509
|
|
|
4501
4510
|
|
|
4502
4511
|
@_tool(
|
|
@@ -4590,10 +4599,15 @@ def upload_images() -> dict:
|
|
|
4590
4599
|
draft_image_assets, or as "upload:<id>" into the image fields of
|
|
4591
4600
|
draft_reddit_ad. Returns the current uploads and how to add more.
|
|
4592
4601
|
"""
|
|
4602
|
+
return _upload_tool_result("image")
|
|
4603
|
+
|
|
4604
|
+
|
|
4605
|
+
def _upload_tool_result(kind: str):
|
|
4606
|
+
"""The upload card (with its code) or the upload link, for ``kind``."""
|
|
4593
4607
|
from adloop.uploads import upload_instructions
|
|
4594
4608
|
|
|
4595
4609
|
shows_cards = client_shows_cards()
|
|
4596
|
-
result = upload_instructions(shows_cards)
|
|
4610
|
+
result = upload_instructions(shows_cards, kind)
|
|
4597
4611
|
if not shows_cards:
|
|
4598
4612
|
return result
|
|
4599
4613
|
from fastmcp.tools import ToolResult
|
|
@@ -4609,6 +4623,29 @@ def upload_images() -> dict:
|
|
|
4609
4623
|
)
|
|
4610
4624
|
|
|
4611
4625
|
|
|
4626
|
+
@_tool(
|
|
4627
|
+
title="Upload a conversion CSV",
|
|
4628
|
+
annotations=_READONLY,
|
|
4629
|
+
tags={"ads"},
|
|
4630
|
+
meta={"ui": {"resourceUri": UPLOAD_CARD_URI}},
|
|
4631
|
+
)
|
|
4632
|
+
@_safe
|
|
4633
|
+
def upload_conversion_csv() -> dict:
|
|
4634
|
+
"""Let the user upload a conversion CSV for an offline conversion upload.
|
|
4635
|
+
|
|
4636
|
+
For the CSV behind draft_upload_call_conversions or
|
|
4637
|
+
draft_upload_enhanced_conversions_for_leads when the file is not on the
|
|
4638
|
+
machine running AdLoop, as on AdLoop Cloud. Apps that show AdLoop's
|
|
4639
|
+
cards display an upload card where the user drops the .csv file; the
|
|
4640
|
+
card then posts the upload id into the chat. Other apps on AdLoop Cloud
|
|
4641
|
+
get an upload link for the user (or a coding agent, with curl). The
|
|
4642
|
+
file holds personal data (phone numbers, emails), so it is kept for two
|
|
4643
|
+
hours at most and deleted as soon as a draft has read it. Returns the
|
|
4644
|
+
CSV uploads waiting to be used and how to add one.
|
|
4645
|
+
"""
|
|
4646
|
+
return _upload_tool_result("csv")
|
|
4647
|
+
|
|
4648
|
+
|
|
4612
4649
|
@_tool(
|
|
4613
4650
|
title="Store an uploaded image",
|
|
4614
4651
|
annotations=_WRITE,
|
|
@@ -4616,16 +4653,19 @@ def upload_images() -> dict:
|
|
|
4616
4653
|
meta={"ui": {"resourceUri": UPLOAD_CARD_URI, "visibility": ["app"]}},
|
|
4617
4654
|
)
|
|
4618
4655
|
@_safe
|
|
4619
|
-
def store_image_upload(name: str, data_base64: str, code: str = "") -> dict:
|
|
4620
|
-
"""Store
|
|
4656
|
+
def store_image_upload(name: str, data_base64: str, code: str = "", kind: str = "image") -> dict:
|
|
4657
|
+
"""Store a file dropped on the AdLoop upload card.
|
|
4621
4658
|
|
|
4622
4659
|
Called only by the upload card; AI apps keep this tool out of the
|
|
4623
|
-
model's tool list. Returns the upload's id, type and dimensions
|
|
4660
|
+
model's tool list. Returns the upload's id, type and size (dimensions
|
|
4661
|
+
for images, lines for CSVs).
|
|
4624
4662
|
|
|
4625
4663
|
Args:
|
|
4626
4664
|
name: The file name, used for the asset name.
|
|
4627
4665
|
data_base64: The file content, base64-encoded.
|
|
4628
|
-
code: The upload card's code from upload_images
|
|
4666
|
+
code: The upload card's code from upload_images or
|
|
4667
|
+
upload_conversion_csv.
|
|
4668
|
+
kind: "image" (default) or "csv" for a conversion CSV.
|
|
4629
4669
|
"""
|
|
4630
4670
|
import base64
|
|
4631
4671
|
import binascii
|
|
@@ -4637,20 +4677,21 @@ def store_image_upload(name: str, data_base64: str, code: str = "") -> dict:
|
|
|
4637
4677
|
except (binascii.Error, ValueError):
|
|
4638
4678
|
return {"error": "The file did not arrive intact; upload it again."}
|
|
4639
4679
|
try:
|
|
4640
|
-
upload = get_upload_store().put(current_tenant(), name, data)
|
|
4680
|
+
upload = get_upload_store().put(current_tenant(), name, data, kind=kind)
|
|
4641
4681
|
except UploadError as exc:
|
|
4642
4682
|
return {"error": f"{name}: {exc}"}
|
|
4643
4683
|
return {"status": "UPLOADED", **upload.summary()}
|
|
4644
4684
|
|
|
4645
4685
|
|
|
4646
|
-
@_tool(title="List
|
|
4686
|
+
@_tool(title="List uploads", annotations=_READONLY, tags={"core"})
|
|
4647
4687
|
@_safe
|
|
4648
4688
|
def list_uploads() -> dict:
|
|
4649
|
-
"""List the
|
|
4689
|
+
"""List the files the user uploaded to AdLoop that have not expired.
|
|
4650
4690
|
|
|
4651
|
-
Returns each upload's upload_id, name, type,
|
|
4652
|
-
|
|
4653
|
-
|
|
4691
|
+
Returns each upload's upload_id, kind (image or csv), name, type, size,
|
|
4692
|
+
dimensions (images) or lines (CSVs) and hours until it expires: images
|
|
4693
|
+
last a day, conversion CSVs two hours or until a draft reads them. The
|
|
4694
|
+
ids of files the user sent through an upload link show up here.
|
|
4654
4695
|
"""
|
|
4655
4696
|
from adloop.uploads import current_tenant, get_upload_store
|
|
4656
4697
|
|
|
@@ -4660,7 +4701,8 @@ def list_uploads() -> dict:
|
|
|
4660
4701
|
"usage": (
|
|
4661
4702
|
"draft_image_assets(upload_ids=[...]) for Google Ads; "
|
|
4662
4703
|
"\"upload:<upload_id>\" as image_url, thumbnail_url or a carousel card's "
|
|
4663
|
-
"image_url in draft_reddit_ad (AdLoop Cloud only)."
|
|
4704
|
+
"image_url in draft_reddit_ad (AdLoop Cloud only). CSV uploads: upload_id in "
|
|
4705
|
+
"draft_upload_call_conversions or draft_upload_enhanced_conversions_for_leads."
|
|
4664
4706
|
),
|
|
4665
4707
|
}
|
|
4666
4708
|
|
|
@@ -6141,7 +6183,8 @@ def draft_remove_conversion_action(
|
|
|
6141
6183
|
@_tool(title="Draft call conversion upload", annotations=_WRITE, tags={"ads"})
|
|
6142
6184
|
@_safe
|
|
6143
6185
|
def draft_upload_call_conversions(
|
|
6144
|
-
csv_path: str,
|
|
6186
|
+
csv_path: str = "",
|
|
6187
|
+
upload_id: str = "",
|
|
6145
6188
|
default_region: str = "",
|
|
6146
6189
|
consent: dict | None = None,
|
|
6147
6190
|
customer_id: str = "",
|
|
@@ -6157,8 +6200,9 @@ def draft_upload_call_conversions(
|
|
|
6157
6200
|
drafting, so a typo fails here rather than after the upload. Optional
|
|
6158
6201
|
`Ad User Data` and `Ad Personalization` columns carry consent per row.
|
|
6159
6202
|
|
|
6160
|
-
|
|
6161
|
-
|
|
6203
|
+
The CSV comes from csv_path (a file on the machine running AdLoop) or
|
|
6204
|
+
from upload_id (a file uploaded with upload_conversion_csv, the only way
|
|
6205
|
+
on AdLoop Cloud). An uploaded file is deleted once the draft has read it.
|
|
6162
6206
|
|
|
6163
6207
|
PII: the caller phone number is required RAW by Google (it cannot be
|
|
6164
6208
|
hashed), so it lives in the plan's apply-only payload — the preview shows
|
|
@@ -6181,8 +6225,10 @@ def draft_upload_call_conversions(
|
|
|
6181
6225
|
|
|
6182
6226
|
Args:
|
|
6183
6227
|
csv_path: Path of the CSV file holding the upload rows, read on the
|
|
6184
|
-
machine that runs AdLoop.
|
|
6185
|
-
|
|
6228
|
+
machine that runs AdLoop. Refused on the hosted server; use
|
|
6229
|
+
upload_id there.
|
|
6230
|
+
upload_id: Id of a CSV uploaded with upload_conversion_csv (up_...),
|
|
6231
|
+
instead of csv_path.
|
|
6186
6232
|
default_region: ISO 3166-1 alpha-2 country assumed for phone numbers
|
|
6187
6233
|
that carry no country code, e.g. "DE" for "0151 12345678". Empty
|
|
6188
6234
|
leaves such numbers unusable and the row is skipped.
|
|
@@ -6202,6 +6248,7 @@ def draft_upload_call_conversions(
|
|
|
6202
6248
|
current_config(),
|
|
6203
6249
|
customer_id=customer_id or current_config().ads.customer_id,
|
|
6204
6250
|
csv_path=csv_path,
|
|
6251
|
+
upload_id=upload_id,
|
|
6205
6252
|
default_region=default_region,
|
|
6206
6253
|
consent=consent,
|
|
6207
6254
|
)
|
|
@@ -6210,7 +6257,8 @@ def draft_upload_call_conversions(
|
|
|
6210
6257
|
@_tool(title="Draft enhanced conversions upload", annotations=_WRITE, tags={"ads"})
|
|
6211
6258
|
@_safe
|
|
6212
6259
|
def draft_upload_enhanced_conversions_for_leads(
|
|
6213
|
-
csv_path: str,
|
|
6260
|
+
csv_path: str = "",
|
|
6261
|
+
upload_id: str = "",
|
|
6214
6262
|
default_region: str = "",
|
|
6215
6263
|
consent: dict | None = None,
|
|
6216
6264
|
customer_id: str = "",
|
|
@@ -6235,8 +6283,9 @@ def draft_upload_enhanced_conversions_for_leads(
|
|
|
6235
6283
|
sharing an Order ID count once at Google; two rows with different Order
|
|
6236
6284
|
IDs are two real orders and are not reported.
|
|
6237
6285
|
|
|
6238
|
-
|
|
6239
|
-
|
|
6286
|
+
The CSV comes from csv_path (a file on the machine running AdLoop) or
|
|
6287
|
+
from upload_id (a file uploaded with upload_conversion_csv, the only way
|
|
6288
|
+
on AdLoop Cloud). An uploaded file is deleted once the draft has read it.
|
|
6240
6289
|
|
|
6241
6290
|
Returns a preview with a plan_id, the row counts, the consent the rows
|
|
6242
6291
|
actually carry (`consent_summary`) and the skipped rows; nothing is
|
|
@@ -6244,8 +6293,10 @@ def draft_upload_enhanced_conversions_for_leads(
|
|
|
6244
6293
|
|
|
6245
6294
|
Args:
|
|
6246
6295
|
csv_path: Path of the CSV file holding the upload rows, read on the
|
|
6247
|
-
machine that runs AdLoop.
|
|
6248
|
-
|
|
6296
|
+
machine that runs AdLoop. Refused on the hosted server; use
|
|
6297
|
+
upload_id there.
|
|
6298
|
+
upload_id: Id of a CSV uploaded with upload_conversion_csv (up_...),
|
|
6299
|
+
instead of csv_path.
|
|
6249
6300
|
default_region: ISO 3166-1 alpha-2 country assumed for phone numbers
|
|
6250
6301
|
that carry no country code, e.g. "DE" for "0151 12345678". Empty
|
|
6251
6302
|
leaves such numbers unusable; the row still uploads when its email
|
|
@@ -6266,6 +6317,7 @@ def draft_upload_enhanced_conversions_for_leads(
|
|
|
6266
6317
|
current_config(),
|
|
6267
6318
|
customer_id=customer_id or current_config().ads.customer_id,
|
|
6268
6319
|
csv_path=csv_path,
|
|
6320
|
+
upload_id=upload_id,
|
|
6269
6321
|
default_region=default_region,
|
|
6270
6322
|
consent=consent,
|
|
6271
6323
|
)
|