102 lines
3.8 KiB
Python
102 lines
3.8 KiB
Python
"""The analysis pipeline, lifted out of serve.py so more than one caller can use it.
|
|
|
|
This module imports only the standard library and `ppcbudget`. Keep it that way:
|
|
serve.py depends on it staying installable with nothing but openpyxl.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping, Sequence
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from ppcbudget import actions as actions_mod
|
|
from ppcbudget import aggregate, metrics, payload, perfjoin
|
|
from ppcbudget.ingest import dedupe_events, load_history
|
|
from ppcbudget.scoring import check_invariants, score_all
|
|
|
|
|
|
@dataclass
|
|
class AnalysisResult:
|
|
"""What one run produced.
|
|
|
|
`payload` is the compact JSON the dashboard renders. `artifacts` is the
|
|
scored data itself, kept because the Excel and CSV exports rebuild their
|
|
output from it rather than from the payload.
|
|
"""
|
|
|
|
payload: dict
|
|
artifacts: dict
|
|
|
|
|
|
def run_analysis(history: Sequence[Path], perf: Path | None,
|
|
settings_in: Mapping[str, Any]) -> AnalysisResult:
|
|
"""Run the pipeline over everything uploaded so far."""
|
|
if not history:
|
|
raise ValueError("No change-history files uploaded yet.")
|
|
|
|
events, metas, qas, skipped = [], [], [], []
|
|
for path in history:
|
|
try:
|
|
evs, meta, qa = load_history(path)
|
|
except (ValueError, KeyError, OSError) as exc:
|
|
message = str(exc)
|
|
skipped.append(message if path.name in message
|
|
else f"{path.name}: {message}")
|
|
continue
|
|
events.extend(evs)
|
|
metas.append(meta)
|
|
qas.append(qa)
|
|
|
|
if not events:
|
|
if skipped:
|
|
# The per-file reason is the useful part; do not bury it behind a
|
|
# generic "could not be read".
|
|
raise ValueError(" ".join(skipped) if len(skipped) == 1
|
|
else "No file could be used. " + " ".join(skipped))
|
|
raise ValueError("The file has no readable change rows.")
|
|
|
|
events, overlap_rows = dedupe_events(events)
|
|
days = score_all(events, merge_gap_min=int(settings_in.get("merge_gap", 5)))
|
|
if not days:
|
|
raise ValueError("No campaigns had budget-state changes, so there is nothing to score.")
|
|
|
|
join_report = None
|
|
roas_source = "account_average"
|
|
if perf:
|
|
records, join_report = perfjoin.load_performance(perf)
|
|
perfjoin.apply_to(days, records, join_report)
|
|
roas_source = "campaign"
|
|
|
|
account_roas = next((m.roas for m in metas if m.roas), None)
|
|
roas_override = settings_in.get("roas")
|
|
settings = metrics.ModelSettings(
|
|
roas=float(roas_override) if roas_override else (account_roas or 4.0),
|
|
roas_source="override" if roas_override else roas_source,
|
|
haircut=float(settings_in.get("haircut", metrics.DEFAULT_ROAS_HAIRCUT)),
|
|
cap_multiple=float(settings_in.get("cap", metrics.DEFAULT_CAP_MULTIPLE)),
|
|
)
|
|
metrics.apply(days, settings)
|
|
totals = metrics.summarize(days)
|
|
rollups = aggregate.rollup(days)
|
|
date_keys = sorted({d.date_key for d in days})
|
|
|
|
scored_names = {d.campaign for d in days}
|
|
acts = actions_mod.build(events, date_keys, scored_names)
|
|
act_summary = actions_mod.summarize(acts)
|
|
|
|
data = payload.build(days, totals, rollups, qas, metas, settings, date_keys,
|
|
join_report, overlap_rows, acts, act_summary)
|
|
problems = check_invariants(days)
|
|
data["invariants"] = {"checked": len(days), "failed": problems[:5]}
|
|
data["skipped"] = skipped
|
|
|
|
artifacts = {
|
|
"days": days, "totals": totals, "rollups": rollups, "qas": qas,
|
|
"metas": metas, "settings": settings, "date_keys": date_keys,
|
|
"join_report": join_report, "overlap_rows": overlap_rows,
|
|
"actions": acts,
|
|
}
|
|
return AnalysisResult(payload=data, artifacts=artifacts)
|