sino-bc-cli 1.8.26 → 1.8.27

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 (29) hide show
  1. package/README.md +6 -0
  2. package/dist/SKILL.md +10 -10
  3. package/dist/affordance/project-analysis.md +18 -0
  4. package/dist/affordance/user.md +8 -2
  5. package/dist/api-doc/oapi/project-analysis.md +86 -0
  6. package/dist/api-doc/oapi/user.md +19 -2
  7. package/dist/generated/api-catalog.json +779 -4
  8. package/dist/index.js +1 -1
  9. package/dist/skills/custom/biz-line-strategy-generate/SKILL.md +36 -17
  10. package/dist/skills/custom/biz-line-strategy-generate/references/api-flow.md +44 -22
  11. package/dist/skills/custom/biz-line-strategy-generate/references/four-strategies.md +18 -74
  12. package/dist/skills/custom/biz-line-strategy-generate/references/metrics.md +20 -32
  13. package/dist/skills/custom/biz-line-strategy-generate/references/output-excel.md +12 -7
  14. package/dist/skills/custom/biz-line-strategy-generate/references/runtime.md +41 -4
  15. package/dist/skills/custom/biz-line-strategy-generate/references/spec.md +176 -6
  16. package/dist/skills/custom/biz-line-strategy-generate/scripts/build_report.py +17 -6
  17. package/dist/skills/custom/biz-line-strategy-generate/scripts/cli_json.py +36 -17
  18. package/dist/skills/custom/biz-line-strategy-generate/scripts/fetch_data.py +44 -17
  19. package/dist/skills/custom/biz-line-strategy-generate/scripts/run_workflow.py +134 -37
  20. package/dist/skills/custom/biz-line-strategy-generate/scripts/strategy_output.py +277 -0
  21. package/dist/skills/custom/biz-line-strategy-generate/scripts/summarize.py +84 -146
  22. package/dist/skills/custom/biz-line-strategy-generate/scripts/test_database_output.py +401 -0
  23. package/dist/skills/custom/biz-line-strategy-generate/scripts/test_strategy_rules.py +18 -8
  24. package/dist/skills/custom/biz-line-strategy-generate/scripts/test_workflow.py +138 -8
  25. package/dist/skills/oapi/sino-bc-project-analysis/SKILL.md +13 -1
  26. package/dist/skills/oapi/sino-bc-project-analysis/references/analysis-workflow.md +4 -0
  27. package/dist/skills/oapi/sino-bc-project-analysis/references/filters-and-metrics.md +2 -2
  28. package/dist/skills/oapi/sino-bc-user/SKILL.md +6 -4
  29. package/package.json +1 -1
@@ -14,6 +14,7 @@ SCRIPTS = Path(__file__).resolve().parent
14
14
  if str(SCRIPTS) not in sys.path:
15
15
  sys.path.insert(0, str(SCRIPTS))
16
16
 
