mcp: fix ClickHouse column names, add get_summary tool with period comparison
This commit is contained in:
+94
-107
@@ -63,27 +63,19 @@ mcp = FastMCP("manar-mcp", lifespan=lifespan)
|
|||||||
async def assert_brand_access(brand_slug: str, actor_email: str) -> dict:
|
async def assert_brand_access(brand_slug: str, actor_email: str) -> dict:
|
||||||
async with state.core_pool.acquire() as conn:
|
async with state.core_pool.acquire() as conn:
|
||||||
user = await conn.fetchrow(
|
user = await conn.fetchrow(
|
||||||
"SELECT id, role FROM users WHERE email = $1",
|
"SELECT id, role FROM users WHERE email = $1", actor_email)
|
||||||
actor_email
|
|
||||||
)
|
|
||||||
if not user:
|
if not user:
|
||||||
raise ValueError(f"User {actor_email} not found")
|
raise ValueError(f"User {actor_email} not found")
|
||||||
|
|
||||||
brand = await conn.fetchrow(
|
brand = await conn.fetchrow(
|
||||||
"SELECT id, slug, name, trust_level, status FROM brands WHERE slug = $1",
|
"SELECT id, slug, name, trust_level, status FROM brands WHERE slug = $1", brand_slug)
|
||||||
brand_slug
|
|
||||||
)
|
|
||||||
if not brand or brand["status"] != "active":
|
if not brand or brand["status"] != "active":
|
||||||
raise ValueError(f"Brand '{brand_slug}' not found or inactive")
|
raise ValueError(f"Brand '{brand_slug}' not found or inactive")
|
||||||
|
|
||||||
if user["role"] != "super_admin":
|
if user["role"] != "super_admin":
|
||||||
membership = await conn.fetchrow(
|
membership = await conn.fetchrow(
|
||||||
"SELECT role FROM brand_memberships WHERE brand_id = $1 AND user_id = $2",
|
"SELECT role FROM brand_memberships WHERE brand_id = $1 AND user_id = $2",
|
||||||
brand["id"], user["id"]
|
brand["id"], user["id"])
|
||||||
)
|
|
||||||
if not membership:
|
if not membership:
|
||||||
raise ValueError(f"User {actor_email} has no access to brand '{brand_slug}'")
|
raise ValueError(f"User {actor_email} has no access to brand '{brand_slug}'")
|
||||||
|
|
||||||
return dict(brand)
|
return dict(brand)
|
||||||
|
|
||||||
|
|
||||||
@@ -93,25 +85,19 @@ async def assert_brand_access(brand_slug: str, actor_email: str) -> dict:
|
|||||||
async def list_brands(actor_email: str) -> list[dict]:
|
async def list_brands(actor_email: str) -> list[dict]:
|
||||||
"""List all active brands accessible to actor_email."""
|
"""List all active brands accessible to actor_email."""
|
||||||
async with state.core_pool.acquire() as conn:
|
async with state.core_pool.acquire() as conn:
|
||||||
user = await conn.fetchrow(
|
user = await conn.fetchrow("SELECT id, role FROM users WHERE email = $1", actor_email)
|
||||||
"SELECT id, role FROM users WHERE email = $1",
|
|
||||||
actor_email
|
|
||||||
)
|
|
||||||
if not user:
|
if not user:
|
||||||
raise ValueError(f"User {actor_email} not found")
|
raise ValueError(f"User {actor_email} not found")
|
||||||
if user["role"] == "super_admin":
|
if user["role"] == "super_admin":
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"SELECT slug, name, trust_level, timezone FROM brands WHERE status = 'active' ORDER BY name"
|
"SELECT slug, name, trust_level, timezone FROM brands WHERE status = 'active' ORDER BY name")
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"""SELECT b.slug, b.name, b.trust_level, b.timezone, m.role
|
"""SELECT b.slug, b.name, b.trust_level, b.timezone, m.role
|
||||||
FROM brands b
|
FROM brands b JOIN brand_memberships m ON m.brand_id = b.id
|
||||||
JOIN brand_memberships m ON m.brand_id = b.id
|
|
||||||
JOIN users u ON u.id = m.user_id
|
JOIN users u ON u.id = m.user_id
|
||||||
WHERE u.email = $1 AND b.status = 'active' ORDER BY b.name""",
|
WHERE u.email = $1 AND b.status = 'active' ORDER BY b.name""",
|
||||||
actor_email
|
actor_email)
|
||||||
)
|
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
@@ -123,8 +109,7 @@ async def get_ad_accounts(brand_slug: str, actor_email: str) -> list[dict]:
|
|||||||
async with state.tenants_pool.acquire() as conn:
|
async with state.tenants_pool.acquire() as conn:
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
f"SELECT id::text, platform_slug, external_id, name, currency, status, created_at::text "
|
f"SELECT id::text, platform_slug, external_id, name, currency, status, created_at::text "
|
||||||
f"FROM {schema}.ad_accounts ORDER BY platform_slug, name"
|
f"FROM {schema}.ad_accounts ORDER BY platform_slug, name")
|
||||||
)
|
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
@@ -143,8 +128,8 @@ async def fetch_google_ads_metrics(
|
|||||||
|
|
||||||
group_fields = {
|
group_fields = {
|
||||||
"campaign": "campaign_id, campaign_name",
|
"campaign": "campaign_id, campaign_name",
|
||||||
"adgroup": "campaign_id, campaign_name, ad_group_id, ad_group_name",
|
"adgroup": "campaign_id, campaign_name, adgroup_id, adgroup_name",
|
||||||
"day": "toDate(hour) AS date",
|
"day": "date",
|
||||||
}.get(breakdown, "campaign_id, campaign_name")
|
}.get(breakdown, "campaign_id, campaign_name")
|
||||||
|
|
||||||
query = f"""
|
query = f"""
|
||||||
@@ -153,15 +138,15 @@ async def fetch_google_ads_metrics(
|
|||||||
sum(clicks) AS clicks,
|
sum(clicks) AS clicks,
|
||||||
round(sum(cost_micros) / 1e6, 2) AS cost,
|
round(sum(cost_micros) / 1e6, 2) AS cost,
|
||||||
sum(conversions) AS conversions,
|
sum(conversions) AS conversions,
|
||||||
sum(conversion_value) AS conv_value,
|
sum(conv_value) AS conv_value,
|
||||||
round(if(sum(impressions) > 0, sum(clicks) / sum(impressions), 0), 4) AS ctr,
|
round(if(sum(impressions) > 0, sum(clicks) / sum(impressions), 0), 4) AS ctr,
|
||||||
round(if(sum(clicks) > 0, sum(cost_micros) / 1e6 / sum(clicks), 0), 2) AS cpc,
|
round(if(sum(clicks) > 0, sum(cost_micros) / 1e6 / sum(clicks), 0), 2) AS cpc,
|
||||||
round(if(sum(cost_micros) > 0, sum(conversion_value) / (sum(cost_micros) / 1e6), 0), 2) AS roas
|
round(if(sum(cost_micros) > 0, sum(conv_value) / (sum(cost_micros) / 1e6), 0), 2) AS roas
|
||||||
FROM manar.ad_metrics_hourly
|
FROM manar.ad_metrics_hourly
|
||||||
WHERE brand_id = '{brand_slug}'
|
WHERE brand_id = '{brand_slug}'
|
||||||
AND platform_slug = 'google_ads'
|
AND platform = 'google_ads'
|
||||||
AND toDate(hour) >= '{date_from}'
|
AND date >= '{date_from}'
|
||||||
AND toDate(hour) <= '{date_to}'
|
AND date <= '{date_to}'
|
||||||
GROUP BY {group_fields}
|
GROUP BY {group_fields}
|
||||||
ORDER BY cost DESC
|
ORDER BY cost DESC
|
||||||
LIMIT 500
|
LIMIT 500
|
||||||
@@ -177,7 +162,7 @@ async def get_search_terms(
|
|||||||
min_cost: float = 0,
|
min_cost: float = 0,
|
||||||
limit: int = 200
|
limit: int = 200
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Get search term performance for keyword expansion / negative keyword decisions."""
|
"""Get search term performance — for keyword expansion / negative keyword decisions."""
|
||||||
await assert_brand_access(brand_slug, actor_email)
|
await assert_brand_access(brand_slug, actor_email)
|
||||||
query = f"""
|
query = f"""
|
||||||
SELECT search_term,
|
SELECT search_term,
|
||||||
@@ -199,23 +184,17 @@ async def get_search_terms(
|
|||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def get_anomalies(
|
async def get_anomalies(brand_slug: str, actor_email: str, resolved: bool = False) -> list[dict]:
|
||||||
brand_slug: str, actor_email: str,
|
|
||||||
resolved: bool = False
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Get open anomaly events for a brand."""
|
"""Get open anomaly events for a brand."""
|
||||||
await assert_brand_access(brand_slug, actor_email)
|
await assert_brand_access(brand_slug, actor_email)
|
||||||
resolved_filter = "IS NOT NULL" if resolved else "IS NULL"
|
resolved_filter = "IS NOT NULL" if resolved else "IS NULL"
|
||||||
query = f"""
|
result = state.ch_client.query(f"""
|
||||||
SELECT brand_id, ad_account_id, campaign_id, metric,
|
SELECT brand_id, account_id, campaign_id, metric,
|
||||||
detected_at, expected_value, actual_value, deviation_pct, severity
|
detected_at, expected_value, actual_value, deviation_pct, severity
|
||||||
FROM manar.anomaly_events
|
FROM manar.anomaly_events
|
||||||
WHERE brand_id = '{brand_slug}'
|
WHERE brand_id = '{brand_slug}' AND resolved_at {resolved_filter}
|
||||||
AND resolved_at {resolved_filter}
|
ORDER BY detected_at DESC LIMIT 100
|
||||||
ORDER BY detected_at DESC
|
""")
|
||||||
LIMIT 100
|
|
||||||
"""
|
|
||||||
result = state.ch_client.query(query)
|
|
||||||
return [dict(zip(result.column_names, row)) for row in result.result_rows]
|
return [dict(zip(result.column_names, row)) for row in result.result_rows]
|
||||||
|
|
||||||
|
|
||||||
@@ -241,21 +220,15 @@ async def create_recommendation(
|
|||||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb)
|
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb)
|
||||||
RETURNING id::text, type, title, status, created_at::text""",
|
RETURNING id::text, type, title, status, created_at::text""",
|
||||||
rec_type, title, description, priority,
|
rec_type, title, description, priority,
|
||||||
json.dumps(payload or {}),
|
json.dumps(payload or {}), json.dumps(estimated_impact or {}))
|
||||||
json.dumps(estimated_impact or {})
|
|
||||||
)
|
|
||||||
return dict(row)
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def list_recommendations(
|
async def list_recommendations(
|
||||||
brand_slug: str, actor_email: str,
|
brand_slug: str, actor_email: str, status: str = "pending"
|
||||||
status: str = "pending"
|
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""
|
"""List recommendations. status: pending | approved | rejected | executed | expired"""
|
||||||
List recommendations for a brand.
|
|
||||||
status: pending | approved | rejected | executed | expired
|
|
||||||
"""
|
|
||||||
await assert_brand_access(brand_slug, actor_email)
|
await assert_brand_access(brand_slug, actor_email)
|
||||||
schema = f"brand_{brand_slug}"
|
schema = f"brand_{brand_slug}"
|
||||||
async with state.tenants_pool.acquire() as conn:
|
async with state.tenants_pool.acquire() as conn:
|
||||||
@@ -263,69 +236,31 @@ async def list_recommendations(
|
|||||||
f"""SELECT id::text, type, title, description, priority, status,
|
f"""SELECT id::text, type, title, description, priority, status,
|
||||||
estimated_impact::text, created_at::text
|
estimated_impact::text, created_at::text
|
||||||
FROM {schema}.recommendations
|
FROM {schema}.recommendations
|
||||||
WHERE status = $1
|
WHERE status = $1 ORDER BY created_at DESC""",
|
||||||
ORDER BY created_at DESC""",
|
status)
|
||||||
status
|
|
||||||
)
|
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def approve_recommendation(
|
async def approve_recommendation(
|
||||||
brand_slug: str, actor_email: str,
|
brand_slug: str, actor_email: str,
|
||||||
recommendation_id: str,
|
recommendation_id: str, decision: str, notes: str = ""
|
||||||
decision: str,
|
|
||||||
notes: str = ""
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""Approve or reject a recommendation. decision: approved | rejected | vetoed"""
|
||||||
Approve or reject a recommendation.
|
|
||||||
decision: approved | rejected | vetoed
|
|
||||||
"""
|
|
||||||
await assert_brand_access(brand_slug, actor_email)
|
await assert_brand_access(brand_slug, actor_email)
|
||||||
schema = f"brand_{brand_slug}"
|
schema = f"brand_{brand_slug}"
|
||||||
async with state.tenants_pool.acquire() as conn:
|
async with state.tenants_pool.acquire() as conn:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
f"""UPDATE {schema}.recommendations
|
f"UPDATE {schema}.recommendations SET status = $1, actioned_at = NOW() WHERE id = $2::uuid",
|
||||||
SET status = $1, actioned_at = NOW()
|
decision, recommendation_id)
|
||||||
WHERE id = $2::uuid""",
|
|
||||||
decision, recommendation_id
|
|
||||||
)
|
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
f"""INSERT INTO {schema}.approvals
|
f"""INSERT INTO {schema}.approvals (recommendation_id, decision, notes)
|
||||||
(recommendation_id, decision, notes)
|
|
||||||
VALUES ($1::uuid, $2, $3)
|
VALUES ($1::uuid, $2, $3)
|
||||||
RETURNING id::text, decision, created_at::text""",
|
RETURNING id::text, decision, created_at::text""",
|
||||||
recommendation_id, decision, notes
|
recommendation_id, decision, notes)
|
||||||
)
|
|
||||||
return dict(row)
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def get_day_part_performance(
|
|
||||||
brand_slug: str, actor_email: str,
|
|
||||||
campaign_id: str,
|
|
||||||
date_from: str, date_to: str
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Get hourly/day-of-week performance breakdown for a campaign."""
|
|
||||||
await assert_brand_access(brand_slug, actor_email)
|
|
||||||
query = f"""
|
|
||||||
SELECT hour_of_day, day_of_week,
|
|
||||||
sum(impressions) AS impressions,
|
|
||||||
sum(clicks) AS clicks,
|
|
||||||
round(sum(cost_micros) / 1e6, 2) AS cost,
|
|
||||||
sum(conversions) AS conversions,
|
|
||||||
round(if(sum(impressions) > 0, sum(clicks) / sum(impressions), 0), 4) AS ctr
|
|
||||||
FROM manar.day_part_performance
|
|
||||||
WHERE brand_id = '{brand_slug}'
|
|
||||||
AND campaign_id = '{campaign_id}'
|
|
||||||
AND date >= '{date_from}' AND date <= '{date_to}'
|
|
||||||
GROUP BY hour_of_day, day_of_week
|
|
||||||
ORDER BY day_of_week, hour_of_day
|
|
||||||
"""
|
|
||||||
result = state.ch_client.query(query)
|
|
||||||
return [dict(zip(result.column_names, row)) for row in result.result_rows]
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def get_geo_performance(
|
async def get_geo_performance(
|
||||||
brand_slug: str, actor_email: str,
|
brand_slug: str, actor_email: str,
|
||||||
@@ -335,24 +270,76 @@ async def get_geo_performance(
|
|||||||
"""Get geographic performance breakdown."""
|
"""Get geographic performance breakdown."""
|
||||||
await assert_brand_access(brand_slug, actor_email)
|
await assert_brand_access(brand_slug, actor_email)
|
||||||
where_campaign = f"AND campaign_id = '{campaign_id}'" if campaign_id else ""
|
where_campaign = f"AND campaign_id = '{campaign_id}'" if campaign_id else ""
|
||||||
query = f"""
|
result = state.ch_client.query(f"""
|
||||||
SELECT country_code, region, city,
|
SELECT country_code, region, city,
|
||||||
sum(impressions) AS impressions,
|
sum(impressions) AS impressions, sum(clicks) AS clicks,
|
||||||
sum(clicks) AS clicks,
|
round(sum(cost_micros) / 1e6, 2) AS cost, sum(conversions) AS conversions
|
||||||
round(sum(cost_micros) / 1e6, 2) AS cost,
|
|
||||||
sum(conversions) AS conversions
|
|
||||||
FROM manar.geo_performance
|
FROM manar.geo_performance
|
||||||
WHERE brand_id = '{brand_slug}'
|
WHERE brand_id = '{brand_slug}'
|
||||||
AND date >= '{date_from}' AND date <= '{date_to}'
|
AND date >= '{date_from}' AND date <= '{date_to}'
|
||||||
{where_campaign}
|
{where_campaign}
|
||||||
GROUP BY country_code, region, city
|
GROUP BY country_code, region, city
|
||||||
ORDER BY cost DESC
|
ORDER BY cost DESC LIMIT 200
|
||||||
LIMIT 200
|
""")
|
||||||
"""
|
|
||||||
result = state.ch_client.query(query)
|
|
||||||
return [dict(zip(result.column_names, row)) for row in result.result_rows]
|
return [dict(zip(result.column_names, row)) for row in result.result_rows]
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_summary(brand_slug: str, actor_email: str, days: int = 30) -> dict:
|
||||||
|
"""
|
||||||
|
Get high-level KPI summary for a brand over the last N days.
|
||||||
|
Returns totals + comparison to prior period.
|
||||||
|
"""
|
||||||
|
await assert_brand_access(brand_slug, actor_email)
|
||||||
|
|
||||||
|
result = state.ch_client.query(f"""
|
||||||
|
SELECT
|
||||||
|
period,
|
||||||
|
sum(impressions) AS impressions,
|
||||||
|
sum(clicks) AS clicks,
|
||||||
|
round(sum(cost_micros) / 1e6, 2) AS cost,
|
||||||
|
round(sum(conversions), 1) AS conversions,
|
||||||
|
round(sum(conv_value), 2) AS conv_value,
|
||||||
|
round(avg(ctr) * 100, 2) AS ctr_pct,
|
||||||
|
round(if(sum(clicks)>0, sum(cost_micros)/1e6/sum(clicks), 0), 2) AS cpc,
|
||||||
|
round(if(sum(cost_micros)>0, sum(conv_value)/(sum(cost_micros)/1e6), 0), 2) AS roas
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
if(date >= today() - {days}, 'current', 'prior') AS period,
|
||||||
|
impressions, clicks, cost_micros, conversions, conv_value, ctr
|
||||||
|
FROM manar.ad_metrics_hourly
|
||||||
|
WHERE brand_id = '{brand_slug}'
|
||||||
|
AND date >= today() - {days * 2}
|
||||||
|
)
|
||||||
|
GROUP BY period
|
||||||
|
""")
|
||||||
|
|
||||||
|
data = {{}}
|
||||||
|
for row in result.result_rows:
|
||||||
|
d = dict(zip(result.column_names, row))
|
||||||
|
period = d.pop('period')
|
||||||
|
data[period] = d
|
||||||
|
|
||||||
|
current = data.get('current', {{}})
|
||||||
|
prior = data.get('prior', {{}})
|
||||||
|
|
||||||
|
def pct_change(curr, prev):
|
||||||
|
if not prev or prev == 0:
|
||||||
|
return None
|
||||||
|
return round((curr - prev) / prev * 100, 1)
|
||||||
|
|
||||||
|
return {{
|
||||||
|
"brand": brand_slug,
|
||||||
|
"days": days,
|
||||||
|
"current": current,
|
||||||
|
"prior": prior,
|
||||||
|
"changes": {{
|
||||||
|
k: pct_change(current.get(k, 0), prior.get(k, 0))
|
||||||
|
for k in ['impressions', 'clicks', 'cost', 'conversions', 'roas', 'cpc']
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
|
||||||
# ── Entry ─────────────────────────────────────────────────────────────────────
|
# ── Entry ─────────────────────────────────────────────────────────────────────
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
start_health_thread()
|
start_health_thread()
|
||||||
|
|||||||
Reference in New Issue
Block a user