sino-bc-cli 1.8.20 → 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.
Files changed (22) hide show
  1. package/dist/SKILL.md +1 -1
  2. package/dist/index.js +1 -1
  3. package/dist/skills/custom/biz-line-strategy-generate/SKILL.md +20 -23
  4. package/dist/skills/custom/biz-line-strategy-generate/references/api-flow.md +5 -5
  5. package/dist/skills/custom/biz-line-strategy-generate/references/four-strategies.md +16 -14
  6. package/dist/skills/custom/biz-line-strategy-generate/references/metrics.md +29 -278
  7. package/dist/skills/custom/biz-line-strategy-generate/references/output-excel.md +8 -57
  8. package/dist/skills/custom/biz-line-strategy-generate/references/runtime.md +22 -57
  9. package/dist/skills/custom/biz-line-strategy-generate/references/spec.md +124 -0
  10. package/dist/skills/custom/biz-line-strategy-generate/scripts/build_report.py +98 -9
  11. package/dist/skills/custom/biz-line-strategy-generate/scripts/cleanup.py +71 -82
  12. package/dist/skills/custom/biz-line-strategy-generate/scripts/cli_json.py +125 -19
  13. package/dist/skills/custom/biz-line-strategy-generate/scripts/fetch_data.py +61 -32
  14. package/dist/skills/custom/biz-line-strategy-generate/scripts/run_workflow.py +189 -0
  15. package/dist/skills/custom/biz-line-strategy-generate/scripts/summarize.py +158 -148
  16. package/dist/skills/custom/biz-line-strategy-generate/scripts/test_strategy_rules.py +16 -2
  17. package/dist/skills/custom/biz-line-strategy-generate/scripts/test_workflow.py +345 -0
  18. package/package.json +1 -1
  19. package/dist/skills/custom/biz-line-strategy-generate/scripts/__pycache__/build_report.cpython-314.pyc +0 -0
  20. package/dist/skills/custom/biz-line-strategy-generate/scripts/__pycache__/cli_json.cpython-314.pyc +0 -0
  21. package/dist/skills/custom/biz-line-strategy-generate/scripts/__pycache__/fetch_data.cpython-314.pyc +0 -0
  22. package/dist/skills/custom/biz-line-strategy-generate/scripts/__pycache__/summarize.cpython-314.pyc +0 -0
@@ -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
 