17
+ from cli_json import learning_text, amount_change
17
18
  from cli_json import (
18
19
  bid_adjust_stats,
19
20
  budget_abs_share,
@@ -73,7 +74,7 @@ def load_merged(bl: Path, stem: str) -> list:
73
74
 
74
75
 
75
76
  def object_name_maps(rows, id_key, name_key) -> tuple[dict[str, str], dict[str, str]]:
76
- """id → 明细展示名(空则回退 id),以及原始 display(空名不当作 id,避免命名规律拆 ID)。"""
77
+ """id → 明细展示名(空则回退 id),以及原始 display(空名不当作 id,避免分布在拆 ID)。"""
77
78
  names: dict[str, str] = {}
78
79
  raw: dict[str, str] = {}
79
80
  for row in rows:
@@ -164,7 +165,7 @@ def bid_changes(rows, fields):
164
165
 
165
166
 
166
167
  def new_items(curr_rows, prev_rows, key, id_key=None):
167
- """按稳定 ID/维度值聚合基础指标,再识别本期新出现的对象。只为新增对象保留行。"""
168
+ """按稳定 ID/维度值聚合基础指标,再识别新增的对象。只为新增对象保留行。"""
168
169
  def identity(row):
169
170
  return str(row.get(id_key) or "") if id_key else disp(row, key)
170
171
  previous: dict[str, str] = {}
@@ -195,7 +196,7 @@ def dim_layer_text(title: str, items: list, stop_labs: list, rule=None):
195
196
  rule = rule or core_rule("")
196
197
  metric = rule["metric"]
197
198
  if not items and not stop_labs:
198
- return f"{title}\n本期无新增,跳过。", None, 0.0, 0.0
199
+ return f"{title}\n本期无新增。", None, 0.0, 0.0
199
200
  lines = [title]
200
201
  usable = [it for it in items if it["spend"] >= 50]
201
202
  for it in usable[:DETAIL_LIMIT]:
@@ -208,7 +209,7 @@ def dim_layer_text(title: str, items: list, stop_labs: list, rule=None):
208
209
  if len(items) > len(usable):
209
210
  lines.append(f"· 另有 {len(items) - len(usable)} 个新增消耗不足 $50,不纳入聚合")
210
211
  if stop_labs:
211
- lines.append(f"· 本期未出现 {len(stop_labs)} 个:{examples(stop_labs)}(不等同已暂停)")
212
+ lines.append(f"· 暂停 {len(stop_labs)} 个:{examples(stop_labs)}")
212
213
  records = [r for it in usable for r in it.get("rows", [])]
213
214
  value = metric_value(records, metric)
214
215
  hit = rule_hit(value, rule)
@@ -217,27 +218,10 @@ def dim_layer_text(title: str, items: list, stop_labs: list, rule=None):
217
218
 
218
219
 
219
220
  def setting_line(title, curr_set, prev_set, first_period=False) -> str:
220
- news, stops = sorted(curr_set - prev_set), sorted(prev_set - curr_set)
221
- lines = [title]
221
+ """主表仅保留集合数量;成员名称统一在明细列出。"""
222
222
  if first_period:
223
- labs = sorted(curr_set)
224
- if title.startswith("【国家】"):
225
- main, _extra = summarize_countries(labs)
226
- lines.append(f"首期展示当期配置:{main};示例 {examples(labs)}")
227
- else:
228
- lines.append(f"首期展示当期 {len(labs)} 个:{examples(labs)}")
229
- return "\n".join(lines)
230
- if news:
231
- if title.startswith("【国家】"):
232
- main, _extra = summarize_countries(news)
233
- lines.append(f"新增 {len(news)} 个({main}):{examples(news)}")
234
- else:
235
- lines.append(f"新增 {len(news)} 个:{examples(news)}")
236
- if stops:
237
- lines.append(f"本期未出现 {len(stops)} 个:{examples(stops)}")
238
- if len(lines) == 1:
239
- lines.append("未发现新增或本期未出现")
240
- return "\n".join(lines)
223
+ return f'{title}\n当期 {len(curr_set)} 个'
224
+ return f'{title}\n新增 {len(curr_set - prev_set)} 个,暂停 {len(prev_set - curr_set)} 个'
241
225
 
242
226
 
243
227
  def ad_keywords(names, n=3):
@@ -255,6 +239,7 @@ def ad_keywords(names, n=3):
255
239
 
256
240
  def summarize_request(req: Path):
257
241
  """仅使用本次通过分页校验的文件,固定生成摘要和完整明细。"""
242
+ from strategy_output import effect_code
258
243
  biz = load_json_file(req / "biz-line.json") or {}
259
244
  detail = load_json_file(req / "biz-line-detail.out.json") or {}
260
245
  detail_data = detail.get("data") if isinstance(detail.get("data"), dict) else {}
@@ -281,12 +266,8 @@ def summarize_request(req: Path):
281
266
  if len(curr) == 2 and len(prev) == 2:
282
267
  period = f"本期:{curr[0]}~{curr[1]}\n上期:{prev[0]}~{prev[1]}"
283
268
  else:
284
- # 本次请求未落 periods.json 时,回退业务线接口上的策略周期
285
- cs = biz.get("periodStart") or detail_data.get("periodStart")
286
- ce = biz.get("periodEnd") or detail_data.get("periodEnd")
287
- ps = biz.get("prevPeriodStart") or detail_data.get("prevPeriodStart")
288
- pe = biz.get("prevPeriodEnd") or detail_data.get("prevPeriodEnd")
289
- period = f"本期:{cs}~{ce}\n上期:{ps}~{pe}" if cs and ce and ps and pe else ""
269
+ # 缺少请求周期时不能以业务线周期冒充本次分析范围。
270
+ raise ValueError('缺少有效 periods.json,请通过统一入口重新拉取数据')
290
271
 
291
272
  spend = {"curr": {}, "prev": {}}
292
273
  imps = {"curr": {}, "prev": {}}
@@ -354,7 +335,7 @@ def summarize_request(req: Path):
354
335
  spend_verdict = "待观察"
355
336
  gate_note = f"本期有效投放 {eff_days} 天、消耗 ${curr_total:,.0f}、安装 {curr_inst:,.0f}"
356
337
  qoq_pct_s = "" if qoq_pct is None else f"{qoq_pct * 100:+.1f}%"
357
- details: list[dict] = [{"bizLine": name, "kind": "口径", "action": "客户 KPI", "channel": "", "name": core_index or "未填写", "extra": rule["reason"]}]
338
+ details: list[dict] = [] # KPI 原文仅参与内部判定,不写入任一 sheet。
358
339
  coverage_notes = []
359
340
  from datetime import date, timedelta
360
341
  for per, bounds in (("curr", periods.get("curr")), ("prev", periods.get("prev"))):
@@ -398,12 +379,7 @@ def summarize_request(req: Path):
398
379
  f"{label}:上调 {stats['raise_n']} 个广告组,下调 {stats['lower_n']} 个;"
399
380
  f"期内变化但首末持平 {stats['flat_n']} 个;幅度中位数 {stats['median_abs']:.2f}"
400
381
  ]
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')} 次)"
406
- )
382
+ lines.append('分布在:' + naming_patterns([b.get('name') or '' for b in items]))
407
383
  return "\n".join(lines)
