Skip to content

Commit

Permalink
167 add geo targeting criteria country city (#334)
Browse files Browse the repository at this point in the history
* Implement add_geo_targeting_to_campaign endpoint

* Integrate create_geo_targeting_for_campaign to the Google ads team

* Update tests for geo targeting

* Fix execute_query exception message when gads authentication error happens

* Update prompts for gads geo targeting
  • Loading branch information
rjambrecic authored Feb 15, 2024
1 parent 36c24c9 commit aa7155a
Show file tree
Hide file tree
Showing 8 changed files with 278 additions and 8 deletions.
36 changes: 36 additions & 0 deletions captn/captn_agents/backend/function_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -885,6 +885,42 @@
},
}

create_geo_targeting_for_campaign_config = {
"name": "create_geo_targeting_for_campaign",
"description": f"""Creates geographical targeting on the campaign level.
When the client provides the location names (country/city/region), use the 'location_names' parameter without the 'location_ids' parameter. By doing so, you will receive a list of avaliable locations and their IDs.
Once the client approves the locations, you can use the 'location_ids' parameter to create the geo targeting for the campaign.
location_ids and location_names parameters are mutually exclusive and they can NOT be set to None at the same time.
{MODIFICATION_WARNING}""",
"parameters": {
"type": "object",
"properties": {
"customer_id": properties_config["customer_id"],
"campaign_id": properties_config["campaign_id"],
"clients_approval_message": properties_config["clients_approval_message"],
"client_approved_modicifation_for_this_resource": properties_config[
"client_approved_modicifation_for_this_resource"
],
"location_ids": {
"type": "array",
"items": {"type": "string"},
"description": "A list of location IDs",
},
"location_names": {
"type": "array",
"items": {"type": "string"},
"description": "A list of location names e.g. ['Croaita', 'Zagreb']. These values MUST be provided by the client, do NOT improvise!",
},
},
"required": [
"customer_id",
"campaign_id",
"clients_approval_message",
"client_approved_modicifation_for_this_resource",
],
},
}

remove_google_ads_resource_config = {
"name": "remove_google_ads_resource",
"description": f"Removes the google ads resource. {MODIFICATION_WARNING}",
Expand Down
36 changes: 30 additions & 6 deletions captn/captn_agents/backend/google_ads_team.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
AdGroupCriterion,
Campaign,
CampaignCriterion,
GeoTargetCriterion,
RemoveResource,
)

Expand All @@ -26,6 +27,7 @@
create_ad_group_ad_config,
create_ad_group_config,
create_campaign_config,
create_geo_targeting_for_campaign_config,
create_keyword_for_ad_group_config,
create_negative_keyword_for_campaign_config,
execute_query_config,
Expand Down Expand Up @@ -71,6 +73,7 @@ class GoogleAdsTeam(Team):
get_info_from_the_web_page_config,
create_ad_group_config,
create_campaign_config,
create_geo_targeting_for_campaign_config,
]

