OOB-Dashboard/app/services/analysis.py

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)