@@ -23,7 +21,10 @@ from cli_json import (
23
21
  disp,
24
22
  examples,
25
23
  fmt_cpm,
26
- hit_kpi,
24
+ metric_value,
25
+ optional_num,
26
+ core_rule,
27
+ rule_hit,
27
28
  is_first_period,
28
29
  is_ghost_enum,
29
30
  load_json_file,
@@ -38,7 +39,6 @@ from cli_json import (
38
39
 
39
40
  CH_LABEL = {"fb": "Meta", "gg": "Google", "tt": "TikTok"}
40
41
  # 第 1 层明细仍用消耗门槛;结论不再用 CPM≤5 当 KPI
41
- KPI_TARGET = 5.0
42
42
  DETAIL_LIMIT = 8
43
43
 
44
44
 
@@ -90,102 +90,74 @@ def share_moved(curr: dict, prev: dict) -> bool:
90
90
 
91
91
 
92
92
  def bid_changes(rows, fields):
93
+ """逐个出价字段比较有效日快照,缺失值不补零;保留字段与对象 ID。"""
93
94
  by_ag = defaultdict(list)
94
- for r in rows:
95
- ag = str(r.get("adgroup_id") or "")
96
- if ag:
97
- 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)
98
98
  result = []
99
- for ag, recs in by_ag.items():
100
- recs = sorted(recs, key=lambda x: str(x.get("calendar_date") or ""))
101
- times = 0
102
- prev_vals = first_vals = last_vals = None
103
- name = strategy = ""
104
- for r in recs:
105
- vals = tuple(num(r, f) for f in fields)
106
- name = disp(r, "adgroup_name") or name
107
- strategy = disp(r, "bid_strategy") or strategy
108
- if first_vals is None:
109
- first_vals = prev_vals = last_vals = vals
110
- continue
111
- if vals != prev_vals:
112
- times += 1
113
- prev_vals = last_vals = vals
114
- if times > 0 and first_vals and last_vals:
115
- from_v, to_v = first_vals[0], last_vals[0]
116
- for i, _f in enumerate(fields):
117
- if first_vals[i] != last_vals[i]:
118
- from_v, to_v = first_vals[i], last_vals[i]
119
- break
120
- result.append(
121
- {"id": ag, "name": name or ag, "times": times, "from": from_v, "to": to_v, "strategy": strategy}
122
- )
123
- result.sort(key=lambda x: (-x["times"], x["name"]))
124
- 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"]))
125
115
 
126
116
 
127
117
  def new_items(curr_rows, prev_rows, key, id_key=None):
128
- if id_key:
129
- curr_map = {str(r.get(id_key)): r for r in curr_rows if r.get(id_key)}
130
- prev_ids = {str(r.get(id_key)) for r in prev_rows if r.get(id_key)}
131
- news = [curr_map[i] for i in curr_map if i not in prev_ids]
132
- 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]
133
- else:
134
- curr_map = {disp(r, key): r for r in curr_rows if disp(r, key)}
135
- prev_set = labels_of(prev_rows, key)
136
- news = [curr_map[k] for k in curr_map if k not in prev_set]
137
- stops = sorted(prev_set - set(curr_map))
138
- items = [
139
- {
140
- "label": disp(r, key) or str(r.get(id_key) or ""),
141
- "spend": num(r, "spend_usd"),
142
- "imps": num(r, "impressions"),
143
- "cpm": cpm(num(r, "spend_usd"), num(r, "impressions")),
144
- }
145
- for r in news
146
- ]
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]
147
134
  return items, stops
148
135
 
149
136
 
150
- def dim_layer_text(title: str, items: list, stop_labs: list):
151
- # 每个维度一块:标题 + 逐条 · 明细,避免分号连成墙
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"]
152
141
  if not items and not stop_labs:
153
142
  return f"{title}\n本期无新增,跳过。", None, 0.0, 0.0
154
143
  lines = [title]
155
- usable = []
156
- low = 0
157
- shown = extra = 0
158
- for it in items:
159
- if it["spend"] < 50:
160
- low += 1
161
- continue
162
- hit = hit_kpi(it["cpm"], KPI_TARGET)
163
- if shown < DETAIL_LIMIT:
164
- lines.append(
165
- f"· 新增「{short_label(it['label'])}」CPM {fmt_cpm(it['cpm'])} "
166
- f"{'达标' if hit else '未达标'}(${it['spend']:.0f})"
167
- )
168
- shown += 1
169
- else:
170
- extra += 1
171
- if hit is not None:
172
- usable.append(it)
173
- if extra:
174
- lines.append(f"· 另有 {extra} 个过门槛新增未展开")
175
- if low:
176
- lines.append(f"· 另有 {low} 个新增消耗不足 $50,不纳入聚合")
177
- if stop_labs and not items:
178
- lines.append(f"· 关停 {len(stop_labs)} 个:{examples(stop_labs)}")
179
- elif stop_labs:
180
- lines.append(f"· 另关停 {len(stop_labs)} 个:{examples(stop_labs)}")
181
- if not usable:
182
- return "\n".join(lines), None, 0.0, 0.0
183
- spend_sum = sum(it["spend"] for it in usable)
184
- dim_cpm = cpm(spend_sum, sum(it["imps"] for it in usable))
185
- dim_hit = hit_kpi(dim_cpm, KPI_TARGET)
186
- lines.append(f"· 合计 CPM {fmt_cpm(dim_cpm)},{'达标' if dim_hit else '未达标'}")
187
- hit_spend = sum(it["spend"] for it in usable if hit_kpi(it["cpm"], KPI_TARGET))
188
- return "\n".join(lines), dim_hit, hit_spend, spend_sum
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)
189
161
 
190
162
 
191
163
  def setting_line(title, curr_set, prev_set, first_period=False) -> str:
@@ -206,9 +178,9 @@ def setting_line(title, curr_set, prev_set, first_period=False) -> str:
206
178
  else:
207
179
  lines.append(f"新增 {len(news)} 个:{examples(news)}")
208
180
  if stops:
209
- lines.append(f"关停 {len(stops)} 个:{examples(stops)}")
181
+ lines.append(f"本期未出现 {len(stops)} 个:{examples(stops)}")
210
182
  if len(lines) == 1:
211
- lines.append("未发现新增或关停")
183
+ lines.append("未发现新增或本期未出现")
212
184
  return "\n".join(lines)
213
185
 
214
186
 
@@ -225,17 +197,20 @@ def ad_keywords(names, n=3):
225
197
  return seen
226
198
 
227
199
 
228
- def main() -> int:
229
- parser = argparse.ArgumentParser(description=__doc__)
230
- parser.add_argument("--request-dir", required=True)
231
- args = parser.parse_args()
232
- req = Path(args.request_dir)
200
+ def summarize_request(req: Path):
201
+ """仅使用本次通过分页校验的文件,固定生成摘要和完整明细。"""
233
202
  biz = load_json_file(req / "biz-line.json") or {}
234
203
  detail = load_json_file(req / "biz-line-detail.out.json") or {}
235
204
  detail_data = detail.get("data") if isinstance(detail.get("data"), dict) else {}
236
205
  projects = load_json_file(req / "projects.json") or {}
237
206
  biz_id = biz.get("id") or detail_data.get("id")
238
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}')
239
214
  group = detail_data.get("ssoTeamCodeName") or biz.get("ssoTeamCodeName") or ""
240
215
  name = detail_data.get("name") or biz.get("name") or ""
241
216
  project = "\n".join(projects.get("projectNames") or detail_data.get("projectNames") or [])
@@ -260,7 +235,7 @@ def main() -> int:
260
235
  for per in ("curr", "prev"):
261
236
  day_rows = load_merged(bl, f"met_{ch}_{per}_calendar_date")
262
237
  acc_rows = load_merged(bl, f"met_{ch}_{per}_account_id")
263
- rows = day_rows or acc_rows
238
+ rows = acc_rows or day_rows
264
239
  spend[per][ch] = sum(num(r, "spend_usd") for r in rows)
265
240
  imps[per][ch] = sum(num(r, "impressions") for r in rows)
266
241
  inst[per][ch] = sum(num(r, "installs") for r in acc_rows or rows)
@@ -283,22 +258,46 @@ def main() -> int:
283
258
  cpi_c = (curr_total / curr_inst) if curr_inst else None
284
259
  cpi_p = (prev_total / prev_inst) if prev_inst else None
285
260
  eff_days = max(days["curr"].values() or [0])
286
- first_period = is_first_period(biz)
287
- qoq_label, qoq_pct = qoq_axis(cpi_c if cpi_c is not None else overall_c, cpi_p if cpi_p is not None else overall_p)
261
+ first_period = not (len(prev) == 2 and all(prev))
288
262
  core_index = str(biz.get("coreIndex") or detail_data.get("coreIndex") or "").strip()
289
- kpi_usable = bool(core_index) and any(tok in core_index.upper() for tok in ("CPA", "CPI", "ROI", "ROAS", "次留", "成本"))
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"]
290
270
  spend_verdict = period_verdict(
291
271
  first_period=first_period,
292
272
  qoq_label=qoq_label,
293
273
  kpi_usable=kpi_usable,
294
- kpi_hit=None,
274
+ kpi_hit=rule_hit(core_c, rule),
295
275
  )
296
276
  gate_note = ""
297
- if curr_total < 50 or eff_days < 3:
277
+ if curr_total < 50 or eff_days < 3 or (metric == "CPI" and curr_inst < 10):
298
278
  spend_verdict = "待观察"
299
- gate_note = f"本期有效投放 {eff_days} 天、消耗 ${curr_total:,.0f}"
279
+ gate_note = f"本期有效投放 {eff_days} 天、消耗 ${curr_total:,.0f}、安装 {curr_inst:,.0f}"
300
280
  qoq_pct_s = "" if qoq_pct is None else f"{qoq_pct * 100:+.1f}%"
301
- details: list[dict] = []
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
+
302
301
 
303
302
  bid_fields = {
304
303
  "fb": ["bid_amount"],
@@ -321,7 +320,7 @@ def main() -> int:
321
320
  return f"{label}:本期未发生明显出价调整"
322
321
  lines = [
323
322
  f"{label}:上调 {stats['raise_n']} 个广告组,下调 {stats['lower_n']} 个;"
324
- f"幅度中位数 {stats['median_abs']:.2f}"
323
+ f"期内变化但首末持平 {stats['flat_n']} 个;幅度中位数 {stats['median_abs']:.2f}"
325
324
  ]
326
325
  if stats["list_all"]:
327
326
  for b in stats["details"]:
@@ -339,10 +338,10 @@ def main() -> int:
339
338
  {
340
339
  "bizLine": name,
341
340
  "kind": "出价",
342
- "action": b.get("action") or ("上调" if float(b.get("to") or 0) > float(b.get("from") or 0) else "下调"),
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 "期内变化但首末持平")),
343
342
  "channel": CH_LABEL[ch],
344
343
  "name": b.get("name") or "",
345
- "extra": f"{b.get('from')} → {b.get('to')},{b.get('times')} 次",
344
+ "extra": f"ID={b['id']};" + json.dumps(b.get("fields", []), ensure_ascii=False),
346
345
  }
347
346
  )
348
347
 
@@ -378,16 +377,13 @@ def main() -> int:
378
377
  f"消耗合计:本期 ${curr_total:,.0f} / 上期 ${prev_total:,.0f}",
379
378
  ]
380
379
  )
