sino-bc-cli 1.8.22 → 1.8.24

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.
@@ -1,12 +1,14 @@
1
1
  #!/usr/bin/env python3
2
2
  # -*- coding: utf-8 -*-
3
- """按固定规则汇总 merged JSON,生成可直接校验及导出的 payload。
3
+ """按固定规则汇总分页临时文件,生成可直接校验及导出的 payload。
4
4
  """
5
5
  from __future__ import annotations
6
6
 
7
7
  import argparse
8
+ import heapq
8
9
  import json
9
10
  import sys
11
+ import tempfile
10
12
  from collections import defaultdict
11
13
  from pathlib import Path
12
14
 
@@ -15,13 +17,16 @@ if str(SCRIPTS) not in sys.path:
15
17
  sys.path.insert(0, str(SCRIPTS))
16
18
 
17
19
  from cli_json import (
20
+ add_metric_acc,
18
21
  bid_adjust_stats,
19
22
  budget_abs_share,
20
23
  cpm,
21
24
  disp,
22
25
  examples,
26
+ finish_metric_acc,
23
27
  fmt_cpm,
24
28
  metric_value,
29
+ new_metric_acc,
25
30
  optional_num,
26
31
  core_rule,
27
32
  rule_hit,
@@ -42,13 +47,47 @@ CH_LABEL = {"fb": "Meta", "gg": "Google", "tt": "TikTok"}
42
47
  DETAIL_LIMIT = 8
43
48
 
44
49
 
45
- def load_merged(bl: Path, stem: str) -> list:
46
- data = load_json_file(bl / f"{stem}.merged.json")
47
- if isinstance(data, list):
48
- return data
50
+ def iter_stem_rows(bl: Path, stem: str):
51
+ """按页 yield 行,不把全表拼成 list。可重复调用,每次重新读盘。"""
52
+ meta_path = bl / f"{stem}.pages.json"
53
+ if meta_path.exists():
54
+ meta = load_json_file(meta_path)
55
+ if not isinstance(meta, dict) or not isinstance(meta.get("counts"), list):
56
+ raise ValueError(f"拉数文件不完整:{stem}")
57
+ accumulated = 0
58
+ for index, expected in enumerate(meta["counts"], start=1):
59
+ env = load_json_file(bl / f"{stem}.p{index}.out.json")
60
+ page_rows, _ = unwrap_page(env or {})
61
+ if len(page_rows) != expected:
62
+ raise ValueError(f"拉数文件不完整:{stem}")
63
+ yield from page_rows
64
+ accumulated += len(page_rows)
65
+ if meta.get("total") != accumulated:
66
+ raise ValueError(f"拉数文件不完整:{stem}")
67
+ return
68
+ extras = [p for p in bl.glob(f"{stem}.p*.out.json") if p.name != f"{stem}.p1.out.json"]
69
+ if extras:
70
+ raise ValueError(f"拉数文件不完整:{stem}")
49
71
  env = load_json_file(bl / f"{stem}.p1.out.json")
50
- rows, _ = unwrap_page(env or {})
51
- return rows
72
+ yield from unwrap_page(env or {})[0]
73
+
74
+
75
+ def load_merged(bl: Path, stem: str) -> list:
76
+ """兼容测试与诊断:需要 list 时才物化。分析路径应走 iter_stem_rows。"""
77
+ return list(iter_stem_rows(bl, stem))
78
+
79
+
80
+ def object_name_maps(rows, id_key, name_key) -> tuple[dict[str, str], dict[str, str]]:
81
+ """id → 明细展示名(空则回退 id),以及原始 display(空名不当作 id,避免命名规律拆 ID)。"""
82
+ names: dict[str, str] = {}
83
+ raw: dict[str, str] = {}
84
+ for row in rows:
85
+ if row.get(id_key):
86
+ ident = str(row[id_key])
87
+ shown = disp(row, name_key)
88
+ raw[ident] = shown
89
+ names[ident] = shown or ident
90
+ return names, raw
52
91
 
53
92
 
54
93
  def labels_of(rows, key) -> set[str]:
@@ -56,10 +95,13 @@ def labels_of(rows, key) -> set[str]:
56
95
 
57
96
 
58
97
  def share_map(rows, dim_key):
59
- total = sum(num(r, "spend_usd") for r in rows)
98
+ """一次扫描累加消耗占比,可接迭代器。"""
99
+ total = 0.0
60
100
  acc = defaultdict(float)
61
- for r in rows:
62
- acc[disp(r, dim_key) or "未标注"] += num(r, "spend_usd")
101
+ for row in rows:
102
+ spend = num(row, "spend_usd")
103
+ total += spend
104
+ acc[disp(row, dim_key) or "未标注"] += spend
63
105
  if total <= 0:
64
106
  return {}, 0.0
65
107
  return {k: v / total * 100 for k, v in acc.items()}, total
@@ -89,48 +131,248 @@ def share_moved(curr: dict, prev: dict) -> bool:
89
131
  return False
90
132
 
91
133
 
92
- def bid_changes(rows, fields):
93
- """逐个出价字段比较有效日快照,缺失值不补零;保留字段与对象 ID。"""
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 落盘。"""
94
208
  by_ag = defaultdict(list)