408
384
 
409
385
  for ch in ("fb", "gg", "tt"):
@@ -424,6 +400,7 @@ def summarize_request(req: Path):
424
400
  budget_strategy = "\n".join(
425
401
  [
426
402
  "【调整出价】",
403
+ f"合计 {sum(len(items) for items in bids.values())} 个广告组调整出价",
427
404
  bid_text("fb"),
428
405
  bid_text("gg"),
429
406
  bid_text("tt"),
@@ -450,53 +427,41 @@ def summarize_request(req: Path):
450
427
  prev_share=prev_share["tt"],
451
428
  curr_share=curr_share["tt"],
452
429
  ),
453
- f"消耗合计:本期 ${curr_total:,.0f} / 上期 ${prev_total:,.0f}",
430
+ f"消耗合计:本期 ${curr_total:,.0f} / 上期 ${prev_total:,.0f};{amount_change(curr_total, prev_total)}",
454
431
  ]
455
432
  )
456
433
  cpi_s = f"全渠道 {metric}:本期 {fmt_cpm(core_c)} / 上期 {fmt_cpm(core_p)}"
457
434
  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
- )
435
+ budget_learning = learning_text(spend_verdict, rule)
482
436
 
483
437
  def share_line(title, dim_key, stem, channels):
484
- segs = []
485
- moved = False
438
+ """占比同时展示对应消耗;主表汇总前三类,其余合并,明细保留全量。"""
439
+ segs, moved = [], False
486
440
  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