381
- cpi_s = (
382
- f"全渠道 CPI:本期 ${cpi_c:.2f} / 上期 ${cpi_p:.2f}"
383
- if cpi_c is not None and cpi_p is not None
384
- else f"全渠道 CPI:本期 {cpi_c if cpi_c is not None else '-'} / 上期 {cpi_p if cpi_p is not None else '-'}"
385
- )
380
+ cpi_s = f"全渠道 {metric}:本期 {fmt_cpm(core_c)} / 上期 {fmt_cpm(core_p)}"
381
+ cpi_s += "(美元消耗 / 平台安装量,非客户激活或留存成本)" if metric == "CPI" else "(同粒度基础指标合计后计算)"
386
382
  budget_learning = "\n".join(
387
383
  [
388
384
  f"结论:{spend_verdict}(第二期起跟环比,KPI 只作依据)" if not first_period else f"结论:{spend_verdict}(首期)",
389
385
  f"依据:{cpi_s},环比{qoq_label}" + (f" {qoq_pct_s}" if qoq_pct_s else ""),
390
- f"coreIndex:{core_index[:180] + ('…' if len(core_index) > 180 else '') or '未填写,不单独决定结论'}",
386
+ f"客户 KPI:{core_index or '未填写'};{rule['reason']}",
391
387
  *([f"门槛:{gate_note} → 待观察"] if gate_note else []),
392
388
  "",
393
389
  "Spend",
@@ -431,20 +427,27 @@ def main() -> int:
431
427
  curr = set()
432
428
  prev = set()
433
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
434
437
  curr |= labels_of(load_merged(bl, f"dim_{ch}_curr_{stem}"), key)
435
438
  prev |= labels_of(load_merged(bl, f"dim_{ch}_prev_{stem}"), key)
436
439
  return curr, prev
437
440
 
438
441
  def add_setting_details(kind, curr_set, prev_set, channel=""):
439
442
  action_curr = "当期" if first_period else "新增"
440
- news = sorted(curr_set if first_period else (curr_set - prev_set))
443
+ news = sorted(curr_set if first_period or kind == "国家" else (curr_set - prev_set))
441
444
  stops = [] if first_period else sorted(prev_set - curr_set)
442
445
  for lab in news:
443
446
  details.append(
444
447
  {
445
448
  "bizLine": name,
446
449
  "kind": kind,
447
- "action": action_curr,
450
+ "action": "持续" if not first_period and lab in prev_set else action_curr,
448
451
  "channel": channel,
449
452
  "name": lab,
450
453
  "extra": "",
@@ -455,7 +458,7 @@ def main() -> int:
455
458
  {
456
459
  "bizLine": name,
457
460
  "kind": kind,
458
- "action": "关停",
461
+ "action": "本期未出现",
459
462
  "channel": channel,
460
463
  "name": lab,
461
464
  "extra": "",
@@ -469,17 +472,23 @@ def main() -> int:
469
472
  add_setting_details("兴趣词", interest_curr, interest_prev)
470
473
  add_setting_details("关键词", kw_curr, kw_prev)
471
474
 
472
- ag_bits = []
473
- for ch in ("fb", "gg", "tt"):
474
- cset = labels_of(load_merged(bl, f"dim_{ch}_curr_adgroup_id+adgroup_name"), "adgroup_name")
475
- pset = labels_of(load_merged(bl, f"dim_{ch}_prev_adgroup_id+adgroup_name"), "adgroup_name")
476
- n, s = sorted(cset - pset), sorted(pset - cset)
477
- pattern = naming_patterns(n)
478
- line = f"{CH_LABEL[ch]}:新增 {len(n)} 个,关停 {len(s)} 个"
479
- if n:
480
- line += f"\n· 命名规律:{pattern}"
481
- ag_bits.append(line)
482
- add_setting_details("广告组", set(n), set(s), CH_LABEL[ch])
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")
483
492
  audience_strategy = "\n\n".join(
484
493
  x
485
494
  for x in [
@@ -494,21 +503,7 @@ def main() -> int:
494
503
  if x
495
504
  )
496
505
 
497
- ad_bits = []
498
- for ch in ("fb", "gg", "tt"):
499
- curr = load_merged(bl, f"dim_{ch}_curr_ad_id+ad_name") or load_merged(bl, f"met_{ch}_curr_ad_id+ad_name")
500
- prev = load_merged(bl, f"dim_{ch}_prev_ad_id+ad_name") or load_merged(bl, f"met_{ch}_prev_ad_id+ad_name")
501
- curr_ids = {str(r.get("ad_id")): r for r in curr if r.get("ad_id")}
502
- prev_ids = {str(r.get("ad_id")): r for r in prev if r.get("ad_id")}
503
- new_ids = [i for i in curr_ids if i not in prev_ids]
504
- stop_ids = [i for i in prev_ids if i not in curr_ids]
505
- new_names = [disp(curr_ids[i], "ad_name") for i in new_ids if disp(curr_ids[i], "ad_name") not in ("", "(空)")]
506
- stop_names = [disp(prev_ids[i], "ad_name") for i in stop_ids if disp(prev_ids[i], "ad_name") not in ("", "(空)")]
507
- line = f"{CH_LABEL[ch]}:新增 {len(new_ids)} 个,关停 {len(stop_ids)} 个"
508
- if new_names:
509
- line += f"\n· 命名规律:{naming_patterns(new_names)}"
510
- ad_bits.append(line)
511
- add_setting_details("广告", set(new_names), set(stop_names), CH_LABEL[ch])
506
+ ad_bits = object_strategy("广告", "ad_id", "ad_name")
512
507
  creative_fmt = share_line("【创意类型】", "ad_format", "ad_format", ["fb", "gg", "tt"])[0] or ""
513
508
  creative_strategy = "【广告】\n" + "\n".join(ad_bits) + (("\n\n" + creative_fmt) if creative_fmt else "")
514
509
 
@@ -529,13 +524,20 @@ def main() -> int:
529
524
  for s in stops
530
525
  if not is_ghost_enum(curr_share_map.get(s, 0), prev_share_map.get(s, 0))
531
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}"})
532
534
  items_all.extend(items)
533
535
  stops_all.extend(stops)
534
536
  if items_all:
535
537
  any_new = True
536
538
  if stops_all and not items_all:
537
539
  only_stop = True
538
- 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)
539
541
  texts.append(text)
540
542
  if ts > 0:
541
543
  hit_spend += hs
@@ -545,19 +547,19 @@ def main() -> int:
545
547
  first_period=first_period,
546
548
  qoq_label=qoq_label,
547
549
  kpi_usable=kpi_usable,
548
- kpi_hit=None,
550
+ kpi_hit=rule_hit(core_c, rule),
549
551
  )
550
552
  if gate_note:
551
553
  verdict = "待观察"
552
554
  evidence = (
553
555
  f"{cpi_s},环比{qoq_label}" + (f" {qoq_pct_s}" if qoq_pct_s else "")
554
- + "。达标占比只作依据,不单独改结论"
556
+ + "。此为整体环比观察,不代表本次单一动作的因果效果;" + rule["reason"]
555
557
  )
556
558
  if not any_new and only_stop:
557
559
  return "\n".join(
558
560
  [
559
561
  f"结论:{verdict}",
560
- "口径:本期无新增,走关停",
562
+ "口径:本期无新增,走本期未出现",
561
563
  f"依据:{evidence}",
562
564
  "",
563
565
  detail,
@@ -591,7 +593,7 @@ def main() -> int:
591
593
  return "\n".join(
592
594
  [
593
595
  f"结论:{verdict}",
594
- f"依据:{evidence};过门槛新增消耗加权达标占比 {ratio * 100:.0f}%(仅备查)",
596
+ f"依据:{evidence}",
595
597
  "",
596
598
  detail,
597
599
  "",
@@ -657,6 +659,14 @@ def main() -> int:
657
659
  "request_dir": str(req),
658
660
  }
659
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))
660
670
  print(json.dumps(summary, ensure_ascii=False), flush=True)
661
671
  return 0
662
672
 
@@ -42,8 +42,8 @@ def test_list_query_uses_data_body_not_flags():
42
42
  def test_exec_cli_to_file_keeps_payload_over_64kb():
43
43
  with tempfile.TemporaryDirectory() as tmp:
44
44
  out = Path(tmp) / "big.out.json"
45
- payload = json.dumps({"ok": True, "pad": "x" * 80000}, ensure_ascii=False)
46
- script = f"import sys; sys.stdout.write({payload!r})"
45
+ # 在子进程内构造大回包,避免 Windows argv 长度上限干扰本测试。
46
+ script = 'import json; print(json.dumps({"ok": True, "pad": "x" * 80000}))'
47
47
  rc, raw = exec_cli_to_file([sys.executable, "-c", script], out)
48
48
  assert rc == 0
49
49
  assert out.stat().st_size > 65536
@@ -173,6 +173,19 @@ def test_build_report_writes_strategy_and_detail_sheets():
173
173
  assert "sheetData" in sheet2
174
174
 
175
175
 
176
+ def test_build_report_preserves_carriage_returns():
177
+ """客户 KPI 与广告名中的 CR/CRLF 必须逐字保留,不能被 XML 换行归一化。"""
178
+ from build_report import write_xlsx, validate_xlsx
179
+
180
+ text = "客户 KPI\r\n第二行\r第三行\n<&> &amp; &#13;"
181
+ rows = [{"bizLine": "换行验证", "budgetLearning": text}]
182
+ details = [{"bizLine": "换行验证", "name": text, "extra": text * 100}]
183
+ with tempfile.TemporaryDirectory() as tmp:
184
+ path = Path(tmp) / "line-endings.xlsx"
185
+ write_xlsx(rows, str(path), details)
186
+ assert validate_xlsx(path, rows, details)["rows"] == 1
187
+
188
+
176
189
  if __name__ == "__main__":
177
190
  tests = [
178
191
  test_list_query_uses_data_body_not_flags,
@@ -187,6 +200,7 @@ if __name__ == "__main__":
187
200
  test_naming_patterns_picks_short_tokens,
188
201
  test_summarize_countries_aggregates_when_over_50,
189
202
  test_build_report_writes_strategy_and_detail_sheets,
203
+ test_build_report_preserves_carriage_returns,
190
204
  ]
191
205
  failed = 0
192
206
  for fn in tests: