sino-bc-cli 1.8.19 → 1.8.21

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,8 +1,6 @@
1
1
  #!/usr/bin/env python3
2
2
  # -*- coding: utf-8 -*-
3
- """把 fetch_data 落盘的 merged JSON 收成 summary + payload 草稿。
4
-
5
- Agent 应先读 payload 草稿再决定要不要改文案;不要从原始回包重新发明聚合。
3
+ """按固定规则汇总 merged JSON,生成可直接校验及导出的 payload。
6
4
  """
7
5
  from __future__ import annotations
8
6
 
@@ -17,25 +15,31 @@ if str(SCRIPTS) not in sys.path:
17
15
  sys.path.insert(0, str(SCRIPTS))
18
16
 
19
17
  from cli_json import (
18
+ bid_adjust_stats,
19
+ budget_abs_share,
20
20
  cpm,
21
21
  disp,
22
- dual_verdict,
23
22
  examples,
24
23
  fmt_cpm,
25
- hit_kpi,
24
+ metric_value,
25
+ optional_num,
26
+ core_rule,
27
+ rule_hit,
28
+ is_first_period,
29
+ is_ghost_enum,
26
30
  load_json_file,
31
+ naming_patterns,
27
32
  num,
33
+ period_verdict,
28
34
  qoq_axis,
29
35
  short_label,
36
+ summarize_countries,
30
37
  unwrap_page,
31
38
  )
32
39
 
33
40
  CH_LABEL = {"fb": "Meta", "gg": "Google", "tt": "TikTok"}
34
- KPI_LABEL = "CPM≤5"
35
- KPI_TARGET = 5.0
36
- # 单元格内明细最多展开条数,其余收成「另有 N 个」
41
+ # 第 1 层明细仍用消耗门槛;结论不再用 CPM≤5 当 KPI
37
42
  DETAIL_LIMIT = 8
38
- BID_SHOW = 6
39
43
 
40
44
 
41
45
  def load_merged(bl: Path, stem: str) -> list:
@@ -78,13 +82,6 @@ def advice_of(verdict: str) -> str:
78
82
  return "建议:复核策略"
79
83
 
80
84
 
81
- def channel_delta(curr, prev) -> str:
82
- d = curr - prev
83
- if abs(d) <= 2:
84
- return "无变化"
85
- return f"{d:+.1f}%"
86
-
87
-
88
85
  def share_moved(curr: dict, prev: dict) -> bool:
89
86
  for lab in set(curr) | set(prev):
90
87
  if abs(curr.get(lab, 0) - prev.get(lab, 0)) > 2:
@@ -93,113 +90,97 @@ def share_moved(curr: dict, prev: dict) -> bool:
93
90
 
94
91
 
95
92
  def bid_changes(rows, fields):
93
+ """逐个出价字段比较有效日快照,缺失值不补零;保留字段与对象 ID。"""
96
94
  by_ag = defaultdict(list)
97
- for r in rows:
98
- ag = str(r.get("adgroup_id") or "")
99
- if ag:
100
- by_ag[ag].append(r)
95
+ for row in rows:
96
+ if row.get("adgroup_id"):
97
+ by_ag[str(row["adgroup_id"])].append(row)
101
98
  result = []
102
- for ag, recs in by_ag.items():
103
- recs = sorted(recs, key=lambda x: str(x.get("calendar_date") or ""))
104
- times = 0
105
- prev_vals = first_vals = last_vals = None
106
- name = strategy = ""
107
- for r in recs:
108
- vals = tuple(num(r, f) for f in fields)
109
- name = disp(r, "adgroup_name") or name
110
- strategy = disp(r, "bid_strategy") or strategy
111
- if first_vals is None:
112
- first_vals = prev_vals = last_vals = vals
113
- continue
114
- if vals != prev_vals:
115
- times += 1
116
- prev_vals = last_vals = vals
117
- if times > 0 and first_vals and last_vals:
118
- from_v, to_v = first_vals[0], last_vals[0]
119
- for i, _f in enumerate(fields):
120
- if first_vals[i] != last_vals[i]:
121
- from_v, to_v = first_vals[i], last_vals[i]
122
- break
123
- result.append(
124
- {"id": ag, "name": name or ag, "times": times, "from": from_v, "to": to_v, "strategy": strategy}
125
- )
126
- result.sort(key=lambda x: (-x["times"], x["name"]))
127
- return result
99
+ 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")})
114
+ return sorted(result, key=lambda r: (-r["times"], r["id"]))
128
115
 
129
116
 
130
117
  def new_items(curr_rows, prev_rows, key, id_key=None):
131
- if id_key:
132
- curr_map = {str(r.get(id_key)): r for r in curr_rows if r.get(id_key)}
133
- prev_ids = {str(r.get(id_key)) for r in prev_rows if r.get(id_key)}
134
- news = [curr_map[i] for i in curr_map if i not in prev_ids]
135
- stops = [disp(r, key) or str(r.get(id_key)) for r in prev_rows if str(r.get(id_key)) not in curr_map]
136
- else:
137
- curr_map = {disp(r, key): r for r in curr_rows if disp(r, key)}
138
- prev_set = labels_of(prev_rows, key)
139
- news = [curr_map[k] for k in curr_map if k not in prev_set]
140
- stops = sorted(prev_set - set(curr_map))
141
- items = [
142
- {
143
- "label": disp(r, key) or str(r.get(id_key) or ""),
144
- "spend": num(r, "spend_usd"),
145
- "imps": num(r, "impressions"),
146
- "cpm": cpm(num(r, "spend_usd"), num(r, "impressions")),
147
- }
148
- for r in news
149
- ]
118
+ """按稳定 ID/维度值聚合基础指标,再识别本期新出现的对象。"""
119
+ def identity(row):
120
+ return str(row.get(id_key) or "") if id_key else disp(row, key)
121
+ grouped = defaultdict(list)
122
+ 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)}
126
+ items = []
127
+ for ident, records in grouped.items():
128
+ if ident in previous:
129
+ continue
130
+ items.append({"id": ident if id_key else "", "label": disp(records[0], key) or ident,
131
+ "spend": sum(num(r, "spend_usd") for r in records), "rows": records,
132
+ "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]
150
134
  return items, stops
151
135
 
152
136
 
153
- def dim_layer_text(title: str, items: list, stop_labs: list):
154
- # 每个维度一块:标题 + 逐条 · 明细,避免分号连成墙
137
+ def dim_layer_text(title: str, items: list, stop_labs: list, rule=None):
138
+ """新增值自身先合计再算指标,不使用默认 CPM 目标或伪造达标占比。"""
139
+ rule = rule or core_rule("")
140
+ metric = rule["metric"]
155
141
  if not items and not stop_labs:
156
142
  return f"{title}\n本期无新增,跳过。", None, 0.0, 0.0
157
143
  lines = [title]
158
- usable = []
159
- low = 0
160
- shown = extra = 0
161
- for it in items:
162
- if it["spend"] < 50:
163
- low += 1
164
- continue
165
- hit = hit_kpi(it["cpm"], KPI_TARGET)
166
- if shown < DETAIL_LIMIT:
167
- lines.append(
168
- f"· 新增「{short_label(it['label'])}」CPM {fmt_cpm(it['cpm'])} "
169
- f"{'达标' if hit else '未达标'}(${it['spend']:.0f})"
170
- )
171
- shown += 1
172
- else:
173
- extra += 1
174
- if hit is not None:
175
- usable.append(it)
176
- if extra:
177
- lines.append(f"· 另有 {extra} 个过门槛新增未展开")
178
- if low:
179
- lines.append(f"· 另有 {low} 个新增消耗不足 $50,不纳入聚合")
180
- if stop_labs and not items:
181
- lines.append(f"· 关停 {len(stop_labs)} 个:{examples(stop_labs)}")
182
- elif stop_labs:
183
- lines.append(f"· 另关停 {len(stop_labs)} 个:{examples(stop_labs)}")
184
- if not usable:
185
- return "\n".join(lines), None, 0.0, 0.0
186
- spend_sum = sum(it["spend"] for it in usable)
187
- dim_cpm = cpm(spend_sum, sum(it["imps"] for it in usable))
188
- dim_hit = hit_kpi(dim_cpm, KPI_TARGET)
189
- lines.append(f"· 合计 CPM {fmt_cpm(dim_cpm)},{'达标' if dim_hit else '未达标'}")
190
- hit_spend = sum(it["spend"] for it in usable if hit_kpi(it["cpm"], KPI_TARGET))
191
- return "\n".join(lines), dim_hit, hit_spend, spend_sum
192
-
193
-
194
- def setting_line(title, curr_set, prev_set) -> str:
144
+ usable = [it for it in items if it["spend"] >= 50]
145
+ for it in usable[:DETAIL_LIMIT]:
146
+ value = metric_value(it.get("rows", []), metric)
147
+ hit = rule_hit(value, rule)
148
+ verdict = "不可判定" if hit is None else ("达标" if hit else "未达标")
149
+ lines.append(f"· 新增「{short_label(it['label'])}」{metric} {fmt_cpm(value)},KPI {verdict}(${it['spend']:.0f})")
150
+ if len(usable) > DETAIL_LIMIT:
151
+ lines.append(f"· 另有 {len(usable) - DETAIL_LIMIT} 个过门槛新增未展开,见明细")
152
+ if len(items) > len(usable):
153
+ lines.append(f"· 另有 {len(items) - len(usable)} 个新增消耗不足 $50,不纳入聚合")
154
+ if stop_labs:
155
+ lines.append(f"· 本期未出现 {len(stop_labs)} 个:{examples(stop_labs)}(不等同已暂停)")
156
+ records = [r for it in usable for r in it.get("rows", [])]
157
+ value = metric_value(records, metric)
158
+ hit = rule_hit(value, rule)
159
+ lines.append(f"· 合计(新增){metric} {fmt_cpm(value)};仅观察同期表现,不推断动作因果")
160
+ return "\n".join(lines), hit, sum(it["spend"] for it in usable if rule_hit(metric_value(it.get("rows", []), metric), rule)), sum(it["spend"] for it in usable)
161
+
162
+
163
+ def setting_line(title, curr_set, prev_set, first_period=False) -> str:
195
164
  news, stops = sorted(curr_set - prev_set), sorted(prev_set - curr_set)
196
165
  lines = [title]
166
+ if first_period:
167
+ labs = sorted(curr_set)
168
+ if title.startswith("【国家】"):
169
+ main, _extra = summarize_countries(labs)
170
+ lines.append(f"首期展示当期配置:{main};示例 {examples(labs)}")
171
+ else:
172
+ lines.append(f"首期展示当期 {len(labs)} 个:{examples(labs)}")
173
+ return "\n".join(lines)
197
174
  if news:
198
- lines.append(f"新增 {len(news)} 个:{examples(news)}")
175
+ if title.startswith("【国家】"):
176
+ main, _extra = summarize_countries(news)
177
+ lines.append(f"新增 {len(news)} 个({main}):{examples(news)}")
178
+ else:
179
+ lines.append(f"新增 {len(news)} 个:{examples(news)}")
199
180
  if stops:
200
- lines.append(f"关停 {len(stops)} 个:{examples(stops)}")
181
+ lines.append(f"本期未出现 {len(stops)} 个:{examples(stops)}")
201
182
  if len(lines) == 1:
202
- lines.append("未发现新增或关停")
183
+ lines.append("未发现新增或本期未出现")
203
184
  return "\n".join(lines)
204
185
 
205
186
 
@@ -216,17 +197,20 @@ def ad_keywords(names, n=3):
216
197
  return seen
217
198
 
218
199
 
219
- def main() -> int:
220
- parser = argparse.ArgumentParser(description=__doc__)
221
- parser.add_argument("--request-dir", required=True)
222
- args = parser.parse_args()
223
- req = Path(args.request_dir)
200
+ def summarize_request(req: Path):
201
+ """仅使用本次通过分页校验的文件,固定生成摘要和完整明细。"""
224
202
  biz = load_json_file(req / "biz-line.json") or {}
225
203
  detail = load_json_file(req / "biz-line-detail.out.json") or {}
226
204
  detail_data = detail.get("data") if isinstance(detail.get("data"), dict) else {}
227
205
  projects = load_json_file(req / "projects.json") or {}
228
206
  biz_id = biz.get("id") or detail_data.get("id")
229
207
  bl = req / f"bl-{biz_id}"
208
+ manifest = load_json_file(req / 'fetch-manifest.json')
209
+ if manifest is not None:
210
+ 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}')
230
214
  group = detail_data.get("ssoTeamCodeName") or biz.get("ssoTeamCodeName") or ""
231
215
  name = detail_data.get("name") or biz.get("name") or ""
232
216
  project = "\n".join(projects.get("projectNames") or detail_data.get("projectNames") or [])
@@ -245,14 +229,16 @@ def main() -> int:
245
229
 
246
230
  spend = {"curr": {}, "prev": {}}
247
231
  imps = {"curr": {}, "prev": {}}
232
+ inst = {"curr": {}, "prev": {}}
248
233
  days = {"curr": {}, "prev": {}}
249
234
  for ch in ("fb", "gg", "tt"):
250
235
  for per in ("curr", "prev"):
251
236
  day_rows = load_merged(bl, f"met_{ch}_{per}_calendar_date")
252
237
  acc_rows = load_merged(bl, f"met_{ch}_{per}_account_id")
253
- rows = day_rows or acc_rows
238
+ rows = acc_rows or day_rows
254
239
  spend[per][ch] = sum(num(r, "spend_usd") for r in rows)
255
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)
256
242
  days[per][ch] = len(
257
243
  {
258
244
  disp(r, "calendar_date") or str(r.get("calendar_date") or "")
@@ -263,19 +249,55 @@ def main() -> int:
263
249
 
264
250
  curr_total = sum(spend["curr"].values())
265
251
  prev_total = sum(spend["prev"].values())
252
+ curr_inst = sum(inst["curr"].values())
253
+ prev_inst = sum(inst["prev"].values())
266
254
  curr_share = {ch: (spend["curr"][ch] / curr_total * 100 if curr_total else 0) for ch in spend["curr"]}
267
255
  prev_share = {ch: (spend["prev"][ch] / prev_total * 100 if prev_total else 0) for ch in spend["prev"]}
268
256
  overall_c = cpm(curr_total, sum(imps["curr"].values()))
269
257
  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
270
260
  eff_days = max(days["curr"].values() or [0])
271
- qoq_label, qoq_pct = qoq_axis(overall_c, overall_p)
272
- spend_hit = hit_kpi(overall_c, KPI_TARGET)
273
- spend_verdict = dual_verdict(spend_hit, qoq_label)
261
+ 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
+ qoq_label, qoq_pct = qoq_axis(core_c, core_p, metric in ("ROAS", "CTR"))
269
+ kpi_usable = rule["usable"]
270
+ spend_verdict = period_verdict(
271
+ first_period=first_period,
272
+ qoq_label=qoq_label,
273
+ kpi_usable=kpi_usable,
274
+ kpi_hit=rule_hit(core_c, rule),
275
+ )
274
276
  gate_note = ""
275
- if curr_total < 50 or eff_days < 3:
277
+ if curr_total < 50 or eff_days < 3 or (metric == "CPI" and curr_inst < 10):
276
278
  spend_verdict = "待观察"
277
- gate_note = f"本期有效投放 {eff_days} 天、消耗 ${curr_total:,.0f}"
279
+ gate_note = f"本期有效投放 {eff_days} 天、消耗 ${curr_total:,.0f}、安装 {curr_inst:,.0f}"
278
280
  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:
291
+ 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
+
279
301
 
280
302
  bid_fields = {
281
303
  "fb": ["bid_amount"],
@@ -288,49 +310,40 @@ def main() -> int:
288
310
  "tt": "dim_tt_curr_calendar_date+adgroup_id+adgroup_name+bid_amount",
289
311
  }
290
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}
291
314
 
292
315
  def bid_text(ch):
293
316
  items = bids[ch]
317
+ stats = bid_stats[ch]
294
318
  label = CH_LABEL[ch]
295
319
  if not items:
296
320
  return f"{label}:本期未发生明显出价调整"
297
- way = items[0].get("strategy") or ""
298
- lines = [f"{label}:{way}" if way else f"{label}:"]
299
- for b in items[:BID_SHOW]:
300
- lines.append(f"· {short_label(b['name'])}:{b['times']} 次,{b['from']} → {b['to']}")
301
- if len(items) > BID_SHOW:
302
- lines.append(f"· 另有 {len(items) - BID_SHOW} 个广告组也有调整")
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')} 次)"
330
+ )
303
331
  return "\n".join(lines)
304
332
 
305
- bid_kpi_bits = []
306
- bid_spend = bid_imps = 0.0
307
333
  for ch in ("fb", "gg", "tt"):
308
- lookup = {
309
- str(r.get("adgroup_id")): r
310
- for r in load_merged(bl, f"met_{ch}_curr_adgroup_id+adgroup_name")
311
- if r.get("adgroup_id")
312
- }
313
- for b in bids[ch]:
314
- r = lookup.get(b["id"])
315
- if not r:
316
- continue
317
- sp = num(r, "spend_usd")
318
- val = cpm(sp, num(r, "impressions"))
319
- if sp < 50:
320
- bid_kpi_bits.append(f"{short_label(b['name'])} 消耗仅 ${sp:.0f},样本不足")
321
- continue
322
- bid_kpi_bits.append(
323
- f"{short_label(b['name'])} CPM {fmt_cpm(val)} "
324
- f"{'达标' if hit_kpi(val, KPI_TARGET) else '未达标'}(消耗 ${sp:.0f})"
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
+ }
325
346
  )
326
- bid_spend += sp
327
- bid_imps += num(r, "impressions")
328
- bid_sum_cpm = cpm(bid_spend, bid_imps)
329
- bid_sum_s = (
330
- f"有消耗的合计 CPM {fmt_cpm(bid_sum_cpm)},{'达标' if hit_kpi(bid_sum_cpm, KPI_TARGET) else '未达标'}"
331
- if bid_kpi_bits
332
- else "无实质动作 / 无法对比"
333
- )
334
347
 
335
348
  budget_strategy = "\n".join(
336
349
  [
@@ -340,36 +353,52 @@ def main() -> int:
340
353
  bid_text("tt"),
341
354
  "",
342
355
  "【预算】",
343
- f"FB:{pct_pair(prev_share['fb'], curr_share['fb'])}({channel_delta(curr_share['fb'], prev_share['fb'])})",
344
- f"GG:{pct_pair(prev_share['gg'], curr_share['gg'])}({channel_delta(curr_share['gg'], prev_share['gg'])})",
345
- f"TT:{pct_pair(prev_share['tt'], curr_share['tt'])}({channel_delta(curr_share['tt'], prev_share['tt'])})",
346
- f"消耗:本期 ${curr_total:,.0f} / 上期 ${prev_total:,.0f}",
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}",
347
378
  ]
348
379
  )
349
- bid_learn_lines = [f"· {x}" for x in bid_kpi_bits[:BID_SHOW]]
350
- if len(bid_kpi_bits) > BID_SHOW:
351
- bid_learn_lines.append(f"· 另有 {len(bid_kpi_bits) - BID_SHOW} 个见上")
352
- if bid_kpi_bits:
353
- bid_learn_lines.append(f"· {bid_sum_s}")
354
- else:
355
- bid_learn_lines.append(f"· {bid_sum_s}")
380
+ cpi_s = f"全渠道 {metric}:本期 {fmt_cpm(core_c)} / 上期 {fmt_cpm(core_p)}"
381
+ cpi_s += "(美元消耗 / 平台安装量,非客户激活或留存成本)" if metric == "CPI" else "(同粒度基础指标合计后计算)"
356
382
  budget_learning = "\n".join(
357
383
  [
358
- f"结论:{spend_verdict}(取 Spend 子项)",
359
- f"KPI:{KPI_LABEL}",
360
- *( [f"门槛:{gate_note} → 待观察"] if gate_note else [] ),
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 []),
361
388
  "",
362
389
  "Spend",
363
- f"· 全渠道 CPM:本期 {fmt_cpm(overall_c)} / 上期 {fmt_cpm(overall_p)},"
364
- f"{'达标' if spend_hit else '未达标'},环比{qoq_label}"
365
- + (f" {qoq_pct_s}" if qoq_pct_s else "")
366
- + f" → {spend_verdict}",
367
- f"· 占比:FB {pct_pair(prev_share['fb'], curr_share['fb'])};"
368
- f"GG {pct_pair(prev_share['gg'], curr_share['gg'])};"
369
- f"TT {pct_pair(prev_share['tt'], curr_share['tt'])}",
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'])}",
370
395
  "",
371
396
  "调整出价",
372
- *bid_learn_lines,
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
+ ],
373
402
  "",
374
403
  advice_of(spend_verdict),
375
404
  ]
@@ -398,53 +427,83 @@ def main() -> int:
398
427
  curr = set()
399
428
  prev = set()
400
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
401
437
  curr |= labels_of(load_merged(bl, f"dim_{ch}_curr_{stem}"), key)
402
438
  prev |= labels_of(load_merged(bl, f"dim_{ch}_prev_{stem}"), key)
403
439
  return curr, prev
404
440
 
405
- ag_bits = []
406
- for ch in ("fb", "gg", "tt"):
407
- cset = labels_of(load_merged(bl, f"dim_{ch}_curr_adgroup_id+adgroup_name"), "adgroup_name")
408
- pset = labels_of(load_merged(bl, f"dim_{ch}_prev_adgroup_id+adgroup_name"), "adgroup_name")
409
- n, s = sorted(cset - pset), sorted(pset - cset)
410
- line = f"{CH_LABEL[ch]}:新增 {len(n)} 个,关停 {len(s)} 个"
411
- extra = []
412
- if n:
413
- extra.append(f"新增 {examples(n)}")
414
- if s:
415
- extra.append(f"关停 {examples(s)}")
416
- ag_bits.append(line + (f"\n· {';'.join(extra)}" if extra else ""))
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")
417
492
  audience_strategy = "\n\n".join(
418
493
  x
419
494
  for x in [
420
- setting_line("【国家】", *dim_sets("country_code", "country_code", ["fb", "gg", "tt"])),
421
- setting_line("【兴趣词包】", *dim_sets("targeting_interests", "targeting_interests", ["fb", "tt"])),
495
+ setting_line("【国家】", country_curr, country_prev, first_period=first_period),
496
+ setting_line("【兴趣词包】", interest_curr, interest_prev, first_period=first_period),
422
497
  share_line("【受众类型】", "audience_type", "audience_type", ["fb", "gg", "tt"])[0],
423
- setting_line("【关键词】", *dim_sets("keywords", "keywords", ["gg"])),
424
- setting_line("【年龄】", *dim_sets("targeting_age", "targeting_age", ["fb", "gg", "tt"])),
425
- setting_line("【性别】", *dim_sets("targeting_genders", "targeting_genders", ["fb", "gg", "tt"])),
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),
426
501
  "【广告组】\n" + "\n".join(ag_bits),
427
502
  ]
428
503
  if x
429
504
  )
430
505
 
431
- ad_bits = []
432
- for ch in ("fb", "gg", "tt"):
433
- curr = load_merged(bl, f"dim_{ch}_curr_ad_id+ad_name") or load_merged(bl, f"met_{ch}_curr_ad_id+ad_name")
434
- prev = load_merged(bl, f"dim_{ch}_prev_ad_id+ad_name") or load_merged(bl, f"met_{ch}_prev_ad_id+ad_name")
435
- curr_ids = {str(r.get("ad_id")): r for r in curr if r.get("ad_id")}
436
- prev_ids = {str(r.get("ad_id")): r for r in prev if r.get("ad_id")}
437
- new_ids = [i for i in curr_ids if i not in prev_ids]
438
- stop_ids = [i for i in prev_ids if i not in curr_ids]
439
- nk = ad_keywords([disp(curr_ids[i], "ad_name") for i in new_ids[:30]])
440
- sk = ad_keywords([disp(prev_ids[i], "ad_name") for i in stop_ids[:30]])
441
- line = f"{CH_LABEL[ch]}:新增 {len(new_ids)} 个,关停 {len(stop_ids)} 个"
442
- extras = []
443
- if nk:
444
- extras.append(f"新增方向「{' / '.join(nk)}」")
445
- if sk:
446
- extras.append(f"关停方向「{' / '.join(sk)}」")
447
- ad_bits.append(line + (f"\n· {';'.join(extras)}" if extras else ""))
506
+ ad_bits = object_strategy("广告", "ad_id", "ad_name")
448
507
  creative_fmt = share_line("【创意类型】", "ad_format", "ad_format", ["fb", "gg", "tt"])[0] or ""
449
508
  creative_strategy = "【广告】\n" + "\n".join(ad_bits) + (("\n\n" + creative_fmt) if creative_fmt else "")
450
509
 
@@ -455,35 +514,53 @@ def main() -> int:
455
514
  for title, stem, key, channels, id_key in dim_specs:
456
515
  items_all, stops_all = [], []
457
516
  for ch in channels:
458
- items, stops = new_items(
459
- load_merged(bl, f"met_{ch}_curr_{stem}"),
460
- load_merged(bl, f"met_{ch}_prev_{stem}"),
461
- key,
462
- id_key,
463
- )
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}"})
464
534
  items_all.extend(items)
465
535
  stops_all.extend(stops)
466
536
  if items_all:
467
537
  any_new = True
468
538
  if stops_all and not items_all:
469
539
  only_stop = True
470
- text, _hit, hs, ts = dim_layer_text(title, items_all, stops_all)
540
+ text, _hit, hs, ts = dim_layer_text(title, items_all, stops_all, rule)
471
541
  texts.append(text)
472
542
  if ts > 0:
473
543
  hit_spend += hs
474
544
  all_spend += ts
475
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"]
557
+ )
476
558
  if not any_new and only_stop:
477
- verdict = dual_verdict(spend_hit, qoq_label)
478
559
  return "\n".join(
479
560
  [
480
561
  f"结论:{verdict}",
481
- "口径:本期无新增,走关停",
482
- f"KPI:{KPI_LABEL}",
483
- "",
484
- f"关停后存量 CPM {fmt_cpm(overall_c)},{'达标' if spend_hit else '未达标'}",
485
- f"关停前 {fmt_cpm(overall_p)},环比{qoq_label}"
486
- + (f" {qoq_pct_s}" if qoq_pct_s else ""),
562
+ "口径:本期无新增,走本期未出现",
563
+ f"依据:{evidence}",
487
564
  "",
488
565
  detail,
489
566
  "",
@@ -492,15 +569,11 @@ def main() -> int:
492
569
  )
493
570
  if not any_new:
494
571
  if allow_share_fallback and share_did_move:
495
- verdict = dual_verdict(spend_hit, qoq_label)
496
572
  return "\n".join(
497
573
  [
498
574
  f"结论:{verdict}",
499
575
  "口径:无新增枚举,占比变化超过 ±2%,按可环比",
500
- f"KPI:{KPI_LABEL}",
501
- "",
502
- f"核心指标 CPM:本期 {fmt_cpm(overall_c)} / 上期 {fmt_cpm(overall_p)},"
503
- f"{'达标' if spend_hit else '未达标'} + {qoq_label} → {verdict}",
576
+ f"依据:{evidence}",
504
577
  "",
505
578
  detail,
506
579
  "",
@@ -509,26 +582,18 @@ def main() -> int:
509
582
  )
510
583
  return "\n".join(
511
584
  [
512
- "结论:待观察",
585
+ f"结论:{verdict}",
513
586
  "口径:本期无实质调整动作",
514
- f"KPI:{KPI_LABEL}",
587
+ f"依据:{evidence}",
515
588
  "",
516
589
  detail,
517
590
  ]
518
591
  )
519
592
  ratio = (hit_spend / all_spend) if all_spend else 0.0
520
- axis_hit = ratio >= 0.70
521
- verdict = dual_verdict(axis_hit, qoq_label)
522
- if gate_note:
523
- verdict = "待观察"
524
593
  return "\n".join(
525
594
  [
526
595
  f"结论:{verdict}",
527
- f"依据:达标占比 {ratio * 100:.0f}%({'≥' if axis_hit else '<'}70% → "
528
- f"{'达标' if axis_hit else '未达标'});环比本期 CPM {fmt_cpm(overall_c)} / "
529
- f"上期 {fmt_cpm(overall_p)},{qoq_label}"
530
- + (f" {qoq_pct_s}" if qoq_pct_s else ""),
531
- f"KPI:{KPI_LABEL}",
596
+ f"依据:{evidence}",
532
597
  "",
533
598
  detail,
534
599
  "",
@@ -578,18 +643,30 @@ def main() -> int:
578
643
  "creativeStrategy": creative_strategy,
579
644
  "creativeLearning": creative_learning,
580
645
  }
581
- payload = {"rows": [row]}
646
+ payload = {"rows": [row], "details": details}
582
647
  (req / "payload.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
583
648
  summary = {
584
649
  "bizLineId": biz_id,
585
650
  "curr_total": curr_total,
586
651
  "prev_total": prev_total,
652
+ "overall_cpi": [cpi_c, cpi_p],
587
653
  "overall_cpm": [overall_c, overall_p],
588
654
  "eff_days": eff_days,
655
+ "first_period": first_period,
589
656
  "spend_verdict": spend_verdict,
590
657
  "bid_counts": {ch: len(bids[ch]) for ch in bids},
658
+ "detail_count": len(details),
659
+ "request_dir": str(req),
591
660
  }
592
661
  (req / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
662
+ return payload, summary
663
+
664
+
665
+ def main() -> int:
666
+ parser = argparse.ArgumentParser(description=__doc__)
667
+ parser.add_argument("--request-dir", required=True)
668
+ args = parser.parse_args()
669
+ _, summary = summarize_request(Path(args.request_dir))
593
670
  print(json.dumps(summary, ensure_ascii=False), flush=True)
594
671
  return 0
595
672