Skip to content

Commit 1d2a90c

Browse files
th0rz05claude
andauthored
Resolve geos via typeahead; drop dead targeting/payload helpers (#26)
Geo targeting previously relied on a hardcoded ~15-country ISO->URN map, so any country outside it (e.g. PT) was silently dropped. Replace it with the same typeahead path the other open facets use: expand each ISO 3166-1 alpha-2 code to a country name via pycountry, resolve it through the locations typeahead, and surface codes that don't resolve under `unresolved["geos"]` instead of dropping them. Geo logic now lives only in TargetingResolver. Also remove confirmed dead code uncovered while unifying this: * linkedin mapping.audience_to_targeting + line_item_payload (only the dead payload helper used the geo-only baseline; the publish flow uses TargetingResolver) and the now-unused ISO_TO_LINKEDIN_GEO_URN map; * meta mapping.line_item_payload (never called); * the write-only `rejection_reason` AgentState field (the reason is already captured in the audit entry). line_item_locale now validates the country via pycountry instead of the deleted map. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 4e72e8f commit 1d2a90c

8 files changed

Lines changed: 96 additions & 114 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ meta = [
3131
linkedin = [
3232
"mcp>=1.2",
3333
"httpx>=0.27",
34+
# ISO 3166 country data for resolving brief geo codes to LinkedIn locations.
35+
"pycountry>=23",
3436
]
3537
agent = [
3638
"langchain>=0.3",

src/yieldagent/agents/campaign_setup/nodes.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,6 @@ def human_gate(state: AgentState) -> dict[str, Any]:
111111
reason = decision.get("reason", "")
112112
return {
113113
"approved": approved,
114-
"rejection_reason": "" if approved else reason,
115114
"audit": _audit(
116115
state,
117116
AuditEntry(

src/yieldagent/agents/campaign_setup/state.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,5 @@ class AgentState(TypedDict, total=False):
2424
brief: Brief
2525
campaign: Campaign
2626
approved: bool
27-
rejection_reason: str
2827
publish_result: dict[str, Any]
2928
audit: list[AuditEntry]

src/yieldagent/integrations/linkedin/mapping.py

Lines changed: 7 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@
2020
from datetime import datetime, time, timezone
2121
from typing import Any
2222

23-
from yieldagent.domain import Audience, Campaign, CreativeAsset, Flight, LineItem, Objective
23+
import pycountry
24+
25+
from yieldagent.domain import Audience, Campaign, CreativeAsset, Flight, Objective
2426

2527
# LinkedIn objectiveType values for Sponsored Content campaigns.
2628
OBJECTIVE_TO_LINKEDIN: dict[Objective, str] = {
@@ -32,27 +34,6 @@
3234
Objective.sales: "WEBSITE_CONVERSIONS",
3335
}
3436

35-
# LinkedIn requires geo URNs (urn:li:geo:{id}) — ISO codes are not accepted.
36-
# Production use should resolve via the geo typeahead endpoint; the few entries
37-
# below cover the common-case countries so simple briefs work out of the box.
38-
ISO_TO_LINKEDIN_GEO_URN: dict[str, str] = {
39-
"US": "urn:li:geo:103644278",
40-
"GB": "urn:li:geo:101165590",
41-
"CA": "urn:li:geo:101174742",
42-
"DE": "urn:li:geo:101282230",
43-
"FR": "urn:li:geo:105015875",
44-
"AU": "urn:li:geo:101452733",
45-
"IN": "urn:li:geo:102713980",
46-
"IL": "urn:li:geo:101620260",
47-
"BR": "urn:li:geo:106057199",
48-
"JP": "urn:li:geo:101355337",
49-
"NL": "urn:li:geo:102890719",
50-
"ES": "urn:li:geo:105646813",
51-
"IT": "urn:li:geo:103350119",
52-
"SE": "urn:li:geo:105117694",
53-
"SG": "urn:li:geo:102454443",
54-
}
55-
5637
# LinkedIn campaign type for standard image / single-share Sponsored Content.
5738
DEFAULT_CAMPAIGN_TYPE = "SPONSORED_UPDATES"
5839

@@ -86,68 +67,18 @@ def campaign_run_schedule(flights: list[Flight]) -> dict[str, int]:
8667
return flight_to_run_schedule(Flight(start_date=earliest, end_date=latest))
8768

8869

89-
def audience_to_targeting(audience: Audience) -> dict[str, Any]:
90-
"""Build a geo-only LinkedIn `targetingCriteria` payload.
91-
92-
This is the static, client-free baseline (locations only). Live B2B facet
93-
resolution — industries, job functions, titles, seniorities, company sizes,
94-
skills — needs API lookups and lives in `targeting.TargetingResolver`, which
95-
the publish flow uses. This helper remains for inspection/test scaffolding.
96-
"""
97-
includes: list[str] = []
98-
for code in audience.geos:
99-
urn = ISO_TO_LINKEDIN_GEO_URN.get(code.upper())
100-
if urn:
101-
includes.append(urn)
102-
if not includes:
103-
# LinkedIn requires at least one location; default to US.
104-
includes.append(ISO_TO_LINKEDIN_GEO_URN["US"])
105-
106-
return {
107-
"include": {
108-
"and": [
109-
{
110-
"or": {
111-
"urn:li:adTargetingFacet:locations": includes,
112-
}
113-
}
114-
]
115-
}
116-
}
117-
118-
11970
def line_item_locale(audience: Audience) -> dict[str, str]:
12071
"""LinkedIn campaigns require a `locale` (country + language).
12172
122-
Derived from the first audience geo if available; defaults to en/US.
73+
Derived from the first audience geo if it is a valid ISO 3166-1 alpha-2
74+
code; defaults to en/US otherwise.
12375
"""
124-
country = (audience.geos[0].upper() if audience.geos else "US")
125-
if country not in ISO_TO_LINKEDIN_GEO_URN:
76+
country = audience.geos[0].upper() if audience.geos else "US"
77+
if pycountry.countries.get(alpha_2=country) is None:
12678
country = "US"
12779
return {"country": country, "language": "en"}
12880

12981

130-
def line_item_payload(
131-
line_item: LineItem,
132-
*,
133-
campaign_group_urn: str,
134-
objective_type: str,
135-
) -> dict[str, Any]:
136-
"""Strict-typed snapshot of the create_campaign call for testing/inspection."""
137-
return {
138-
"campaignGroup": campaign_group_urn,
139-
"name": line_item.name,
140-
"objectiveType": objective_type,
141-
"type": DEFAULT_CAMPAIGN_TYPE,
142-
"totalBudget": money_to_linkedin_amount(
143-
line_item.budget.amount, line_item.budget.currency
144-
),
145-
"runSchedule": flight_to_run_schedule(line_item.flight),
146-
"targetingCriteria": audience_to_targeting(line_item.targeting.audience),
147-
"locale": line_item_locale(line_item.targeting.audience),
148-
}
149-
150-
15182
def post_article_content(creative: CreativeAsset) -> dict[str, Any]:
15283
"""Build the `article` block for a Posts API dark post.
15384
@@ -181,15 +112,12 @@ def creative_content_reference(post_urn: str) -> dict[str, Any]:
181112

182113
__all__ = [
183114
"DEFAULT_CAMPAIGN_TYPE",
184-
"ISO_TO_LINKEDIN_GEO_URN",
185115
"OBJECTIVE_TO_LINKEDIN",
186-
"audience_to_targeting",
187116
"campaign_objective",
188117
"campaign_run_schedule",
189118
"creative_content_reference",
190119
"flight_to_run_schedule",
191120
"line_item_locale",
192-
"line_item_payload",
193121
"money_to_linkedin_amount",
194122
"post_article_content",
195123
"post_commentary",

src/yieldagent/integrations/linkedin/targeting.py

Lines changed: 53 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@
1111
and match names against it. URNs are `urn:li:seniority:{id}` / `urn:li:function:{id}`.
1212
* Open taxonomies — industries, titles, skills — are resolved via the
1313
typeahead finder, which returns `{urn, name}` ranked by relevance.
14-
* company_sizes — a fixed 9-bucket enum whose targeting values are plain
15-
strings (`SIZE_11_TO_50`, ...), mapped statically.
16-
* locations — geo URNs from the static ISO map in `mapping`.
14+
* company_sizes — a fixed 9-bucket enum whose targeting values are range
15+
tuple URNs (`urn:li:staffCountRange:(min,max)`), mapped statically.
16+
* locations — the brief carries ISO 3166-1 alpha-2 codes; each is expanded to
17+
its country name via `pycountry` and resolved through the same typeahead
18+
finder, so any country works (not just a hardcoded shortlist).
1719
1820
Names that resolve to no real LinkedIn URN are never guessed: they are collected
1921
in `ResolvedTargeting.unresolved` so the caller can surface them as a manual step
@@ -25,9 +27,9 @@
2527
from dataclasses import dataclass, field
2628
from typing import Any, Protocol
2729

28-
from yieldagent.domain import Audience
30+
import pycountry
2931

30-
from .mapping import ISO_TO_LINKEDIN_GEO_URN
32+
from yieldagent.domain import Audience
3133

3234
FACET_LOCATIONS = "urn:li:adTargetingFacet:locations"
3335
FACET_SENIORITIES = "urn:li:adTargetingFacet:seniorities"
@@ -37,6 +39,11 @@
3739
FACET_SKILLS = "urn:li:adTargetingFacet:skills"
3840
FACET_STAFF_COUNT = "urn:li:adTargetingFacet:staffCountRanges"
3941

42+
# LinkedIn requires at least one location. When a brief names no resolvable
43+
# country we fall back to this (United States) so the campaign is still valid;
44+
# the unresolved codes are still surfaced so the caller knows to fix them.
45+
DEFAULT_GEO_URN = "urn:li:geo:103644278"
46+
4047
# Brief company-size buckets -> LinkedIn staffCountRange URNs. In targetingCriteria
4148
# the value is a range tuple URN (min,max), NOT the SIZE_* enum the facet listing
4249
# returns; the open-ended top bucket uses INT_MAX (2147483647) as its upper bound.
@@ -79,6 +86,19 @@ def _localized_name(entity: dict[str, Any]) -> str | None:
7986
return entity.get("name", {}).get("localized", {}).get("en_US")
8087

8188

89+
def _country_name(code: str) -> str | None:
90+
"""Map an ISO 3166-1 alpha-2 code to a country name LinkedIn will recognise.
91+
92+
Prefers the colloquial `common_name` (e.g. "South Korea" over "Korea,
93+
Republic of") since that is what the locations typeahead indexes. Returns
94+
None for codes `pycountry` does not know, so they surface as unresolved.
95+
"""
96+
country = pycountry.countries.get(alpha_2=code.strip().upper())
97+
if country is None:
98+
return None
99+
return getattr(country, "common_name", None) or country.name
100+
101+
82102
def _best_typeahead_match(query: str, hits: list[dict[str, Any]]) -> str | None:
83103
"""Prefer an exact (normalized) name match; else LinkedIn's top-ranked hit.
84104
@@ -163,21 +183,36 @@ def _resolve_company_sizes(sizes: list[str]) -> tuple[list[str], list[str]]:
163183
unresolved.append(size)
164184
return values, unresolved
165185

166-
@staticmethod
167-
def _geo_urns(audience: Audience) -> list[str]:
168-
urns = [
169-
ISO_TO_LINKEDIN_GEO_URN[code.upper()]
170-
for code in audience.geos
171-
if code.upper() in ISO_TO_LINKEDIN_GEO_URN
172-
]
173-
# LinkedIn requires at least one location.
174-
return urns or [ISO_TO_LINKEDIN_GEO_URN["US"]]
186+
async def _resolve_geos(self, audience: Audience) -> tuple[list[str], list[str]]:
187+
"""Resolve ISO country codes to geo URNs via the locations typeahead.
188+
189+
Each code is expanded to a country name (`pycountry`) and looked up; a
190+
code we cannot expand, or that the typeahead does not match, is returned
191+
as unresolved. Falls back to the default location when nothing resolves,
192+
since LinkedIn requires at least one.
193+
"""
194+
urns: list[str] = []
195+
unresolved: list[str] = []
196+
for code in audience.geos:
197+
name = _country_name(code)
198+
urn = None
199+
if name:
200+
hits = await self._client.typeahead_targeting_entities(
201+
facet=FACET_LOCATIONS, query=name
202+
)
203+
urn = _best_typeahead_match(name, hits)
204+
if urn:
205+
urns.append(urn)
206+
else:
207+
unresolved.append(code)
208+
return urns or [DEFAULT_GEO_URN], unresolved
175209

176210
async def resolve(self, audience: Audience) -> ResolvedTargeting:
177-
clauses: list[dict[str, Any]] = [
178-
{"or": {FACET_LOCATIONS: self._geo_urns(audience)}}
179-
]
211+
geo_urns, geo_unresolved = await self._resolve_geos(audience)
212+
clauses: list[dict[str, Any]] = [{"or": {FACET_LOCATIONS: geo_urns}}]
180213
unresolved: dict[str, list[str]] = {}
214+
if geo_unresolved:
215+
unresolved["geos"] = geo_unresolved
181216

182217
def _add(facet: str, urns: list[str], missing: list[str], key: str) -> None:
183218
if urns:
@@ -218,6 +253,7 @@ def _add(facet: str, urns: list[str], missing: list[str], key: str) -> None:
218253

219254
__all__ = [
220255
"COMPANY_SIZE_TO_STAFF_RANGE",
256+
"DEFAULT_GEO_URN",
221257
"FACET_INDUSTRIES",
222258
"FACET_JOB_FUNCTIONS",
223259
"FACET_LOCATIONS",

src/yieldagent/integrations/meta/mapping.py

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from decimal import Decimal
1111
from typing import Any
1212

13-
from yieldagent.domain import Audience, Campaign, CreativeAsset, Flight, LineItem, Objective
13+
from yieldagent.domain import Audience, Campaign, CreativeAsset, Flight, Objective
1414

1515
OBJECTIVE_TO_META: dict[Objective, str] = {
1616
Objective.awareness: "OUTCOME_AWARENESS",
@@ -67,20 +67,6 @@ def audience_to_targeting(audience: Audience) -> dict[str, Any]:
6767
return targeting
6868

6969

70-
def line_item_payload(line_item: LineItem, campaign_id: str) -> dict[str, Any]:
71-
start, end = flight_to_meta_times(line_item.flight)
72-
return {
73-
"campaign_id": campaign_id,
74-
"name": line_item.name,
75-
"lifetime_budget_minor": to_minor_units(
76-
line_item.budget.amount, line_item.budget.currency
77-
),
78-
"start_time": start,
79-
"end_time": end,
80-
"targeting": audience_to_targeting(line_item.targeting.audience),
81-
}
82-
83-
8470
def creative_payload(creative: CreativeAsset, page_id: str) -> dict[str, Any]:
8571
"""Build a minimal link-ad creative.
8672

tests/integrations/test_linkedin_publish.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ async def __aexit__(self, *_exc: object) -> None:
6868
def assert_account_allowed(self) -> None:
6969
pass
7070

71+
async def typeahead_targeting_entities(self, *, facet: str, query: str) -> list[dict]:
72+
# The audience here is geo-only; resolve the locations typeahead and
73+
# leave every other facet empty.
74+
if facet.endswith("locations"):
75+
return [{"urn": "urn:li:geo:103644278", "name": "United States"}]
76+
return []
77+
7178
async def get_ad_account(self) -> dict:
7279
return {"reference": _ORG_URN}
7380

tests/integrations/test_linkedin_targeting.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from yieldagent.domain import Audience
1111
from yieldagent.integrations.linkedin.targeting import (
12+
DEFAULT_GEO_URN,
1213
FACET_INDUSTRIES,
1314
FACET_JOB_FUNCTIONS,
1415
FACET_LOCATIONS,
@@ -20,6 +21,7 @@
2021
)
2122

2223
_US_GEO = "urn:li:geo:103644278"
24+
_PT_GEO = "urn:li:geo:100364837"
2325

2426

2527
def _named(id_: int, name: str) -> dict:
@@ -35,6 +37,13 @@ def __init__(self) -> None:
3537
self.typeahead_calls: list[tuple[str, str]] = []
3638
# facet -> query(lowercased) -> hits
3739
self.typeahead: dict[str, dict[str, list[dict]]] = {
40+
FACET_LOCATIONS: {
41+
"united states": [{"urn": _US_GEO, "name": "United States"}],
42+
"portugal": [
43+
{"urn": _PT_GEO, "name": "Portugal"},
44+
{"urn": "urn:li:geo:105374601", "name": "Porto, Portugal"},
45+
],
46+
},
3847
FACET_INDUSTRIES: {
3948
"advertising": [{"urn": "urn:li:industry:80", "name": "Advertising Services"}],
4049
},
@@ -76,10 +85,26 @@ def _clause_facets(criteria: dict) -> dict[str, list]:
7685
async def test_geo_only_defaults_to_us_when_empty() -> None:
7786
resolver = TargetingResolver(_FakeTargetingClient())
7887
resolved = await resolver.resolve(Audience(description="x", geos=[]))
79-
assert _clause_facets(resolved.criteria) == {FACET_LOCATIONS: [_US_GEO]}
88+
assert _clause_facets(resolved.criteria) == {FACET_LOCATIONS: [DEFAULT_GEO_URN]}
8089
assert resolved.unresolved == {}
8190

8291

92+
async def test_geos_resolve_via_typeahead_by_country_name() -> None:
93+
resolver = TargetingResolver(_FakeTargetingClient())
94+
resolved = await resolver.resolve(Audience(description="x", geos=["US", "PT"]))
95+
# "PT" -> "Portugal" exact match wins over "Porto, Portugal".
96+
assert _clause_facets(resolved.criteria) == {FACET_LOCATIONS: [_US_GEO, _PT_GEO]}
97+
assert resolved.unresolved == {}
98+
99+
100+
async def test_unknown_geo_code_is_unresolved_not_guessed() -> None:
101+
resolver = TargetingResolver(_FakeTargetingClient())
102+
resolved = await resolver.resolve(Audience(description="x", geos=["ZZ"]))
103+
# Invalid ISO code -> surfaced as unresolved; locations falls back to default.
104+
assert _clause_facets(resolved.criteria) == {FACET_LOCATIONS: [DEFAULT_GEO_URN]}
105+
assert resolved.unresolved == {"geos": ["ZZ"]}
106+
107+
83108
async def test_enum_facets_resolve_and_surface_misses() -> None:
84109
resolver = TargetingResolver(_FakeTargetingClient())
85110
resolved = await resolver.resolve(

0 commit comments

Comments
 (0)