441
+ current, current_total = share_map(iter_stem_rows(bl, f'met_{ch}_curr_{stem}'), dim_key)
442
+ previous, previous_total = share_map(iter_stem_rows(bl, f'met_{ch}_prev_{stem}'), dim_key)
443
+ moved = moved or share_moved(current, previous)
444
+ keys = [key for key in sorted(set(current) | set(previous), key=lambda key: (-current.get(key, 0), key))
445
+ if not is_ghost_enum(current.get(key, 0), previous.get(key, 0))]
446
+ def aggregate_line(label, members):
447
+ cp, pp = sum(current.get(k, 0) for k in members), sum(previous.get(k, 0) for k in members)
448
+ ca, pa = cp * current_total / 100, pp * previous_total / 100
449
+ return f'{label}:消耗 ${pa:,.0f} → ${ca:,.0f},占比 {pp:.1f}% → {cp:.1f}%;{amount_change(ca, pa)}'
450
+ for key in keys:
451
+ details.append({'bizLine': name, 'kind': title.strip('【】') + '/分布', 'action': '对比',
452
+ 'channel': CH_LABEL[ch], 'name': key, 'extra': aggregate_line(key, [key])})
453
+ parts = [aggregate_line(key, [key]) for key in keys[:3]]
454
+ if len(keys) > 3:
455
+ parts.append(aggregate_line('其余类别合计', keys[3:]))
456
+ if parts:
457
+ segs.append(CH_LABEL[ch] + ':' + ';'.join(parts))
458
+ return ('\n'.join([title] + segs) if segs else ''), moved
494
459
 
495
460
  p_obj, m1 = share_line("【广告目标】", "objective", "objective", ["fb", "tt"])
496
461
  p_ch, m2 = share_line("【广告系列类型】", "advertising_channel_type", "advertising_channel_type", ["gg"])
497
462
  p_opt, m3 = share_line("【成效目标】", "optimization_goal", "optimization_goal", ["fb", "gg", "tt"])
498
463
  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)
464
+ product_strategy = "\n".join(x for x in [p_obj, p_ch, p_opt, p_bid] if x) or '本期无可识别的广告产品策略数据'
500
465
  product_share_moved = any([m1, m2, m3, m4])
501
466
 
502
467
  def dim_sets(stem, key, channels):
@@ -538,7 +503,7 @@ def summarize_request(req: Path):
538
503
  {
539
504
  "bizLine": name,
540
505
  "kind": kind,
541
- "action": "本期未出现",
506
+ "action": "暂停",
542
507
  "channel": channel,
543
508
  "name": lab,
544
509
  "extra": "",
@@ -551,9 +516,12 @@ def summarize_request(req: Path):
551
516
  add_setting_details("国家", country_curr, country_prev)
552
517
  add_setting_details("兴趣词", interest_curr, interest_prev)
553
518
  add_setting_details("关键词", kw_curr, kw_prev)
519
+ for label, field in (("年龄", "targeting_age"), ("性别", "targeting_genders")):
520
+ add_setting_details(label, *dim_sets(field, field, ["fb", "gg", "tt"]))
554
521
 
555
522
  def object_strategy(kind, id_key, name_key):
556
523
  bits = []
524
+ total_new = total_absent = 0
557
525
  for ch in CH_LABEL:
558
526
  stem = f"{id_key}+{name_key}"
559
527
 
@@ -563,20 +531,25 @@ def summarize_request(req: Path):
563
531
  c, c_raw = index_objects("curr")
564
532
  p, _p_raw = index_objects("prev")
565
533
  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])}")
534
+ total_new += len(new)
535
+ total_absent += len(absent)
536
+ if not c and not p:
537
+ continue
538
+ bits.append(f"{CH_LABEL[ch]}:新增 {len(new)} 个,暂停 {len(absent)} 个;新增分布在:{naming_patterns([c_raw[i] for i in new])};暂停分布在:{naming_patterns([_p_raw[i] for i in absent])}")
567
539
  for ident in sorted(c.keys() | p.keys()):
