fix(meta): declare Advantage+ Audience on every ad set's targeting
Fourth failure in the same chain, one object further each time: "Advantage audience flag required ... setting the advantage_audience flag to either 1 or 0 within the targeting_automation field" (code 100, subcode 1870227). We were never sending targeting_automation at all. Defaulting to 0 (off) for the same reason bid_strategy defaults to LOWEST_COST_WITHOUT_CAP: an ad set should reach the audience it was told to — the countries/age/gender actually set on the campaign — not whatever Meta's Advantage+ expansion additionally decides to include. advanced.advantage_audience (0 or 1) overrides it per campaign. Ordering note: targeting_automation is added unconditionally, so the existing "no audience" guard (raises when targeting is empty) had to move before it — otherwise targeting would never be empty and that guard would go silently dead. A test pins this: an empty CampaignSpec.targeting must still be refused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -159,15 +159,33 @@ def build_ad_set_payload(spec: CampaignSpec, campaign_id: str) -> dict[str, Any]
|
|||||||
if genders:
|
if genders:
|
||||||
targeting["genders"] = list(genders)
|
targeting["genders"] = list(genders)
|
||||||
|
|
||||||
# Graph requires targeting on every ad set. Catching it here names the
|
# Graph requires targeting on every ad set. Checked before
|
||||||
# missing field; letting it through produces an opaque Meta rejection
|
# targeting_automation is added below, which is unconditional and
|
||||||
# part-way into building the object chain.
|
# would otherwise make `targeting` always non-empty and this guard
|
||||||
|
# dead code. Catching it here names the missing field; letting it
|
||||||
|
# through produces an opaque Meta rejection part-way into building the
|
||||||
|
# object chain.
|
||||||
if not targeting:
|
if not targeting:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"CampaignSpec.targeting is empty, so the ad set has no audience. "
|
"CampaignSpec.targeting is empty, so the ad set has no audience. "
|
||||||
"Set at least one of countries, age_min, age_max or genders.",
|
"Set at least one of countries, age_min, age_max or genders.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Graph requires this on every ad set — code 100 / subcode 1870227,
|
||||||
|
# which is what stopped the connection test once optimisation_goal was
|
||||||
|
# fixed. Advantage+ Audience lets Meta expand delivery beyond the
|
||||||
|
# countries/age/gender actually specified. Defaulting to 0 (off) is the
|
||||||
|
# same call already made for bid_strategy: an ad set matches the
|
||||||
|
# audience it was told to, not whatever Meta additionally guesses.
|
||||||
|
# advanced.advantage_audience (0 or 1) overrides this per campaign.
|
||||||
|
advantage_audience = spec.advanced.get("advantage_audience", 0)
|
||||||
|
if advantage_audience not in (0, 1):
|
||||||
|
raise ValueError(
|
||||||
|
f"advanced.advantage_audience must be 0 or 1, got "
|
||||||
|
f"{advantage_audience!r}.",
|
||||||
|
)
|
||||||
|
targeting["targeting_automation"] = {"advantage_audience": advantage_audience}
|
||||||
|
|
||||||
optimization_goal = spec.advanced.get("optimization_goal") or (
|
optimization_goal = spec.advanced.get("optimization_goal") or (
|
||||||
DEFAULT_OPTIMIZATION_GOAL_BY_OBJECTIVE.get(
|
DEFAULT_OPTIMIZATION_GOAL_BY_OBJECTIVE.get(
|
||||||
spec.objective or "", DEFAULT_OPTIMIZATION_GOAL,
|
spec.objective or "", DEFAULT_OPTIMIZATION_GOAL,
|
||||||
|
|||||||
@@ -448,6 +448,7 @@ def test_build_ad_set_payload_maps_targeting_and_budget():
|
|||||||
"age_min": 25,
|
"age_min": 25,
|
||||||
"age_max": 45,
|
"age_max": 45,
|
||||||
"geo_locations": {"countries": ["US", "CA"]},
|
"geo_locations": {"countries": ["US", "CA"]},
|
||||||
|
"targeting_automation": {"advantage_audience": 0},
|
||||||
}
|
}
|
||||||
assert payload["optimization_goal"] == "LEAD_GENERATION"
|
assert payload["optimization_goal"] == "LEAD_GENERATION"
|
||||||
assert payload["billing_event"] == "IMPRESSIONS"
|
assert payload["billing_event"] == "IMPRESSIONS"
|
||||||
@@ -485,7 +486,10 @@ def test_build_ad_set_payload_requires_an_audience():
|
|||||||
def test_build_ad_set_payload_accepts_any_single_targeting_field():
|
def test_build_ad_set_payload_accepts_any_single_targeting_field():
|
||||||
payload = build_ad_set_payload(_spec(targeting={"countries": ["IN"]}), "c1")
|
payload = build_ad_set_payload(_spec(targeting={"countries": ["IN"]}), "c1")
|
||||||
|
|
||||||
assert payload["targeting"] == {"geo_locations": {"countries": ["IN"]}}
|
assert payload["targeting"] == {
|
||||||
|
"geo_locations": {"countries": ["IN"]},
|
||||||
|
"targeting_automation": {"advantage_audience": 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_build_ad_set_payload_accepts_allowlisted_optimization_goal():
|
def test_build_ad_set_payload_accepts_allowlisted_optimization_goal():
|
||||||
@@ -705,6 +709,54 @@ def test_an_unknown_objective_falls_back_to_the_widest_goal():
|
|||||||
assert payload["optimization_goal"] == "LINK_CLICKS"
|
assert payload["optimization_goal"] == "LINK_CLICKS"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Advantage+ Audience: the field Graph requires on every ad set's targeting
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_targeting_automation_defaults_to_advantage_audience_off():
|
||||||
|
"""Graph rejects an ad set whose targeting spec omits this — code 100 /
|
||||||
|
subcode 1870227, the failure that stopped the live connection test
|
||||||
|
once optimisation_goal was fixed.
|
||||||
|
|
||||||
|
Off by default for the same reason as bid_strategy: an ad set matches
|
||||||
|
the audience actually specified, not whatever Meta additionally
|
||||||
|
expands it to.
|
||||||
|
"""
|
||||||
|
payload = build_ad_set_payload(_spec(), campaign_id="c1")
|
||||||
|
|
||||||
|
assert payload["targeting"]["targeting_automation"] == {
|
||||||
|
"advantage_audience": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_advantage_audience_can_be_turned_on():
|
||||||
|
spec = _spec(advanced={"advantage_audience": 1})
|
||||||
|
|
||||||
|
payload = build_ad_set_payload(spec, campaign_id="c1")
|
||||||
|
|
||||||
|
assert payload["targeting"]["targeting_automation"] == {
|
||||||
|
"advantage_audience": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_advantage_audience_rejects_anything_but_0_or_1():
|
||||||
|
spec = _spec(advanced={"advantage_audience": 2})
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="advantage_audience"):
|
||||||
|
build_ad_set_payload(spec, campaign_id="c1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_empty_audience_is_still_refused_even_though_targeting_automation_is_always_set():
|
||||||
|
"""targeting_automation is added unconditionally, so `targeting` is
|
||||||
|
never an empty dict any more. The no-audience guard has to run before
|
||||||
|
that, or this refusal silently stops firing."""
|
||||||
|
spec = _spec(targeting={})
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="no audience"):
|
||||||
|
build_ad_set_payload(spec, campaign_id="c1")
|
||||||
|
|
||||||
|
|
||||||
def test_an_explicit_optimisation_goal_still_wins():
|
def test_an_explicit_optimisation_goal_still_wins():
|
||||||
spec = _spec(
|
spec = _spec(
|
||||||
objective="OUTCOME_TRAFFIC",
|
objective="OUTCOME_TRAFFIC",
|
||||||
|
|||||||
Reference in New Issue
Block a user