95
209
  for row in rows:
96
- if row.get("adgroup_id"):
97
- by_ag[str(row["adgroup_id"])].append(row)
210
+ snap = _bid_snap(row, fields)
211
+ if snap:
212
+ by_ag[snap["id"]].append(snap)
98
213
  result = []
99
214
  for ag, records in by_ag.items():
100
- records = sorted(records, key=lambda r: str(r.get("calendar_date") or ""))
101
- changes = []
102
- changed_dates = set()
103
- for field in fields:
104
- valid = [(str(r.get("calendar_date") or ""), optional_num(r, field)) for r in records if optional_num(r, field) is not None]
105
- times = sum(a[1] != b[1] for a, b in zip(valid, valid[1:]))
106
- changed_dates.update(b[0] for a, b in zip(valid, valid[1:]) if a[1] != b[1])
107
- if times:
108
- changes.append({"field": field, "from": valid[0][1], "to": valid[-1][1], "times": times})
109
- if changes:
110
- # 多字段变动以首个首末不同的字段展示方向,明细完整保留各字段。
111
- primary = next((c for c in changes if c["from"] != c["to"]), changes[0])
112
- result.append({"id": ag, "name": disp(records[-1], "adgroup_name") or ag,
113
- **primary, "times": len(changed_dates), "fields": changes, "strategy": disp(records[-1], "bid_strategy")})
215
+ item = _bid_result_from_records(ag, records, fields)
216
+ if item:
217
+ result.append(item)
114
218
  return sorted(result, key=lambda r: (-r["times"], r["id"]))
115
219
 
116
220
 