568
540
  record_name = c.get(ident) or p[ident]
569
- action = "当期" if first_period else ("新增" if ident in new else ("本期未出现" if ident in absent else "持续"))
541
+ action = "当期" if first_period else ("新增" if ident in new else ("暂停" if ident in absent else "持续"))
570
542
  details.append({"bizLine": name, "kind": kind, "action": action, "channel": CH_LABEL[ch], "name": record_name, "extra": f"ID={ident};集合差异不等同创建/暂停操作"})
571
- return bits
543
+ return [f'合计新增 {total_new} 个{kind},暂停 {total_absent} 个'] + bits
572
544
 
545
+ audience_type_text, audience_type_moved = share_line("【受众类型】", "audience_type", "audience_type", ["fb", "gg", "tt"])
573
546
  ag_bits = object_strategy("广告组", "adgroup_id", "adgroup_name")
574
- audience_strategy = "\n\n".join(
547
+ audience_strategy = "\n".join(
575
548
  x
576
549
  for x in [
577
550
  setting_line("【国家】", country_curr, country_prev, first_period=first_period),
578
551
  setting_line("【兴趣词包】", interest_curr, interest_prev, first_period=first_period),
579
- share_line("【受众类型】", "audience_type", "audience_type", ["fb", "gg", "tt"])[0],
552
+ audience_type_text,
580
553
  setting_line("【关键词】", kw_curr, kw_prev, first_period=first_period),
581
554
  setting_line("【年龄】", *dim_sets("targeting_age", "targeting_age", ["fb", "gg", "tt"]), first_period=first_period),
582
555
  setting_line("【性别】", *dim_sets("targeting_genders", "targeting_genders", ["fb", "gg", "tt"]), first_period=first_period),
@@ -589,12 +562,11 @@ def summarize_request(req: Path):
589
562
  creative_fmt = share_line("【创意类型】", "ad_format", "ad_format", ["fb", "gg", "tt"])[0] or ""
590
563
  creative_strategy = "【广告】\n" + "\n".join(ad_bits) + (("\n\n" + creative_fmt) if creative_fmt else "")
591
564
 
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
565
+ # 文案和效果在同一判断点产出,不扫描 learning 文本反推状态。
566
+ learning_effects = {}
567
+
568
+ def strategy_learning(dim_specs, allow_share_fallback=False, share_did_move=False, strategy_key=''):
596
569
  for title, stem, key, channels, id_key in dim_specs:
597
- items_all, stops_all = [], []
598
570
  for ch in channels:
599
571
  curr_stem = f"met_{ch}_curr_{stem}"
600
572
  prev_stem = f"met_{ch}_prev_{stem}"
@@ -613,18 +585,6 @@ def summarize_request(req: Path):
613
585
  hit = rule_hit(value, rule)
614
586
  verdict_text = '不可判定' if hit is None else ('达标' if hit else '未达标')
615
587
  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
588
  verdict = period_verdict(
629
589
  first_period=first_period,
630
590
  qoq_label=qoq_label,
@@ -633,55 +593,8 @@ def summarize_request(req: Path):
633
593
  )
634
594
  if gate_note:
635
595
  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
- ]
651
- )
652
- if not any_new:
653
- if allow_share_fallback and share_did_move:
654
- return "\n".join(
655
- [
656
- f"结论:{verdict}",
657
- "口径:无新增枚举,占比变化超过 ±2%,按可环比",
658
- f"依据:{evidence}",
659
- "",
660
- detail,
661
- "",
662
- advice_of(verdict),
663
- ]
664
- )
665
- return "\n".join(
666
- [
667
- f"结论:{verdict}",
668
- "口径:本期无实质调整动作",
669
- f"依据:{evidence}",
670
- "",
671
- detail,
672
- ]
673
- )
674
- ratio = (hit_spend / all_spend) if all_spend else 0.0
675
- return "\n".join(
676
- [
677
- f"结论:{verdict}",
678
- f"依据:{evidence}",
679
- "",
680
- detail,
681
- "",
682
- advice_of(verdict),
683
- ]
684
- )
596
+ learning_effects[strategy_key] = effect_code(verdict)
597
+ return learning_text(verdict, rule)
685
598
 
686
599
  product_learning = strategy_learning(
687
600
  [
@@ -692,6 +605,7 @@ def summarize_request(req: Path):
692
605
  ],
693
606
  allow_share_fallback=True,
694
607
  share_did_move=product_share_moved,
608
+ strategy_key='product',
695
609
  )
696
610
  audience_learning = strategy_learning(
697
611
  [
@@ -702,13 +616,13 @@ def summarize_request(req: Path):
702
616
  ("年龄", "targeting_age", "targeting_age", ["fb", "gg", "tt"], None),
703
617
  ("性别", "targeting_genders", "targeting_genders", ["fb", "gg", "tt"], None),
704
618
  ("广告组", "adgroup_id+adgroup_name", "adgroup_name", ["fb", "gg", "tt"], "adgroup_id"),
705
- ]
619
+ ], strategy_key='audience',
706
620
  )
