Initial Daily Campaign Agent release

This commit is contained in:
joliehill294-hue
2026-09-16 21:00:13 +08:00
commit 14fc94b3f0
86 changed files with 10766 additions and 0 deletions
@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""Normalize CSV/TSV/XLSX ad reports into deterministic Ad-level evidence JSON."""
from __future__ import annotations
import argparse
import csv
import datetime as dt
import json
import math
import re
import sys
import zipfile
from collections import defaultdict
from pathlib import Path
from xml.etree import ElementTree as ET
NS = {"m": "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships"}
ALIASES = {
"date": ["date", "日期", "day", "report date"],
"campaign": ["campaign", "campaign name", "平台 campaign name", "external_campaign_name"],
"ad_group": ["ad group", "ad group name", "平台 ad group name", "external_ad_group_name", "adgroup"],
"ad": ["ad", "ad name", "平台 ad name", "external_ad_name", "广告名称", "素材名称"],
"spend": ["spend", "cost", "消耗", "花费", "广告花费"],
"installs": ["installs", "install", "归因安装量", "安装量", "转化数"],
"retained": ["d1 retained", "d1 retention users", "d1 留存人数", "次留人数", "次日留存人数"],
"impressions": ["impressions", "展示量", "曝光量"],
"clicks": ["clicks", "点击量", "归因点击量"],
}
def norm(value: object) -> str:
return re.sub(r"[\s_\-/]+", " ", str(value or "").strip().lower())
def col_index(reference: str) -> int:
letters = re.match(r"[A-Z]+", reference or "A").group(0)
n = 0
for char in letters:
n = n * 26 + ord(char) - 64
return n - 1
def xlsx_rows(path: Path, sheet_name: str | None = None):
with zipfile.ZipFile(path) as archive:
shared = []
if "xl/sharedStrings.xml" in archive.namelist():
root = ET.fromstring(archive.read("xl/sharedStrings.xml"))
for item in root.findall("m:si", NS):
shared.append("".join(node.text or "" for node in item.iter(f"{{{NS['m']}}}t")))
workbook = ET.fromstring(archive.read("xl/workbook.xml"))
rels_root = ET.fromstring(archive.read("xl/_rels/workbook.xml.rels"))
rels = {item.attrib["Id"]: item.attrib["Target"] for item in rels_root}
sheets = []
for sheet in workbook.find("m:sheets", NS):
target = rels[sheet.attrib[f"{{{NS['r']}}}id"]].lstrip("/")
if not target.startswith("xl/"):
target = "xl/" + target
sheets.append((sheet.attrib["name"], target))
if sheet_name:
matches = [entry for entry in sheets if entry[0] == sheet_name]
if not matches:
raise ValueError(f"Sheet not found: {sheet_name}; available: {', '.join(x[0] for x in sheets)}")
sheets = matches
name, target = sheets[0]
root = ET.fromstring(archive.read(target))
for row in root.findall(".//m:sheetData/m:row", NS):
values = {}
for cell in row.findall("m:c", NS):
idx = col_index(cell.attrib.get("r", "A1"))
kind = cell.attrib.get("t")
value_node = cell.find("m:v", NS)
inline = cell.find("m:is", NS)
raw = value_node.text if value_node is not None else ""
if kind == "s" and raw:
value = shared[int(raw)]
elif kind == "inlineStr" and inline is not None:
value = "".join(n.text or "" for n in inline.iter(f"{{{NS['m']}}}t"))
elif kind == "b":
value = raw == "1"
else:
value = raw
values[idx] = value
if values:
yield name, [values.get(i, "") for i in range(max(values) + 1)]
def tabular_rows(path: Path, sheet: str | None = None):
suffix = path.suffix.lower()
if suffix == ".xlsx":
iterator = xlsx_rows(path, sheet)
try:
sheet_name, headers = next(iterator)
except StopIteration:
raise ValueError("Workbook contains no rows")
for _, row in iterator:
yield sheet_name, headers, row
return
delimiter = "\t" if suffix == ".tsv" else ","
with path.open("r", encoding="utf-8-sig", newline="") as handle:
reader = csv.reader(handle, delimiter=delimiter)
try:
headers = next(reader)
except StopIteration:
raise ValueError("File contains no rows")
for row in reader:
yield path.name, headers, row
def resolve_columns(headers: list[str], mapping: dict[str, str] | None = None) -> dict[str, int]:
normalized = {norm(header): i for i, header in enumerate(headers)}
result = {}
for field, aliases in ALIASES.items():
requested = (mapping or {}).get(field)
candidates = [requested] if requested else aliases
for candidate in candidates:
if norm(candidate) in normalized:
result[field] = normalized[norm(candidate)]
break
missing = [field for field in ("date", "ad", "spend", "installs") if field not in result]
if missing:
raise ValueError(f"Missing required columns: {', '.join(missing)}. Headers: {headers}")
return result
def number(value: object) -> float:
text = str(value or "").strip().replace(",", "").replace("$", "").replace("¥", "")
if not text or text.lower() in {"nan", "none", "null", "-"}:
return 0.0
if text.endswith("%"):
return float(text[:-1]) / 100
return float(text)
def date_value(value: object) -> dt.date:
text = str(value or "").strip()
if re.fullmatch(r"\d+(\.\d+)?", text):
serial = int(float(text))
if serial > 1000:
return dt.date(1899, 12, 30) + dt.timedelta(days=serial)
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d", "%m/%d/%Y", "%d/%m/%Y"):
try:
return dt.datetime.strptime(text[:10], fmt).date()
except ValueError:
pass
try:
return dt.datetime.fromisoformat(text.replace("Z", "+00:00")).date()
except ValueError as exc:
raise ValueError(f"Unrecognized date: {text!r}") from exc
def ratio(numerator: float, denominator: float):
return None if denominator == 0 else numerator / denominator
def rounded(value):
if value is None:
return None
return round(value, 6)
def metrics(rows):
spend = sum(r["spend"] for r in rows)
installs = sum(r["installs"] for r in rows)
retained = sum(r["retained"] for r in rows)
return {
"spend": rounded(spend), "installs": rounded(installs), "retained": rounded(retained),
"cpi": rounded(ratio(spend, installs)),
"retention_rate": rounded(ratio(retained, installs)),
"retention_cost": rounded(ratio(spend, retained)),
"delivery_days": len({r["date"] for r in rows if r["spend"] > 0}),
}
def in_window(rows, end: dt.date, days: int):
start = end - dt.timedelta(days=days - 1)
return [row for row in rows if start <= row["date"] <= end]
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("input", type=Path)
parser.add_argument("--analysis-date", help="YYYY-MM-DD; defaults to latest source date")
parser.add_argument("--retention-lag-days", type=int, default=1)
parser.add_argument("--sheet")
parser.add_argument("--mapping", type=Path, help="JSON mapping canonical fields to source headers")
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args(argv)
if not args.input.exists():
parser.error(f"Input not found: {args.input}")
mapping = json.loads(args.mapping.read_text(encoding="utf-8")) if args.mapping else None
records, source_headers, source_sheet = [], None, None
for source_sheet, source_headers, row in tabular_rows(args.input, args.sheet):
columns = resolve_columns(source_headers, mapping)
def get(field):
idx = columns.get(field)
return row[idx] if idx is not None and idx < len(row) else ""
if not any(str(v).strip() for v in row):
continue
ad = str(get("ad")).strip()
if not ad:
continue
records.append({
"date": date_value(get("date")), "campaign": str(get("campaign")).strip(),
"ad_group": str(get("ad_group")).strip(), "ad": ad,
"spend": number(get("spend")), "installs": number(get("installs")),
"retained": number(get("retained")), "impressions": number(get("impressions")),
"clicks": number(get("clicks")),
})
if not records:
raise ValueError("No usable Ad-level records found")
latest = max(row["date"] for row in records)
analysis_date = dt.date.fromisoformat(args.analysis_date) if args.analysis_date else latest
mature_date = analysis_date - dt.timedelta(days=args.retention_lag_days)
by_ad = defaultdict(list)
for row in records:
by_ad[(row["campaign"], row["ad_group"], row["ad"])].append(row)
ads = []
for (campaign, ad_group, ad), rows in by_ad.items():
all_dates = [r["date"] for r in rows]
acquisition_7 = in_window(rows, analysis_date, 7)
retention_7 = in_window(rows, mature_date, 7)
previous_7 = in_window(rows, mature_date - dt.timedelta(days=7), 7)
ads.append({
"ad": ad, "ad_group_context": ad_group or None, "campaign_context": campaign or None,
"first_date": min(all_dates).isoformat(), "last_date": max(all_dates).isoformat(),
"age_days_at_analysis": (analysis_date - min(all_dates)).days + 1,
"analysis_day": metrics([r for r in rows if r["date"] == analysis_date]),
"recent_7_acquisition": metrics(acquisition_7),
"recent_7_mature_retention": metrics(retention_7),
"previous_7_mature_retention": metrics(previous_7),
"recent_14_mature_retention": metrics(in_window(rows, mature_date, 14)),
})
ads.sort(key=lambda x: (-x["recent_7_acquisition"]["spend"], x["ad"]))
result = {
"contract_version": "1.0", "source": {"file": args.input.name, "sheet": source_sheet,
"row_count": len(records), "columns": source_headers},
"dates": {"analysis_date": analysis_date.isoformat(), "latest_source_date": latest.isoformat(),
"retention_mature_date": mature_date.isoformat(), "retention_lag_days": args.retention_lag_days},
"portfolio": {"analysis_day": metrics([r for r in records if r["date"] == analysis_date]),
"recent_7_acquisition": metrics(in_window(records, analysis_date, 7)),
"recent_7_mature_retention": metrics(in_window(records, mature_date, 7))},
"ads": ads,
"notes": ["Ratios are recomputed from summed numerators and denominators.",
"Campaign and Ad Group are identity context only; no parent-level ranking is produced.",
"This file contains evidence only. KPI verdicts and actions require the Skill decision gates."],
}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"Wrote {args.output} ({len(ads)} Ads; analysis date {analysis_date})")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (ValueError, zipfile.BadZipFile, json.JSONDecodeError) as exc:
print(f"error: {exc}", file=sys.stderr)
raise SystemExit(2)
@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""Build a restrained four-section DOCX from the Skill's brief JSON contract."""
from __future__ import annotations
import argparse
import datetime as dt
import json
import sys
from pathlib import Path
from xml.sax.saxutils import escape
from zipfile import ZIP_DEFLATED, ZipFile
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
FORBIDDEN = ("核心判断", "规则一", "规则二", "Campaign层级", "层级判断")
def run(text, bold=False, color="1F2937", size=20):
props = (f'<w:rFonts w:ascii="Arial" w:hAnsi="Arial" w:eastAsia="Microsoft YaHei"/>'
f'<w:color w:val="{color}"/><w:sz w:val="{size}"/><w:szCs w:val="{size}"/>'
+ ("<w:b/><w:bCs/>" if bold else ""))
return f'<w:r><w:rPr>{props}</w:rPr><w:t xml:space="preserve">{escape(str(text))}</w:t></w:r>'
def para(text="", bold=False, color="1F2937", size=20, align=None, before=0,
after=90, keep_next=False, border=False):
ppr = [f'<w:spacing w:before="{before}" w:after="{after}" w:line="276" w:lineRule="auto"/>',
"<w:keepLines/>"]
if align:
ppr.append(f'<w:jc w:val="{align}"/>')
if keep_next:
ppr.append("<w:keepNext/>")
if border:
ppr.append('<w:pBdr><w:bottom w:val="single" w:sz="8" w:space="5" w:color="2E74B5"/></w:pBdr>')
return f'<w:p><w:pPr>{"".join(ppr)}</w:pPr>{run(text, bold, color, size)}</w:p>'
def rich(label, text):
props = '<w:spacing w:before="20" w:after="110" w:line="276" w:lineRule="auto"/>'
return f'<w:p><w:pPr>{props}</w:pPr>{run(label + ":", True, "0B2545", 20)}{run(text)}</w:p>'
def heading(text, major=True):
return para(text, True, "0B2545" if major else "2E74B5", 28 if major else 23,
before=240 if major else 150, after=110, keep_next=True, border=major)
def cell(value, width, header=False, align="left"):
fill = '<w:shd w:val="clear" w:fill="E8EEF5"/>' if header else ""
tcpr = (f'<w:tcW w:w="{width}" w:type="dxa"/>{fill}<w:vAlign w:val="center"/>'
'<w:tcMar><w:top w:w="85" w:type="dxa"/><w:left w:w="100" w:type="dxa"/>'
'<w:bottom w:w="85" w:type="dxa"/><w:right w:w="100" w:type="dxa"/></w:tcMar>')
return f'<w:tc><w:tcPr>{tcpr}</w:tcPr>{para(value, header, "1F3A5F" if header else "1F2937", 17, align, after=0)}</w:tc>'
def table(headers, rows, widths, aligns=None):
if sum(widths) != 9360 or len(headers) != len(widths):
raise ValueError("Every table width list must match headers and sum to 9360")
aligns = aligns or ["left"] * len(widths)
borders = "".join(f'<w:{edge} w:val="single" w:sz="4" w:color="CBD5E1"/>'
for edge in ("top", "left", "bottom", "right", "insideH", "insideV"))
props = f'<w:tblW w:w="9360" w:type="dxa"/><w:tblLayout w:type="fixed"/><w:tblBorders>{borders}</w:tblBorders>'
grid = "".join(f'<w:gridCol w:w="{width}"/>' for width in widths)
header = "".join(cell(value, width, True, aligns[i]) for i, (value, width) in enumerate(zip(headers, widths)))
xml_rows = [f'<w:tr><w:trPr><w:tblHeader/></w:trPr>{header}</w:tr>']
for row in rows:
normalized = list(row) + [""] * (len(headers) - len(row))
cells = "".join(cell(normalized[i], width, False, aligns[i]) for i, width in enumerate(widths))
xml_rows.append(f'<w:tr><w:trPr><w:cantSplit/></w:trPr>{cells}</w:tr>')
return f'<w:tbl><w:tblPr>{props}</w:tblPr><w:tblGrid>{grid}</w:tblGrid>{"".join(xml_rows)}</w:tbl>'
def rows(items, fields):
return [[str(item.get(field, "")) for field in fields] for item in items]
def validate(data):
required = ("analysis_date", "portfolio_kpis", "learning_pool", "stable_pool", "actions", "good_ads")
missing = [key for key in required if key not in data]
if missing:
raise ValueError("Missing brief keys: " + ", ".join(missing))
payload = json.dumps(data, ensure_ascii=False)
found = [term for term in FORBIDDEN if term in payload]
if found:
raise ValueError("Forbidden daily-brief content: " + ", ".join(found))
for item in data["stable_pool"]:
tier = str(item.get("tier", ""))
if tier and not any(tier.startswith(prefix) for prefix in ("S-优质", "A-稳定", "B-波动待优化", "C-劣质")):
raise ValueError(f"Invalid stable tier: {tier}")
def document_body(data):
title = data.get("title") or f'{data["analysis_date"]} Campaign Triage'
label = data.get("account_label", "广告投放日报")
mature = data.get("retention_mature_date")
date_line = f'投放数据日:{data["analysis_date"]}' + (f' | D1次留成熟日:{mature}' if mature else "")
body = [para(label, True, "64748B", 18, after=55), para(title, True, "0B2545", 34, after=60),
para(date_line, color="475569", size=19, after=160, border=True)]
body += [heading("1. 是否达标"), table(
["指标", "实际值", "目标", "结论", "口径"],
rows(data["portfolio_kpis"], ["metric", "actual", "target", "verdict", "basis"]),
[1450, 1300, 1300, 1200, 4110], ["left", "center", "center", "center", "left"])]
if data.get("kpi_note"):
body.append(rich("口径说明", data["kpi_note"]))
body += [heading("2. 问题分析"), heading("学习期池排名", False)]
learning = data["learning_pool"]
if learning:
body.append(table(["排名", "Ad", "阶段", "成熟表现", "决定"],
rows(learning, ["rank", "ad", "stage", "evidence", "decision"]),
[650, 2300, 950, 3400, 2060], ["center", "left", "center", "left", "left"]))
else:
body.append(para("无", color="475569"))
body.append(heading("稳定期池排名", False))
stable = data["stable_pool"]
if stable:
body.append(table(["排名", "分级", "Ad", "判断", "决定"],
rows(stable, ["rank", "tier", "ad", "evidence", "decision"]),
[650, 1550, 2050, 3000, 2110], ["center", "center", "left", "left", "left"]))
else:
body.append(para("无", color="475569"))
body.append(heading("3. 建议动作"))
actions = data["actions"]
if actions:
body.append(table(["动作", "对象", "证据", "预算影响"],
rows(actions, ["action", "ad", "evidence", "budget_impact"]),
[1100, 2500, 4260, 1500], ["center", "left", "left", "center"]))
else:
body.append(para("无", color="475569"))
for note in data.get("action_notes", []):
body.append(rich(note.get("label", "说明"), note.get("text", "")))
body.append(heading("4. 近7天表现好的 Ads(3–5个)"))
good = data["good_ads"]
if good:
body.append(table(["排名", "Ad", "近7日数据", "为什么好", "下一步"],
rows(good, ["rank", "ad", "data", "why_good", "next_step"]),
[650, 1900, 2900, 2400, 1510], ["center", "left", "left", "left", "left"]))
else:
body.append(para("无符合标准的成熟 Ad;不降低标准凑数。", color="475569"))
if data.get("data_note"):
body += [heading("数据与决策说明", False), para(data["data_note"], color="475569", size=18)]
body.append(para("以上均为人工决策建议,未执行任何平台变更。", True, "0B2545", 19, before=60, after=0, border=True))
return "".join(body)
def write_docx(data, output: Path):
validate(data)
body = document_body(data)
sect = '<w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1080" w:right="1440" w:bottom="1080" w:left="1440" w:header="720" w:footer="720"/></w:sectPr>'
document = f'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="{W}"><w:body>{body}{sect}</w:body></w:document>'
styles = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="{W}">
<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="Arial" w:hAnsi="Arial" w:eastAsia="Microsoft YaHei"/><w:sz w:val="20"/><w:szCs w:val="20"/><w:color w:val="1F2937"/></w:rPr></w:rPrDefault></w:docDefaults>
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style></w:styles>'''
content_types = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/></Types>'''
root_rels = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/></Relationships>'''
doc_rels = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>'''
stamp = dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
core = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:title>{escape(data.get("title", "Daily Campaign Triage"))}</dc:title><dc:creator>daily-campaign-triage</dc:creator><dcterms:created xsi:type="dcterms:W3CDTF">{stamp}</dcterms:created><dcterms:modified xsi:type="dcterms:W3CDTF">{stamp}</dcterms:modified></cp:coreProperties>'''
output.parent.mkdir(parents=True, exist_ok=True)
with ZipFile(output, "w", ZIP_DEFLATED) as archive:
for name, payload in (("[Content_Types].xml", content_types), ("_rels/.rels", root_rels),
("word/document.xml", document), ("word/styles.xml", styles),
("word/_rels/document.xml.rels", doc_rels), ("docProps/core.xml", core)):
archive.writestr(name, payload)
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("input", type=Path, help="Brief JSON")
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args(argv)
data = json.loads(args.input.read_text(encoding="utf-8"))
write_docx(data, args.output)
print(f"Wrote {args.output}")
if __name__ == "__main__":
try:
main()
except (ValueError, json.JSONDecodeError) as exc:
print(f"error: {exc}", file=sys.stderr)
raise SystemExit(2)