117
- def new_items(curr_rows, prev_rows, key, id_key=None):
118
- """按稳定 ID/维度值聚合基础指标,再识别本期新出现的对象。"""
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 迭代器各扫一遍,同时得到占比与新增对象。"""
119
309
  def identity(row):
120
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
+
121
323
  grouped = defaultdict(list)
324
+ current_ids: set[str] = set()
325
+ curr_acc: dict[str, float] = defaultdict(float)
326
+ curr_total = 0.0
122
327
  for row in curr_rows:
123
- if identity(row):
124
- grouped[identity(row)].append(row)
125
- previous = {identity(r): r for r in prev_rows if identity(r)}
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
+
126
343
  items = []
127
344
  for ident, records in grouped.items():
128
- if ident in previous:
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
+ def new_items(curr_rows, prev_rows, key, id_key=None):
353
+ """按稳定 ID/维度值聚合基础指标,再识别本期新出现的对象。只为新增对象保留行。"""
354
+ def identity(row):
355
+ return str(row.get(id_key) or "") if id_key else disp(row, key)
356
+ previous: dict[str, str] = {}
357
+ for row in prev_rows:
358
+ ident = identity(row)
359
+ if ident:
360
+ previous[ident] = disp(row, key) or ident
361
+ grouped = defaultdict(list)
362
+ current_ids: set[str] = set()
363
+ for row in curr_rows:
364
+ ident = identity(row)
365
+ if not ident:
129
366
  continue
367
+ current_ids.add(ident)
368
+ if ident not in previous:
369
+ grouped[ident].append(row)
370
+ items = []
371
+ for ident, records in grouped.items():
130
372
  items.append({"id": ident if id_key else "", "label": disp(records[0], key) or ident,
131
373
  "spend": sum(num(r, "spend_usd") for r in records), "rows": records,
132
374
  "cpi": metric_value(records), "cpm": metric_value(records, "CPM")})
133
- stops = [disp(r, key) or ident for ident, r in previous.items() if ident not in grouped]
375
+ stops = [label for ident, label in previous.items() if ident not in current_ids]
134
376
  return items, stops
135
377
 
136
378
 
@@ -208,9 +450,14 @@ def summarize_request(req: Path):
208
450
  manifest = load_json_file(req / 'fetch-manifest.json')
209
451
  if manifest is not None:
210
452
  for stem, count in manifest.items():
211
- data = load_json_file(bl / f'{stem}.merged.json')
212
- if not isinstance(data, list) or len(data) != count:
213
- raise ValueError(f'拉数文件不完整:{stem}')
453
+ meta = load_json_file(bl / f"{stem}.pages.json")
454
+ if (
455
+ not isinstance(meta, dict)
456
+ or meta.get("total") != count
457
+ or not isinstance(meta.get("counts"), list)
458
+ or sum(meta["counts"]) != count
459
+ ):
460
+ raise ValueError(f"拉数文件不完整:{stem}")
214
461
  group = detail_data.get("ssoTeamCodeName") or biz.get("ssoTeamCodeName") or ""
215
462
  name = detail_data.get("name") or biz.get("name") or ""
216
463
  project = "\n".join(projects.get("projectNames") or detail_data.get("projectNames") or [])
@@ -231,21 +478,40 @@ def summarize_request(req: Path):
231
478
  imps = {"curr": {}, "prev": {}}
232
479
  inst = {"curr": {}, "prev": {}}
233
480
  days = {"curr": {}, "prev": {}}
481
+ 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")}
234
487
  for ch in ("fb", "gg", "tt"):
235
488
  for per in ("curr", "prev"):
236
- day_rows = load_merged(bl, f"met_{ch}_{per}_calendar_date")
237
- acc_rows = load_merged(bl, f"met_{ch}_{per}_account_id")
238
- rows = acc_rows or day_rows
239
- spend[per][ch] = sum(num(r, "spend_usd") for r in rows)
240
- imps[per][ch] = sum(num(r, "impressions") for r in rows)
241
- inst[per][ch] = sum(num(r, "installs") for r in acc_rows or rows)
242
- days[per][ch] = len(
243
- {
244
- disp(r, "calendar_date") or str(r.get("calendar_date") or "")
245
- for r in day_rows
246
- if num(r, "spend_usd") > 0
247
- }
248
- )
489
+ day_spend = day_imps = day_inst = 0.0
490
+ dates_all: set[str] = set()
491
+ dates_spend: set[str] = set()
492
+ for row in iter_stem_rows(bl, f"met_{ch}_{per}_calendar_date"):
493
+ day_spend += num(row, "spend_usd")
494
+ day_imps += num(row, "impressions")
495
+ day_inst += num(row, "installs")
496
+ raw = row.get("calendar_date")
497
+ if raw:
498
+ dates_all.add(str(raw)[:10])
499
+ if num(row, "spend_usd") > 0:
500
+ dates_spend.add(disp(row, "calendar_date") or str(raw or ""))
501
+ acc_spend = acc_imps = acc_inst = 0.0
502
+ has_acc = False
503
+ for row in iter_stem_rows(bl, f"met_{ch}_{per}_account_id"):
504
+ has_acc = True
505
+ acc_spend += num(row, "spend_usd")
506
+ acc_imps += num(row, "impressions")
507
+ acc_inst += num(row, "installs")
508
+ add_metric_acc(core_acc[per], row)
509
+ add_metric_acc(cpi_acc[per], row)
510
+ spend[per][ch] = acc_spend if has_acc else day_spend
511
+ imps[per][ch] = acc_imps if has_acc else day_imps
512
+ inst[per][ch] = acc_inst if has_acc else day_inst
513
+ days[per][ch] = len(dates_spend)
514
+ coverage_dates[per][ch] = dates_all
249
515
 
250
516
  curr_total = sum(spend["curr"].values())
251
517
  prev_total = sum(spend["prev"].values())
@@ -255,16 +521,10 @@ def summarize_request(req: Path):
255
521
  prev_share = {ch: (spend["prev"][ch] / prev_total * 100 if prev_total else 0) for ch in spend["prev"]}
256
522
  overall_c = cpm(curr_total, sum(imps["curr"].values()))
257
523
  overall_p = cpm(prev_total, sum(imps["prev"].values()))
258
- cpi_c = (curr_total / curr_inst) if curr_inst else None
259
- cpi_p = (prev_total / prev_inst) if prev_inst else None
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"])
260
526
  eff_days = max(days["curr"].values() or [0])
261
527
  first_period = not (len(prev) == 2 and all(prev))
262
- core_index = str(biz.get("coreIndex") or detail_data.get("coreIndex") or "").strip()
263
- rule = core_rule(core_index)
264
- metric = rule["metric"]
265
- all_rows = {per: [r for ch in CH_LABEL for r in load_merged(bl, f"met_{ch}_{per}_account_id")] for per in ("curr", "prev")}
266
- core_c, core_p = (metric_value(all_rows[per], metric) for per in ("curr", "prev"))
267
- cpi_c, cpi_p = (metric_value(all_rows[per], "CPI") for per in ("curr", "prev"))
268
528
  qoq_label, qoq_pct = qoq_axis(core_c, core_p, metric in ("ROAS", "CTR"))
269
529
  kpi_usable = rule["usable"]
270
530
  spend_verdict = period_verdict(
@@ -278,301 +538,296 @@ def summarize_request(req: Path):
278
538
  spend_verdict = "待观察"
279
539
  gate_note = f"本期有效投放 {eff_days} 天、消耗 ${curr_total:,.0f}、安装 {curr_inst:,.0f}"
280
540
  qoq_pct_s = "" if qoq_pct is None else f"{qoq_pct * 100:+.1f}%"
281
- details: list[dict] = [{"bizLine": name, "kind": "口径", "action": "客户 KPI", "channel": "", "name": core_index or "未填写", "extra": rule["reason"]}]
282
- coverage_notes = []
283
- from datetime import date, timedelta
284
- for per, bounds in (("curr", periods.get("curr")), ("prev", periods.get("prev"))):
285
- if not bounds or not all(bounds):
286
- continue
287
- start, end = (date.fromisoformat(d) for d in bounds)
288
- expected = {(start + timedelta(days=i)).isoformat() for i in range((end - start).days + 1)}
289
- for ch, accounts in projects.get("accounts", {}).items():
290
- if not accounts:
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):
291
548
  continue
292
- dates = sorted({str(r.get("calendar_date") or "")[:10] for r in load_merged(bl, f"met_{ch}_{per}_calendar_date") if r.get("calendar_date")})
293
- missing = expected - set(dates)
294
- label = "本期" if per == "curr" else "上期"
295
- 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 ""))
296
- details.append({"bizLine": name, "kind": "周期覆盖", "channel": CH_LABEL[ch], "action": label, "name": f"请求 {bounds[0]}~{bounds[1]}", "extra": "实际日期:" + "、".join(dates)})
297
- period += "\n" + "\n".join(coverage_notes)
298
- if any("覆盖不完整" in n for n in coverage_notes):
299
- period += "\n周期覆盖不完整,消耗减少不能直接解释为主动减预算"
300
-
301
-
302
- bid_fields = {
303
- "fb": ["bid_amount"],
304
- "gg": ["cpc_bid_amount", "cpm_bid_amount", "cpv_bid_amount"],
305
- "tt": ["bid_amount", "conversion_bid", "deep_cpabid"],
306
- }
307
- bid_stems = {
308
- "fb": "dim_fb_curr_calendar_date+adgroup_id+adgroup_name+bid_amount",
309
- "gg": "dim_gg_curr_calendar_date+adgroup_id+adgroup_name+cpc_bid_amount",
310
- "tt": "dim_tt_curr_calendar_date+adgroup_id+adgroup_name+bid_amount",
311
- }
312
- bids = {ch: bid_changes(load_merged(bl, bid_stems[ch]), bid_fields[ch]) for ch in bid_fields}
313
- bid_stats = {ch: bid_adjust_stats(bids[ch]) for ch in bids}
314
-
315
- def bid_text(ch):
316
- items = bids[ch]
317
- stats = bid_stats[ch]
318
- label = CH_LABEL[ch]
319
- if not items:
320
- return f"{label}:本期未发生明显出价调整"
321
- lines = [
322
- f"{label}:上调 {stats['raise_n']} 个广告组,下调 {stats['lower_n']} 个;"
323
- f"期内变化但首末持平 {stats['flat_n']} 个;幅度中位数 {stats['median_abs']:.2f}"
324
- ]
325
- if stats["list_all"]:
326
- for b in stats["details"]:
327
- lines.append(
328
- f"· {short_label(b.get('name') or '')}:{b.get('action')} "
329
- f"{b.get('from')} → {b.get('to')}({b.get('times')} 次)"
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
+ }
330
608
  )
331
- return "\n".join(lines)
332
-
333
- for ch in ("fb", "gg", "tt"):
334
- stats = bid_stats[ch]
335
- src = stats["details"] if stats["list_all"] else bids[ch]
336
- for b in src:
337
- details.append(
338
- {
339
- "bizLine": name,
340
- "kind": "出价",
341
- "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 "期内变化但首末持平")),
342
- "channel": CH_LABEL[ch],
343
- "name": b.get("name") or "",
344
- "extra": f"ID={b['id']};" + json.dumps(b.get("fields", []), ensure_ascii=False),
345
- }
346
- )
347
-
348
- budget_strategy = "\n".join(
349
- [
350
- "【调整出价】",
351
- bid_text("fb"),
352
- bid_text("gg"),
353
- bid_text("tt"),
354
- "",
355
- "【预算】",
356
- budget_abs_share(
357
- "FB",
358
- prev_abs=spend["prev"]["fb"],
359
- curr_abs=spend["curr"]["fb"],
360
- prev_share=prev_share["fb"],
361
- curr_share=curr_share["fb"],
362
- ),
363
- budget_abs_share(
364
- "GG",
365
- prev_abs=spend["prev"]["gg"],
366
- curr_abs=spend["curr"]["gg"],
367
- prev_share=prev_share["gg"],
368
- curr_share=curr_share["gg"],
369
- ),
370
- budget_abs_share(
371
- "TT",
372
- prev_abs=spend["prev"]["tt"],
373
- curr_abs=spend["curr"]["tt"],
374
- prev_share=prev_share["tt"],
375
- curr_share=curr_share["tt"],
376
- ),
377
- f"消耗合计:本期 ${curr_total:,.0f} / 上期 ${prev_total:,.0f}",
378
- ]
379
- )
380
- cpi_s = f"全渠道 {metric}:本期 {fmt_cpm(core_c)} / 上期 {fmt_cpm(core_p)}"
381
- cpi_s += "(美元消耗 / 平台安装量,非客户激活或留存成本)" if metric == "CPI" else "(同粒度基础指标合计后计算)"
382
- budget_learning = "\n".join(
383
- [
384
- f"结论:{spend_verdict}(第二期起跟环比,KPI 只作依据)" if not first_period else f"结论:{spend_verdict}(首期)",
385
- f"依据:{cpi_s},环比{qoq_label}" + (f" {qoq_pct_s}" if qoq_pct_s else ""),
386
- f"客户 KPI:{core_index or '未填写'};{rule['reason']}",
387
- *([f"门槛:{gate_note} → 待观察"] if gate_note else []),
388
- "",
389
- "Spend",
390
- f"· {cpi_s}",
391
- f"· 参考 CPM:本期 {fmt_cpm(overall_c)} / 上期 {fmt_cpm(overall_p)}(不作结论轴)",
392
- 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'])}",
393
- 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'])}",
394
- 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'])}",
395
- "",
396
- "调整出价",
397
- *[
398
- f"· {CH_LABEL[ch]}:上调 {bid_stats[ch]['raise_n']} / 下调 {bid_stats[ch]['lower_n']},"
399
- f"幅度中位数 {bid_stats[ch]['median_abs']:.2f}"
400
- for ch in ("fb", "gg", "tt")
401
- ],
402
- "",
403
- advice_of(spend_verdict),
404
- ]
405
- )
406
-
407
- def share_line(title, dim_key, stem, channels):
408
- segs = []
409
- moved = False
410
- for ch in channels:
411
- curr, _ = share_map(load_merged(bl, f"met_{ch}_curr_{stem}"), dim_key)
412
- prev, _ = share_map(load_merged(bl, f"met_{ch}_prev_{stem}"), dim_key)
413
- if share_moved(curr, prev):
414
- moved = True
415
- if curr or prev:
416
- segs.append(f"{CH_LABEL[ch]}:{fmt_share(curr, prev)}")
417
- return ("\n".join([title] + segs) if segs else ""), moved
418
-
419
- p_obj, m1 = share_line("【广告目标】", "objective", "objective", ["fb", "tt"])
420
- p_ch, m2 = share_line("【广告系列类型】", "advertising_channel_type", "advertising_channel_type", ["gg"])
421
- p_opt, m3 = share_line("【成效目标】", "optimization_goal", "optimization_goal", ["fb", "gg", "tt"])
422
- p_bid, m4 = share_line("【出价方式】", "bid_strategy", "bid_strategy", ["fb", "gg", "tt"])
423
- product_strategy = "\n\n".join(x for x in [p_obj, p_ch, p_opt, p_bid] if x)
424
- product_share_moved = any([m1, m2, m3, m4])
425
-
426
- def dim_sets(stem, key, channels):
427
- curr = set()
428
- prev = set()
429
- for ch in channels:
430
- # Meta 兴趣的三个配置字段来自同一组合请求,不能找不存在的单字段文件。
431
- if ch == 'fb' and stem == 'targeting_interests':
432
- combined = 'targeting_interests+targeting_flexible_interests+targeting_exclusion_interests'
433
- for field in combined.split('+'):
434
- curr |= labels_of(load_merged(bl, f'dim_{ch}_curr_{combined}'), field)
435
- prev |= labels_of(load_merged(bl, f'dim_{ch}_prev_{combined}'), field)
436
- continue
437
- curr |= labels_of(load_merged(bl, f"dim_{ch}_curr_{stem}"), key)
438
- prev |= labels_of(load_merged(bl, f"dim_{ch}_prev_{stem}"), key)
439
- return curr, prev
440
-
441
- def add_setting_details(kind, curr_set, prev_set, channel=""):
442
- action_curr = "当期" if first_period else "新增"
443
- news = sorted(curr_set if first_period or kind == "国家" else (curr_set - prev_set))
444
- stops = [] if first_period else sorted(prev_set - curr_set)
445
- for lab in news:
446
- details.append(
447
- {
448
- "bizLine": name,
449
- "kind": kind,
450
- "action": "持续" if not first_period and lab in prev_set else action_curr,
451
- "channel": channel,
452
- "name": lab,
453
- "extra": "",
454
- }
455
- )
456
- for lab in stops:
457
- details.append(
458
- {
459
- "bizLine": name,
460
- "kind": kind,
461
- "action": "本期未出现",
462
- "channel": channel,
463
- "name": lab,
464
- "extra": "",
465
- }
466
- )
467
-
468
- country_curr, country_prev = dim_sets("country_code", "country_code", ["fb", "gg", "tt"])
469
- interest_curr, interest_prev = dim_sets("targeting_interests", "targeting_interests", ["fb", "tt"])
470
- kw_curr, kw_prev = dim_sets("keywords", "keywords", ["gg"])
471
- add_setting_details("国家", country_curr, country_prev)
472
- add_setting_details("兴趣词", interest_curr, interest_prev)
473
- add_setting_details("关键词", kw_curr, kw_prev)
474
-
475
- def object_strategy(kind, id_key, name_key):
476
- bits = []
477
- for ch in CH_LABEL:
478
- stem = f"{id_key}+{name_key}"
479
- crows = load_merged(bl, f"dim_{ch}_curr_{stem}")
480
- prows = load_merged(bl, f"dim_{ch}_prev_{stem}")
481
- c = {str(r[id_key]): r for r in crows if r.get(id_key)}
482
- p = {str(r[id_key]): r for r in prows if r.get(id_key)}
483
- new, absent = sorted(c.keys() - p.keys()), sorted(p.keys() - c.keys())
484
- bits.append(f"{CH_LABEL[ch]}:本期新出现 {len(new)} 个,本期未出现 {len(absent)} 个;命名规律:{naming_patterns([disp(c[i], name_key) for i in new])}")
485
- for ident in sorted(c.keys() | p.keys()):
486
- record = c.get(ident) or p[ident]
487
- action = "当期" if first_period else ("新增" if ident in new else ("本期未出现" if ident in absent else "持续"))
488
- details.append({"bizLine": name, "kind": kind, "action": action, "channel": CH_LABEL[ch], "name": disp(record, name_key) or ident, "extra": f"ID={ident};集合差异不等同创建/暂停操作"})
489
- return bits
490
-
491
- ag_bits = object_strategy("广告组", "adgroup_id", "adgroup_name")
492
- audience_strategy = "\n\n".join(
493
- x
494
- for x in [
495
- setting_line("【国家】", country_curr, country_prev, first_period=first_period),
496
- setting_line("【兴趣词包】", interest_curr, interest_prev, first_period=first_period),
497
- share_line("【受众类型】", "audience_type", "audience_type", ["fb", "gg", "tt"])[0],
498
- setting_line("【关键词】", kw_curr, kw_prev, first_period=first_period),
499
- setting_line("【年龄】", *dim_sets("targeting_age", "targeting_age", ["fb", "gg", "tt"]), first_period=first_period),
500
- setting_line("【性别】", *dim_sets("targeting_genders", "targeting_genders", ["fb", "gg", "tt"]), first_period=first_period),
501
- "【广告组】\n" + "\n".join(ag_bits),
502
- ]
503
- if x
504
- )
505
609
 
506
- ad_bits = object_strategy("广告", "ad_id", "ad_name")
507
- creative_fmt = share_line("【创意类型】", "ad_format", "ad_format", ["fb", "gg", "tt"])[0] or ""
508
- creative_strategy = "【广告】\n" + "\n".join(ad_bits) + (("\n\n" + creative_fmt) if creative_fmt else "")
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
+ )
509
668
 
510
- def strategy_learning(dim_specs, allow_share_fallback=False, share_did_move=False):
511
- texts = []
512
- hit_spend = all_spend = 0.0
513
- any_new = only_stop = False
514
- for title, stem, key, channels, id_key in dim_specs:
515
- items_all, stops_all = [], []
669
+ def share_line(title, dim_key, stem, channels):
670
+ segs = []
671
+ moved = False
516
672
  for ch in channels:
517
- curr_rows = load_merged(bl, f"met_{ch}_curr_{stem}")
518
- prev_rows = load_merged(bl, f"met_{ch}_prev_{stem}")
519
- curr_share_map, _ = share_map(curr_rows, key)
520
- prev_share_map, _ = share_map(prev_rows, key)
521
- items, stops = new_items(curr_rows, prev_rows, key, id_key)
522
- stops = [
523
- s
524
- for s in stops
525
- if not is_ghost_enum(curr_share_map.get(s, 0), prev_share_map.get(s, 0))
526
- ]
527
- for item in items:
528
- item["channel"] = CH_LABEL[ch]
529
- if item["spend"] >= 50:
530
- value = metric_value(item["rows"], metric)
531
- hit = rule_hit(value, rule)
532
- verdict_text = '不可判定' if hit is None else ('达标' if hit else '未达标')
533
- 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}"})
534
- items_all.extend(items)
535
- stops_all.extend(stops)
536
- if items_all:
537
- any_new = True
538
- if stops_all and not items_all:
539
- only_stop = True
540
- text, _hit, hs, ts = dim_layer_text(title, items_all, stops_all, rule)
541
- texts.append(text)
542
- if ts > 0:
543
- hit_spend += hs
544
- all_spend += ts
545
- detail = "\n\n".join(texts)
546
- verdict = period_verdict(
547
- first_period=first_period,
548
- qoq_label=qoq_label,
549
- kpi_usable=kpi_usable,
550
- kpi_hit=rule_hit(core_c, rule),
551
- )
552
- if gate_note:
553
- verdict = "待观察"
554
- evidence = (
555
- f"{cpi_s},环比{qoq_label}" + (f" {qoq_pct_s}" if qoq_pct_s else "")
556
- + "。此为整体环比观察,不代表本次单一动作的因果效果;" + rule["reason"]
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
+ )
733
+
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
557
772
  )
558
- if not any_new and only_stop:
559
- return "\n".join(
560
- [
561
- f"结论:{verdict}",
562
- "口径:本期无新增,走本期未出现",
563
- f"依据:{evidence}",
564
- "",
565
- detail,
566
- "",
567
- advice_of(verdict),
568
- ]
773
+
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),
569
819
  )
570
- if not any_new:
571
- if allow_share_fallback and share_did_move:
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"]
825
+ )
826
+ if not any_new and only_stop:
572
827
  return "\n".join(
573
828
  [
574
829
  f"结论:{verdict}",
575
- "口径:无新增枚举,占比变化超过 ±2%,按可环比",
830
+ "口径:本期无新增,走本期未出现",
576
831
  f"依据:{evidence}",
577
832
  "",
578
833
  detail,
@@ -580,86 +835,101 @@ def summarize_request(req: Path):
580
835
  advice_of(verdict),
581
836
  ]
582
837
  )
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
583
861
  return "\n".join(
584
862
  [
585
863
  f"结论:{verdict}",
586
- "口径:本期无实质调整动作",
587
864
  f"依据:{evidence}",
588
865
  "",
589
866
  detail,
867
+ "",
868
+ advice_of(verdict),
590
869
  ]
591
870
  )
592
- ratio = (hit_spend / all_spend) if all_spend else 0.0
593
- return "\n".join(
871
+
872
+ product_learning = strategy_learning(
594
873
  [
595
- f"结论:{verdict}",
596
- f"依据:{evidence}",
597
- "",
598
- detail,
599
- "",
600
- advice_of(verdict),
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(
894
+ [
895
+ ("广告", "ad_id+ad_name", "ad_name", ["fb", "gg", "tt"], "ad_id"),
896
+ ("创意类型", "ad_format", "ad_format", ["fb", "gg", "tt"], None),
601
897
  ]
602
898
  )
603
899
 
604
- product_learning = strategy_learning(
605
- [
606
- ("广告目标", "objective", "objective", ["fb", "tt"], None),
607
- ("广告系列类型", "advertising_channel_type", "advertising_channel_type", ["gg"], None),
608
- ("成效目标", "optimization_goal", "optimization_goal", ["fb", "gg", "tt"], None),
609
- ("出价方式", "bid_strategy", "bid_strategy", ["fb", "gg", "tt"], None),
610
- ],
611
- allow_share_fallback=True,
612
- share_did_move=product_share_moved,
613
- )
614
- audience_learning = strategy_learning(
615
- [
616
- ("国家", "country_code", "country_code", ["fb", "gg", "tt"], None),
617
- ("兴趣词包", "targeting_interests", "targeting_interests", ["fb", "tt"], None),
618
- ("受众类型", "audience_type", "audience_type", ["fb", "gg", "tt"], None),
619
- ("关键词", "keywords", "keywords", ["gg"], None),
620
- ("年龄", "targeting_age", "targeting_age", ["fb", "gg", "tt"], None),
621
- ("性别", "targeting_genders", "targeting_genders", ["fb", "gg", "tt"], None),
622
- ("广告组", "adgroup_id+adgroup_name", "adgroup_name", ["fb", "gg", "tt"], "adgroup_id"),
623
- ]
624
- )
625
- creative_learning = strategy_learning(
626
- [
627
- ("广告", "ad_id+ad_name", "ad_name", ["fb", "gg", "tt"], "ad_id"),
628
- ("创意类型", "ad_format", "ad_format", ["fb", "gg", "tt"], None),
629
- ]
630
- )
631
-
632
- row = {
633
- "group": group,
634
- "bizLine": name,
635
- "period": period,
636
- "project": project,
637
- "budgetStrategy": budget_strategy,
638
- "budgetLearning": budget_learning,
639
- "productStrategy": product_strategy,
640
- "productLearning": product_learning,
641
- "audienceStrategy": audience_strategy,
642
- "audienceLearning": audience_learning,
643
- "creativeStrategy": creative_strategy,
644
- "creativeLearning": creative_learning,
645
- }
646
- payload = {"rows": [row], "details": details}
647
- (req / "payload.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
648
- summary = {
649
- "bizLineId": biz_id,
650
- "curr_total": curr_total,
651
- "prev_total": prev_total,
652
- "overall_cpi": [cpi_c, cpi_p],
653
- "overall_cpm": [overall_c, overall_p],
654
- "eff_days": eff_days,
655
- "first_period": first_period,
656
- "spend_verdict": spend_verdict,
657
- "bid_counts": {ch: len(bids[ch]) for ch in bids},
658
- "detail_count": len(details),
659
- "request_dir": str(req),
660
- }
661
- (req / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
662
- return payload, summary
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()
663
933
 
664
934
 
665
935
  def main() -> int: