sino-bc-cli 1.0.0-beta.0

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 (123) hide show
  1. package/README.md +256 -0
  2. package/dist/affordance/auth.md +31 -0
  3. package/dist/affordance/bill.md +48 -0
  4. package/dist/affordance/biz-line-ai-strategy.md +35 -0
  5. package/dist/affordance/biz-line-metrics.md +47 -0
  6. package/dist/affordance/biz-line.md +84 -0
  7. package/dist/affordance/common-file.md +25 -0
  8. package/dist/affordance/dashboard.md +75 -0
  9. package/dist/affordance/dict.md +28 -0
  10. package/dist/affordance/document.md +67 -0
  11. package/dist/affordance/im.md +91 -0
  12. package/dist/affordance/invoice.md +49 -0
  13. package/dist/affordance/media.md +17 -0
  14. package/dist/affordance/organization.md +47 -0
  15. package/dist/affordance/project-analysis.md +95 -0
  16. package/dist/affordance/project.md +321 -0
  17. package/dist/affordance/quota.md +19 -0
  18. package/dist/affordance/reconciliation.md +41 -0
  19. package/dist/affordance/report.md +75 -0
  20. package/dist/affordance/sso-team-group.md +30 -0
  21. package/dist/affordance/task.md +148 -0
  22. package/dist/affordance/tenant.md +17 -0
  23. package/dist/affordance/user.md +41 -0
  24. package/dist/affordance/wallet.md +100 -0
  25. package/dist/api-doc/README.md +67 -0
  26. package/dist/api-doc/custom/template.md +0 -0
  27. package/dist/api-doc/oapi/auth.md +61 -0
  28. package/dist/api-doc/oapi/bill.md +130 -0
  29. package/dist/api-doc/oapi/biz-line-ai-strategy.md +119 -0
  30. package/dist/api-doc/oapi/biz-line-metrics.md +419 -0
  31. package/dist/api-doc/oapi/biz-line.md +382 -0
  32. package/dist/api-doc/oapi/common-file.md +50 -0
  33. package/dist/api-doc/oapi/dashboard.md +218 -0
  34. package/dist/api-doc/oapi/dict.md +49 -0
  35. package/dist/api-doc/oapi/document.md +182 -0
  36. package/dist/api-doc/oapi/im.md +278 -0
  37. package/dist/api-doc/oapi/invoice.md +129 -0
  38. package/dist/api-doc/oapi/media.md +17 -0
  39. package/dist/api-doc/oapi/organization.md +94 -0
  40. package/dist/api-doc/oapi/project-analysis.md +364 -0
  41. package/dist/api-doc/oapi/project.md +1114 -0
  42. package/dist/api-doc/oapi/quota.md +21 -0
  43. package/dist/api-doc/oapi/reconciliation.md +98 -0
  44. package/dist/api-doc/oapi/report.md +254 -0
  45. package/dist/api-doc/oapi/sso-team-group.md +54 -0
  46. package/dist/api-doc/oapi/task.md +528 -0
  47. package/dist/api-doc/oapi/tenant.md +17 -0
  48. package/dist/api-doc/oapi/user.md +98 -0
  49. package/dist/api-doc/oapi/wallet.md +275 -0
  50. package/dist/generated/api-catalog.json +39681 -0
  51. package/dist/generated/api-directory-index.json +1509 -0
  52. package/dist/index.js +2 -0
  53. package/dist/skill-manifest.json +336 -0
  54. package/dist/skills/biz-line-strategy-generate/SKILL.md +122 -0
  55. package/dist/skills/biz-line-strategy-generate/references/ai-strategy-prompt.md +50 -0
  56. package/dist/skills/biz-line-strategy-generate/references/api-flow.md +155 -0
  57. package/dist/skills/biz-line-strategy-generate/references/four-strategies.md +58 -0
  58. package/dist/skills/biz-line-strategy-generate/references/metrics.md +119 -0
  59. package/dist/skills/biz-line-strategy-generate/references/output-excel.md +63 -0
  60. package/dist/skills/biz-line-strategy-generate/references/runtime.md +127 -0
  61. package/dist/skills/biz-line-strategy-generate/references/spec.md +500 -0
  62. package/dist/skills/biz-line-strategy-generate/scripts/ai_strategy.py +89 -0
  63. package/dist/skills/biz-line-strategy-generate/scripts/build_report.py +629 -0
  64. package/dist/skills/biz-line-strategy-generate/scripts/cleanup.py +87 -0
  65. package/dist/skills/biz-line-strategy-generate/scripts/cli_json.py +705 -0
  66. package/dist/skills/biz-line-strategy-generate/scripts/fetch_data.py +449 -0
  67. package/dist/skills/biz-line-strategy-generate/scripts/run_workflow.py +402 -0
  68. package/dist/skills/biz-line-strategy-generate/scripts/strategy_output.py +556 -0
  69. package/dist/skills/biz-line-strategy-generate/scripts/submit_tags.py +32 -0
  70. package/dist/skills/biz-line-strategy-generate/scripts/summarize.py +932 -0
  71. package/dist/skills/biz-line-strategy-generate/scripts/test_ai_strategy.py +235 -0
  72. package/dist/skills/biz-line-strategy-generate/scripts/test_cli_json.py +97 -0
  73. package/dist/skills/biz-line-strategy-generate/scripts/test_database_output.py +904 -0
  74. package/dist/skills/biz-line-strategy-generate/scripts/test_pipeline.py +253 -0
  75. package/dist/skills/biz-line-strategy-generate/scripts/test_strategy_rules.py +224 -0
  76. package/dist/skills/biz-line-strategy-generate/scripts/test_tag_submission.py +238 -0
  77. package/dist/skills/biz-line-strategy-generate/scripts/test_workflow.py +1233 -0
  78. package/dist/skills/sino-bc-cli/SKILL.md +62 -0
  79. package/dist/skills/sino-bc-cli/domains/sino-bc-auth/SKILL.md +36 -0
  80. package/dist/skills/sino-bc-cli/domains/sino-bc-bill/SKILL.md +48 -0
  81. package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line/SKILL.md +58 -0
  82. package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line/references/common-mistakes.md +7 -0
  83. package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line/references/enums.md +13 -0
  84. package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line-ai-strategy/SKILL.md +46 -0
  85. package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line-metrics/SKILL.md +46 -0
  86. package/dist/skills/sino-bc-cli/domains/sino-bc-common-file/SKILL.md +44 -0
  87. package/dist/skills/sino-bc-cli/domains/sino-bc-dashboard/SKILL.md +50 -0
  88. package/dist/skills/sino-bc-cli/domains/sino-bc-dict/SKILL.md +48 -0
  89. package/dist/skills/sino-bc-cli/domains/sino-bc-document/SKILL.md +53 -0
  90. package/dist/skills/sino-bc-cli/domains/sino-bc-im/SKILL.md +62 -0
  91. package/dist/skills/sino-bc-cli/domains/sino-bc-invoice/SKILL.md +48 -0
  92. package/dist/skills/sino-bc-cli/domains/sino-bc-media/SKILL.md +40 -0
  93. package/dist/skills/sino-bc-cli/domains/sino-bc-organization/SKILL.md +46 -0
  94. package/dist/skills/sino-bc-cli/domains/sino-bc-project/SKILL.md +112 -0
  95. package/dist/skills/sino-bc-cli/domains/sino-bc-project-analysis/SKILL.md +61 -0
  96. package/dist/skills/sino-bc-cli/domains/sino-bc-project-analysis/references/analysis-workflow.md +21 -0
  97. package/dist/skills/sino-bc-cli/domains/sino-bc-project-analysis/references/filters-and-metrics.md +30 -0
  98. package/dist/skills/sino-bc-cli/domains/sino-bc-quota/SKILL.md +44 -0
  99. package/dist/skills/sino-bc-cli/domains/sino-bc-reconciliation/SKILL.md +47 -0
  100. package/dist/skills/sino-bc-cli/domains/sino-bc-report/SKILL.md +53 -0
  101. package/dist/skills/sino-bc-cli/domains/sino-bc-sso-team-group/SKILL.md +45 -0
  102. package/dist/skills/sino-bc-cli/domains/sino-bc-task/SKILL.md +73 -0
  103. package/dist/skills/sino-bc-cli/domains/sino-bc-task/references/state-machine.md +18 -0
  104. package/dist/skills/sino-bc-cli/domains/sino-bc-tenant/SKILL.md +34 -0
  105. package/dist/skills/sino-bc-cli/domains/sino-bc-user/SKILL.md +57 -0
  106. package/dist/skills/sino-bc-cli/domains/sino-bc-wallet/SKILL.md +55 -0
  107. package/dist/skills/sino-bc-cli/references/domain-index/module-8534789.md +53 -0
  108. package/dist/skills/sino-bc-cli/references/domain-index/module-8551546.md +70 -0
  109. package/dist/skills/sino-bc-cli/references/domain-index/module-8562638.md +13 -0
  110. package/dist/skills/sino-bc-cli/references/domain-index/module-8566367.md +13 -0
  111. package/dist/skills/sino-bc-cli/references/domain-index/module-8581524.md +17 -0
  112. package/dist/skills/sino-bc-cli/references/domain-index/module-8601540.md +13 -0
  113. package/dist/skills/sino-bc-cli/references/domain-index/module-8614891.md +33 -0
  114. package/dist/skills/sino-bc-cli/references/domain-index/module-8621792.md +17 -0
  115. package/dist/skills/sino-bc-shared/SKILL.md +31 -0
  116. package/dist/skills/sino-bc-shared/references/auth-recovery.md +36 -0
  117. package/dist/skills/sino-bc-shared/references/config-and-env.md +60 -0
  118. package/dist/skills/sino-bc-shared/references/output-contract.md +86 -0
  119. package/dist/skills/sino-bc-shared/references/skill-lifecycle.md +17 -0
  120. package/dist/skills/sino-bc-shared/references/tenant-context.md +21 -0
  121. package/dist/skills/sino-bc-shared/references/write-approval.md +16 -0
  122. package/package.json +70 -0
  123. package/scripts/postinstall.cjs +36 -0
@@ -0,0 +1,932 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """按固定规则汇总分页临时文件,生成可直接校验及导出的 payload。
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import json
9
+ import sys
10
+ from collections import defaultdict
11
+ from pathlib import Path
12
+
13
+ SCRIPTS = Path(__file__).resolve().parent
14
+ if str(SCRIPTS) not in sys.path:
15
+ sys.path.insert(0, str(SCRIPTS))
16
+
17
+ from cli_json import BASE_METRICS, REPORT_METRICS, amount_change, evaluate_learning, metric_fields, metric_formula, learning_text
18
+ from cli_json import (
19
+ bid_adjust_stats,
20
+ budget_abs_share,
21
+ cpm,
22
+ disp,
23
+ examples,
24
+ fmt_cpm,
25
+ metric_value,
26
+ optional_num,
27
+ core_rule,
28
+ rule_hit,
29
+ is_first_period,
30
+ is_ghost_enum,
31
+ load_json_file,
32
+ naming_patterns,
33
+ num,
34
+ short_label,
35
+ summarize_countries,
36
+ unwrap_page,
37
+ )
38
+
39
+ CH_LABEL = {"fb": "Meta", "gg": "Google", "tt": "TikTok"}
40
+ # 第 1 层明细仍用消耗门槛;结论不再用 CPM≤5 当 KPI
41
+ DETAIL_LIMIT = 8
42
+
43
+
44
+ def iter_stem_rows(bl: Path, stem: str):
45
+ """按页 yield 行,不把全表拼成 list。可重复调用,每次重新读盘。"""
46
+ meta_path = bl / f"{stem}.pages.json"
47
+ if meta_path.exists():
48
+ meta = load_json_file(meta_path)
49
+ if not isinstance(meta, dict) or not isinstance(meta.get("counts"), list):
50
+ raise ValueError(f"拉数文件不完整:{stem}")
51
+ accumulated = 0
52
+ for index, expected in enumerate(meta["counts"], start=1):
53
+ env = load_json_file(bl / f"{stem}.p{index}.out.json")
54
+ page_rows, _ = unwrap_page(env or {})
55
+ if len(page_rows) != expected:
56
+ raise ValueError(f"拉数文件不完整:{stem}")
57
+ yield from page_rows
58
+ accumulated += len(page_rows)
59
+ if meta.get("total") != accumulated:
60
+ raise ValueError(f"拉数文件不完整:{stem}")
61
+ return
62
+ extras = [p for p in bl.glob(f"{stem}.p*.out.json") if p.name != f"{stem}.p1.out.json"]
63
+ if extras:
64
+ raise ValueError(f"拉数文件不完整:{stem}")
65
+ env = load_json_file(bl / f"{stem}.p1.out.json")
66
+ yield from unwrap_page(env or {})[0]
67
+
68
+
69
+ def load_merged(bl: Path, stem: str) -> list:
70
+ """兼容测试与诊断:需要 list 时才物化。分析路径应走 iter_stem_rows。"""
71
+ return list(iter_stem_rows(bl, stem))
72
+
73
+
74
+ def object_name_maps(rows, id_key, name_key) -> tuple[dict[str, str], dict[str, str]]:
75
+ """id → 明细展示名(空则回退 id),以及原始 display(空名不当作 id,避免分布在拆 ID)。"""
76
+ names: dict[str, str] = {}
77
+ raw: dict[str, str] = {}
78
+ for row in rows:
79
+ if row.get(id_key):
80
+ ident = str(row[id_key])
81
+ shown = disp(row, name_key)
82
+ raw[ident] = shown
83
+ names[ident] = shown or ident
84
+ return names, raw
85
+
86
+
87
+ def labels_of(rows, key) -> set[str]:
88
+ return {disp(r, key) for r in rows if disp(r, key)}
89
+
90
+
91
+ def share_map(rows, dim_key):
92
+ """一次扫描累加消耗占比,可接迭代器。"""
93
+ total = 0.0
94
+ acc = defaultdict(float)
95
+ for row in rows:
96
+ spend = num(row, "spend_usd")
97
+ total += spend
98
+ acc[disp(row, dim_key) or "未标注"] += spend
99
+ if total <= 0:
100
+ return {}, 0.0
101
+ return {k: v / total * 100 for k, v in acc.items()}, total
102
+
103
+
104
+ def pct_pair(prev: float, curr: float) -> str:
105
+ return f"{prev:.1f}% → {curr:.1f}%"
106
+
107
+
108
+ def fmt_share(curr: dict, prev: dict) -> str:
109
+ keys = [k for k in sorted(set(curr) | set(prev), key=lambda x: -curr.get(x, 0)) if k]
110
+ return ";".join(f"{k} {pct_pair(prev.get(k, 0), curr.get(k, 0))}" for k in keys[:8])
111
+
112
+
113
+ def advice_of(verdict: str) -> str:
114
+ if verdict.startswith("待观察"):
115
+ return "建议:再观察一周"
116
+ if verdict == "有效":
117
+ return "建议:继续执行"
118
+ return "建议:复核策略"
119
+
120
+
121
+ def share_moved(curr: dict, prev: dict) -> bool:
122
+ for lab in set(curr) | set(prev):
123
+ if abs(curr.get(lab, 0) - prev.get(lab, 0)) > 2:
124
+ return True
125
+ return False
126
+
127
+
128
+ def bid_changes(rows, fields):
129
+ """逐个出价字段比较有效日快照,缺失值不补零;只保留日期与出价字段,可接迭代器。"""
130
+ by_ag = defaultdict(list)
131
+ names: dict[str, tuple[str, str]] = {}
132
+ strategies: dict[str, str] = {}
133
+ for row in rows:
134
+ ag = row.get("adgroup_id")
135
+ if not ag:
136
+ continue
137
+ ident = str(ag)
138
+ date_s = str(row.get("calendar_date") or "")
139
+ if ident not in names or date_s >= names[ident][0]:
140
+ names[ident] = (date_s, disp(row, "adgroup_name") or ident)
141
+ strategies[ident] = disp(row, "bid_strategy")
142
+ snap = {"calendar_date": str(row.get("calendar_date") or "")}
143
+ for field in fields:
144
+ snap[field] = optional_num(row, field)
145
+ by_ag[ident].append(snap)
146
+ result = []
147
+ for ag, records in by_ag.items():
148
+ records = sorted(records, key=lambda r: r["calendar_date"])
149
+ changes = []
150
+ changed_dates = set()
151
+ for field in fields:
152
+ valid = [(r["calendar_date"], r[field]) for r in records if r[field] is not None]
153
+ times = sum(a[1] != b[1] for a, b in zip(valid, valid[1:]))
154
+ changed_dates.update(b[0] for a, b in zip(valid, valid[1:]) if a[1] != b[1])
155
+ if times:
156
+ changes.append({"field": field, "from": valid[0][1], "to": valid[-1][1], "times": times})
157
+ if changes:
158
+ # 多字段变动以首个首末不同的字段展示方向,明细完整保留各字段。
159
+ primary = next((c for c in changes if c["from"] != c["to"]), changes[0])
160
+ result.append({"id": ag, "name": names[ag][1],
161
+ **primary, "times": len(changed_dates), "fields": changes, "strategy": strategies[ag]})
162
+ return sorted(result, key=lambda r: (-r["times"], r["id"]))
163
+
164
+
165
+ def new_items(curr_rows, prev_rows, key, id_key=None):
166
+ """按稳定 ID/维度值聚合基础指标,再识别新增的对象。只为新增对象保留行。"""
167
+ def identity(row):
168
+ return str(row.get(id_key) or "") if id_key else disp(row, key)
169
+ previous: dict[str, str] = {}
170
+ for row in prev_rows:
171
+ ident = identity(row)
172
+ if ident:
173
+ previous[ident] = disp(row, key) or ident
174
+ grouped = defaultdict(list)
175
+ current_ids: set[str] = set()
176
+ for row in curr_rows:
177
+ ident = identity(row)
178
+ if not ident:
179
+ continue
180
+ current_ids.add(ident)
181
+ if ident not in previous:
182
+ grouped[ident].append(row)
183
+ items = []
184
+ for ident, records in grouped.items():
185
+ items.append({"id": ident if id_key else "", "label": disp(records[0], key) or ident,
186
+ "spend": sum(num(r, "spend_usd") for r in records), "rows": records,
187
+ "cpi": metric_value(records), "cpm": metric_value(records, "CPM")})
188
+ stops = [label for ident, label in previous.items() if ident not in current_ids]
189
+ return items, stops
190
+
191
+
192
+ def learning_sample_sufficient(spend):
193
+ """预算与新增枚举的 learning 样本仅要求本期消耗大于零。"""
194
+ return spend is not None and spend > 0
195
+
196
+
197
+ def new_scope_learning(rule, rows, *, previous_total=None, has_previous=False):
198
+ """本期新增枚举对比上期业务线总体指标;样本门槛只取本期枚举自身。"""
199
+ spend = metric_value(rows, 'spend_usd')
200
+ sufficient = learning_sample_sufficient(spend)
201
+ return evaluate_learning(rule, metric_value(rows, rule.get('metric')), previous_total, has_previous, sufficient)
202
+
203
+
204
+ def combine_scope_learning(rule, results):
205
+ """按新增维度合计消耗加权:有效占比严格超过70%;不跨维度去重。"""
206
+ if not rule.get('usable'):
207
+ return evaluate_learning(rule, None, None, False)
208
+ total = sum(spend for spend, _ in results if spend is not None)
209
+ if not results or any(spend is None or spend < 0 for spend, _ in results) or total <= 0:
210
+ return '待观察', learning_text('待观察')
211
+ # 十进制比较锁住70%边界,避免浮点累加让恰好70%被误判为超过。
212
+ from decimal import Decimal
213
+ total_d = sum((Decimal(str(spend)) for spend, _ in results), Decimal(0))
214
+ valid_d = sum((Decimal(str(spend)) for spend, result in results if result[0] == '有效'), Decimal(0))
215
+ if valid_d > total_d * Decimal('0.7'):
216
+ return '有效', learning_text('有效')
217
+ # 未达到有效占比且仍有不可判定枚举时,不把未知数据当成无效。
218
+ unsupported = next((result for _, result in results if '核心指标不支持' in result[1]), None)
219
+ if unsupported:
220
+ return unsupported
221
+ if any(result[0] == '待观察' for _, result in results):
222
+ return '待观察', learning_text('待观察')
223
+ return '无效', learning_text('无效')
224
+
225
+
226
+ def learning_comparison(rule, current, previous, label):
227
+ """只展示标准化指标及计算值,不回显用户核心指标原文。"""
228
+ metric = rule.get('metric')
229
+ names = {'cost_per_complete_registration': '注册成本', 'cost_per_leads': '线索成本',
230
+ 'cost_per_purchase': '购买成本', 'average_purchase_value': '客单价'}
231
+ report_names = {code: label for code, label, _is_percent in REPORT_METRICS}
232
+ name = names.get(metric, report_names.get(metric, BASE_METRICS.get(metric, (metric,))[0]))
233
+ formula = metric_formula(metric)
234
+ suffix = '%' if formula and formula[2] == 100 else ''
235
+ def value(number):
236
+ return '数据不足' if number is None else f'{number:.2f}{suffix}'
237
+ change = '不可计算' if current is None or previous in (None, 0) else f'{(current - previous) / previous * 100:+.2f}%'
238
+ return f'{label}{name} 本期 {value(current)},上期总体 {value(previous)},环比 {change}'
239
+
240
+
241
+ def describe_learning(text, rule, comparisons):
242
+ """结论仍由原规则产出,说明补充维度合计、标准化阈值和行动建议。"""
243
+ verdict = text.split('\n', 1)[0].removeprefix('结论:')
244
+ advice = {'有效': '建议继续执行', '无效': '建议关停', '待观察': '建议再观察一周'}[verdict]
245
+ details = []
246
+ if rule.get('usable'):
247
+ details.extend(comparisons)
248
+ if rule.get('target') is not None:
249
+ formula = metric_formula(rule['metric'])
250
+ suffix = '%' if formula and formula[2] == 100 else ''
251
+ target = f"KPI目标 {rule['op']} {rule['target']:g}{suffix}"
252
+ if details:
253
+ details[-1] += ';' + target
254
+ else:
255
+ details.append(target)
256
+ # 每个维度的指标明细独占一行,目标和建议接在最后一行。
257
+ return text + ('\n' + '\n'.join(details) if details else '') + ';' + advice
258
+
259
+
260
+ def dim_layer_text(title: str, items: list, stop_labs: list, rule=None):
261
+ """新增值自身先合计再算指标,不使用默认 CPM 目标或伪造达标占比。"""
262
+ rule = rule or core_rule("")
263
+ metric = rule["metric"]
264
+ if not items and not stop_labs:
265
+ return f"{title}\n本期无新增。", None, 0.0, 0.0
266
+ lines = [title]
267
+ # 零消耗项没有可判定成效;正消耗的新增项不设金额下限。
268
+ usable = [it for it in items if it["spend"] > 0]
269
+ for it in usable[:DETAIL_LIMIT]:
270
+ value = metric_value(it.get("rows", []), metric)
271
+ hit = rule_hit(value, rule)
272
+ verdict = "不可判定" if hit is None else ("达标" if hit else "未达标")
273
+ lines.append(f"· 新增「{short_label(it['label'])}」{metric or '核心指标'} {fmt_cpm(value)},KPI {verdict}(${it['spend']:.0f})")
274
+ if len(usable) > DETAIL_LIMIT:
275
+ lines.append(f"· 另有 {len(usable) - DETAIL_LIMIT} 个新增未展开,见明细")
276
+ if len(items) > len(usable):
277
+ lines.append(f"· 另有 {len(items) - len(usable)} 个零消耗新增未纳入成效汇总")
278
+ if stop_labs:
279
+ lines.append(f"· 暂停 {len(stop_labs)} 个:{examples(stop_labs)}")
280
+ records = [r for it in usable for r in it.get("rows", [])]
281
+ value = metric_value(records, metric)
282
+ hit = rule_hit(value, rule)
283
+ lines.append(f"· 合计(新增){metric or '核心指标'} {fmt_cpm(value)};仅观察同期表现,不推断动作因果")
284
+ 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)
285
+
286
+
287
+ def action_summary(action, names, *, distribution=False, total=None, raw_names=None):
288
+ """新增/暂停分别按五个边界展示完整名称;大批对象只展示名称分布。"""
289
+ names = list(names)
290
+ count = len(names)
291
+ text = f'{action} {count} 个'
292
+ if not count:
293
+ return ''
294
+ if (count if total is None else total) <= 5:
295
+ return text + ':' + '、'.join(names)
296
+ if distribution:
297
+ return text + ',分布在:' + naming_patterns(raw_names if raw_names is not None else names)
298
+ return text
299
+
300
+
301
+ def setting_line(title, curr_set, prev_set, first_period=False, *, show_names=True) -> str:
302
+ """新增和暂停独立判断数量,小集合在策略中列名称,明细仍保留全量。"""
303
+ if first_period:
304
+ return f'{title}\n当期 {len(curr_set)} 个'
305
+ # 关键词通常是长文本,主表始终只给数量,完整内容由明细承载。
306
+ parts = []
307
+ for action, values in (('新增', curr_set - prev_set), ('暂停', prev_set - curr_set)):
308
+ if values:
309
+ parts.append(action_summary(action, sorted(values)) if show_names else f'{action} {len(values)} 个')
310
+ return title + '\n' + (';'.join(parts) or '未出现变化')
311
+
312
+
313
+ def ad_keywords(names, n=3):
314
+ skip = {"ios", "and", "android", "fb", "tt", "gg", "bid", "cap", "all"}
315
+ seen = []
316
+ for name in names:
317
+ for tok in name.replace("_", "-").split("-"):
318
+ tok = tok.strip()
319
+ if tok and not tok.isdigit() and len(tok) >= 2 and tok.lower() not in skip and tok not in seen:
320
+ seen.append(tok)
321
+ if len(seen) >= n:
322
+ return seen
323
+ return seen
324
+
325
+
326
+ def detail_metric_text(rows, code, is_percent):
327
+ """先合计基础字段再求衍生指标;缺字段或零分母都显示缺值。"""
328
+ value = metric_value(rows, code)
329
+ if value is None:
330
+ return '-'
331
+ if is_percent:
332
+ return f'{value:,.2f}%'
333
+ if code in ('spend_usd', 'purchase_value') or metric_formula(code):
334
+ return f'{value:,.2f}'
335
+ return f'{value:,.0f}' if value.is_integer() else f'{value:,.2f}'
336
+
337
+
338
+ def detail_metric_cells(current_rows, previous_rows, current_conflicts=(), previous_conflicts=()):
339
+ """本期数值在前;存在可计算的上期值时把上期数值放入同一单元格。"""
340
+ cells = {}
341
+ current_conflicts, previous_conflicts = set(current_conflicts), set(previous_conflicts)
342
+ for code, _label, is_percent in REPORT_METRICS:
343
+ required = metric_fields(code)
344
+ current = '-' if required & current_conflicts else detail_metric_text(current_rows, code, is_percent)
345
+ previous = '-' if required & previous_conflicts else detail_metric_text(previous_rows, code, is_percent)
346
+ cells[code] = current + (f'(上期:{previous})' if previous != '-' else '')
347
+ return cells
348
+
349
+
350
+ def detail_conflicts(bl: Path, stem: str):
351
+ """接口明确拒绝的基础字段在明细中视为缺值,避免把占位零误报成真实成效。"""
352
+ meta = load_json_file(bl / f'{stem}.pages.json') or {}
353
+ return set(meta.get('conflictField') or [])
354
+
355
+
356
+ def add_detail_metric_row(total, row):
357
+ """分页行逐条累加 12 个基础字段;任一缺值保持缺失而非补零。"""
358
+ if not total:
359
+ total.update({field: optional_num(row, field) for field in BASE_METRICS})
360
+ return
361
+ for field in BASE_METRICS:
362
+ value = optional_num(row, field)
363
+ total[field] = total[field] + value if total[field] is not None and value is not None else None
364
+
365
+
366
+ def build_dimension_details(bl: Path, active_channels, bids):
367
+ """明细只按真实维度值出行;同一渠道、维度、ID 的按日成效先聚合。"""
368
+ dimensions = [
369
+ ('广告目标', 'objective', ('fb', 'tt'), None),
370
+ ('广告系列类型', 'advertising_channel_type', ('gg',), None),
371
+ ('成效目标', 'optimization_goal', tuple(CH_LABEL), None),
372
+ ('出价方式', 'bid_strategy', tuple(CH_LABEL), None),
373
+ ('国家', 'country_code', tuple(CH_LABEL), None),
374
+ ('兴趣词包', 'targeting_interests', ('fb', 'tt'), None),
375
+ ('受众类型', 'audience_type', tuple(CH_LABEL), None),
376
+ ('关键词', 'keywords', ('gg',), None),
377
+ ('年龄', 'targeting_age', tuple(CH_LABEL), None),
378
+ ('性别', 'targeting_genders', tuple(CH_LABEL), None),
379
+ ('广告组', 'adgroup_id+adgroup_name', tuple(CH_LABEL), 'adgroup_id'),
380
+ ('广告', 'ad_id+ad_name', tuple(CH_LABEL), 'ad_id'),
381
+ ('创意类型', 'ad_format', tuple(CH_LABEL), None),
382
+ ]
383
+ details = []
384
+ # 预算按渠道列成效;渠道编码就是此枚举的稳定 ID。
385
+ for ch in active_channels:
386
+ totals = {'curr': {}, 'prev': {}}
387
+ conflicts = {}
388
+ for period in totals:
389
+ stem = f'met_{ch}_{period}_account_id'
390
+ conflicts[period] = detail_conflicts(bl, stem)
391
+ for row in iter_stem_rows(bl, stem):
392
+ add_detail_metric_row(totals[period], row)
393
+ details.append({'kind': '预算', 'action': '持续' if totals['prev'] else '新增',
394
+ 'channel': CH_LABEL[ch], 'id': ch, 'name': CH_LABEL[ch],
395
+ **detail_metric_cells([totals['curr']] if totals['curr'] else [],
396
+ [totals['prev']] if totals['prev'] else [],
397
+ conflicts['curr'], conflicts['prev']), 'extra': ''})
398
+
399
+ for kind, stem, channels, id_key in dimensions:
400
+ key = stem.split('+')[-1] if id_key else stem
401
+ for ch in (channel for channel in channels if channel in active_channels):
402
+ names = {'curr': {}, 'prev': {}}
403
+ values = {'curr': defaultdict(dict), 'prev': defaultdict(dict)}
404
+ conflicts = {}
405
+ for period in ('curr', 'prev'):
406
+ # Meta 兴趣设置接口有三个字段,成效接口只按 targeting_interests 查询。
407
+ snapshot = ('targeting_interests+targeting_flexible_interests+targeting_exclusion_interests'
408
+ if ch == 'fb' and kind == '兴趣词包' else stem)
409
+ fields = snapshot.split('+') if kind == '兴趣词包' and ch == 'fb' else [key]
410
+ for row in iter_stem_rows(bl, f'dim_{ch}_{period}_{snapshot}'):
411
+ for field in fields:
412
+ raw = row.get(id_key or field)
413
+ ident = '' if raw is None else str(raw).strip()
414
+ if ident:
415
+ names[period][ident] = disp(row, 'adgroup_name' if kind == '广告组' else
416
+ 'ad_name' if kind == '广告' else field) or ident
417
+ metric_stem = f'met_{ch}_{period}_{stem}'
418
+ conflicts[period] = detail_conflicts(bl, metric_stem)
419
+ for row in iter_stem_rows(bl, metric_stem):
420
+ raw = row.get(id_key or key)
421
+ ident = '' if raw is None else str(raw).strip()
422
+ if ident:
423
+ add_detail_metric_row(values[period][ident], row)
424
+ names[period].setdefault(ident, disp(row, key) or ident)
425
+ for ident in sorted(set(names['curr']) | set(names['prev'])):
426
+ action = ('持续' if ident in names['curr'] and ident in names['prev'] else
427
+ '新增' if ident in names['curr'] else '暂停')
428
+ details.append({'kind': kind, 'action': action, 'channel': CH_LABEL[ch],
429
+ 'id': ident, 'name': names['curr'].get(ident) or names['prev'][ident],
430
+ **detail_metric_cells([values['curr'][ident]] if values['curr'][ident] else [],
431
+ [values['prev'][ident]] if values['prev'][ident] else [],
432
+ conflicts['curr'], conflicts['prev']),
433
+ 'extra': ''})
434
+
435
+ # 出价沿用既有上调/下调/持平动作;金额取广告组同 ID 的成效,补充只列首末出价。
436
+ for ch in active_channels:
437
+ ids = {str(item['id']) for item in bids[ch]}
438
+ values = {'curr': defaultdict(dict), 'prev': defaultdict(dict)}
439
+ conflicts = {}
440
+ for period in ('curr', 'prev'):
441
+ metric_stem = f'met_{ch}_{period}_adgroup_id+adgroup_name'
442
+ conflicts[period] = detail_conflicts(bl, metric_stem)
443
+ for row in iter_stem_rows(bl, metric_stem):
444
+ raw = row.get('adgroup_id')
445
+ ident = '' if raw is None else str(raw)
446
+ if ident in ids:
447
+ add_detail_metric_row(values[period][ident], row)
448
+ for item in bids[ch]:
449
+ ident = str(item['id'])
450
+ start, end = float(item.get('from') or 0), float(item.get('to') or 0)
451
+ action = item.get('action') or ('上调' if end > start else '下调' if end < start else '期内变化但首末持平')
452
+ changes = ';'.join(f"{field['field']} {field['from']:g} → {field['to']:g}"
453
+ for field in item.get('fields', []))
454
+ details.append({'kind': '出价', 'action': action, 'channel': CH_LABEL[ch],
455
+ 'id': ident, 'name': item.get('name') or ident,
456
+ **detail_metric_cells([values['curr'][ident]] if values['curr'][ident] else [],
457
+ [values['prev'][ident]] if values['prev'][ident] else [],
458
+ conflicts['curr'], conflicts['prev']),
459
+ 'extra': changes})
460
+ return details
461
+
462
+
463
+ def summarize_request(req: Path):
464
+ """仅使用本次通过分页校验的文件,固定生成摘要和完整明细。"""
465
+ from strategy_output import effect_code
466
+ biz = load_json_file(req / "biz-line.json") or {}
467
+ detail = load_json_file(req / "biz-line-detail.out.json") or {}
468
+ detail_data = detail.get("data") if isinstance(detail.get("data"), dict) else {}
469
+ projects = load_json_file(req / "projects.json") or {}
470
+ # 渠道范围以本次解析后的账号为准(包含用户覆盖),不能按消耗是否为零判断。
471
+ active_channels = [ch for ch in CH_LABEL if projects.get("accounts", {}).get(ch)]
472
+ if not active_channels:
473
+ raise ValueError("缺少有效广告账号渠道,请通过统一入口重新拉取数据")
474
+ biz_id = biz.get("id") or detail_data.get("id")
475
+ bl = req / f"bl-{biz_id}"
476
+ manifest = load_json_file(req / 'fetch-manifest.json')
477
+ if manifest is not None:
478
+ for stem, count in manifest.items():
479
+ meta = load_json_file(bl / f"{stem}.pages.json")
480
+ if (
481
+ not isinstance(meta, dict)
482
+ or meta.get("total") != count
483
+ or not isinstance(meta.get("counts"), list)
484
+ or sum(meta["counts"]) != count
485
+ ):
486
+ raise ValueError(f"拉数文件不完整:{stem}")
487
+ group = detail_data.get("ssoTeamCodeName") or biz.get("ssoTeamCodeName") or ""
488
+ name = detail_data.get("name") or biz.get("name") or ""
489
+ project = "\n".join(projects.get("projectNames") or detail_data.get("projectNames") or [])
490
+ periods = load_json_file(req / "periods.json") or {}
491
+ curr = periods.get("curr") or []
492
+ prev = periods.get("prev") or []
493
+ if len(curr) == 2 and len(prev) == 2:
494
+ period = f"本期:{curr[0]}~{curr[1]}\n上期:{prev[0]}~{prev[1]}"
495
+ else:
496
+ # 缺少请求周期时不能以业务线周期冒充本次分析范围。
497
+ raise ValueError('缺少有效 periods.json,请通过统一入口重新拉取数据')
498
+
499
+ spend = {"curr": {}, "prev": {}}
500
+ imps = {"curr": {}, "prev": {}}
501
+ inst = {"curr": {}, "prev": {}}
502
+ days = {"curr": {}, "prev": {}}
503
+ coverage_dates = {"curr": {}, "prev": {}}
504
+ for ch in active_channels:
505
+ for per in ("curr", "prev"):
506
+ day_spend = day_imps = day_inst = 0.0
507
+ dates_all: set[str] = set()
508
+ dates_spend: set[str] = set()
509
+ for row in iter_stem_rows(bl, f"met_{ch}_{per}_calendar_date"):
510
+ day_spend += num(row, "spend_usd")
511
+ day_imps += num(row, "impressions")
512
+ day_inst += num(row, "installs")
513
+ raw = row.get("calendar_date")
514
+ if raw:
515
+ dates_all.add(str(raw)[:10])
516
+ if num(row, "spend_usd") > 0:
517
+ dates_spend.add(disp(row, "calendar_date") or str(raw or ""))
518
+ acc_spend = acc_imps = acc_inst = 0.0
519
+ has_acc = False
520
+ for row in iter_stem_rows(bl, f"met_{ch}_{per}_account_id"):
521
+ has_acc = True
522
+ acc_spend += num(row, "spend_usd")
523
+ acc_imps += num(row, "impressions")
524
+ acc_inst += num(row, "installs")
525
+ spend[per][ch] = acc_spend if has_acc else day_spend
526
+ imps[per][ch] = acc_imps if has_acc else day_imps
527
+ inst[per][ch] = acc_inst if has_acc else day_inst
528
+ days[per][ch] = len(dates_spend)
529
+ coverage_dates[per][ch] = dates_all
530
+
531
+ curr_total = sum(spend["curr"].values())
532
+ prev_total = sum(spend["prev"].values())
533
+ curr_inst = sum(inst["curr"].values())
534
+ prev_inst = sum(inst["prev"].values())
535
+ curr_share = {ch: (spend["curr"][ch] / curr_total * 100 if curr_total else 0) for ch in spend["curr"]}
536
+ prev_share = {ch: (spend["prev"][ch] / prev_total * 100 if prev_total else 0) for ch in spend["prev"]}
537
+ overall_c = cpm(curr_total, sum(imps["curr"].values()))
538
+ overall_p = cpm(prev_total, sum(imps["prev"].values()))
539
+ cpi_c = (curr_total / curr_inst) if curr_inst else None
540
+ cpi_p = (prev_total / prev_inst) if prev_inst else None
541
+ eff_days = max(days["curr"].values() or [0])
542
+ first_period = not (len(prev) == 2 and all(prev))
543
+ core_index = str(biz.get("coreIndex") or detail_data.get("coreIndex") or "").strip()
544
+ settings = load_json_file(req / 'analysis-settings.json') or {}
545
+ if settings.get('bizLineId') == biz_id and 'coreIndex' in settings:
546
+ core_index = str(settings['coreIndex']).strip()
547
+ rule = core_rule(core_index)
548
+ metric = rule["metric"]
549
+ # 任一请求渠道明确不支持所需字段时,不拿其余渠道局部数据代表整条业务线。
550
+ for ch in active_channels:
551
+ for per in ('curr', 'prev'):
552
+ meta = load_json_file(bl / f'met_{ch}_{per}_account_id.pages.json') or {}
553
+ if metric_fields(metric) & set(meta.get('conflictField', [])):
554
+ rule = {**rule, 'usable': False, 'reason': '核心指标不支持'}
555
+ def iter_account_rows(per: str):
556
+ for ch in active_channels:
557
+ yield from iter_stem_rows(bl, f"met_{ch}_{per}_account_id")
558
+
559
+ core_c, core_p = (metric_value(iter_account_rows(per), metric) for per in ("curr", "prev"))
560
+ cpi_c, cpi_p = (metric_value(iter_account_rows(per), "CPI") for per in ("curr", "prev"))
561
+ # 是否存在上期记录由实际回包决定,不以周期参数或消耗是否为零代替。
562
+ has_previous = any(True for _ in iter_account_rows('prev'))
563
+ # 核心指标可计算性单独判断;样本门槛只检查本期总体正消耗。
564
+ sample_sufficient = learning_sample_sufficient(curr_total)
565
+ spend_verdict, budget_learning = evaluate_learning(rule, core_c, core_p, has_previous, sample_sufficient)
566
+ budget_learning = describe_learning(budget_learning, rule, [learning_comparison(rule, core_c, core_p, '')])
567
+ coverage_notes = []
568
+ from datetime import date, timedelta
569
+ for per, bounds in (("curr", periods.get("curr")), ("prev", periods.get("prev"))):
570
+ if not bounds or not all(bounds):
571
+ continue
572
+ start, end = (date.fromisoformat(d) for d in bounds)
573
+ expected = {(start + timedelta(days=i)).isoformat() for i in range((end - start).days + 1)}
574
+ for ch, accounts in projects.get("accounts", {}).items():
575
+ if not accounts:
576
+ continue
577
+ dates = sorted(coverage_dates[per].get(ch) or set())
578
+ missing = expected - set(dates)
579
+ label = "本期" if per == "curr" else "上期"
580
+ 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 ""))
581
+ period += "\n" + "\n".join(coverage_notes)
582
+ if any("覆盖不完整" in n for n in coverage_notes):
583
+ period += "\n周期覆盖不完整,消耗减少不能直接解释为主动减预算"
584
+
585
+
586
+ bid_fields = {
587
+ "fb": ["bid_amount"],
588
+ "gg": ["cpc_bid_amount", "cpm_bid_amount", "cpv_bid_amount"],
589
+ "tt": ["bid_amount", "conversion_bid", "deep_cpabid"],
590
+ }
591
+ bid_stems = {
592
+ "fb": "dim_fb_curr_calendar_date+adgroup_id+adgroup_name+bid_amount",
593
+ "gg": "dim_gg_curr_calendar_date+adgroup_id+adgroup_name+cpc_bid_amount",
594
+ "tt": "dim_tt_curr_calendar_date+adgroup_id+adgroup_name+bid_amount",
595
+ }
596
+ bids = {ch: bid_changes(iter_stem_rows(bl, bid_stems[ch]), bid_fields[ch]) for ch in active_channels}
597
+ bid_stats = {ch: bid_adjust_stats(bids[ch]) for ch in bids}
598
+
599
+ def bid_text(ch):
600
+ items = bids[ch]
601
+ stats = bid_stats[ch]
602
+ label = CH_LABEL[ch]
603
+ if not items:
604
+ return f"{label}:本期未发生明显出价调整"
605
+ # 正文只保留调价汇总,广告组名称及逐组调价值继续保留在明细。
606
+ parts = [f'{action} {stats[field]} 个广告组' for action, field in
607
+ [('上调', 'raise_n'), ('下调', 'lower_n'), ('期内变化但首末持平', 'flat_n')] if stats[field]]
608
+ return label + ':' + ';'.join(parts) + f";幅度中位数 {stats['median_abs']:.2f}"
609
+
610
+ budget_strategy = "\n".join(
611
+ [
612
+ "【调整出价】",
613
+ f"合计 {sum(len(items) for items in bids.values())} 个广告组调整出价",
614
+ *[bid_text(ch) for ch in active_channels],
615
+ "",
616
+ "【预算】",
617
+ f"消耗合计:本期 ${curr_total:,.0f} / 上期 ${prev_total:,.0f};{amount_change(curr_total, prev_total)}",
618
+ *[budget_abs_share(
619
+ CH_LABEL[ch],
620
+ prev_abs=spend["prev"][ch], curr_abs=spend["curr"][ch],
621
+ prev_share=prev_share[ch], curr_share=curr_share[ch],
622
+ ) for ch in active_channels],
623
+ ]
624
+ )
625
+
626
+ share_notes = {}
627
+ def share_line(title, dim_key, stem, channels):
628
+ """占比同时展示对应消耗;主表汇总前三类,其余合并,明细保留全量。"""
629
+ segs, moved = [], False
630
+ for ch in (ch for ch in channels if ch in active_channels):
631
+ current, current_total = share_map(iter_stem_rows(bl, f'met_{ch}_curr_{stem}'), dim_key)
632
+ previous, previous_total = share_map(iter_stem_rows(bl, f'met_{ch}_prev_{stem}'), dim_key)
633
+ moved = moved or share_moved(current, previous)
634
+ keys = [key for key in sorted(set(current) | set(previous), key=lambda key: (-current.get(key, 0), key))
635
+ if not is_ghost_enum(current.get(key, 0), previous.get(key, 0))]
636
+ def aggregate_line(label, members):
637
+ cp, pp = sum(current.get(k, 0) for k in members), sum(previous.get(k, 0) for k in members)
638
+ ca, pa = cp * current_total / 100, pp * previous_total / 100
639
+ share = '' if round(pp, 1) == round(cp, 1) == 100 else f',占比 {pp:.1f}% → {cp:.1f}%'
640
+ return f'{label}:消耗 ${pa:,.0f} → ${ca:,.0f};{amount_change(ca, pa)}{share}'
641
+ parts = [aggregate_line(key, [key]) for key in keys[:3]]
642
+ if len(keys) > 3:
643
+ parts.append(aggregate_line('其余类别合计', keys[3:]))
644
+ if parts:
645
+ # 占比分布维度同样补充新增/暂停名称;零占比残留不算发生动作。
646
+ current_labels = {key for key in keys if current.get(key, 0) > 0}
647
+ previous_labels = {key for key in keys if previous.get(key, 0) > 0}
648
+ if not first_period:
649
+ changes = [action_summary('新增', sorted(current_labels - previous_labels)),
650
+ action_summary('暂停', sorted(previous_labels - current_labels))]
651
+ parts.insert(0, ';'.join(filter(None, changes)) or '未出现变化')
652
+ segs.append(CH_LABEL[ch] + ':\n' + '\n'.join(parts))
653
+ # 每类总结抓住实际占比变化最大的枚举,不凭空解释业务意图。
654
+ changed = sorted(keys, key=lambda k: -abs(current.get(k, 0) - previous.get(k, 0)))
655
+ if changed and abs(current.get(changed[0], 0) - previous.get(changed[0], 0)) > 0.2:
656
+ k = changed[0]
657
+ share_notes.setdefault(title, []).append(f'{CH_LABEL[ch]}{k}占比由{previous.get(k, 0):.1f}%变为{current.get(k, 0):.1f}%')
658
+ return ('\n'.join([title] + segs) if segs else ''), moved
659
+
660
+ p_obj, m1 = share_line("【广告目标】", "objective", "objective", ["fb", "tt"])
661
+ p_ch, m2 = share_line("【广告系列类型】", "advertising_channel_type", "advertising_channel_type", ["gg"])
662
+ p_opt, m3 = share_line("【成效目标】", "optimization_goal", "optimization_goal", ["fb", "gg", "tt"])
663
+ p_bid, m4 = share_line("【出价方式】", "bid_strategy", "bid_strategy", ["fb", "gg", "tt"])
664
+ product_strategy = "\n".join(x for x in [p_obj, p_ch, p_opt, p_bid] if x) or '本期无可识别的广告产品策略数据'
665
+ product_share_moved = any([m1, m2, m3, m4])
666
+
667
+ def dim_sets(stem, key, channels):
668
+ curr = set()
669
+ prev = set()
670
+ for ch in (ch for ch in channels if ch in active_channels):
671
+ # Meta 兴趣的三个配置字段来自同一组合请求,不能找不存在的单字段文件。
672
+ if ch == 'fb' and stem == 'targeting_interests':
673
+ combined = 'targeting_interests+targeting_flexible_interests+targeting_exclusion_interests'
674
+ fields = combined.split('+')
675
+ for target, per in ((curr, 'curr'), (prev, 'prev')):
676
+ for row in iter_stem_rows(bl, f'dim_{ch}_{per}_{combined}'):
677
+ for field in fields:
678
+ lab = disp(row, field)
679
+ if lab:
680
+ target.add(lab)
681
+ continue
682
+ curr |= labels_of(iter_stem_rows(bl, f"dim_{ch}_curr_{stem}"), key)
683
+ prev |= labels_of(iter_stem_rows(bl, f"dim_{ch}_prev_{stem}"), key)
684
+ return curr, prev
685
+
686
+ country_curr, country_prev = dim_sets("country_code", "country_code", ["fb", "gg", "tt"])
687
+ interest_curr, interest_prev = dim_sets("targeting_interests", "targeting_interests", ["fb", "tt"])
688
+ kw_curr, kw_prev = dim_sets("keywords", "keywords", ["gg"])
689
+
690
+ object_notes = {}
691
+ def object_strategy(kind, id_key, name_key):
692
+ bits = []
693
+ total_new = total_absent = 0
694
+ action_amounts = {'新增': [], '暂停': []}
695
+ groups = []
696
+ for ch in active_channels:
697
+ stem = f"{id_key}+{name_key}"
698
+
699
+ def index_objects(period: str) -> tuple[dict[str, str], dict[str, str]]:
700
+ return object_name_maps(iter_stem_rows(bl, f"dim_{ch}_{period}_{stem}"), id_key, name_key)
701
+
702
+ c, c_raw = index_objects("curr")
703
+ p, _p_raw = index_objects("prev")
704
+ new, absent = sorted(c.keys() - p.keys()), sorted(p.keys() - c.keys())
705
+ total_new += len(new)
706
+ total_absent += len(absent)
707
+ groups.append((ch, c, c_raw, p, _p_raw, new, absent))
708
+ # 先算该维度跨渠道总数,避免超过五个仍因拆渠道而展开全部对象。
709
+ for ch, c, c_raw, p, _p_raw, new, absent in groups:
710
+ if not c and not p:
711
+ continue
712
+ parts = []
713
+ for action, ids, mapping, raw, total, per in (
714
+ ('新增', new, c, c_raw, total_new, 'curr'), ('暂停', absent, p, _p_raw, total_absent, 'prev')):
715
+ if not ids:
716
+ continue
717
+ part = action_summary(action, [mapping[i] for i in ids], distribution=True, total=total, raw_names=[raw[i] for i in ids])
718
+ if kind == '广告组':
719
+ # 新增用本期、暂停用上期;分母为该渠道该期账户总消耗。
720
+ selected = set(ids)
721
+ records = (r for r in iter_stem_rows(bl, f'met_{ch}_{per}_{id_key}+{name_key}') if str(r.get(id_key)) in selected)
722
+ amount = metric_value(records, 'spend_usd')
723
+ action_amounts[action].append(amount)
724
+ denominator = spend[per][ch]
725
+ amount_s = '数据不足' if amount is None else f'${amount:,.2f}'
726
+ share_s = f'{amount / denominator * 100:.1f}%' if amount is not None and denominator > 0 else '不可计算'
727
+ part += f';{"本期" if per == "curr" else "上期"}合计消耗 {amount_s},占该渠道同期消耗 {share_s}'
728
+ parts.append(part)
729
+ bits.append(CH_LABEL[ch] + ':\n' + ('\n'.join(parts) or '未出现变化'))
730
+ totals = [f'{action} {count} 个{kind}' for action, count in [('新增', total_new), ('暂停', total_absent)] if count]
731
+ leaders = sorted(((len(new), CH_LABEL[ch]) for ch, _, _, _, _, new, _ in groups), reverse=True)
732
+ object_notes[kind] = ('、'.join(totals) or '未出现变化') + (f',新增主要集中在{leaders[0][1]}' if leaders and leaders[0][0] else '')
733
+ if kind == '广告组':
734
+ # 跨渠道合计同时展示,暂停仍使用上期总体分母,不混用本期预算。
735
+ totals = []
736
+ for action, count, denominator, period_label in [('新增', total_new, curr_total, '本期'), ('暂停', total_absent, prev_total, '上期')]:
737
+ if count:
738
+ amounts = action_amounts[action]
739
+ amount = sum(amounts) if amounts and all(v is not None for v in amounts) else None
740
+ amount_s = '数据不足' if amount is None else f'${amount:,.2f}'
741
+ share_s = f'{amount / denominator * 100:.1f}%' if amount is not None and denominator > 0 else '不可计算'
742
+ totals.append(f'{action} {count} 个广告组,{period_label}合计消耗 {amount_s},占业务线同期消耗 {share_s}')
743
+ return ['合计:' + ('\n'.join(totals) or '未出现变化')] + bits
744
+
745
+ audience_type_text, audience_type_moved = share_line("【受众类型】", "audience_type", "audience_type", ["fb", "gg", "tt"])
746
+ ag_bits = object_strategy("广告组", "adgroup_id", "adgroup_name")
747
+ audience_strategy = "\n".join(
748
+ x
749
+ for x in [
750
+ setting_line("【国家】", country_curr, country_prev, first_period=first_period),
751
+ setting_line("【兴趣词包】", interest_curr, interest_prev, first_period=first_period),
752
+ audience_type_text,
753
+ setting_line("【关键词】", kw_curr, kw_prev, first_period=first_period, show_names=False),
754
+ setting_line("【年龄】", *dim_sets("targeting_age", "targeting_age", ["fb", "gg", "tt"]), first_period=first_period),
755
+ setting_line("【性别】", *dim_sets("targeting_genders", "targeting_genders", ["fb", "gg", "tt"]), first_period=first_period),
756
+ "【广告组】\n" + "\n".join(ag_bits),
757
+ ]
758
+ if x
759
+ )
760
+
761
+ ad_bits = object_strategy("广告", "ad_id", "ad_name")
762
+ creative_fmt = share_line("【创意类型】", "ad_format", "ad_format", ["fb", "gg", "tt"])[0] or ""
763
+ creative_strategy = "【广告】\n" + "\n".join(ad_bits) + (("\n\n" + creative_fmt) if creative_fmt else "")
764
+ # 参照样例四种总结结构,分别讲预算、目标结构、定向调整、广告与创意结构。
765
+ gains = [ch for ch in active_channels if curr_share[ch] - prev_share[ch] > 0.2]
766
+ leader = max(gains, key=lambda ch: curr_share[ch] - prev_share[ch]) if gains else None
767
+ bid_leader = max(bids, key=lambda ch: len(bids[ch]))
768
+ budget_summary = f'总结:本期整体消耗{amount_change(curr_total, prev_total)},' + (f'预算占比向{CH_LABEL[leader]}集中' if leader else '预算分配未明显向单一渠道集中')
769
+ budget_summary += f';出价操作主要发生在{CH_LABEL[bid_leader]}。' if bids[bid_leader] else ';未观察到出价调整。'
770
+ budget_strategy = budget_summary + '\n\n' + budget_strategy
771
+ product_summary = '总结:' + ('广告目标和系列类型整体保持稳定' if not (m1 or m2) else '广告目标或系列类型出现调整')
772
+ product_summary += ';' + ';'.join(share_notes.get('【成效目标】', []) + share_notes.get('【出价方式】', [])) if (m3 or m4) else ';成效目标与出价结构整体保持稳定'
773
+ product_strategy = product_summary + '。\n\n' + product_strategy
774
+ changes, stable = [], []
775
+ for label, pair in [('国家定向', (country_curr, country_prev)), ('兴趣词包', (interest_curr, interest_prev)),
776
+ ('关键词', (kw_curr, kw_prev)), ('年龄定向', dim_sets('targeting_age', 'targeting_age', list(CH_LABEL))),
777
+ ('性别定向', dim_sets('targeting_genders', 'targeting_genders', list(CH_LABEL)))]:
778
+ (changes if pair[0] != pair[1] else stable).append(label)
779
+ (changes if audience_type_moved else stable).append('受众类型')
780
+ audience_summary = '总结:' + ('本期定向调整涉及' + '、'.join(changes) if changes else '本期定向设置整体保持稳定')
781
+ audience_summary += ';广告组' + object_notes['广告组'] + (';' + '、'.join(stable) + '未出现变化' if stable else '')
782
+ audience_strategy = audience_summary + '。\n\n' + audience_strategy
783
+ creative_summary = '总结:本期广告' + object_notes['广告'] + ';' + (';'.join(share_notes.get('【创意类型】', [])) or '创意类型结构整体保持稳定')
784
+ creative_strategy = creative_summary + '。\n\n' + creative_strategy
785
+
786
+ # 文案和效果在同一判断点产出,不扫描 learning 文本反推状态。
787
+ learning_effects = {}
788
+ # 新增枚举的环比基准是上期全部请求渠道的账户总体,不能忽略其字段冲突。
789
+ previous_rule = core_rule(core_index)
790
+ if has_previous:
791
+ for ch in active_channels:
792
+ meta = load_json_file(bl / f'met_{ch}_prev_account_id.pages.json') or {}
793
+ if metric_fields(metric) & set(meta.get('conflictField', [])):
794
+ previous_rule = {**previous_rule, 'usable': False, 'reason': '核心指标不支持'}
795
+
796
+ def strategy_learning(dim_specs, strategy_key=''):
797
+ results = []
798
+ comparisons = []
799
+ for title, stem, key, channels, id_key in dim_specs:
800
+ dimension_rows = []
801
+ dimension_supported = True
802
+ for ch in (ch for ch in channels if ch in active_channels):
803
+ scoped_rule = dict(previous_rule)
804
+ curr_stem = f"met_{ch}_curr_{stem}"
805
+ prev_stem = f"met_{ch}_prev_{stem}"
806
+ items, _stops = new_items(iter_stem_rows(bl, curr_stem), iter_stem_rows(bl, prev_stem), key, id_key)
807
+ # 上期维度仅识别集合差,指标基准来自上期账户总体。
808
+ for source in (curr_stem,):
809
+ meta = load_json_file(bl / f'{source}.pages.json') or {}
810
+ if metric_fields(metric) & set(meta.get('conflictField', [])):
811
+ scoped_rule = {**scoped_rule, 'usable': False, 'reason': '核心指标不支持'}
812
+ for item in items:
813
+ dimension_rows.extend(item['rows'])
814
+ dimension_supported = dimension_supported and scoped_rule.get('usable', False)
815
+ if dimension_rows:
816
+ # 同维度所有渠道与新增枚举先合计基础指标,再判定一次并按该维度消耗计权。
817
+ dimension_rule = dict(previous_rule)
818
+ if not dimension_supported:
819
+ dimension_rule = {**dimension_rule, 'usable': False, 'reason': '核心指标不支持'}
820
+ results.append((metric_value(dimension_rows, 'spend_usd'), new_scope_learning(
821
+ dimension_rule, dimension_rows, previous_total=core_p, has_previous=has_previous)))
822
+ comparisons.append(learning_comparison(core_rule(core_index),
823
+ metric_value(dimension_rows, metric) if dimension_supported else None,
824
+ core_p if previous_rule.get('usable') else None, f'{title}新增合计:'))
825
+ verdict, text = combine_scope_learning(core_rule(core_index), results)
826
+ learning_effects[strategy_key] = effect_code(verdict)
827
+ return describe_learning(text, core_rule(core_index), comparisons)
828
+
829
+
830
+ product_learning = strategy_learning(
831
+ [
832
+ ("广告目标", "objective", "objective", ["fb", "tt"], None),
833
+ ("广告系列类型", "advertising_channel_type", "advertising_channel_type", ["gg"], None),
834
+ ("成效目标", "optimization_goal", "optimization_goal", ["fb", "gg", "tt"], None),
835
+ ("出价方式", "bid_strategy", "bid_strategy", ["fb", "gg", "tt"], None),
836
+ ],
837
+ strategy_key='product',
838
+ )
839
+ audience_learning = strategy_learning(
840
+ [
841
+ ("国家", "country_code", "country_code", ["fb", "gg", "tt"], None),
842
+ ("兴趣词包", "targeting_interests", "targeting_interests", ["fb", "tt"], None),
843
+ ("受众类型", "audience_type", "audience_type", ["fb", "gg", "tt"], None),
844
+ ("关键词", "keywords", "keywords", ["gg"], None),
845
+ ("年龄", "targeting_age", "targeting_age", ["fb", "gg", "tt"], None),
846
+ ("性别", "targeting_genders", "targeting_genders", ["fb", "gg", "tt"], None),
847
+ ("广告组", "adgroup_id+adgroup_name", "adgroup_name", ["fb", "gg", "tt"], "adgroup_id"),
848
+ ], strategy_key='audience',
849
+ )
850
+ creative_learning = strategy_learning(
851
+ [
852
+ ("广告", "ad_id+ad_name", "ad_name", ["fb", "gg", "tt"], "ad_id"),
853
+ ("创意类型", "ad_format", "ad_format", ["fb", "gg", "tt"], None),
854
+ ], strategy_key='creative',
855
+ )
856
+
857
+ row = {
858
+ "group": group,
859
+ "bizLine": name,
860
+ "period": period,
861
+ "project": project,
862
+ # Excel 展示业务线全部账号;旧拉数文件兼容使用实际分析账号。
863
+ "adAccounts": "\n".join(
864
+ CH_LABEL[ch] + ":\n" + "\n".join(dict.fromkeys(str(account) for account in accounts))
865
+ for ch in CH_LABEL
866
+ if (accounts := projects.get("allAccounts", projects.get("accounts", {})).get(ch))
867
+ ),
868
+ "budgetStrategy": budget_strategy,
869
+ "budgetLearning": budget_learning,
870
+ "productStrategy": product_strategy,
871
+ "productLearning": product_learning,
872
+ "audienceStrategy": audience_strategy,
873
+ "audienceLearning": audience_learning,
874
+ "creativeStrategy": creative_strategy,
875
+ "creativeLearning": creative_learning,
876
+ "budgetEffect": effect_code(spend_verdict),
877
+ **{key + 'Effect': value for key, value in learning_effects.items()},
878
+ }
879
+ # 证据只来自实际快照或集合/占比变化;不把消耗下降推断为主动减预算。
880
+ evidence = {'budget': [], 'product': [], 'audience': []}
881
+ if any(bids.values()):
882
+ evidence['budget'].append('出价调整')
883
+ for field, action in [('raise_n', '上调出价'), ('lower_n', '下调出价')]:
884
+ if any(stats[field] for stats in bid_stats.values()):
885
+ evidence['budget'].append(action)
886
+ if not first_period:
887
+ for moved, label in zip((m1, m2, m3, m4), ('广告目标调整', '广告系列类型调整', '成效目标调整', '出价方式调整')):
888
+ if moved:
889
+ evidence['product'].append(label)
890
+ for current, previous, label in [(country_curr, country_prev, '国家调整'),
891
+ (interest_curr, interest_prev, '兴趣词调整'), (kw_curr, kw_prev, '关键词调整')]:
892
+ if current != previous:
893
+ evidence['audience'].append(label)
894
+ for stem, label in [('targeting_age', '年龄调整'), ('targeting_genders', '性别调整')]:
895
+ current, previous = dim_sets(stem, stem, ['fb', 'gg', 'tt'])
896
+ if current != previous:
897
+ evidence['audience'].append(label)
898
+ if audience_type_moved:
899
+ evidence['audience'].append('受众类型调整')
900
+ # 旧的覆盖、分布及逐枚举成效说明不再进入 Excel;明细统一按维度和指标重建。
901
+ details = build_dimension_details(bl, active_channels, bids)
902
+ payload = {"rows": [row], "details": details}
903
+ (req / "payload.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
904
+ summary = {
905
+ "bizLineId": biz_id,
906
+ "curr_total": curr_total,
907
+ "prev_total": prev_total,
908
+ "overall_cpi": [cpi_c, cpi_p],
909
+ "overall_cpm": [overall_c, overall_p],
910
+ "eff_days": eff_days,
911
+ "first_period": first_period,
912
+ "spend_verdict": spend_verdict,
913
+ "bid_counts": {ch: len(bids[ch]) for ch in bids},
914
+ "detail_count": len(details),
915
+ "tag_evidence": evidence,
916
+ "request_dir": str(req),
917
+ }
918
+ (req / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
919
+ return payload, summary
920
+
921
+
922
+ def main() -> int:
923
+ parser = argparse.ArgumentParser(description=__doc__)
924
+ parser.add_argument("--request-dir", required=True)
925
+ args = parser.parse_args()
926
+ _, summary = summarize_request(Path(args.request_dir))
927
+ print(json.dumps(summary, ensure_ascii=False), flush=True)
928
+ return 0
929
+
930
+
931
+ if __name__ == "__main__":
932
+ sys.exit(main())