"""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()), }