Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 20 additions & 22 deletions src/ad_buyer/clients/mixpeek_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,24 @@

# Brand-safety sensitive IAB categories that advertisers typically
# want to avoid or require explicit opt-in for.
BRAND_UNSAFE_CATEGORIES = frozenset({
"Poker and Professional Gambling",
"Casinos & Gambling",
"Casino Games",
"Lotteries and Scratchcards",
"Sensitive Topics",
"Adult Content",
"Illegal Content",
"Debated Sensitive Social Topics",
"Terrorism",
"Crime",
"Drugs",
"Tobacco",
"Arms & Ammunition",
"Death & Grieving",
})
BRAND_UNSAFE_CATEGORIES = frozenset(
{
"Poker and Professional Gambling",
"Casinos & Gambling",
"Casino Games",
"Lotteries and Scratchcards",
"Sensitive Topics",
"Adult Content",
"Illegal Content",
"Debated Sensitive Social Topics",
"Terrorism",
"Crime",
"Drugs",
"Tobacco",
"Arms & Ammunition",
"Death & Grieving",
}
)


class MixpeekError(Exception):
Expand Down Expand Up @@ -127,16 +129,12 @@ async def _request(

async def list_taxonomies(self, namespace: str | None = None) -> list[dict]:
"""List available taxonomies in the namespace."""
data = await self._request(
"POST", "/v1/taxonomies/list", namespace=namespace, json={}
)
data = await self._request("POST", "/v1/taxonomies/list", namespace=namespace, json={})
return data.get("results", [])

async def list_retrievers(self, namespace: str | None = None) -> list[dict]:
"""List available retriever pipelines in the namespace."""
data = await self._request(
"POST", "/v1/retrievers/list", namespace=namespace, json={}
)
data = await self._request("POST", "/v1/retrievers/list", namespace=namespace, json={})
return data.get("results", [])

async def classify_content(
Expand Down
12 changes: 7 additions & 5 deletions src/ad_buyer/crews/channel_crews.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,13 @@ def _create_research_tools(client: OpenDirectClient) -> list[Any]:

# Add Mixpeek contextual enrichment tools when configured
if settings.mixpeek_api_key:
tools.extend([
ClassifyContentTool(),
BrandSafetyTool(),
ContextualSearchTool(),
])
tools.extend(
[
ClassifyContentTool(),
BrandSafetyTool(),
ContextualSearchTool(),
]
)

return tools

Expand Down
24 changes: 14 additions & 10 deletions src/ad_buyer/interfaces/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
from mcp.server.fastmcp.prompts.base import Message

from ..auth.key_store import ApiKeyStore
from ..clients.mixpeek_client import MixpeekClient, MixpeekError
from ..config.settings import Settings
from ..media_kit.client import MediaKitClient
from ..media_kit.models import MediaKitError
Expand All @@ -69,7 +70,6 @@
ManualDealEntry,
create_manual_deal,
)
from ..clients.mixpeek_client import MixpeekClient, MixpeekError

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -2982,13 +2982,17 @@ async def classify_content(
if not rid:
rid = await _discover_iab_retriever(client)
if not rid:
return json.dumps({
"error": "No IAB retriever found in this namespace. "
"Set MIXPEEK_NAMESPACE or pass retriever_id explicitly."
})
return json.dumps(
{
"error": "No IAB retriever found in this namespace. "
"Set MIXPEEK_NAMESPACE or pass retriever_id explicitly."
}
)

result = await client.classify_content(
retriever_id=rid, text=text, limit=limit,
retriever_id=rid,
text=text,
limit=limit,
)
docs = result.get("documents", [])
categories = [
Expand Down Expand Up @@ -3031,12 +3035,12 @@ async def check_brand_safety(
if not rid:
rid = await _discover_iab_retriever(client)
if not rid:
return json.dumps({
"error": "No IAB retriever found in this namespace."
})
return json.dumps({"error": "No IAB retriever found in this namespace."})

result = await client.check_brand_safety(
retriever_id=rid, text=text, threshold=threshold,
retriever_id=rid,
text=text,
threshold=threshold,
)
return json.dumps(result, indent=2)
except MixpeekError as exc:
Expand Down
37 changes: 20 additions & 17 deletions src/ad_buyer/tools/research/contextual_enrichment.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from __future__ import annotations

import json
from typing import Any

from crewai.tools import BaseTool
from pydantic import BaseModel, Field
Expand All @@ -31,6 +30,7 @@ def _get_mixpeek_client() -> MixpeekClient:
# 1. Content Classification (IAB Taxonomy)
# -----------------------------------------------------------------------


class ClassifyContentInput(BaseModel):
"""Input for contextual content classification."""

Expand Down Expand Up @@ -97,14 +97,18 @@ async def _arun(
if not rid:
rid = await _discover_iab_retriever(client)
if not rid:
return json.dumps({
"error": "No IAB retriever found in this namespace. "
"Set MIXPEEK_NAMESPACE to a namespace with IAB data, "
"or pass retriever_id explicitly."
})
return json.dumps(
{
"error": "No IAB retriever found in this namespace. "
"Set MIXPEEK_NAMESPACE to a namespace with IAB data, "
"or pass retriever_id explicitly."
}
)

result = await client.classify_content(
retriever_id=rid, text=text, limit=limit,
retriever_id=rid,
text=text,
limit=limit,
)

# Simplify output for the agent
Expand All @@ -130,6 +134,7 @@ async def _arun(
# 2. Brand Safety Check
# -----------------------------------------------------------------------


class BrandSafetyInput(BaseModel):
"""Input for brand-safety evaluation."""

Expand Down Expand Up @@ -174,9 +179,7 @@ def _run(
retriever_id: str | None = None,
threshold: float = 0.80,
) -> str:
return run_async(
self._arun(text=text, retriever_id=retriever_id, threshold=threshold)
)
return run_async(self._arun(text=text, retriever_id=retriever_id, threshold=threshold))

async def _arun(
self,
Expand All @@ -193,12 +196,12 @@ async def _arun(
if not rid:
rid = await _discover_iab_retriever(client)
if not rid:
return json.dumps({
"error": "No IAB retriever found in this namespace."
})
return json.dumps({"error": "No IAB retriever found in this namespace."})

result = await client.check_brand_safety(
retriever_id=rid, text=text, threshold=threshold,
retriever_id=rid,
text=text,
threshold=threshold,
)
return json.dumps(result, indent=2)

Expand All @@ -212,6 +215,7 @@ async def _arun(
# 3. Contextual Search (inventory enrichment)
# -----------------------------------------------------------------------


class ContextualSearchInput(BaseModel):
"""Input for contextual inventory search."""

Expand Down Expand Up @@ -253,9 +257,7 @@ def _run(
retriever_id: str = "",
limit: int = 10,
) -> str:
return run_async(
self._arun(query=query, retriever_id=retriever_id, limit=limit)
)
return run_async(self._arun(query=query, retriever_id=retriever_id, limit=limit))

async def _arun(
self,
Expand All @@ -281,6 +283,7 @@ async def _arun(
# Helpers
# -----------------------------------------------------------------------


async def _discover_iab_retriever(client: MixpeekClient) -> str | None:
"""Find an IAB text search retriever in the current namespace."""
retrievers = await client.list_retrievers()
Expand Down
22 changes: 12 additions & 10 deletions tests/e2e/test_mixpeek_production.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,7 @@
NAMESPACE = os.environ.get("MIXPEEK_NAMESPACE", "golden_adtech_iab")

# Known retriever in golden_adtech_iab namespace
IAB_TEXT_RETRIEVER = os.environ.get(
"MIXPEEK_IAB_RETRIEVER_ID", "ret_f7fbefced358bd"
)
IAB_TEXT_RETRIEVER = os.environ.get("MIXPEEK_IAB_RETRIEVER_ID", "ret_f7fbefced358bd")

pytestmark = [
pytest.mark.e2e,
Expand All @@ -46,6 +44,7 @@
# Fixtures
# ---------------------------------------------------------------------------


@pytest_asyncio.fixture
async def client():
c = MixpeekClient(
Expand All @@ -61,6 +60,7 @@ async def client():
# 1. Health & Discovery
# ---------------------------------------------------------------------------


class TestHealthAndDiscovery:
@pytest.mark.asyncio
async def test_mcp_health(self, client: MixpeekClient):
Expand All @@ -85,15 +85,14 @@ async def test_list_retrievers(self, client: MixpeekClient):
retrievers = await client.list_retrievers()
assert len(retrievers) > 0
names = {r["retriever_name"] for r in retrievers}
assert any("iab" in n.lower() for n in names), (
f"No IAB retriever found. Available: {names}"
)
assert any("iab" in n.lower() for n in names), f"No IAB retriever found. Available: {names}"


# ---------------------------------------------------------------------------
# 2. IAB Content Classification
# ---------------------------------------------------------------------------


class TestIABClassification:
@pytest.mark.asyncio
async def test_classify_sports_content(self, client: MixpeekClient):
Expand All @@ -113,7 +112,9 @@ async def test_classify_sports_content(self, client: MixpeekClient):
assert "Sports" in top["iab_path"]
# Top result should be American Football or Sports
assert top["iab_category_name"] in (
"American Football", "Sports", "College Football",
"American Football",
"Sports",
"College Football",
)

@pytest.mark.asyncio
Expand Down Expand Up @@ -153,9 +154,7 @@ async def test_classify_food_content(self, client: MixpeekClient):
top = docs[0]
assert top["score"] > 0.80
# Should be in Food & Drink or Cooking
paths_flat = [
cat for d in docs[:3] for cat in d.get("iab_path", [])
]
paths_flat = [cat for d in docs[:3] for cat in d.get("iab_path", [])]
assert "Food & Drink" in paths_flat or "Cooking" in paths_flat

@pytest.mark.asyncio
Expand Down Expand Up @@ -196,6 +195,7 @@ async def test_classify_returns_hierarchical_paths(self, client: MixpeekClient):
# 3. Brand Safety
# ---------------------------------------------------------------------------


class TestBrandSafety:
@pytest.mark.asyncio
async def test_safe_content(self, client: MixpeekClient):
Expand Down Expand Up @@ -255,6 +255,7 @@ async def test_brand_safety_threshold(self, client: MixpeekClient):
# 4. Contextual Search
# ---------------------------------------------------------------------------


class TestContextualSearch:
@pytest.mark.asyncio
async def test_search_returns_results(self, client: MixpeekClient):
Expand Down Expand Up @@ -305,6 +306,7 @@ async def test_search_returns_iab_enriched_results(self, client: MixpeekClient):
# 5. Error Handling
# ---------------------------------------------------------------------------


class TestErrorHandling:
@pytest.mark.asyncio
async def test_invalid_retriever_id(self, client: MixpeekClient):
Expand Down
22 changes: 10 additions & 12 deletions tests/unit/test_contextual_enrichment.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,7 @@ async def test_classify_with_retriever_id(self, classify_tool):
"ad_buyer.tools.research.contextual_enrichment._get_mixpeek_client",
return_value=mock_client,
):
result = await classify_tool._arun(
text="NFL scores", retriever_id="ret-123"
)
result = await classify_tool._arun(text="NFL scores", retriever_id="ret-123")

data = json.loads(result)
assert data["categories"][0]["category"] == "Sports"
Expand All @@ -83,10 +81,12 @@ async def test_classify_auto_discovers_retriever(self, classify_tool):
"ad_buyer.tools.research.contextual_enrichment._get_mixpeek_client",
return_value=mock_client,
):
result = await classify_tool._arun(text="test content")
await classify_tool._arun(text="test content")

mock_client.classify_content.assert_called_once_with(
retriever_id="ret-1", text="test content", limit=10,
retriever_id="ret-1",
text="test content",
limit=10,
)

@pytest.mark.asyncio
Expand Down Expand Up @@ -141,9 +141,7 @@ async def test_unsafe_content(self, brand_safety_tool):
mock_client.check_brand_safety.return_value = {
"safe": False,
"risk_level": "high",
"flagged_categories": [
{"category": "Casinos & Gambling", "score": 0.88}
],
"flagged_categories": [{"category": "Casinos & Gambling", "score": 0.88}],
"categories": [],
}
mock_client.close = AsyncMock()
Expand Down Expand Up @@ -175,13 +173,13 @@ async def test_search(self, search_tool):
"ad_buyer.tools.research.contextual_enrichment._get_mixpeek_client",
return_value=mock_client,
):
result = await search_tool._arun(
query="sports news", retriever_id="ret-1", limit=5
)
result = await search_tool._arun(query="sports news", retriever_id="ret-1", limit=5)

data = json.loads(result)
assert data["documents"][0]["score"] == 0.85
mock_client.search_content.assert_called_once_with(
retriever_id="ret-1", query="sports news", limit=5,
retriever_id="ret-1",
query="sports news",
limit=5,
)
mock_client.close.assert_called_once()
Loading
Loading