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.
Files changed (34) hide show
  1. package/dist/SKILL.md +30 -22
  2. package/dist/affordance/biz-line.md +7 -13
  3. package/dist/affordance/common-file.md +23 -0
  4. package/dist/affordance/wallet.md +207 -0
  5. package/dist/api-doc/README.md +4 -2
  6. package/dist/api-doc/oapi/biz-line-ai-strategy.md +45 -0
  7. package/dist/api-doc/oapi/biz-line-query.md +10 -3
  8. package/dist/api-doc/oapi/biz-line-strategy.md +12 -44
  9. package/dist/api-doc/oapi/common-file.md +44 -0
  10. package/dist/api-doc/oapi/wallet-bill.md +118 -0
  11. package/dist/api-doc/oapi/wallet-invoice.md +117 -0
  12. package/dist/api-doc/oapi/wallet-prepaid.md +249 -0
  13. package/dist/api-doc/oapi/wallet-quota.md +17 -0
  14. package/dist/api-doc/oapi/wallet-reconciliation.md +88 -0
  15. package/dist/generated/api-catalog.json +4183 -91
  16. package/dist/index.js +1 -1
  17. package/dist/skills/custom/biz-line-strategy-generate/SKILL.md +2 -3
  18. package/dist/skills/custom/biz-line-strategy-generate/references/api-flow.md +27 -1
  19. package/dist/skills/custom/biz-line-strategy-generate/references/output-excel.md +1 -1
  20. package/dist/skills/custom/biz-line-strategy-generate/references/runtime.md +3 -3
  21. package/dist/skills/custom/biz-line-strategy-generate/references/spec.md +2 -9
  22. package/dist/skills/custom/biz-line-strategy-generate/scripts/build_report.py +79 -225
  23. package/dist/skills/custom/biz-line-strategy-generate/scripts/cli_json.py +25 -91
  24. package/dist/skills/custom/biz-line-strategy-generate/scripts/fetch_data.py +5 -8
  25. package/dist/skills/custom/biz-line-strategy-generate/scripts/summarize.py +400 -588
  26. package/dist/skills/custom/biz-line-strategy-generate/scripts/test_strategy_rules.py +6 -53
  27. package/dist/skills/custom/biz-line-strategy-generate/scripts/test_workflow.py +6 -168
  28. package/dist/skills/default/sino-bc-shared/SKILL.md +1 -1
  29. package/dist/skills/default/sino-bc-shared/references/output-contract.md +2 -2
  30. package/dist/skills/oapi/sino-bc-biz-line/SKILL.md +11 -4
  31. package/dist/skills/oapi/sino-bc-common-file/SKILL.md +42 -0
  32. package/dist/skills/oapi/sino-bc-file/SKILL.md +3 -2
  33. package/dist/skills/oapi/sino-bc-wallet/SKILL.md +72 -0
  34. 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 _bid_snap(row: dict, fields: list[str]) -> dict | None:
135
- ag = row.get("adgroup_id")
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
- snap = _bid_snap(row, fields)
211
- if snap:
212
- by_ag[snap["id"]].append(snap)
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
- item = _bid_result_from_records(ag, records, fields)
216
- if item:
217
- result.append(item)
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
- core_c, core_p = finish_metric_acc(core_acc["curr"]), finish_metric_acc(core_acc["prev"])
525
- cpi_c, cpi_p = finish_metric_acc(cpi_acc["curr"]), finish_metric_acc(cpi_acc["prev"])
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 = DetailWriter(req / "details.ndjson")
542
- try:
543
- details.append({"bizLine": name, "kind": "口径", "action": "客户 KPI", "channel": "", "name": core_index or "未填写", "extra": rule["reason"]})
544
- coverage_notes = []
545
- from datetime import date, timedelta
546
- for per, bounds in (("curr", periods.get("curr")), ("prev", periods.get("prev"))):
547
- if not bounds or not all(bounds):
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
- start, end = (date.fromisoformat(d) for d in bounds)
550
- expected = {(start + timedelta(days=i)).isoformat() for i in range((end - start).days + 1)}
551
- for ch, accounts in projects.get("accounts", {}).items():
552
- if not accounts:
553
- continue
554
- dates = sorted(coverage_dates[per].get(ch) or set())
555
- missing = expected - set(dates)
556
- label = "本期" if per == "curr" else "上期"
557
- 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 ""))
558
- details.append({"bizLine": name, "kind": "周期覆盖", "channel": CH_LABEL[ch], "action": label, "name": f"请求 {bounds[0]}~{bounds[1]}", "extra": "实际日期:" + "、".join(dates)})
559
- period += "\n" + "\n".join(coverage_notes)
560
- if any("覆盖不完整" in n for n in coverage_notes):
561
- period += "\n周期覆盖不完整,消耗减少不能直接解释为主动减预算"
562
-
563
-
564
- bid_fields = {
565
- "fb": ["bid_amount"],
566
- "gg": ["cpc_bid_amount", "cpm_bid_amount", "cpv_bid_amount"],
567
- "tt": ["bid_amount", "conversion_bid", "deep_cpabid"],
568
- }
569
- bid_stems = {
570
- "fb": "dim_fb_curr_calendar_date+adgroup_id+adgroup_name+bid_amount",
571
- "gg": "dim_gg_curr_calendar_date+adgroup_id+adgroup_name+cpc_bid_amount",
572
- "tt": "dim_tt_curr_calendar_date+adgroup_id+adgroup_name+bid_amount",
573
- }
574
- bids = {ch: bid_changes(iter_stem_rows(bl, bid_stems[ch]), bid_fields[ch], work_dir=bl / f"_bid_{ch}") for ch in bid_fields}
575
- bid_stats = {ch: bid_adjust_stats(bids[ch]) for ch in bids}
576
-
577
- def bid_text(ch):
578
- items = bids[ch]
579
- stats = bid_stats[ch]
580
- label = CH_LABEL[ch]
581
- if not items:
582
- return f"{label}:本期未发生明显出价调整"
583
- lines = [
584
- f"{label}:上调 {stats['raise_n']} 个广告组,下调 {stats['lower_n']} 个;"
585
- f"期内变化但首末持平 {stats['flat_n']} 个;幅度中位数 {stats['median_abs']:.2f}"
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
- budget_strategy = "\n".join(
611
- [
612
- "【调整出价】",
613
- bid_text("fb"),
614
- bid_text("gg"),
615
- bid_text("tt"),
616
- "",
617
- "【预算】",
618
- budget_abs_share(
619
- "FB",
620
- prev_abs=spend["prev"]["fb"],
621
- curr_abs=spend["curr"]["fb"],
622
- prev_share=prev_share["fb"],
623
- curr_share=curr_share["fb"],
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
- country_curr, country_prev = dim_sets("country_code", "country_code", ["fb", "gg", "tt"])
735
- interest_curr, interest_prev = dim_sets("targeting_interests", "targeting_interests", ["fb", "tt"])
736
- kw_curr, kw_prev = dim_sets("keywords", "keywords", ["gg"])
737
- add_setting_details("国家", country_curr, country_prev)
738
- add_setting_details("兴趣词", interest_curr, interest_prev)
739
- add_setting_details("关键词", kw_curr, kw_prev)
740
-
741
- def object_strategy(kind, id_key, name_key):
742
- bits = []
743
- for ch in CH_LABEL:
744
- stem = f"{id_key}+{name_key}"
745
-
746
- def index_objects(period: str) -> tuple[dict[str, str], dict[str, str]]:
747
- return object_name_maps(iter_stem_rows(bl, f"dim_{ch}_{period}_{stem}"), id_key, name_key)
748
-
749
- c, c_raw = index_objects("curr")
750
- p, _p_raw = index_objects("prev")
751
- new, absent = sorted(c.keys() - p.keys()), sorted(p.keys() - c.keys())
752
- bits.append(f"{CH_LABEL[ch]}:本期新出现 {len(new)} 个,本期未出现 {len(absent)} 个;命名规律:{naming_patterns([c_raw[i] for i in new])}")
753
- for ident in sorted(c.keys() | p.keys()):
754
- record_name = c.get(ident) or p[ident]
755
- action = "当期" if first_period else ("新增" if ident in new else ("本期未出现" if ident in absent else "持续"))
756
- details.append({"bizLine": name, "kind": kind, "action": action, "channel": CH_LABEL[ch], "name": record_name, "extra": f"ID={ident};集合差异不等同创建/暂停操作"})
757
- return bits
758
-
759
- ag_bits = object_strategy("广告组", "adgroup_id", "adgroup_name")
760
- audience_strategy = "\n\n".join(
761
- x
762
- for x in [
763
- setting_line("【国家】", country_curr, country_prev, first_period=first_period),
764
- setting_line("【兴趣词包】", interest_curr, interest_prev, first_period=first_period),
765
- share_line("【受众类型】", "audience_type", "audience_type", ["fb", "gg", "tt"])[0],
766
- setting_line("【关键词】", kw_curr, kw_prev, first_period=first_period),
767
- setting_line("【年龄】", *dim_sets("targeting_age", "targeting_age", ["fb", "gg", "tt"]), first_period=first_period),
768
- setting_line("【性别】", *dim_sets("targeting_genders", "targeting_genders", ["fb", "gg", "tt"]), first_period=first_period),
769
- "【广告组】\n" + "\n".join(ag_bits),
770
- ]
771
- if x
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
- ad_bits = object_strategy("广告", "ad_id", "ad_name")
775
- creative_fmt = share_line("【创意类型】", "ad_format", "ad_format", ["fb", "gg", "tt"])[0] or ""
776
- creative_strategy = "【广告】\n" + "\n".join(ad_bits) + (("\n\n" + creative_fmt) if creative_fmt else "")
777
-
778
- def strategy_learning(dim_specs, allow_share_fallback=False, share_did_move=False):
779
- texts = []
780
- hit_spend = all_spend = 0.0
781
- any_new = only_stop = False
782
- for title, stem, key, channels, id_key in dim_specs:
783
- items_all, stops_all = [], []
784
- for ch in channels:
785
- curr_stem = f"met_{ch}_curr_{stem}"
786
- prev_stem = f"met_{ch}_prev_{stem}"
787
- curr_share_map, prev_share_map, items, stops = share_and_new_items(
788
- iter_stem_rows(bl, curr_stem), iter_stem_rows(bl, prev_stem), key, id_key
789
- )
790
- stops = [
791
- s
792
- for s in stops
793
- if not is_ghost_enum(curr_share_map.get(s, 0), prev_share_map.get(s, 0))
794
- ]
795
- for item in items:
796
- item["channel"] = CH_LABEL[ch]
797
- if item["spend"] >= 50:
798
- value = metric_value(item["rows"], metric)
799
- hit = rule_hit(value, rule)
800
- verdict_text = '不可判定' if hit is None else ('达标' if hit else '未达标')
801
- 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}"})
802
- items_all.extend(items)
803
- stops_all.extend(stops)
804
- if items_all:
805
- any_new = True
806
- if stops_all and not items_all:
807
- only_stop = True
808
- text, _hit, hs, ts = dim_layer_text(title, items_all, stops_all, rule)
809
- texts.append(text)
810
- if ts > 0:
811
- hit_spend += hs
812
- all_spend += ts
813
- detail = "\n\n".join(texts)
814
- verdict = period_verdict(
815
- first_period=first_period,
816
- qoq_label=qoq_label,
817
- kpi_usable=kpi_usable,
818
- kpi_hit=rule_hit(core_c, rule),
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
- if gate_note:
821
- verdict = "待观察"
822
- evidence = (
823
- f"{cpi_s},环比{qoq_label}" + (f" {qoq_pct_s}" if qoq_pct_s else "")
824
- + "。此为整体环比观察,不代表本次单一动作的因果效果;" + rule["reason"]
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
- if not any_new and only_stop:
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
- product_learning = strategy_learning(
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
- ("广告", "ad_id+ad_name", "ad_name", ["fb", "gg", "tt"], "ad_id"),
896
- ("创意类型", "ad_format", "ad_format", ["fb", "gg", "tt"], None),
677
+ f"结论:{verdict}",
678
+ f"依据:{evidence}",
679
+ "",
680
+ detail,
681
+ "",
682
+ advice_of(verdict),
897
683
  ]
898
684
  )
899
685
 
900
- row = {
901
- "group": group,
902
- "bizLine": name,
903
- "period": period,
904
- "project": project,
905
- "budgetStrategy": budget_strategy,
906
- "budgetLearning": budget_learning,
907
- "productStrategy": product_strategy,
908
- "productLearning": product_learning,
909
- "audienceStrategy": audience_strategy,
910
- "audienceLearning": audience_learning,
911
- "creativeStrategy": creative_strategy,
912
- "creativeLearning": creative_learning,
913
- }
914
- payload = {"rows": [row], "details": str(details.path), "detail_count": details.count}
915
- (req / "payload.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
916
- summary = {
917
- "bizLineId": biz_id,
918
- "curr_total": curr_total,
919
- "prev_total": prev_total,
920
- "overall_cpi": [cpi_c, cpi_p],
921
- "overall_cpm": [overall_c, overall_p],
922
- "eff_days": eff_days,
923
- "first_period": first_period,
924
- "spend_verdict": spend_verdict,
925
- "bid_counts": {ch: len(bids[ch]) for ch in bids},
926
- "detail_count": details.count,
927
- "request_dir": str(req),
928
- }
929
- (req / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
930
- return payload, summary
931
- finally:
932
- details.close()
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: