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
2 changes: 1 addition & 1 deletion docs/mcp-server.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# MCP Server Guide

mureo exposes 188 tools via the [Model Context Protocol](https://modelcontextprotocol.io/) (MCP): 175 advertising and SEO operation tools across Google Ads (83), Meta Ads (82), and Search Console (10), 2 rollback tools, 1 cross-platform anomaly-detection tool, 7 mureo-context tools (strategy / state), 1 analytics-registry tool, and 2 learning tools (`mureo_learning_insights_get` for the operator's local `/learn` history and `mureo_consult_advisor` for federated retrieval against external advisor MCP servers — see [`docs/insight-federation.md`](insight-federation.md)). Any MCP-compatible client can connect and call these tools over stdio. Re-check this count when MCP tools are added or removed (`test_list_tools_returns_all_tools` pins the exact number).
mureo exposes 189 tools via the [Model Context Protocol](https://modelcontextprotocol.io/) (MCP): 175 advertising and SEO operation tools across Google Ads (83), Meta Ads (82), and Search Console (10), 2 rollback tools, 1 cross-platform anomaly-detection tool, 8 mureo-context tools (strategy / state), 1 analytics-registry tool, and 2 learning tools (`mureo_learning_insights_get` for the operator's local `/learn` history and `mureo_consult_advisor` for federated retrieval against external advisor MCP servers — see [`docs/insight-federation.md`](insight-federation.md)). Any MCP-compatible client can connect and call these tools over stdio. Re-check this count when MCP tools are added or removed (`test_list_tools_returns_all_tools` pins the exact number).

## Starting the Server

Expand Down
25 changes: 25 additions & 0 deletions mureo/_data/skills/_mureo-meta-ads/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,31 @@ metadata:
```
Breakdown options: `age`, `gender`, `age,gender`, `country`, `region`, `publisher_platform`, `platform_position`, `device_platform`, `impression_device`

### conversion definition (custom / non-standard events)

mureo counts conversions from the Insights `actions` array using a **default
deduped set of standard events** (`lead`, `purchase`, `complete_registration`).
If an advertiser's real conversion is a **custom pixel event**
(`offsite_conversion.custom.<id>`, e.g. "Booking Completed"), or their account
only emits a component row (`offsite_conversion.fb_pixel_lead`) with no generic
aggregate, the default counts **0** for them — which silently wrong-foots CPA,
daily-check, and budget decisions.

To fix, register the account's real conversion `action_type`(s):

1. Look up the account's **actual** labels — call `meta_ads_insights_report`
(or `_breakdown`) and inspect the `actions` array's `action_type` values.
Don't guess the string; custom slugs like `offsite_conversion.custom.123`
are easy to mistype.
2. Confirm with the operator which label(s) are *their* conversion.
3. Call **`mureo_state_set_conversion_events`** with `platform="meta_ads"`,
the `account_id`, and `conversion_action_types=[…the exact string(s)…]`.

This is a **replacement** (the listed types become the complete conversion set
for that account; never summed on top of the defaults). It is stored on
`platforms[meta_ads]` in STATE.json and applied by every Meta conversion
counter. Pass an empty list to clear it and restore the default.

### analysis

- `performance` -- Analyze overall performance trends.
Expand Down
21 changes: 18 additions & 3 deletions mureo/analytics/builtin/_budget_efficiency.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,13 @@

from __future__ import annotations

from typing import Any
from typing import TYPE_CHECKING, Any

from mureo.analytics.models import BudgetEfficiency
from mureo.context.state import load_conversion_action_types

if TYPE_CHECKING:
from collections.abc import Collection

INEFFICIENT_THRESHOLD = 0.3
EFFICIENT_THRESHOLD = 0.7
Expand All @@ -34,6 +38,7 @@ def _extract_cost_and_conversions(
*,
spend_key: str,
nested_metrics: bool,
conversion_action_types: Collection[str] | None = None,
) -> tuple[str, float, float] | None:
"""Return ``(campaign_id, cost, conversions)`` for one row, or
``None`` when the row is unusable (missing id / non-positive cost).
Expand Down Expand Up @@ -69,7 +74,9 @@ def _extract_cost_and_conversions(
count_conversions_from_actions,
)

conversions = count_conversions_from_actions(row.get("actions"))
conversions = count_conversions_from_actions(
row.get("actions"), conversion_action_types=conversion_action_types
)

if cost <= 0:
return None
Expand Down Expand Up @@ -99,9 +106,17 @@ def score_budget_efficiency(
"""
rates: dict[str, tuple[float, float]] = {} # campaign_id -> (rate, cost)
total_unused = 0.0
# #342 — the operator conversion override is Meta-specific; resolve once
# for the account (None for non-Meta platforms / unset accounts).
cv_types = (
load_conversion_action_types(account_id) if platform == "meta_ads" else None
)
for row in rows:
extracted = _extract_cost_and_conversions(
row, spend_key=spend_key, nested_metrics=nested_metrics
row,
spend_key=spend_key,
nested_metrics=nested_metrics,
conversion_action_types=cv_types,
)
if extracted is None:
continue
Expand Down
17 changes: 14 additions & 3 deletions mureo/analytics/builtin/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

from __future__ import annotations

from collections.abc import Awaitable, Callable
from collections.abc import Awaitable, Callable, Collection
from typing import TYPE_CHECKING, Any, Protocol

from mureo.analysis.anomaly_detector import (
Expand Down Expand Up @@ -70,7 +70,11 @@ def google_row_metrics(row: dict[str, Any]) -> dict[str, Any]:
return row


def meta_row_conversions(row: dict[str, Any]) -> float:
def meta_row_conversions(
row: dict[str, Any],
*,
conversion_action_types: Collection[str] | None = None,
) -> float:
"""Return the conversion count for a Meta performance row.

Live: conversions live inside an ``actions`` list keyed by
Expand All @@ -79,6 +83,11 @@ def meta_row_conversions(row: dict[str, Any]) -> float:
the #120 live-wiring validation — accepting only the live shape
silently zeroes BYOD conversions.

``conversion_action_types`` (#342) is the operator's per-account override
for which action_types count as conversions; ``None`` uses the built-in
deduped generic set. It applies only to the live ``actions`` shape (BYOD
rows carry a pre-aggregated total).

Expected runtime shape: :class:`MetaLivePerformanceRow` /
:class:`MetaByodPerformanceRow`. Same caller-ergonomics rationale
as :func:`google_row_metrics` for the looser parameter type.
Expand All @@ -93,7 +102,9 @@ def meta_row_conversions(row: dict[str, Any]) -> float:
# mureo.meta_ads client weight (this runs only on the live path).
from mureo.meta_ads._conversion_count import count_conversions_from_actions

return count_conversions_from_actions(actions)
return count_conversions_from_actions(
actions, conversion_action_types=conversion_action_types
)
return float(row.get("conversions") or 0)


Expand Down
17 changes: 12 additions & 5 deletions mureo/analytics/builtin/_live_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from mureo.analytics.builtin._common import (
meta_row_conversions as _meta_row_conversions,
)
from mureo.context.state import load_conversion_action_types

if TYPE_CHECKING:
from collections.abc import Callable
Expand Down Expand Up @@ -294,19 +295,24 @@ async def fetch_google_ads_per_campaign_metrics(

def _index_meta_rows_by_campaign(
rows: list[dict[str, Any]],
account_id: str,
) -> dict[str, CampaignMetrics]:
"""Build ``{campaign_id: metrics}`` from Meta rows.

Mirrors :func:`_index_google_rows_by_campaign` for Meta's flatter
shape — Meta exposes ``spend`` and either an ``actions`` list
(Live) or a top-level ``conversions`` field (BYOD).
(Live) or a top-level ``conversions`` field (BYOD). ``account_id``
resolves the operator's per-account conversion override (#342).
"""
cv_types = load_conversion_action_types(account_id)
indexed: dict[str, CampaignMetrics] = {}
for row in rows:
metric = _row_to_campaign_metrics(
row,
spend_key="spend",
conversion_getter=_meta_row_conversions,
conversion_getter=lambda r: _meta_row_conversions(
r, conversion_action_types=cv_types
),
)
if metric is None:
continue
Expand Down Expand Up @@ -363,8 +369,8 @@ async def fetch_meta_ads_per_campaign_metrics(
period=baseline_period
)

current_index = _index_meta_rows_by_campaign(current_rows)
baseline_index = _index_meta_rows_by_campaign(baseline_rows)
current_index = _index_meta_rows_by_campaign(current_rows, account_id)
baseline_index = _index_meta_rows_by_campaign(baseline_rows, account_id)

out: dict[str, tuple[CampaignMetrics, CampaignMetrics | None]] = {}
for campaign_id, current in current_index.items():
Expand Down Expand Up @@ -402,11 +408,12 @@ def _aggregate_meta_metrics(
impressions = 0
clicks = 0
conversions = 0.0
cv_types = load_conversion_action_types(account_id) # #342 per-account override
for row in rows:
cost += float(row.get("spend") or 0)
impressions += int(row.get("impressions") or 0)
clicks += int(row.get("clicks") or 0)
conversions += _meta_row_conversions(row)
conversions += _meta_row_conversions(row, conversion_action_types=cv_types)
return CampaignMetrics(
campaign_id=account_id,
cost=cost,
Expand Down
4 changes: 3 additions & 1 deletion mureo/analytics/builtin/meta_ads.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
meta_row_conversions,
to_analytics_anomalies,
)
from mureo.context.state import load_conversion_action_types

if TYPE_CHECKING:
from mureo.analysis.anomaly_detector import CampaignMetrics
Expand Down Expand Up @@ -295,13 +296,14 @@ def _summarise_meta_performance(
impressions = 0
clicks = 0
conversions = 0.0
cv_types = load_conversion_action_types(account_id) # #342 per-account override
for row in rows:
row_cost = float(row.get("spend") or 0)
row_impressions = int(row.get("impressions") or 0)
row_clicks = int(row.get("clicks") or 0)
# Tolerates live (actions list) and BYOD (flat conversions);
# both shapes are valid factory outputs.
row_conversions = meta_row_conversions(row)
row_conversions = meta_row_conversions(row, conversion_action_types=cv_types)
cost += row_cost
impressions += row_impressions
clicks += row_clicks
Expand Down
19 changes: 19 additions & 0 deletions mureo/context/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,14 @@ class PlatformState:
# legacy entries parse unchanged and emit no extra key. sync-state writes
# LAST_30_DAYS; daily-check writes YESTERDAY.
periods: dict[str, dict[str, Any]] | None = None
# Optional operator-declared conversion ``action_type`` allow-list (#342).
# When set (non-None), the Meta conversion counters treat EXACTLY these
# action_types as this account's conversions — overriding the default
# deduped generic set — so a custom-event advertiser
# (``offsite_conversion.custom.<id>``) or a component-only account is
# counted correctly. None (the default) keeps the built-in generic set, so
# legacy entries parse unchanged and emit no extra key.
conversion_action_types: tuple[str, ...] | None = None

def __post_init__(self) -> None:
"""Ensure campaigns is a tuple (defensive copy)."""
Expand All @@ -131,6 +139,17 @@ def __post_init__(self) -> None:
object.__setattr__(self, "totals", copy.deepcopy(self.totals))
if self.periods is not None:
object.__setattr__(self, "periods", copy.deepcopy(self.periods))
if self.conversion_action_types is not None and not isinstance(
self.conversion_action_types, tuple
):
# A bare str must NOT be char-split into a tuple of letters; wrap
# it as a single action_type. Other iterables tuple-ify normally.
normalized = (
(self.conversion_action_types,)
if isinstance(self.conversion_action_types, str)
else tuple(self.conversion_action_types)
)
object.__setattr__(self, "conversion_action_types", normalized)


@dataclass(frozen=True)
Expand Down
Loading