_shared_system_message = (
Expand Down Expand Up @@ -221,7 +224,6 @@ def _guidelines(self) -> str:
20. Always double check with the client for which customer/campaign/ad-group/ad the updates needs to be done
21. NEVER suggest making changes which you can NOT perform!
Do NOT suggest making changes of the following things, otherwise you will be penalized:
- Targeting settings
- Ad Extensions
- Budgeting
- Ad Scheduling
Expand Down Expand Up @@ -279,9 +281,9 @@ def _guidelines(self) -> str:
- create/update/remove headlines and descriptions in the Ad Copy. Make sure to follow the restrictions for the headlines and descriptions (MAXIMUM 30 characters for headlines and MAXIMUM 90 characters for descriptions)
- create/update/remove new keywords
- create/update/remove campaign/ ad group / ad / positive and negative keywords
- create/remove Geo Targeting for the campaign
Do NOT suggest making changes of the following things:
- Targeting settings
- Ad Extensions
- Budgeting
- Ad Scheduling
Expand All @@ -293,7 +295,7 @@ def _guidelines(self) -> str:
Currently we are in a demo phase and clients need to see what we are CURRENTLY able to do.
So you do NOT need to suggest optimal Google Ads solutions, just suggest making changes which we can do right away.
If you are asked to optimize campaigns, start with updating ad copy or creating/removing positive and negative keywords.
If you are asked to optimize campaigns, start with updating ad copy or creating/removing positive/negative keywords and geo targeting.
- Use 'get_info_from_the_web_page' command when the client provides you some url or for already existing ad copies (based on the final_url).
This command can be very useful for figuring out the clients business and what he wants to achieve.
Before asking the client for additional information, ask him for his company/product url and try to figure out as much as possible yourself (WITHOUT doing any permanent modifications).
Expand Down Expand Up @@ -472,17 +474,26 @@ def _commands(self) -> str:
If the client specifies the 'budget_amount_micros' in another currency, you must convert it to the local currency!
Otherwise, incorrect budget will be set for the campaign!
15. 'create_geo_targeting_for_campaign': Creates geographical targeting on the campaign level, params: (customer_id: string,
campaign_id: string, clients_approval_message: string, client_approved_modicifation_for_this_resource: boolean,
location_names: Optional[List[str]], location_ids: Optional[List[str]])
When the client provides the location names (country/city/region), use the 'location_names' parameter without the 'location_ids' parameter. By doing so, you will receive a list of avaliable locations and their IDs.
Once the client approves the locations, you can use the 'location_ids' parameter to create the geo targeting for the campaign.
15. 'remove_google_ads_resource': Removes the google ads resource, params: (customer_id: string, resource_id: string,
Later, if you want to remove the geo targeting, you can use the following query to retrieve the criterion_id and geo_target_constant (location_id and name):
SELECT campaign_criterion.criterion_id, campaign_criterion.location.geo_target_constant, campaign_criterion.negative, campaign_criterion.type FROM campaign_criterion WHERE campaign_criterion.type = 'LOCATION' AND campaign.id = '121212'"
SELECT geo_target_constant.name, geo_target_constant.id FROM geo_target_constant WHERE geo_target_constant.id IN ('123', '345')
16. 'remove_google_ads_resource': Removes the google ads resource, params: (customer_id: string, resource_id: string,
resource_type: Literal['campaign', 'ad_group', 'ad', 'ad_group_criterion', 'campaign_criterion'],
clients_approval_message: string, parent_id: Optional[string], client_approved_modicifation_for_this_resource: boolean)
If not explicitly asked, you MUST ask the client for approval before removing any kind of resource!!!!
16. 'remove_ad_copy_headline_or_description_config': Remove headline and/or description from the the Google Ads Copy,
17. 'remove_ad_copy_headline_or_description_config': Remove headline and/or description from the the Google Ads Copy,
params: (customer_id: string, ad_id: string, clients_approval_message: string, client_approved_modicifation_for_this_resource: boolean
update_existing_headline_index: Optional[str], update_existing_description_index: Optional[str])
17. 'get_info_from_the_web_page': Retrieve wanted information from the web page, params: (url: string, task: string, task_guidelines: string)
18. 'get_info_from_the_web_page': Retrieve wanted information from the web page, params: (url: string, task: string, task_guidelines: string)
It should be used only for the clients web page(s), final_url(s) etc.
This command should be used for retrieving the information from clients web page.
Expand Down Expand Up @@ -756,6 +767,19 @@ def _string_to_list(
),
endpoint="/create-campaign",
),
"create_geo_targeting_for_campaign": lambda customer_id, campaign_id, clients_approval_message, client_approved_modicifation_for_this_resource, location_names=None, location_ids=None: google_ads_create_update(
user_id=user_id,
conv_id=conv_id,
clients_approval_message=clients_approval_message,
client_approved_modicifation_for_this_resource=client_approved_modicifation_for_this_resource,
ad=GeoTargetCriterion(
customer_id=customer_id,
campaign_id=campaign_id,
location_names=location_names,
location_ids=location_ids,
),
endpoint="/create-geo-targeting-for-campaign",
),
"remove_google_ads_resource": lambda customer_id, resource_id, resource_type, clients_approval_message, client_approved_modicifation_for_this_resource, parent_id=None: google_ads_create_update(
user_id=user_id,
conv_id=conv_id,
Expand Down
8 changes: 7 additions & 1 deletion captn/google_ads/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ def clean_error_response(content: bytes) -> str:
)


AUTHENTICATION_ERROR = "Please try to execute the command again."


def execute_query(
user_id: int,
conv_id: int,
Expand All @@ -83,7 +86,10 @@ def execute_query(

response = requests.get(f"{BASE_URL}/search", params=params, timeout=60)
if not response.ok:
content = clean_error_response(response.content)
if AUTHENTICATION_ERROR in response.text:
content = AUTHENTICATION_ERROR
else:
content = clean_error_response(response.content)
raise ValueError(content)

response_json = response.json()
Expand Down
97 changes: 97 additions & 0 deletions google_ads/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
Campaign,
CampaignCriterion,
Criterion,
GeoTargetCriterion,
RemoveResource,
)

Expand Down Expand Up @@ -1159,6 +1160,102 @@ async def add_keywords_to_ad_group(
)


def _get_geo_target_constant_by_names(
client: GoogleAdsClient, location_names: List[str]
) -> str:
gtc_service = client.get_service("GeoTargetConstantService")
gtc_request = client.get_type("SuggestGeoTargetConstantsRequest")

# The location names to get suggested geo target constants.
gtc_request.location_names.names.extend(location_names)

results = gtc_service.suggest_geo_target_constants(gtc_request)

return_text = (
"Below is a list of possible locations in the following format '(name, country_code, target_type)'."
"Please send them to the client as smart suggestions with type 'manyOf' (do not display the location_id to him):\n\n"
)
for suggestion in results.geo_target_constant_suggestions:
geo_target_constant = suggestion.geo_target_constant
text = (
f"location_id: {geo_target_constant.id}, "
f"({geo_target_constant.name}, "
f"{geo_target_constant.country_code}, "
f"{geo_target_constant.target_type}), "
f"is found from search term ({suggestion.search_term}).\n"
)
return_text += text

return return_text


def _create_location_op(
client: GoogleAdsClient, customer_id: str, campaign_id: str, location_id: str
) -> Any:
campaign_service = client.get_service("CampaignService")
geo_target_constant_service = client.get_service("GeoTargetConstantService")

# Create the campaign criterion.
campaign_criterion_operation = client.get_type("CampaignCriterionOperation")
campaign_criterion = campaign_criterion_operation.create
campaign_criterion.campaign = campaign_service.campaign_path(
customer_id, campaign_id
)

campaign_criterion.location.geo_target_constant = (
geo_target_constant_service.geo_target_constant_path(location_id)
)

return campaign_criterion_operation


def _create_locations_by_ids_to_campaign(
client: GoogleAdsClient,
customer_id: str,
campaign_id: str,
location_ids: List[str],
) -> str:
campaign_criterion_service = client.get_service("CampaignCriterionService")

operations = [
_create_location_op(client, customer_id, campaign_id, location_id)
for location_id in location_ids
]

campaign_criterion_response = campaign_criterion_service.mutate_campaign_criteria(
customer_id=customer_id, operations=operations
)

result_msg = ""
for result in campaign_criterion_response.results:
result_msg += f"Added campaign geo target criterion {result.resource_name}.\n"

return result_msg


@router.get("/create-geo-targeting-for-campaign")
async def create_geo_targeting_for_campaign(
user_id: int, model: GeoTargetCriterion = Depends()
) -> str:
if model.location_names is None and model.location_ids is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Either location_names or location_ids must be provided.",
)

client = await _get_client(user_id=user_id)

if model.location_ids is None:
return _get_geo_target_constant_by_names(client=client, location_names=model.location_names) # type: ignore

return _create_locations_by_ids_to_campaign(
client=client,
customer_id=model.customer_id, # type: ignore
campaign_id=model.campaign_id, # type: ignore
location_ids=model.location_ids, # type: ignore
)


@router.get("/remove-google-ads-resource")
async def remove_google_ads_resource(
user_id: int, model: RemoveResource = Depends()
Expand Down
13 changes: 12 additions & 1 deletion google_ads/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,5 +109,16 @@ class RemoveResource(BaseModel):
parent_id: Optional[str] = None
resource_id: str
resource_type: Literal[
"campaign", "ad_group", "ad", "ad_group_criterion", "campaign_criterion"
"campaign",
"ad_group",
"ad",
"ad_group_criterion",
"campaign_criterion",
]


class GeoTargetCriterion(BaseModel):
customer_id: Optional[str] = None
campaign_id: Optional[str] = None
location_ids: Optional[List[str]] = Field(Query(default=None))
location_names: Optional[List[str]] = Field(Query(default=None))
Empty file added tests/ci/__init__.py
Empty file.
66 changes: 66 additions & 0 deletions tests/ci/test_google_ads_application.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import unittest

import pytest
from fastapi import HTTPException

from google_ads.application import create_geo_targeting_for_campaign
from google_ads.model import GeoTargetCriterion


@pytest.mark.asyncio
async def test_add_geo_targeting_to_campaign_raises_exception_if_location_names_and_location_ids_are_none() -> None:
geo_target = GeoTargetCriterion(customer_id="123", campaign_id="456")

with pytest.raises(HTTPException):
await create_geo_targeting_for_campaign(user_id=-1, model=geo_target)


@pytest.mark.asyncio
async def test_add_geo_targeting_to_campaign_raises_exception_if_location_ids_are_none() -> None:
geo_target = GeoTargetCriterion(
customer_id="123",
campaign_id="456",
location_names=["New York"],
location_ids=None,
)

with unittest.mock.patch(
"google_ads.application._get_geo_target_constant_by_names",
) as mock_get_geo_target_constant_by_names:
with unittest.mock.patch(
"google_ads.application._get_client",
) as mock_get_client:
mock_get_geo_target_constant_by_names.return_value = None
mock_get_client.return_value = None

await create_geo_targeting_for_campaign(user_id=-1, model=geo_target)
mock_get_geo_target_constant_by_names.assert_called_once_with(
client=None, location_names=["New York"]
)


@pytest.mark.asyncio
async def test_add_geo_targeting_to_campaign_raises_exception_if_location_ids_are_not_none() -> None:
geo_target = GeoTargetCriterion(
customer_id="123",
campaign_id="456",
location_names=None,
location_ids=["7", "8"],
)

with unittest.mock.patch(
"google_ads.application._create_locations_by_ids_to_campaign",
) as mock_create_locations_by_ids_to_campaign:
with unittest.mock.patch(
"google_ads.application._get_client",
) as mock_get_client:
mock_create_locations_by_ids_to_campaign.return_value = None
mock_get_client.return_value = None

await create_geo_targeting_for_campaign(user_id=-1, model=geo_target)
mock_create_locations_by_ids_to_campaign.assert_called_once_with(
client=None,
location_ids=["7", "8"],
campaign_id="456",
customer_id="123",
)
30 changes: 30 additions & 0 deletions tests/ci/test_google_ads_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import unittest

import pytest
from requests.models import Response

from captn.google_ads.client import (
ALREADY_AUTHENTICATED,
AUTHENTICATION_ERROR,
execute_query,
)


def test_execute_query_when_authentication_error_occurs_raises_value_error() -> None:
with unittest.mock.patch(
"captn.google_ads.client.get_login_url",
) as mock_get_login_url:
with unittest.mock.patch(
"requests.get",
) as mock_requests_get:
mock_get_login_url.return_value = {"login_url": ALREADY_AUTHENTICATED}

response = Response()
response.status_code = 500
response._content = b'{"detail":"Please try to execute the command again."}'
mock_requests_get.return_value = response

with pytest.raises(ValueError) as exc_info:
execute_query(user_id=-1, conv_id=-1)

assert exc_info.value.args[0] == AUTHENTICATION_ERROR

0 comments on commit aa7155a

Please sign in to comment.