195 lines
7.1 KiB
Python
195 lines
7.1 KiB
Python
"""Track when a human last actually touched each campaign.
|
|
|
|
The point is to separate "this campaign is starving" from "this campaign is
|
|
starving and nobody has looked at it in nine days". The second is the one worth
|
|
opening first.
|
|
|
|
The hard part is that most rows in the export are *not* actions. Amazon's own
|
|
pacing engine writes an In-budget/Out-of-budget row every time a campaign hits
|
|
its cap -- 2,639 of 2,989 `Campaign status` rows in the reference file. Counting
|
|
those would make every starving campaign look actively managed, which is exactly
|
|
backwards. Only the delivery half of that change type (Delivering/Paused) is a
|
|
person, and it is told apart by vocabulary, the same split the budget scoring
|
|
uses.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from datetime import date
|
|
|
|
from .ingest import BUDGET_STATES, DELIVERY_STATES, Event
|
|
|
|
# Checked in order; the first match wins, so specific beats generic.
|
|
# Each rule is (category, human label, substrings matched against a lowered
|
|
# change type). Change types carry variable tails -- keyword text, product
|
|
# titles -- so these are substring rules, never equality.
|
|
RULES: list[tuple[str, str, tuple[str, ...]]] = [
|
|
("budget", "Budget", ("campaign daily budget", "budget rule")),
|
|
("placement", "Placement", ("bid adjustment for",)),
|
|
("strategy", "Strategy", ("campaign bidding strategy",)),
|
|
("bid", "Bid", ("bid",)),
|
|
("targeting", "Targeting", ("keyword", "target", "negative")),
|
|
("structure", "Structure", ("created", "added to ad group",
|
|
"removed from ad group")),
|
|
("status", "Status", ("status",)),
|
|
("portfolio", "Portfolio", ("portfolio",)),
|
|
]
|
|
|
|
# Real changes, but not optimisation. Kept out of "last action" so a rename
|
|
# does not make a neglected campaign look tended.
|
|
COSMETIC = ("name changed", "ad group name", "campaign name")
|
|
|
|
RECENT_LIMIT = 12 # what the detail panel shows
|
|
|
|
CATEGORY_ORDER = [r[0] for r in RULES]
|
|
CATEGORY_LABEL = {r[0]: r[1] for r in RULES}
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class CampaignActions:
|
|
campaign: str
|
|
window_days: int
|
|
window_start: str
|
|
window_end: str
|
|
last_at: str | None = None # 'YYYY-MM-DD HH:MM'
|
|
last_date: str | None = None
|
|
last_category: str | None = None
|
|
last_label: str | None = None # the raw change type, trimmed
|
|
days_since: int | None = None # measured from the last day in the window
|
|
count: int = 0
|
|
categories: list[str] = field(default_factory=list)
|
|
cosmetic_only: bool = False # touched, but only renames
|
|
# Most recent first, capped -- enough for the detail panel to show what was
|
|
# actually done without shipping every row of history to the browser.
|
|
recent: list[tuple[str, str, str]] = field(default_factory=list)
|
|
|
|
@property
|
|
def untouched(self) -> bool:
|
|
return self.last_at is None
|
|
|
|
@property
|
|
def summary(self) -> str:
|
|
"""One phrase for a report cell. Never implies a longer window than observed."""
|
|
span = f"{self.window_days} day{'' if self.window_days == 1 else 's'}"
|
|
if self.untouched:
|
|
extra = " (only a rename)" if self.cosmetic_only else ""
|
|
return f"No action in {span}{extra}"
|
|
label = CATEGORY_LABEL.get(self.last_category, "Change")
|
|
if self.days_since == 0:
|
|
return f"{label} · last day"
|
|
return f"{label} · {self.days_since}d ago"
|
|
|
|
|
|
def classify(event: Event) -> str | None:
|
|
"""Category of optimisation action, or None if the row is not one."""
|
|
ct = event.change_type.strip()
|
|
low = ct.lower()
|
|
|
|
if ct == "Campaign status":
|
|
# Two state machines share this change type. Only the delivery half is
|
|
# a person; the budget half is Amazon's pacing engine.
|
|
if event.from_val in BUDGET_STATES or event.to_val in BUDGET_STATES:
|
|
return None
|
|
if event.from_val in DELIVERY_STATES or event.to_val in DELIVERY_STATES:
|
|
return "status"
|
|
return None
|
|
|
|
if any(c in low for c in COSMETIC):
|
|
return "cosmetic"
|
|
|
|
for category, _, needles in RULES:
|
|
if any(n in low for n in needles):
|
|
return category
|
|
return None
|
|
|
|
|
|
def _stamp(e: Event) -> str:
|
|
return f"{e.date_key} {e.minute // 60:02d}:{e.minute % 60:02d}"
|
|
|
|
|
|
def _trim(change_type: str, limit: int = 60) -> str:
|
|
"""Change types embed whole product titles; keep the head."""
|
|
s = re.sub(r"\s+", " ", change_type.strip())
|
|
return s if len(s) <= limit else s[: limit - 1] + "…"
|
|
|
|
|
|
def build(events: list[Event], date_keys: list[str],
|
|
campaigns: set[str] | None = None) -> dict[str, CampaignActions]:
|
|
"""Last meaningful action per campaign, over the days actually observed.
|
|
|
|
The window is the span the data covers -- never what was asked for. If the
|
|
export holds one day, this reports on one day and says so.
|
|
"""
|
|
if not date_keys:
|
|
return {}
|
|
start, end = date_keys[0], date_keys[-1]
|
|
window_days = len(date_keys)
|
|
end_date = date.fromisoformat(end)
|
|
|
|
names = set(campaigns) if campaigns is not None else {e.campaign for e in events}
|
|
out = {
|
|
name: CampaignActions(campaign=name, window_days=window_days,
|
|
window_start=start, window_end=end)
|
|
for name in names
|
|
}
|
|
|
|
for e in events:
|
|
rec = out.get(e.campaign)
|
|
if rec is None:
|
|
continue
|
|
category = classify(e)
|
|
if category is None:
|
|
continue
|
|
if category == "cosmetic":
|
|
rec.cosmetic_only = True
|
|
continue
|
|
|
|
rec.count += 1
|
|
if category not in rec.categories:
|
|
rec.categories.append(category)
|
|
stamp = _stamp(e)
|
|
rec.recent.append((stamp, category, _trim(e.change_type)))
|
|
if rec.last_at is None or stamp > rec.last_at:
|
|
rec.last_at = stamp
|
|
rec.last_date = e.date_key
|
|
rec.last_category = category
|
|
rec.last_label = _trim(e.change_type)
|
|
|
|
for rec in out.values():
|
|
if rec.last_date:
|
|
rec.days_since = (end_date - date.fromisoformat(rec.last_date)).days
|
|
rec.cosmetic_only = False
|
|
rec.categories.sort(key=CATEGORY_ORDER.index)
|
|
rec.recent.sort(key=lambda r: r[0], reverse=True)
|
|
del rec.recent[RECENT_LIMIT:]
|
|
return out
|
|
|
|
|
|
def summarize(actions: dict[str, CampaignActions]) -> dict:
|
|
"""Account-level counts for the headline."""
|
|
total = len(actions)
|
|
untouched = [a for a in actions.values() if a.untouched]
|
|
buckets = {"0-1": 0, "2-3": 0, "4-7": 0, "8+": 0}
|
|
for a in actions.values():
|
|
if a.days_since is None:
|
|
continue
|
|
if a.days_since <= 1:
|
|
buckets["0-1"] += 1
|
|
elif a.days_since <= 3:
|
|
buckets["2-3"] += 1
|
|
elif a.days_since <= 7:
|
|
buckets["4-7"] += 1
|
|
else:
|
|
buckets["8+"] += 1
|
|
window = next(iter(actions.values())).window_days if actions else 0
|
|
return {
|
|
"campaigns": total,
|
|
"untouched": len(untouched),
|
|
"touched": total - len(untouched),
|
|
"window_days": window,
|
|
"buckets": buckets,
|
|
"actions_total": sum(a.count for a in actions.values()),
|
|
}
|