Skip to content

Commit 8804826

Browse files
esteiningerSirajmx
andauthored
feat: Mixpeek contextual enrichment for IAB taxonomy classification (#78)
* feat: add Mixpeek contextual enrichment for IAB taxonomy classification Add MixpeekClient and contextual enrichment tools that enable buyer agents to classify content into IAB v3.0 taxonomy categories and search indexed inventory via Mixpeek retriever pipelines. New files: - MixpeekClient: async HTTP client for Mixpeek content-intelligence API - ClassifyContentTool: CrewAI tool for IAB taxonomy classification - ContextualSearchTool: CrewAI tool for multimodal inventory search - MCP tools: classify_content and contextual_search via @mcp.tool() - Unit tests: 18 tests covering client and tool behavior Configuration: MIXPEEK_API_KEY, MIXPEEK_BASE_URL, MIXPEEK_NAMESPACE env vars (all optional, tools gracefully degrade when unconfigured). * feat: add brand-safety scoring, production e2e tests, crew integration Major improvements to the Mixpeek contextual enrichment integration: - Rearchitect classify_content to use retriever-based IAB classification (semantic search against IAB category corpus) instead of batch taxonomy execute endpoint which returns empty for real-time queries - Add check_brand_safety method and BrandSafetyTool that flags sensitive IAB categories (gambling, adult, etc.) with risk levels - Add auto-discovery of IAB retrievers when no retriever_id is specified - Wire ClassifyContentTool, BrandSafetyTool, ContextualSearchTool into the research crew (channel_crews.py) — tools activate when MIXPEEK_API_KEY is configured - Register check_brand_safety as MCP tool alongside classify_content and contextual_search - Add 16 e2e tests hitting production Mixpeek API (golden_adtech_iab namespace with 700+ IAB category documents) verifying: - Sports content → "American Football" (score > 0.80) - Automotive content → "Automotive" hierarchy (score > 0.80) - Food content → "Food & Drink" / "Cooking" - Tech content → "Artificial Intelligence" (score > 0.85) - Gambling content flagged as brand-unsafe (high risk) - Safe content not flagged - Threshold filtering works correctly - Error handling for invalid keys and retriever IDs All 41 tests pass (25 unit + 16 e2e against production). --------- Co-authored-by: Siraj M <sirajkodescan@gmail.com>
1 parent d3c8e9b commit 8804826

12 files changed

Lines changed: 1535 additions & 2 deletions

File tree

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ POSTGRES_POOL_MAX=10
4040
# Routes durable data (deals, campaigns, orders) to Postgres
4141
# and ephemeral data (sessions, caches, locks) to Redis
4242

43+
# Mixpeek Contextual Enrichment (optional: enables classify_content and contextual_search tools)
44+
MIXPEEK_API_KEY=
45+
MIXPEEK_BASE_URL=https://api.mixpeek.com
46+
MIXPEEK_NAMESPACE=
47+
4348
# Environment
4449
ENVIRONMENT=development
4550
LOG_LEVEL=INFO

src/ad_buyer/clients/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from .a2a_client import A2AClient, A2AError, A2AResponse
77
from .deals_client import DealsClient, DealsClientError
88
from .mcp_client import IABMCPClient, MCPClientError, MCPToolResult
9+
from .mixpeek_client import MixpeekClient, MixpeekError
910
from .opendirect_client import OpenDirectClient
1011
from .sgp_client import SGPAuthError, SGPClient, SGPClientError
1112
from .ucp_client import UCPClient, UCPExchangeResult
@@ -32,6 +33,9 @@
3233
# IAB Deals API v1.0 client (quote-then-book flow)
3334
"DealsClient",
3435
"DealsClientError",
36+
# Mixpeek contextual enrichment
37+
"MixpeekClient",
38+
"MixpeekError",
3539
# IAB Diligence Platform (SGP) approval gate
3640
"SGPClient",
3741
"SGPClientError",
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
# Mixpeek contextual enrichment client for the Ad Buyer Agent.
2+
#
3+
# Provides IAB taxonomy classification via retriever pipelines,
4+
# brand-safety scoring, and contextual inventory search via the
5+
# Mixpeek REST API.
6+
7+
from __future__ import annotations
8+
9+
import logging
10+
from typing import Any
11+
12+
import httpx
13+
14+
logger = logging.getLogger(__name__)
15+
16+
# Default timeout for Mixpeek API calls (seconds).
17+
_DEFAULT_TIMEOUT = 30.0
18+
19+
# Brand-safety sensitive IAB categories that advertisers typically
20+
# want to avoid or require explicit opt-in for.
21+
BRAND_UNSAFE_CATEGORIES = frozenset({
22+
"Poker and Professional Gambling",
23+
"Casinos & Gambling",
24+
"Casino Games",
25+
"Lotteries and Scratchcards",
26+
"Sensitive Topics",
27+
"Adult Content",
28+
"Illegal Content",
29+
"Debated Sensitive Social Topics",
30+
"Terrorism",
31+
"Crime",
32+
"Drugs",
33+
"Tobacco",
34+
"Arms & Ammunition",
35+
"Death & Grieving",
36+
})
37+
38+
39+
class MixpeekError(Exception):
40+
"""Raised when a Mixpeek API call fails."""
41+
42+
def __init__(self, message: str, status_code: int | None = None):
43+
super().__init__(message)
44+
self.status_code = status_code
45+
46+
47+
class MixpeekClient:
48+
"""Async HTTP client for the Mixpeek content-intelligence API.
49+
50+
The client wraps capabilities useful during buyer-agent research
51+
and execution phases:
52+
53+
1. **IAB classification** – classify text/URL content against IAB
54+
v3.0 taxonomy categories using a retriever pipeline that performs
55+
semantic search against an IAB category reference corpus.
56+
2. **Brand-safety check** – score content for brand-safety risk
57+
by identifying sensitive IAB categories in the classification.
58+
3. **Contextual search** – search indexed ad inventory via retriever
59+
pipelines combining multimodal search, taxonomy enrichment,
60+
and reranking.
61+
62+
All methods are async and use ``httpx.AsyncClient`` under the hood.
63+
"""
64+
65+
def __init__(
66+
self,
67+
api_key: str,
68+
base_url: str = "https://api.mixpeek.com",
69+
namespace: str | None = None,
70+
timeout: float = _DEFAULT_TIMEOUT,
71+
):
72+
self.api_key = api_key
73+
self.base_url = base_url.rstrip("/")
74+
self.namespace = namespace
75+
self._client = httpx.AsyncClient(
76+
base_url=self.base_url,
77+
timeout=timeout,
78+
follow_redirects=True,
79+
)
80+
81+
# ------------------------------------------------------------------
82+
# Internal helpers
83+
# ------------------------------------------------------------------
84+
85+
def _headers(self, namespace: str | None = None) -> dict[str, str]:
86+
"""Build request headers with auth and optional namespace."""
87+
h: dict[str, str] = {
88+
"Authorization": f"Bearer {self.api_key}",
89+
"Content-Type": "application/json",
90+
}
91+
ns = namespace or self.namespace
92+
if ns:
93+
h["X-Namespace"] = ns
94+
return h
95+
96+
async def _request(
97+
self,
98+
method: str,
99+
path: str,
100+
*,
101+
namespace: str | None = None,
102+
json: dict | None = None,
103+
params: dict | None = None,
104+
) -> dict[str, Any]:
105+
"""Send a request and return the parsed JSON response."""
106+
try:
107+
resp = await self._client.request(
108+
method,
109+
path,
110+
headers=self._headers(namespace),
111+
json=json,
112+
params=params,
113+
)
114+
except httpx.HTTPError as exc:
115+
raise MixpeekError(f"HTTP error: {exc}") from exc
116+
117+
if resp.status_code >= 400:
118+
raise MixpeekError(
119+
f"Mixpeek API error {resp.status_code}: {resp.text}",
120+
status_code=resp.status_code,
121+
)
122+
return resp.json()
123+
124+
# ------------------------------------------------------------------
125+
# Public API
126+
# ------------------------------------------------------------------
127+
128+
async def list_taxonomies(self, namespace: str | None = None) -> list[dict]:
129+
"""List available taxonomies in the namespace."""
130+
data = await self._request(
131+
"POST", "/v1/taxonomies/list", namespace=namespace, json={}
132+
)
133+
return data.get("results", [])
134+
135+
async def list_retrievers(self, namespace: str | None = None) -> list[dict]:
136+
"""List available retriever pipelines in the namespace."""
137+
data = await self._request(
138+
"POST", "/v1/retrievers/list", namespace=namespace, json={}
139+
)
140+
return data.get("results", [])
141+
142+
async def classify_content(
143+
self,
144+
retriever_id: str,
145+
text: str,
146+
*,
147+
namespace: str | None = None,
148+
limit: int = 10,
149+
) -> dict[str, Any]:
150+
"""Classify content into IAB taxonomy categories.
151+
152+
Uses a retriever pipeline that performs semantic search against
153+
an IAB category reference corpus. Each result represents an
154+
IAB category match with a confidence score.
155+
156+
Args:
157+
retriever_id: Retriever pipeline configured for IAB search.
158+
text: Content text to classify.
159+
namespace: Override namespace for this call.
160+
limit: Max category matches to return.
161+
162+
Returns:
163+
Dict with ``documents`` list, each containing
164+
``iab_category_name``, ``iab_path``, ``iab_tier``,
165+
and ``score``.
166+
"""
167+
body: dict[str, Any] = {
168+
"inputs": {"query": text},
169+
"page_size": limit,
170+
}
171+
return await self._request(
172+
"POST",
173+
f"/v1/retrievers/{retriever_id}/execute",
174+
namespace=namespace,
175+
json=body,
176+
)
177+
178+
async def check_brand_safety(
179+
self,
180+
retriever_id: str,
181+
text: str,
182+
*,
183+
namespace: str | None = None,
184+
threshold: float = 0.80,
185+
limit: int = 10,
186+
) -> dict[str, Any]:
187+
"""Check content for brand-safety risk.
188+
189+
Classifies content via the IAB retriever and flags any matches
190+
against known sensitive categories (gambling, adult, etc.).
191+
192+
Args:
193+
retriever_id: Retriever pipeline configured for IAB search.
194+
text: Content text to evaluate.
195+
namespace: Override namespace for this call.
196+
threshold: Minimum score to consider a category match.
197+
limit: Max category matches to evaluate.
198+
199+
Returns:
200+
Dict with ``safe`` bool, ``risk_level`` (low/medium/high),
201+
``flagged_categories`` list, and full ``categories`` list.
202+
"""
203+
result = await self.classify_content(
204+
retriever_id=retriever_id,
205+
text=text,
206+
namespace=namespace,
207+
limit=limit,
208+
)
209+
210+
docs = result.get("documents", [])
211+
categories = []
212+
flagged = []
213+
214+
for doc in docs:
215+
score = doc.get("score", 0)
216+
if score < threshold:
217+
continue
218+
cat_name = doc.get("iab_category_name", "")
219+
entry = {
220+
"category": cat_name,
221+
"path": doc.get("iab_path", []),
222+
"tier": doc.get("iab_tier"),
223+
"score": score,
224+
}
225+
categories.append(entry)
226+
if cat_name in BRAND_UNSAFE_CATEGORIES:
227+
flagged.append(entry)
228+
229+
if flagged:
230+
max_score = max(f["score"] for f in flagged)
231+
risk_level = "high" if max_score >= 0.85 else "medium"
232+
else:
233+
risk_level = "low"
234+
235+
return {
236+
"safe": len(flagged) == 0,
237+
"risk_level": risk_level,
238+
"flagged_categories": flagged,
239+
"categories": categories,
240+
}
241+
242+
async def search_content(
243+
self,
244+
retriever_id: str,
245+
query: str,
246+
*,
247+
namespace: str | None = None,
248+
limit: int = 10,
249+
filters: dict | None = None,
250+
) -> dict[str, Any]:
251+
"""Execute a retriever pipeline (contextual search).
252+
253+
The retriever can combine feature search, brand-safety filtering,
254+
taxonomy enrichment, and reranking in a single call.
255+
"""
256+
inputs: dict[str, Any] = {"query": query}
257+
if filters:
258+
inputs.update(filters)
259+
260+
body: dict[str, Any] = {
261+
"inputs": inputs,
262+
"page_size": limit,
263+
}
264+
265+
return await self._request(
266+
"POST",
267+
f"/v1/retrievers/{retriever_id}/execute",
268+
namespace=namespace,
269+
json=body,
270+
)
271+
272+
async def get_tools(self) -> list[dict]:
273+
"""Fetch the public MCP tools list (no auth required).
274+
275+
Hits the ``/tools`` REST endpoint on the MCP server, which
276+
returns all 48 tool definitions without authentication.
277+
"""
278+
resp = await self._client.get(
279+
"https://mcp.mixpeek.com/tools",
280+
timeout=10,
281+
)
282+
return resp.json().get("tools", [])
283+
284+
async def health(self) -> dict[str, Any]:
285+
"""Check MCP server health (no auth required)."""
286+
resp = await self._client.get(
287+
"https://mcp.mixpeek.com/health",
288+
timeout=5,
289+
)
290+
return resp.json()
291+
292+
async def close(self) -> None:
293+
"""Shut down the underlying HTTP client."""
294+
await self._client.aclose()

src/ad_buyer/config/settings.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@ def get_cors_origins(self) -> list[str]:
9393
return []
9494
return [o.strip() for o in self.cors_allowed_origins.split(",") if o.strip()]
9595

96+
# Mixpeek contextual enrichment
97+
mixpeek_api_key: str = ""
98+
mixpeek_base_url: str = "https://api.mixpeek.com"
99+
mixpeek_namespace: str = ""
100+
96101
# Environment
97102
environment: str = "development"
98103
log_level: str = "INFO"

src/ad_buyer/crews/channel_crews.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,16 +44,31 @@
4444
from ..tools.execution.line_management import BookLineTool, CreateLineTool, ReserveLineTool
4545
from ..tools.execution.order_management import CreateOrderTool
4646
from ..tools.research.avails_check import AvailsCheckTool
47+
from ..tools.research.contextual_enrichment import (
48+
BrandSafetyTool,
49+
ClassifyContentTool,
50+
ContextualSearchTool,
51+
)
4752
from ..tools.research.product_search import ProductSearchTool
4853

4954

5055
def _create_research_tools(client: OpenDirectClient) -> list[Any]:
5156
"""Create research tools with the OpenDirect client."""
52-
return [
57+
tools: list[Any] = [
5358
ProductSearchTool(client),
5459
AvailsCheckTool(client),
5560
]
5661

62+
# Add Mixpeek contextual enrichment tools when configured
63+
if settings.mixpeek_api_key:
64+
tools.extend([
65+
ClassifyContentTool(),
66+
BrandSafetyTool(),
67+
ContextualSearchTool(),
68+
])
69+
70+
return tools
71+
5772

5873
def _create_execution_tools(client: OpenDirectClient) -> list[Any]:
5974
"""Create execution tools with the OpenDirect client."""

0 commit comments

Comments
 (0)