sino-bc-cli 1.8.24 → 1.8.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/SKILL.md +30 -22
- package/dist/affordance/biz-line.md +7 -13
- package/dist/affordance/common-file.md +23 -0
- package/dist/affordance/wallet.md +207 -0
- package/dist/api-doc/README.md +4 -2
- package/dist/api-doc/oapi/biz-line-ai-strategy.md +45 -0
- package/dist/api-doc/oapi/biz-line-query.md +10 -3
- package/dist/api-doc/oapi/biz-line-strategy.md +12 -44
- package/dist/api-doc/oapi/common-file.md +44 -0
- package/dist/api-doc/oapi/wallet-bill.md +118 -0
- package/dist/api-doc/oapi/wallet-invoice.md +117 -0
- package/dist/api-doc/oapi/wallet-prepaid.md +249 -0
- package/dist/api-doc/oapi/wallet-quota.md +17 -0
- package/dist/api-doc/oapi/wallet-reconciliation.md +88 -0
- package/dist/generated/api-catalog.json +4183 -91
- package/dist/index.js +1 -1
- package/dist/skills/custom/biz-line-strategy-generate/SKILL.md +2 -3
- package/dist/skills/custom/biz-line-strategy-generate/references/api-flow.md +27 -1
- package/dist/skills/custom/biz-line-strategy-generate/references/output-excel.md +1 -1
- package/dist/skills/custom/biz-line-strategy-generate/references/runtime.md +3 -3
- package/dist/skills/custom/biz-line-strategy-generate/references/spec.md +2 -9
- package/dist/skills/custom/biz-line-strategy-generate/scripts/build_report.py +79 -225
- package/dist/skills/custom/biz-line-strategy-generate/scripts/cli_json.py +25 -91
- package/dist/skills/custom/biz-line-strategy-generate/scripts/fetch_data.py +5 -8
- package/dist/skills/custom/biz-line-strategy-generate/scripts/summarize.py +400 -588
- package/dist/skills/custom/biz-line-strategy-generate/scripts/test_strategy_rules.py +6 -53
- package/dist/skills/custom/biz-line-strategy-generate/scripts/test_workflow.py +6 -168
- package/dist/skills/default/sino-bc-shared/SKILL.md +1 -1
- package/dist/skills/default/sino-bc-shared/references/output-contract.md +2 -2
- package/dist/skills/oapi/sino-bc-biz-line/SKILL.md +11 -4
- package/dist/skills/oapi/sino-bc-common-file/SKILL.md +42 -0
- package/dist/skills/oapi/sino-bc-file/SKILL.md +3 -2
- package/dist/skills/oapi/sino-bc-wallet/SKILL.md +72 -0
- package/package.json +1 -1
|
@@ -5,10 +5,8 @@
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
7
|
import argparse
|
|
8
|
-
import heapq
|
|
9
8
|
import json
|
|
10
9
|
import sys
|
|
11
|
-
import tempfile
|
|
12
10
|
from collections import defaultdict
|
|
13
11
|
from pathlib import Path
|
|
14
12
|
|
|
@@ -17,16 +15,13 @@ if str(SCRIPTS) not in sys.path:
|
|
|
17
15
|
sys.path.insert(0, str(SCRIPTS))
|
|
18
16
|
|
|
19
17
|
from cli_json import (
|
|
20
|
-
add_metric_acc,
|
|
21
18
|
bid_adjust_stats,
|
|
22
19
|
budget_abs_share,
|
|
23
20
|
cpm,
|
|
24
21
|
disp,
|
|
25
22
|
examples,
|
|
26
|
-
finish_metric_acc,
|
|
27
23
|
fmt_cpm,
|
|
28
24
|
metric_value,
|
|
29
|
-
new_metric_acc,
|
|
30
25
|
optional_num,
|
|
31
26
|
core_rule,
|
|
32
27
|
rule_hit,
|
|
@@ -131,224 +126,43 @@ def share_moved(curr: dict, prev: dict) -> bool:
|
|
|
131
126
|
return False
|
|
132
127
|
|
|
133
128
|
|
|
134
|
-
def
|
|
135
|
-
|
|
136
|
-
if not ag:
|
|
137
|
-
return None
|
|
138
|
-
ident = str(ag)
|
|
139
|
-
date_s = str(row.get("calendar_date") or "")
|
|
140
|
-
snap = {
|
|
141
|
-
"id": ident,
|
|
142
|
-
"calendar_date": date_s,
|
|
143
|
-
"name": disp(row, "adgroup_name") or ident,
|
|
144
|
-
"strategy": disp(row, "bid_strategy"),
|
|
145
|
-
}
|
|
146
|
-
for field in fields:
|
|
147
|
-
snap[field] = optional_num(row, field)
|
|
148
|
-
return snap
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
def _bid_result_from_records(ag: str, records: list[dict], fields: list[str]) -> dict | None:
|
|
152
|
-
records = sorted(records, key=lambda r: r["calendar_date"])
|
|
153
|
-
changes = []
|
|
154
|
-
changed_dates = set()
|
|
155
|
-
for field in fields:
|
|
156
|
-
valid = [(r["calendar_date"], r[field]) for r in records if r[field] is not None]
|
|
157
|
-
times = sum(a[1] != b[1] for a, b in zip(valid, valid[1:]))
|
|
158
|
-
changed_dates.update(b[0] for a, b in zip(valid, valid[1:]) if a[1] != b[1])
|
|
159
|
-
if times:
|
|
160
|
-
changes.append({"field": field, "from": valid[0][1], "to": valid[-1][1], "times": times})
|
|
161
|
-
if not changes:
|
|
162
|
-
return None
|
|
163
|
-
primary = next((c for c in changes if c["from"] != c["to"]), changes[0])
|
|
164
|
-
last = records[-1]
|
|
165
|
-
return {"id": ag, "name": last["name"], **primary, "times": len(changed_dates), "fields": changes, "strategy": last["strategy"]}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
def _sort_line_file(src: Path, chunk_size: int = 4000) -> Path:
|
|
169
|
-
"""按行外排序,一次只留一块在内存。"""
|
|
170
|
-
parts: list[Path] = []
|
|
171
|
-
buf: list[str] = []
|
|
172
|
-
|
|
173
|
-
def flush() -> None:
|
|
174
|
-
if not buf:
|
|
175
|
-
return
|
|
176
|
-
buf.sort()
|
|
177
|
-
part = src.with_name(f"{src.name}.s{len(parts)}")
|
|
178
|
-
part.write_text("".join(buf), encoding="utf-8")
|
|
179
|
-
parts.append(part)
|
|
180
|
-
buf.clear()
|
|
181
|
-
|
|
182
|
-
with src.open(encoding="utf-8") as handle:
|
|
183
|
-
for line in handle:
|
|
184
|
-
buf.append(line)
|
|
185
|
-
if len(buf) >= chunk_size:
|
|
186
|
-
flush()
|
|
187
|
-
flush()
|
|
188
|
-
if not parts:
|
|
189
|
-
return src
|
|
190
|
-
if len(parts) == 1:
|
|
191
|
-
parts[0].replace(src)
|
|
192
|
-
return src
|
|
193
|
-
out = src.with_name(src.name + ".sorted")
|
|
194
|
-
handles = [part.open(encoding="utf-8") for part in parts]
|
|
195
|
-
try:
|
|
196
|
-
with out.open("w", encoding="utf-8") as dest:
|
|
197
|
-
dest.writelines(heapq.merge(*handles))
|
|
198
|
-
finally:
|
|
199
|
-
for handle in handles:
|
|
200
|
-
handle.close()
|
|
201
|
-
for part in parts:
|
|
202
|
-
part.unlink(missing_ok=True)
|
|
203
|
-
return out
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
def _bid_changes_memory(rows, fields):
|
|
207
|
-
"""小样本对照:内存按广告组聚合。生产路径必须走 bid_changes 落盘。"""
|
|
129
|
+
def bid_changes(rows, fields):
|
|
130
|
+
"""逐个出价字段比较有效日快照,缺失值不补零;只保留日期与出价字段,可接迭代器。"""
|
|
208
131
|
by_ag = defaultdict(list)
|
|
132
|
+
names: dict[str, tuple[str, str]] = {}
|
|
133
|
+
strategies: dict[str, str] = {}
|
|
209
134
|
for row in rows:
|
|
210
|
-
|
|
211
|
-
if
|
|
212
|
-
|
|
135
|
+
ag = row.get("adgroup_id")
|
|
136
|
+
if not ag:
|
|
137
|
+
continue
|
|
138
|
+
ident = str(ag)
|
|
139
|
+
date_s = str(row.get("calendar_date") or "")
|
|
140
|
+
if ident not in names or date_s >= names[ident][0]:
|
|
141
|
+
names[ident] = (date_s, disp(row, "adgroup_name") or ident)
|
|
142
|
+
strategies[ident] = disp(row, "bid_strategy")
|
|
143
|
+
snap = {"calendar_date": str(row.get("calendar_date") or "")}
|
|
144
|
+
for field in fields:
|
|
145
|
+
snap[field] = optional_num(row, field)
|
|
146
|
+
by_ag[ident].append(snap)
|
|
213
147
|
result = []
|
|
214
148
|
for ag, records in by_ag.items():
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
149
|
+
records = sorted(records, key=lambda r: r["calendar_date"])
|
|
150
|
+
changes = []
|
|
151
|
+
changed_dates = set()
|
|
152
|
+
for field in fields:
|
|
153
|
+
valid = [(r["calendar_date"], r[field]) for r in records if r[field] is not None]
|
|
154
|
+
times = sum(a[1] != b[1] for a, b in zip(valid, valid[1:]))
|
|
155
|
+
changed_dates.update(b[0] for a, b in zip(valid, valid[1:]) if a[1] != b[1])
|
|
156
|
+
if times:
|
|
157
|
+
changes.append({"field": field, "from": valid[0][1], "to": valid[-1][1], "times": times})
|
|
158
|
+
if changes:
|
|
159
|
+
# 多字段变动以首个首末不同的字段展示方向,明细完整保留各字段。
|
|
160
|
+
primary = next((c for c in changes if c["from"] != c["to"]), changes[0])
|
|
161
|
+
result.append({"id": ag, "name": names[ag][1],
|
|
162
|
+
**primary, "times": len(changed_dates), "fields": changes, "strategy": strategies[ag]})
|
|
218
163
|
return sorted(result, key=lambda r: (-r["times"], r["id"]))
|
|
219
164
|
|
|
220
165
|
|
|
221
|
-
def bid_changes(rows, fields, work_dir: Path | None = None):
|
|
222
|
-
"""逐个出价字段比较有效日快照,缺失值不补零。始终按广告组落盘再逐组处理。"""
|
|
223
|
-
if work_dir is None:
|
|
224
|
-
with tempfile.TemporaryDirectory(prefix="bid-") as tmp:
|
|
225
|
-
return bid_changes(rows, fields, work_dir=Path(tmp))
|
|
226
|
-
work_dir.mkdir(parents=True, exist_ok=True)
|
|
227
|
-
spill = work_dir / "snaps.ndjson"
|
|
228
|
-
with spill.open("w", encoding="utf-8") as handle:
|
|
229
|
-
for row in rows:
|
|
230
|
-
snap = _bid_snap(row, fields)
|
|
231
|
-
if not snap:
|
|
232
|
-
continue
|
|
233
|
-
handle.write(f"{snap['id']}\t{snap['calendar_date']}\t{json.dumps(snap, ensure_ascii=False)}\n")
|
|
234
|
-
ordered = _sort_line_file(spill)
|
|
235
|
-
result = []
|
|
236
|
-
current_id = ""
|
|
237
|
-
group: list[dict] = []
|
|
238
|
-
|
|
239
|
-
def flush_group() -> None:
|
|
240
|
-
if not group:
|
|
241
|
-
return
|
|
242
|
-
item = _bid_result_from_records(current_id, group, fields)
|
|
243
|
-
if item:
|
|
244
|
-
result.append(item)
|
|
245
|
-
group.clear()
|
|
246
|
-
|
|
247
|
-
with ordered.open(encoding="utf-8") as handle:
|
|
248
|
-
for line in handle:
|
|
249
|
-
ident, _date, payload = line.split("\t", 2)
|
|
250
|
-
snap = json.loads(payload)
|
|
251
|
-
if ident != current_id:
|
|
252
|
-
flush_group()
|
|
253
|
-
current_id = ident
|
|
254
|
-
group.append(snap)
|
|
255
|
-
flush_group()
|
|
256
|
-
return sorted(result, key=lambda r: (-r["times"], r["id"]))
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
class DetailWriter:
|
|
260
|
-
"""明细逐条写 ndjson,分析阶段不攒全表 list。"""
|
|
261
|
-
|
|
262
|
-
def __init__(self, path: Path):
|
|
263
|
-
self.path = path
|
|
264
|
-
self.count = 0
|
|
265
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
266
|
-
self._fh = path.open("w", encoding="utf-8")
|
|
267
|
-
|
|
268
|
-
def append(self, record: dict) -> None:
|
|
269
|
-
self._fh.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
270
|
-
self.count += 1
|
|
271
|
-
|
|
272
|
-
def close(self) -> None:
|
|
273
|
-
if self._fh and not self._fh.closed:
|
|
274
|
-
self._fh.close()
|
|
275
|
-
|
|
276
|
-
def __enter__(self):
|
|
277
|
-
return self
|
|
278
|
-
|
|
279
|
-
def __exit__(self, *_exc) -> None:
|
|
280
|
-
self.close()
|
|
281
|
-
|
|
282
|
-
def __len__(self) -> int:
|
|
283
|
-
return self.count
|
|
284
|
-
|
|
285
|
-
def __iter__(self):
|
|
286
|
-
self.close()
|
|
287
|
-
yield from iter_details(self.path)
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
def iter_details(details):
|
|
291
|
-
"""统一遍历 list / DetailWriter / ndjson 路径。"""
|
|
292
|
-
if isinstance(details, DetailWriter):
|
|
293
|
-
yield from details
|
|
294
|
-
return
|
|
295
|
-
if isinstance(details, (str, Path)):
|
|
296
|
-
path = Path(details)
|
|
297
|
-
if not path.exists():
|
|
298
|
-
return
|
|
299
|
-
with path.open(encoding="utf-8") as handle:
|
|
300
|
-
for line in handle:
|
|
301
|
-
if line.strip():
|
|
302
|
-
yield json.loads(line)
|
|
303
|
-
return
|
|
304
|
-
yield from details or []
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
def share_and_new_items(curr_rows, prev_rows, key, id_key=None):
|
|
308
|
-
"""同一对 curr/prev 迭代器各扫一遍,同时得到占比与新增对象。"""
|
|
309
|
-
def identity(row):
|
|
310
|
-
return str(row.get(id_key) or "") if id_key else disp(row, key)
|
|
311
|
-
|
|
312
|
-
previous: dict[str, str] = {}
|
|
313
|
-
prev_acc: dict[str, float] = defaultdict(float)
|
|
314
|
-
prev_total = 0.0
|
|
315
|
-
for row in prev_rows:
|
|
316
|
-
ident = identity(row)
|
|
317
|
-
if ident:
|
|
318
|
-
previous[ident] = disp(row, key) or ident
|
|
319
|
-
spend = num(row, "spend_usd")
|
|
320
|
-
prev_total += spend
|
|
321
|
-
prev_acc[disp(row, key) or "未标注"] += spend
|
|
322
|
-
|
|
323
|
-
grouped = defaultdict(list)
|
|
324
|
-
current_ids: set[str] = set()
|
|
325
|
-
curr_acc: dict[str, float] = defaultdict(float)
|
|
326
|
-
curr_total = 0.0
|
|
327
|
-
for row in curr_rows:
|
|
328
|
-
spend = num(row, "spend_usd")
|
|
329
|
-
curr_total += spend
|
|
330
|
-
curr_acc[disp(row, key) or "未标注"] += spend
|
|
331
|
-
ident = identity(row)
|
|
332
|
-
if not ident:
|
|
333
|
-
continue
|
|
334
|
-
current_ids.add(ident)
|
|
335
|
-
if ident not in previous:
|
|
336
|
-
grouped[ident].append(row)
|
|
337
|
-
|
|
338
|
-
def to_share(acc, total):
|
|
339
|
-
if total <= 0:
|
|
340
|
-
return {}
|
|
341
|
-
return {k: v / total * 100 for k, v in acc.items()}
|
|
342
|
-
|
|
343
|
-
items = []
|
|
344
|
-
for ident, records in grouped.items():
|
|
345
|
-
items.append({"id": ident if id_key else "", "label": disp(records[0], key) or ident,
|
|
346
|
-
"spend": sum(num(r, "spend_usd") for r in records), "rows": records,
|
|
347
|
-
"cpi": metric_value(records), "cpm": metric_value(records, "CPM")})
|
|
348
|
-
stops = [label for ident, label in previous.items() if ident not in current_ids]
|
|
349
|
-
return to_share(curr_acc, curr_total), to_share(prev_acc, prev_total), items, stops
|
|
350
|
-
|
|
351
|
-
|
|
352
166
|
def new_items(curr_rows, prev_rows, key, id_key=None):
|
|
353
167
|
"""按稳定 ID/维度值聚合基础指标,再识别本期新出现的对象。只为新增对象保留行。"""
|
|
354
168
|
def identity(row):
|
|
@@ -479,11 +293,6 @@ def summarize_request(req: Path):
|
|
|
479
293
|
inst = {"curr": {}, "prev": {}}
|
|
480
294
|
days = {"curr": {}, "prev": {}}
|
|
481
295
|
coverage_dates = {"curr": {}, "prev": {}}
|
|
482
|
-
core_index = str(biz.get("coreIndex") or detail_data.get("coreIndex") or "").strip()
|
|
483
|
-
rule = core_rule(core_index)
|
|
484
|
-
metric = rule["metric"]
|
|
485
|
-
core_acc = {"curr": new_metric_acc(metric), "prev": new_metric_acc(metric)}
|
|
486
|
-
cpi_acc = {"curr": new_metric_acc("CPI"), "prev": new_metric_acc("CPI")}
|
|
487
296
|
for ch in ("fb", "gg", "tt"):
|
|
488
297
|
for per in ("curr", "prev"):
|
|
489
298
|
day_spend = day_imps = day_inst = 0.0
|
|
@@ -505,8 +314,6 @@ def summarize_request(req: Path):
|
|
|
505
314
|
acc_spend += num(row, "spend_usd")
|
|
506
315
|
acc_imps += num(row, "impressions")
|
|
507
316
|
acc_inst += num(row, "installs")
|
|
508
|
-
add_metric_acc(core_acc[per], row)
|
|
509
|
-
add_metric_acc(cpi_acc[per], row)
|
|
510
317
|
spend[per][ch] = acc_spend if has_acc else day_spend
|
|
511
318
|
imps[per][ch] = acc_imps if has_acc else day_imps
|
|
512
319
|
inst[per][ch] = acc_inst if has_acc else day_inst
|
|
@@ -521,10 +328,19 @@ def summarize_request(req: Path):
|
|
|
521
328
|
prev_share = {ch: (spend["prev"][ch] / prev_total * 100 if prev_total else 0) for ch in spend["prev"]}
|
|
522
329
|
overall_c = cpm(curr_total, sum(imps["curr"].values()))
|
|
523
330
|
overall_p = cpm(prev_total, sum(imps["prev"].values()))
|
|
524
|
-
|
|
525
|
-
|
|
331
|
+
cpi_c = (curr_total / curr_inst) if curr_inst else None
|
|
332
|
+
cpi_p = (prev_total / prev_inst) if prev_inst else None
|
|
526
333
|
eff_days = max(days["curr"].values() or [0])
|
|
527
334
|
first_period = not (len(prev) == 2 and all(prev))
|
|
335
|
+
core_index = str(biz.get("coreIndex") or detail_data.get("coreIndex") or "").strip()
|
|
336
|
+
rule = core_rule(core_index)
|
|
337
|
+
metric = rule["metric"]
|
|
338
|
+
def iter_account_rows(per: str):
|
|
339
|
+
for ch in CH_LABEL:
|
|
340
|
+
yield from iter_stem_rows(bl, f"met_{ch}_{per}_account_id")
|
|
341
|
+
|
|
342
|
+
core_c, core_p = (metric_value(iter_account_rows(per), metric) for per in ("curr", "prev"))
|
|
343
|
+
cpi_c, cpi_p = (metric_value(iter_account_rows(per), "CPI") for per in ("curr", "prev"))
|
|
528
344
|
qoq_label, qoq_pct = qoq_axis(core_c, core_p, metric in ("ROAS", "CTR"))
|
|
529
345
|
kpi_usable = rule["usable"]
|
|
530
346
|
spend_verdict = period_verdict(
|
|
@@ -538,296 +354,307 @@ def summarize_request(req: Path):
|
|
|
538
354
|
spend_verdict = "待观察"
|
|
539
355
|
gate_note = f"本期有效投放 {eff_days} 天、消耗 ${curr_total:,.0f}、安装 {curr_inst:,.0f}"
|
|
540
356
|
qoq_pct_s = "" if qoq_pct is None else f"{qoq_pct * 100:+.1f}%"
|
|
541
|
-
details =
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
357
|
+
details: list[dict] = [{"bizLine": name, "kind": "口径", "action": "客户 KPI", "channel": "", "name": core_index or "未填写", "extra": rule["reason"]}]
|
|
358
|
+
coverage_notes = []
|
|
359
|
+
from datetime import date, timedelta
|
|
360
|
+
for per, bounds in (("curr", periods.get("curr")), ("prev", periods.get("prev"))):
|
|
361
|
+
if not bounds or not all(bounds):
|
|
362
|
+
continue
|
|
363
|
+
start, end = (date.fromisoformat(d) for d in bounds)
|
|
364
|
+
expected = {(start + timedelta(days=i)).isoformat() for i in range((end - start).days + 1)}
|
|
365
|
+
for ch, accounts in projects.get("accounts", {}).items():
|
|
366
|
+
if not accounts:
|
|
548
367
|
continue
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
stats
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
if stats["list_all"]:
|
|
588
|
-
for b in stats["details"]:
|
|
589
|
-
lines.append(
|
|
590
|
-
f"· {short_label(b.get('name') or '')}:{b.get('action')} "
|
|
591
|
-
f"{b.get('from')} → {b.get('to')}({b.get('times')} 次)"
|
|
592
|
-
)
|
|
593
|
-
return "\n".join(lines)
|
|
594
|
-
|
|
595
|
-
for ch in ("fb", "gg", "tt"):
|
|
596
|
-
stats = bid_stats[ch]
|
|
597
|
-
src = stats["details"] if stats["list_all"] else bids[ch]
|
|
598
|
-
for b in src:
|
|
599
|
-
details.append(
|
|
600
|
-
{
|
|
601
|
-
"bizLine": name,
|
|
602
|
-
"kind": "出价",
|
|
603
|
-
"action": b.get("action") or ("上调" if float(b.get("to") or 0) > float(b.get("from") or 0) else ("下调" if float(b.get("to") or 0) < float(b.get("from") or 0) else "期内变化但首末持平")),
|
|
604
|
-
"channel": CH_LABEL[ch],
|
|
605
|
-
"name": b.get("name") or "",
|
|
606
|
-
"extra": f"ID={b['id']};" + json.dumps(b.get("fields", []), ensure_ascii=False),
|
|
607
|
-
}
|
|
368
|
+
dates = sorted(coverage_dates[per].get(ch) or set())
|
|
369
|
+
missing = expected - set(dates)
|
|
370
|
+
label = "本期" if per == "curr" else "上期"
|
|
371
|
+
coverage_notes.append(f"{CH_LABEL[ch]}{label}实际覆盖 {dates[0] if dates else '-'}~{dates[-1] if dates else '-'},{len(dates)}天/{len(expected)}天" + ("(覆盖不完整)" if missing else ""))
|
|
372
|
+
details.append({"bizLine": name, "kind": "周期覆盖", "channel": CH_LABEL[ch], "action": label, "name": f"请求 {bounds[0]}~{bounds[1]}", "extra": "实际日期:" + "、".join(dates)})
|
|
373
|
+
period += "\n" + "\n".join(coverage_notes)
|
|
374
|
+
if any("覆盖不完整" in n for n in coverage_notes):
|
|
375
|
+
period += "\n周期覆盖不完整,消耗减少不能直接解释为主动减预算"
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
bid_fields = {
|
|
379
|
+
"fb": ["bid_amount"],
|
|
380
|
+
"gg": ["cpc_bid_amount", "cpm_bid_amount", "cpv_bid_amount"],
|
|
381
|
+
"tt": ["bid_amount", "conversion_bid", "deep_cpabid"],
|
|
382
|
+
}
|
|
383
|
+
bid_stems = {
|
|
384
|
+
"fb": "dim_fb_curr_calendar_date+adgroup_id+adgroup_name+bid_amount",
|
|
385
|
+
"gg": "dim_gg_curr_calendar_date+adgroup_id+adgroup_name+cpc_bid_amount",
|
|
386
|
+
"tt": "dim_tt_curr_calendar_date+adgroup_id+adgroup_name+bid_amount",
|
|
387
|
+
}
|
|
388
|
+
bids = {ch: bid_changes(iter_stem_rows(bl, bid_stems[ch]), bid_fields[ch]) for ch in bid_fields}
|
|
389
|
+
bid_stats = {ch: bid_adjust_stats(bids[ch]) for ch in bids}
|
|
390
|
+
|
|
391
|
+
def bid_text(ch):
|
|
392
|
+
items = bids[ch]
|
|
393
|
+
stats = bid_stats[ch]
|
|
394
|
+
label = CH_LABEL[ch]
|
|
395
|
+
if not items:
|
|
396
|
+
return f"{label}:本期未发生明显出价调整"
|
|
397
|
+
lines = [
|
|
398
|
+
f"{label}:上调 {stats['raise_n']} 个广告组,下调 {stats['lower_n']} 个;"
|
|
399
|
+
f"期内变化但首末持平 {stats['flat_n']} 个;幅度中位数 {stats['median_abs']:.2f}"
|
|
400
|
+
]
|
|
401
|
+
if stats["list_all"]:
|
|
402
|
+
for b in stats["details"]:
|
|
403
|
+
lines.append(
|
|
404
|
+
f"· {short_label(b.get('name') or '')}:{b.get('action')} "
|
|
405
|
+
f"{b.get('from')} → {b.get('to')}({b.get('times')} 次)"
|
|
608
406
|
)
|
|
407
|
+
return "\n".join(lines)
|
|
609
408
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
"
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
),
|
|
625
|
-
budget_abs_share(
|
|
626
|
-
"GG",
|
|
627
|
-
prev_abs=spend["prev"]["gg"],
|
|
628
|
-
curr_abs=spend["curr"]["gg"],
|
|
629
|
-
prev_share=prev_share["gg"],
|
|
630
|
-
curr_share=curr_share["gg"],
|
|
631
|
-
),
|
|
632
|
-
budget_abs_share(
|
|
633
|
-
"TT",
|
|
634
|
-
prev_abs=spend["prev"]["tt"],
|
|
635
|
-
curr_abs=spend["curr"]["tt"],
|
|
636
|
-
prev_share=prev_share["tt"],
|
|
637
|
-
curr_share=curr_share["tt"],
|
|
638
|
-
),
|
|
639
|
-
f"消耗合计:本期 ${curr_total:,.0f} / 上期 ${prev_total:,.0f}",
|
|
640
|
-
]
|
|
641
|
-
)
|
|
642
|
-
cpi_s = f"全渠道 {metric}:本期 {fmt_cpm(core_c)} / 上期 {fmt_cpm(core_p)}"
|
|
643
|
-
cpi_s += "(美元消耗 / 平台安装量,非客户激活或留存成本)" if metric == "CPI" else "(同粒度基础指标合计后计算)"
|
|
644
|
-
budget_learning = "\n".join(
|
|
645
|
-
[
|
|
646
|
-
f"结论:{spend_verdict}(第二期起跟环比,KPI 只作依据)" if not first_period else f"结论:{spend_verdict}(首期)",
|
|
647
|
-
f"依据:{cpi_s},环比{qoq_label}" + (f" {qoq_pct_s}" if qoq_pct_s else ""),
|
|
648
|
-
f"客户 KPI:{core_index or '未填写'};{rule['reason']}",
|
|
649
|
-
*([f"门槛:{gate_note} → 待观察"] if gate_note else []),
|
|
650
|
-
"",
|
|
651
|
-
"Spend",
|
|
652
|
-
f"· {cpi_s}",
|
|
653
|
-
f"· 参考 CPM:本期 {fmt_cpm(overall_c)} / 上期 {fmt_cpm(overall_p)}(不作结论轴)",
|
|
654
|
-
f"· {budget_abs_share('FB', prev_abs=spend['prev']['fb'], curr_abs=spend['curr']['fb'], prev_share=prev_share['fb'], curr_share=curr_share['fb'])}",
|
|
655
|
-
f"· {budget_abs_share('GG', prev_abs=spend['prev']['gg'], curr_abs=spend['curr']['gg'], prev_share=prev_share['gg'], curr_share=curr_share['gg'])}",
|
|
656
|
-
f"· {budget_abs_share('TT', prev_abs=spend['prev']['tt'], curr_abs=spend['curr']['tt'], prev_share=prev_share['tt'], curr_share=curr_share['tt'])}",
|
|
657
|
-
"",
|
|
658
|
-
"调整出价",
|
|
659
|
-
*[
|
|
660
|
-
f"· {CH_LABEL[ch]}:上调 {bid_stats[ch]['raise_n']} / 下调 {bid_stats[ch]['lower_n']},"
|
|
661
|
-
f"幅度中位数 {bid_stats[ch]['median_abs']:.2f}"
|
|
662
|
-
for ch in ("fb", "gg", "tt")
|
|
663
|
-
],
|
|
664
|
-
"",
|
|
665
|
-
advice_of(spend_verdict),
|
|
666
|
-
]
|
|
667
|
-
)
|
|
668
|
-
|
|
669
|
-
def share_line(title, dim_key, stem, channels):
|
|
670
|
-
segs = []
|
|
671
|
-
moved = False
|
|
672
|
-
for ch in channels:
|
|
673
|
-
curr, _ = share_map(iter_stem_rows(bl, f"met_{ch}_curr_{stem}"), dim_key)
|
|
674
|
-
prev, _ = share_map(iter_stem_rows(bl, f"met_{ch}_prev_{stem}"), dim_key)
|
|
675
|
-
if share_moved(curr, prev):
|
|
676
|
-
moved = True
|
|
677
|
-
if curr or prev:
|
|
678
|
-
segs.append(f"{CH_LABEL[ch]}:{fmt_share(curr, prev)}")
|
|
679
|
-
return ("\n".join([title] + segs) if segs else ""), moved
|
|
680
|
-
|
|
681
|
-
p_obj, m1 = share_line("【广告目标】", "objective", "objective", ["fb", "tt"])
|
|
682
|
-
p_ch, m2 = share_line("【广告系列类型】", "advertising_channel_type", "advertising_channel_type", ["gg"])
|
|
683
|
-
p_opt, m3 = share_line("【成效目标】", "optimization_goal", "optimization_goal", ["fb", "gg", "tt"])
|
|
684
|
-
p_bid, m4 = share_line("【出价方式】", "bid_strategy", "bid_strategy", ["fb", "gg", "tt"])
|
|
685
|
-
product_strategy = "\n\n".join(x for x in [p_obj, p_ch, p_opt, p_bid] if x)
|
|
686
|
-
product_share_moved = any([m1, m2, m3, m4])
|
|
687
|
-
|
|
688
|
-
def dim_sets(stem, key, channels):
|
|
689
|
-
curr = set()
|
|
690
|
-
prev = set()
|
|
691
|
-
for ch in channels:
|
|
692
|
-
# Meta 兴趣的三个配置字段来自同一组合请求,不能找不存在的单字段文件。
|
|
693
|
-
if ch == 'fb' and stem == 'targeting_interests':
|
|
694
|
-
combined = 'targeting_interests+targeting_flexible_interests+targeting_exclusion_interests'
|
|
695
|
-
fields = combined.split('+')
|
|
696
|
-
for target, per in ((curr, 'curr'), (prev, 'prev')):
|
|
697
|
-
for row in iter_stem_rows(bl, f'dim_{ch}_{per}_{combined}'):
|
|
698
|
-
for field in fields:
|
|
699
|
-
lab = disp(row, field)
|
|
700
|
-
if lab:
|
|
701
|
-
target.add(lab)
|
|
702
|
-
continue
|
|
703
|
-
curr |= labels_of(iter_stem_rows(bl, f"dim_{ch}_curr_{stem}"), key)
|
|
704
|
-
prev |= labels_of(iter_stem_rows(bl, f"dim_{ch}_prev_{stem}"), key)
|
|
705
|
-
return curr, prev
|
|
706
|
-
|
|
707
|
-
def add_setting_details(kind, curr_set, prev_set, channel=""):
|
|
708
|
-
action_curr = "当期" if first_period else "新增"
|
|
709
|
-
news = sorted(curr_set if first_period or kind == "国家" else (curr_set - prev_set))
|
|
710
|
-
stops = [] if first_period else sorted(prev_set - curr_set)
|
|
711
|
-
for lab in news:
|
|
712
|
-
details.append(
|
|
713
|
-
{
|
|
714
|
-
"bizLine": name,
|
|
715
|
-
"kind": kind,
|
|
716
|
-
"action": "持续" if not first_period and lab in prev_set else action_curr,
|
|
717
|
-
"channel": channel,
|
|
718
|
-
"name": lab,
|
|
719
|
-
"extra": "",
|
|
720
|
-
}
|
|
721
|
-
)
|
|
722
|
-
for lab in stops:
|
|
723
|
-
details.append(
|
|
724
|
-
{
|
|
725
|
-
"bizLine": name,
|
|
726
|
-
"kind": kind,
|
|
727
|
-
"action": "本期未出现",
|
|
728
|
-
"channel": channel,
|
|
729
|
-
"name": lab,
|
|
730
|
-
"extra": "",
|
|
731
|
-
}
|
|
732
|
-
)
|
|
409
|
+
for ch in ("fb", "gg", "tt"):
|
|
410
|
+
stats = bid_stats[ch]
|
|
411
|
+
src = stats["details"] if stats["list_all"] else bids[ch]
|
|
412
|
+
for b in src:
|
|
413
|
+
details.append(
|
|
414
|
+
{
|
|
415
|
+
"bizLine": name,
|
|
416
|
+
"kind": "出价",
|
|
417
|
+
"action": b.get("action") or ("上调" if float(b.get("to") or 0) > float(b.get("from") or 0) else ("下调" if float(b.get("to") or 0) < float(b.get("from") or 0) else "期内变化但首末持平")),
|
|
418
|
+
"channel": CH_LABEL[ch],
|
|
419
|
+
"name": b.get("name") or "",
|
|
420
|
+
"extra": f"ID={b['id']};" + json.dumps(b.get("fields", []), ensure_ascii=False),
|
|
421
|
+
}
|
|
422
|
+
)
|
|
733
423
|
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
if
|
|
772
|
-
|
|
424
|
+
budget_strategy = "\n".join(
|
|
425
|
+
[
|
|
426
|
+
"【调整出价】",
|
|
427
|
+
bid_text("fb"),
|
|
428
|
+
bid_text("gg"),
|
|
429
|
+
bid_text("tt"),
|
|
430
|
+
"",
|
|
431
|
+
"【预算】",
|
|
432
|
+
budget_abs_share(
|
|
433
|
+
"FB",
|
|
434
|
+
prev_abs=spend["prev"]["fb"],
|
|
435
|
+
curr_abs=spend["curr"]["fb"],
|
|
436
|
+
prev_share=prev_share["fb"],
|
|
437
|
+
curr_share=curr_share["fb"],
|
|
438
|
+
),
|
|
439
|
+
budget_abs_share(
|
|
440
|
+
"GG",
|
|
441
|
+
prev_abs=spend["prev"]["gg"],
|
|
442
|
+
curr_abs=spend["curr"]["gg"],
|
|
443
|
+
prev_share=prev_share["gg"],
|
|
444
|
+
curr_share=curr_share["gg"],
|
|
445
|
+
),
|
|
446
|
+
budget_abs_share(
|
|
447
|
+
"TT",
|
|
448
|
+
prev_abs=spend["prev"]["tt"],
|
|
449
|
+
curr_abs=spend["curr"]["tt"],
|
|
450
|
+
prev_share=prev_share["tt"],
|
|
451
|
+
curr_share=curr_share["tt"],
|
|
452
|
+
),
|
|
453
|
+
f"消耗合计:本期 ${curr_total:,.0f} / 上期 ${prev_total:,.0f}",
|
|
454
|
+
]
|
|
455
|
+
)
|
|
456
|
+
cpi_s = f"全渠道 {metric}:本期 {fmt_cpm(core_c)} / 上期 {fmt_cpm(core_p)}"
|
|
457
|
+
cpi_s += "(美元消耗 / 平台安装量,非客户激活或留存成本)" if metric == "CPI" else "(同粒度基础指标合计后计算)"
|
|
458
|
+
budget_learning = "\n".join(
|
|
459
|
+
[
|
|
460
|
+
f"结论:{spend_verdict}(第二期起跟环比,KPI 只作依据)" if not first_period else f"结论:{spend_verdict}(首期)",
|
|
461
|
+
f"依据:{cpi_s},环比{qoq_label}" + (f" {qoq_pct_s}" if qoq_pct_s else ""),
|
|
462
|
+
f"客户 KPI:{core_index or '未填写'};{rule['reason']}",
|
|
463
|
+
*([f"门槛:{gate_note} → 待观察"] if gate_note else []),
|
|
464
|
+
"",
|
|
465
|
+
"Spend",
|
|
466
|
+
f"· {cpi_s}",
|
|
467
|
+
f"· 参考 CPM:本期 {fmt_cpm(overall_c)} / 上期 {fmt_cpm(overall_p)}(不作结论轴)",
|
|
468
|
+
f"· {budget_abs_share('FB', prev_abs=spend['prev']['fb'], curr_abs=spend['curr']['fb'], prev_share=prev_share['fb'], curr_share=curr_share['fb'])}",
|
|
469
|
+
f"· {budget_abs_share('GG', prev_abs=spend['prev']['gg'], curr_abs=spend['curr']['gg'], prev_share=prev_share['gg'], curr_share=curr_share['gg'])}",
|
|
470
|
+
f"· {budget_abs_share('TT', prev_abs=spend['prev']['tt'], curr_abs=spend['curr']['tt'], prev_share=prev_share['tt'], curr_share=curr_share['tt'])}",
|
|
471
|
+
"",
|
|
472
|
+
"调整出价",
|
|
473
|
+
*[
|
|
474
|
+
f"· {CH_LABEL[ch]}:上调 {bid_stats[ch]['raise_n']} / 下调 {bid_stats[ch]['lower_n']},"
|
|
475
|
+
f"幅度中位数 {bid_stats[ch]['median_abs']:.2f}"
|
|
476
|
+
for ch in ("fb", "gg", "tt")
|
|
477
|
+
],
|
|
478
|
+
"",
|
|
479
|
+
advice_of(spend_verdict),
|
|
480
|
+
]
|
|
481
|
+
)
|
|
773
482
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
483
|
+
def share_line(title, dim_key, stem, channels):
|
|
484
|
+
segs = []
|
|
485
|
+
moved = False
|
|
486
|
+
for ch in channels:
|
|
487
|
+
curr, _ = share_map(iter_stem_rows(bl, f"met_{ch}_curr_{stem}"), dim_key)
|
|
488
|
+
prev, _ = share_map(iter_stem_rows(bl, f"met_{ch}_prev_{stem}"), dim_key)
|
|
489
|
+
if share_moved(curr, prev):
|
|
490
|
+
moved = True
|
|
491
|
+
if curr or prev:
|
|
492
|
+
segs.append(f"{CH_LABEL[ch]}:{fmt_share(curr, prev)}")
|
|
493
|
+
return ("\n".join([title] + segs) if segs else ""), moved
|
|
494
|
+
|
|
495
|
+
p_obj, m1 = share_line("【广告目标】", "objective", "objective", ["fb", "tt"])
|
|
496
|
+
p_ch, m2 = share_line("【广告系列类型】", "advertising_channel_type", "advertising_channel_type", ["gg"])
|
|
497
|
+
p_opt, m3 = share_line("【成效目标】", "optimization_goal", "optimization_goal", ["fb", "gg", "tt"])
|
|
498
|
+
p_bid, m4 = share_line("【出价方式】", "bid_strategy", "bid_strategy", ["fb", "gg", "tt"])
|
|
499
|
+
product_strategy = "\n\n".join(x for x in [p_obj, p_ch, p_opt, p_bid] if x)
|
|
500
|
+
product_share_moved = any([m1, m2, m3, m4])
|
|
501
|
+
|
|
502
|
+
def dim_sets(stem, key, channels):
|
|
503
|
+
curr = set()
|
|
504
|
+
prev = set()
|
|
505
|
+
for ch in channels:
|
|
506
|
+
# Meta 兴趣的三个配置字段来自同一组合请求,不能找不存在的单字段文件。
|
|
507
|
+
if ch == 'fb' and stem == 'targeting_interests':
|
|
508
|
+
combined = 'targeting_interests+targeting_flexible_interests+targeting_exclusion_interests'
|
|
509
|
+
fields = combined.split('+')
|
|
510
|
+
for target, per in ((curr, 'curr'), (prev, 'prev')):
|
|
511
|
+
for row in iter_stem_rows(bl, f'dim_{ch}_{per}_{combined}'):
|
|
512
|
+
for field in fields:
|
|
513
|
+
lab = disp(row, field)
|
|
514
|
+
if lab:
|
|
515
|
+
target.add(lab)
|
|
516
|
+
continue
|
|
517
|
+
curr |= labels_of(iter_stem_rows(bl, f"dim_{ch}_curr_{stem}"), key)
|
|
518
|
+
prev |= labels_of(iter_stem_rows(bl, f"dim_{ch}_prev_{stem}"), key)
|
|
519
|
+
return curr, prev
|
|
520
|
+
|
|
521
|
+
def add_setting_details(kind, curr_set, prev_set, channel=""):
|
|
522
|
+
action_curr = "当期" if first_period else "新增"
|
|
523
|
+
news = sorted(curr_set if first_period or kind == "国家" else (curr_set - prev_set))
|
|
524
|
+
stops = [] if first_period else sorted(prev_set - curr_set)
|
|
525
|
+
for lab in news:
|
|
526
|
+
details.append(
|
|
527
|
+
{
|
|
528
|
+
"bizLine": name,
|
|
529
|
+
"kind": kind,
|
|
530
|
+
"action": "持续" if not first_period and lab in prev_set else action_curr,
|
|
531
|
+
"channel": channel,
|
|
532
|
+
"name": lab,
|
|
533
|
+
"extra": "",
|
|
534
|
+
}
|
|
535
|
+
)
|
|
536
|
+
for lab in stops:
|
|
537
|
+
details.append(
|
|
538
|
+
{
|
|
539
|
+
"bizLine": name,
|
|
540
|
+
"kind": kind,
|
|
541
|
+
"action": "本期未出现",
|
|
542
|
+
"channel": channel,
|
|
543
|
+
"name": lab,
|
|
544
|
+
"extra": "",
|
|
545
|
+
}
|
|
819
546
|
)
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
547
|
+
|
|
548
|
+
country_curr, country_prev = dim_sets("country_code", "country_code", ["fb", "gg", "tt"])
|
|
549
|
+
interest_curr, interest_prev = dim_sets("targeting_interests", "targeting_interests", ["fb", "tt"])
|
|
550
|
+
kw_curr, kw_prev = dim_sets("keywords", "keywords", ["gg"])
|
|
551
|
+
add_setting_details("国家", country_curr, country_prev)
|
|
552
|
+
add_setting_details("兴趣词", interest_curr, interest_prev)
|
|
553
|
+
add_setting_details("关键词", kw_curr, kw_prev)
|
|
554
|
+
|
|
555
|
+
def object_strategy(kind, id_key, name_key):
|
|
556
|
+
bits = []
|
|
557
|
+
for ch in CH_LABEL:
|
|
558
|
+
stem = f"{id_key}+{name_key}"
|
|
559
|
+
|
|
560
|
+
def index_objects(period: str) -> tuple[dict[str, str], dict[str, str]]:
|
|
561
|
+
return object_name_maps(iter_stem_rows(bl, f"dim_{ch}_{period}_{stem}"), id_key, name_key)
|
|
562
|
+
|
|
563
|
+
c, c_raw = index_objects("curr")
|
|
564
|
+
p, _p_raw = index_objects("prev")
|
|
565
|
+
new, absent = sorted(c.keys() - p.keys()), sorted(p.keys() - c.keys())
|
|
566
|
+
bits.append(f"{CH_LABEL[ch]}:本期新出现 {len(new)} 个,本期未出现 {len(absent)} 个;命名规律:{naming_patterns([c_raw[i] for i in new])}")
|
|
567
|
+
for ident in sorted(c.keys() | p.keys()):
|
|
568
|
+
record_name = c.get(ident) or p[ident]
|
|
569
|
+
action = "当期" if first_period else ("新增" if ident in new else ("本期未出现" if ident in absent else "持续"))
|
|
570
|
+
details.append({"bizLine": name, "kind": kind, "action": action, "channel": CH_LABEL[ch], "name": record_name, "extra": f"ID={ident};集合差异不等同创建/暂停操作"})
|
|
571
|
+
return bits
|
|
572
|
+
|
|
573
|
+
ag_bits = object_strategy("广告组", "adgroup_id", "adgroup_name")
|
|
574
|
+
audience_strategy = "\n\n".join(
|
|
575
|
+
x
|
|
576
|
+
for x in [
|
|
577
|
+
setting_line("【国家】", country_curr, country_prev, first_period=first_period),
|
|
578
|
+
setting_line("【兴趣词包】", interest_curr, interest_prev, first_period=first_period),
|
|
579
|
+
share_line("【受众类型】", "audience_type", "audience_type", ["fb", "gg", "tt"])[0],
|
|
580
|
+
setting_line("【关键词】", kw_curr, kw_prev, first_period=first_period),
|
|
581
|
+
setting_line("【年龄】", *dim_sets("targeting_age", "targeting_age", ["fb", "gg", "tt"]), first_period=first_period),
|
|
582
|
+
setting_line("【性别】", *dim_sets("targeting_genders", "targeting_genders", ["fb", "gg", "tt"]), first_period=first_period),
|
|
583
|
+
"【广告组】\n" + "\n".join(ag_bits),
|
|
584
|
+
]
|
|
585
|
+
if x
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
ad_bits = object_strategy("广告", "ad_id", "ad_name")
|
|
589
|
+
creative_fmt = share_line("【创意类型】", "ad_format", "ad_format", ["fb", "gg", "tt"])[0] or ""
|
|
590
|
+
creative_strategy = "【广告】\n" + "\n".join(ad_bits) + (("\n\n" + creative_fmt) if creative_fmt else "")
|
|
591
|
+
|
|
592
|
+
def strategy_learning(dim_specs, allow_share_fallback=False, share_did_move=False):
|
|
593
|
+
texts = []
|
|
594
|
+
hit_spend = all_spend = 0.0
|
|
595
|
+
any_new = only_stop = False
|
|
596
|
+
for title, stem, key, channels, id_key in dim_specs:
|
|
597
|
+
items_all, stops_all = [], []
|
|
598
|
+
for ch in channels:
|
|
599
|
+
curr_stem = f"met_{ch}_curr_{stem}"
|
|
600
|
+
prev_stem = f"met_{ch}_prev_{stem}"
|
|
601
|
+
curr_share_map, _ = share_map(iter_stem_rows(bl, curr_stem), key)
|
|
602
|
+
prev_share_map, _ = share_map(iter_stem_rows(bl, prev_stem), key)
|
|
603
|
+
items, stops = new_items(iter_stem_rows(bl, curr_stem), iter_stem_rows(bl, prev_stem), key, id_key)
|
|
604
|
+
stops = [
|
|
605
|
+
s
|
|
606
|
+
for s in stops
|
|
607
|
+
if not is_ghost_enum(curr_share_map.get(s, 0), prev_share_map.get(s, 0))
|
|
608
|
+
]
|
|
609
|
+
for item in items:
|
|
610
|
+
item["channel"] = CH_LABEL[ch]
|
|
611
|
+
if item["spend"] >= 50:
|
|
612
|
+
value = metric_value(item["rows"], metric)
|
|
613
|
+
hit = rule_hit(value, rule)
|
|
614
|
+
verdict_text = '不可判定' if hit is None else ('达标' if hit else '未达标')
|
|
615
|
+
details.append({"bizLine": name, "kind": f"新增成效/{title}", "action": "新增", "channel": CH_LABEL[ch], "name": item["label"], "extra": f"ID={item['id']};消耗美元 {item['spend']:.2f};{metric} {fmt_cpm(value)};KPI {verdict_text}"})
|
|
616
|
+
items_all.extend(items)
|
|
617
|
+
stops_all.extend(stops)
|
|
618
|
+
if items_all:
|
|
619
|
+
any_new = True
|
|
620
|
+
if stops_all and not items_all:
|
|
621
|
+
only_stop = True
|
|
622
|
+
text, _hit, hs, ts = dim_layer_text(title, items_all, stops_all, rule)
|
|
623
|
+
texts.append(text)
|
|
624
|
+
if ts > 0:
|
|
625
|
+
hit_spend += hs
|
|
626
|
+
all_spend += ts
|
|
627
|
+
detail = "\n\n".join(texts)
|
|
628
|
+
verdict = period_verdict(
|
|
629
|
+
first_period=first_period,
|
|
630
|
+
qoq_label=qoq_label,
|
|
631
|
+
kpi_usable=kpi_usable,
|
|
632
|
+
kpi_hit=rule_hit(core_c, rule),
|
|
633
|
+
)
|
|
634
|
+
if gate_note:
|
|
635
|
+
verdict = "待观察"
|
|
636
|
+
evidence = (
|
|
637
|
+
f"{cpi_s},环比{qoq_label}" + (f" {qoq_pct_s}" if qoq_pct_s else "")
|
|
638
|
+
+ "。此为整体环比观察,不代表本次单一动作的因果效果;" + rule["reason"]
|
|
639
|
+
)
|
|
640
|
+
if not any_new and only_stop:
|
|
641
|
+
return "\n".join(
|
|
642
|
+
[
|
|
643
|
+
f"结论:{verdict}",
|
|
644
|
+
"口径:本期无新增,走本期未出现",
|
|
645
|
+
f"依据:{evidence}",
|
|
646
|
+
"",
|
|
647
|
+
detail,
|
|
648
|
+
"",
|
|
649
|
+
advice_of(verdict),
|
|
650
|
+
]
|
|
825
651
|
)
|
|
826
|
-
|
|
652
|
+
if not any_new:
|
|
653
|
+
if allow_share_fallback and share_did_move:
|
|
827
654
|
return "\n".join(
|
|
828
655
|
[
|
|
829
656
|
f"结论:{verdict}",
|
|
830
|
-
"
|
|
657
|
+
"口径:无新增枚举,占比变化超过 ±2%,按可环比",
|
|
831
658
|
f"依据:{evidence}",
|
|
832
659
|
"",
|
|
833
660
|
detail,
|
|
@@ -835,101 +662,86 @@ def summarize_request(req: Path):
|
|
|
835
662
|
advice_of(verdict),
|
|
836
663
|
]
|
|
837
664
|
)
|
|
838
|
-
if not any_new:
|
|
839
|
-
if allow_share_fallback and share_did_move:
|
|
840
|
-
return "\n".join(
|
|
841
|
-
[
|
|
842
|
-
f"结论:{verdict}",
|
|
843
|
-
"口径:无新增枚举,占比变化超过 ±2%,按可环比",
|
|
844
|
-
f"依据:{evidence}",
|
|
845
|
-
"",
|
|
846
|
-
detail,
|
|
847
|
-
"",
|
|
848
|
-
advice_of(verdict),
|
|
849
|
-
]
|
|
850
|
-
)
|
|
851
|
-
return "\n".join(
|
|
852
|
-
[
|
|
853
|
-
f"结论:{verdict}",
|
|
854
|
-
"口径:本期无实质调整动作",
|
|
855
|
-
f"依据:{evidence}",
|
|
856
|
-
"",
|
|
857
|
-
detail,
|
|
858
|
-
]
|
|
859
|
-
)
|
|
860
|
-
ratio = (hit_spend / all_spend) if all_spend else 0.0
|
|
861
665
|
return "\n".join(
|
|
862
666
|
[
|
|
863
667
|
f"结论:{verdict}",
|
|
668
|
+
"口径:本期无实质调整动作",
|
|
864
669
|
f"依据:{evidence}",
|
|
865
670
|
"",
|
|
866
671
|
detail,
|
|
867
|
-
"",
|
|
868
|
-
advice_of(verdict),
|
|
869
672
|
]
|
|
870
673
|
)
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
[
|
|
874
|
-
("广告目标", "objective", "objective", ["fb", "tt"], None),
|
|
875
|
-
("广告系列类型", "advertising_channel_type", "advertising_channel_type", ["gg"], None),
|
|
876
|
-
("成效目标", "optimization_goal", "optimization_goal", ["fb", "gg", "tt"], None),
|
|
877
|
-
("出价方式", "bid_strategy", "bid_strategy", ["fb", "gg", "tt"], None),
|
|
878
|
-
],
|
|
879
|
-
allow_share_fallback=True,
|
|
880
|
-
share_did_move=product_share_moved,
|
|
881
|
-
)
|
|
882
|
-
audience_learning = strategy_learning(
|
|
883
|
-
[
|
|
884
|
-
("国家", "country_code", "country_code", ["fb", "gg", "tt"], None),
|
|
885
|
-
("兴趣词包", "targeting_interests", "targeting_interests", ["fb", "tt"], None),
|
|
886
|
-
("受众类型", "audience_type", "audience_type", ["fb", "gg", "tt"], None),
|
|
887
|
-
("关键词", "keywords", "keywords", ["gg"], None),
|
|
888
|
-
("年龄", "targeting_age", "targeting_age", ["fb", "gg", "tt"], None),
|
|
889
|
-
("性别", "targeting_genders", "targeting_genders", ["fb", "gg", "tt"], None),
|
|
890
|
-
("广告组", "adgroup_id+adgroup_name", "adgroup_name", ["fb", "gg", "tt"], "adgroup_id"),
|
|
891
|
-
]
|
|
892
|
-
)
|
|
893
|
-
creative_learning = strategy_learning(
|
|
674
|
+
ratio = (hit_spend / all_spend) if all_spend else 0.0
|
|
675
|
+
return "\n".join(
|
|
894
676
|
[
|
|
895
|
-
|
|
896
|
-
|
|
677
|
+
f"结论:{verdict}",
|
|
678
|
+
f"依据:{evidence}",
|
|
679
|
+
"",
|
|
680
|
+
detail,
|
|
681
|
+
"",
|
|
682
|
+
advice_of(verdict),
|
|
897
683
|
]
|
|
898
684
|
)
|
|
899
685
|
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
"
|
|
903
|
-
"
|
|
904
|
-
"
|
|
905
|
-
"
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
"
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
"
|
|
918
|
-
"
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
"
|
|
924
|
-
"
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
686
|
+
product_learning = strategy_learning(
|
|
687
|
+
[
|
|
688
|
+
("广告目标", "objective", "objective", ["fb", "tt"], None),
|
|
689
|
+
("广告系列类型", "advertising_channel_type", "advertising_channel_type", ["gg"], None),
|
|
690
|
+
("成效目标", "optimization_goal", "optimization_goal", ["fb", "gg", "tt"], None),
|
|
691
|
+
("出价方式", "bid_strategy", "bid_strategy", ["fb", "gg", "tt"], None),
|
|
692
|
+
],
|
|
693
|
+
allow_share_fallback=True,
|
|
694
|
+
share_did_move=product_share_moved,
|
|
695
|
+
)
|
|
696
|
+
audience_learning = strategy_learning(
|
|
697
|
+
[
|
|
698
|
+
("国家", "country_code", "country_code", ["fb", "gg", "tt"], None),
|
|
699
|
+
("兴趣词包", "targeting_interests", "targeting_interests", ["fb", "tt"], None),
|
|
700
|
+
("受众类型", "audience_type", "audience_type", ["fb", "gg", "tt"], None),
|
|
701
|
+
("关键词", "keywords", "keywords", ["gg"], None),
|
|
702
|
+
("年龄", "targeting_age", "targeting_age", ["fb", "gg", "tt"], None),
|
|
703
|
+
("性别", "targeting_genders", "targeting_genders", ["fb", "gg", "tt"], None),
|
|
704
|
+
("广告组", "adgroup_id+adgroup_name", "adgroup_name", ["fb", "gg", "tt"], "adgroup_id"),
|
|
705
|
+
]
|
|
706
|
+
)
|
|
707
|
+
creative_learning = strategy_learning(
|
|
708
|
+
[
|
|
709
|
+
("广告", "ad_id+ad_name", "ad_name", ["fb", "gg", "tt"], "ad_id"),
|
|
710
|
+
("创意类型", "ad_format", "ad_format", ["fb", "gg", "tt"], None),
|
|
711
|
+
]
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
row = {
|
|
715
|
+
"group": group,
|
|
716
|
+
"bizLine": name,
|
|
717
|
+
"period": period,
|
|
718
|
+
"project": project,
|
|
719
|
+
"budgetStrategy": budget_strategy,
|
|
720
|
+
"budgetLearning": budget_learning,
|
|
721
|
+
"productStrategy": product_strategy,
|
|
722
|
+
"productLearning": product_learning,
|
|
723
|
+
"audienceStrategy": audience_strategy,
|
|
724
|
+
"audienceLearning": audience_learning,
|
|
725
|
+
"creativeStrategy": creative_strategy,
|
|
726
|
+
"creativeLearning": creative_learning,
|
|
727
|
+
}
|
|
728
|
+
payload = {"rows": [row], "details": details}
|
|
729
|
+
(req / "payload.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
730
|
+
summary = {
|
|
731
|
+
"bizLineId": biz_id,
|
|
732
|
+
"curr_total": curr_total,
|
|
733
|
+
"prev_total": prev_total,
|
|
734
|
+
"overall_cpi": [cpi_c, cpi_p],
|
|
735
|
+
"overall_cpm": [overall_c, overall_p],
|
|
736
|
+
"eff_days": eff_days,
|
|
737
|
+
"first_period": first_period,
|
|
738
|
+
"spend_verdict": spend_verdict,
|
|
739
|
+
"bid_counts": {ch: len(bids[ch]) for ch in bids},
|
|
740
|
+
"detail_count": len(details),
|
|
741
|
+
"request_dir": str(req),
|
|
742
|
+
}
|
|
743
|
+
(req / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
744
|
+
return payload, summary
|
|
933
745
|
|
|
934
746
|
|
|
935
747
|
def main() -> int:
|