707
621
  creative_learning = strategy_learning(
708
622
  [
709
623
  ("广告", "ad_id+ad_name", "ad_name", ["fb", "gg", "tt"], "ad_id"),
710
624
  ("创意类型", "ad_format", "ad_format", ["fb", "gg", "tt"], None),
711
- ]
625
+ ], strategy_key='creative',
712
626
  )
713
627
 
714
628
  row = {
@@ -724,7 +638,30 @@ def summarize_request(req: Path):
724
638
  "audienceLearning": audience_learning,
725
639
  "creativeStrategy": creative_strategy,
726
640
  "creativeLearning": creative_learning,
641
+ "budgetEffect": effect_code(spend_verdict),
642
+ **{key + 'Effect': value for key, value in learning_effects.items()},
727
643
  }
644
+ # 证据只来自实际快照或集合/占比变化;不把消耗下降推断为主动减预算。
645
+ evidence = {'budget': [], 'product': [], 'audience': []}
646
+ if any(bids.values()):
647
+ evidence['budget'].append('出价调整')
648
+ for field, action in [('raise_n', '上调出价'), ('lower_n', '下调出价')]:
649
+ if any(stats[field] for stats in bid_stats.values()):
650
+ evidence['budget'].append(action)
651
+ if not first_period:
652
+ for moved, label in zip((m1, m2, m3, m4), ('广告目标调整', '广告系列类型调整', '成效目标调整', '出价方式调整')):
653
+ if moved:
654
+ evidence['product'].append(label)
655
+ for current, previous, label in [(country_curr, country_prev, '国家调整'),
656
+ (interest_curr, interest_prev, '兴趣词调整'), (kw_curr, kw_prev, '关键词调整')]:
657
+ if current != previous:
658
+ evidence['audience'].append(label)
659
+ for stem, label in [('targeting_age', '年龄调整'), ('targeting_genders', '性别调整')]:
660
+ current, previous = dim_sets(stem, stem, ['fb', 'gg', 'tt'])
661
+ if current != previous:
662
+ evidence['audience'].append(label)
663
+ if audience_type_moved:
664
+ evidence['audience'].append('受众类型调整')
728
665
  payload = {"rows": [row], "details": details}
729
666
  (req / "payload.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
730
667
  summary = {
@@ -738,6 +675,7 @@ def summarize_request(req: Path):
738
675
  "spend_verdict": spend_verdict,
739
676
  "bid_counts": {ch: len(bids[ch]) for ch in bids},
740
677
  "detail_count": len(details),
678
+ "tag_evidence": evidence,
741
679
  "request_dir": str(req),
742
680
  }
743
681
  (req / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")