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.
- package/dist/SKILL.md +1 -1
- package/dist/index.js +1 -1
- package/dist/skills/custom/biz-line-strategy-generate/SKILL.md +20 -23
- package/dist/skills/custom/biz-line-strategy-generate/references/api-flow.md +5 -5
- package/dist/skills/custom/biz-line-strategy-generate/references/four-strategies.md +16 -14
- package/dist/skills/custom/biz-line-strategy-generate/references/metrics.md +29 -278
- package/dist/skills/custom/biz-line-strategy-generate/references/output-excel.md +8 -57
- package/dist/skills/custom/biz-line-strategy-generate/references/runtime.md +22 -57
- package/dist/skills/custom/biz-line-strategy-generate/references/spec.md +124 -0
- package/dist/skills/custom/biz-line-strategy-generate/scripts/build_report.py +98 -9
- package/dist/skills/custom/biz-line-strategy-generate/scripts/cleanup.py +71 -82
- package/dist/skills/custom/biz-line-strategy-generate/scripts/cli_json.py +125 -19
- package/dist/skills/custom/biz-line-strategy-generate/scripts/fetch_data.py +61 -32
- package/dist/skills/custom/biz-line-strategy-generate/scripts/run_workflow.py +189 -0
- package/dist/skills/custom/biz-line-strategy-generate/scripts/summarize.py +158 -148
- package/dist/skills/custom/biz-line-strategy-generate/scripts/test_strategy_rules.py +16 -2
- package/dist/skills/custom/biz-line-strategy-generate/scripts/test_workflow.py +345 -0
- package/package.json +1 -1
- package/dist/skills/custom/biz-line-strategy-generate/scripts/__pycache__/build_report.cpython-314.pyc +0 -0
- package/dist/skills/custom/biz-line-strategy-generate/scripts/__pycache__/cli_json.cpython-314.pyc +0 -0
- package/dist/skills/custom/biz-line-strategy-generate/scripts/__pycache__/fetch_data.cpython-314.pyc +0 -0
- 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
|
-
"""
|
|
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
|
-
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
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,
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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"
|
|
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
|
|
229
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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=
|
|
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"
|
|
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
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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=
|
|
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}
|
|
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
|
-
|
|
46
|
-
script =
|
|
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<&> & "
|
|
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:
|