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
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
| --- | --- |
|
|
9
9
|
| 组别 | `biz-line-list` 出参 `ssoTeamCodeName`(组别名称,已直接返回,**不需要**再用 `ssoTeamCode` 去 `sso +group-list` 反查) |
|
|
10
10
|
| 业务线 | `biz-line-list` 出参 `name` |
|
|
11
|
-
| 本期/上期 |
|
|
11
|
+
| 本期/上期 | 本次请求实际使用的时间范围,**写在同一单元格内,并附实际覆盖范围**:`本期:yyyy-MM-dd~yyyy-MM-dd` + 换行 + `上期:yyyy-MM-dd~yyyy-MM-dd`。用户指定了就用用户的,否则用 `periodStart/periodEnd` 与 `prevPeriodStart/prevPeriodEnd` |
|
|
12
12
|
| 项目名称 | `biz-line-project-list` 出参 `result[].projectName`;一个业务线的多个项目**写在同一单元格内,一个项目一行**(用 `\n` 分隔) |
|
|
13
13
|
| 预算策略内容 / 预算策略learning | 见 [`four-strategies.md`](four-strategies.md) 预算策略 与 [`metrics.md`](metrics.md) learning 生成规则 |
|
|
14
14
|
| 广告产品策略内容 / 广告产品策略learning | 同上,广告产品策略 |
|
|
@@ -27,65 +27,16 @@
|
|
|
27
27
|
- learning:先 `结论` / `依据` / `KPI`,再按维度用 `·` 列明细(最多 8 条,其余写「另有 N 个」),最后一行 `建议`。
|
|
28
28
|
- 不要再用 `{...}` 把明细包成一段,也不要用分号把几十条广告串在同一行。
|
|
29
29
|
|
|
30
|
-
##
|
|
30
|
+
## 固定导出流程
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
业务生成调用 `scripts/run_workflow.py`,每条业务线单独生成一份 Excel;不先汇总全部业务线再导一份。`build_report.py` 是内部标准库导出器,正常流程不由 Agent 手改 payload 或另用第三方库二次排版。
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
主表 12 列不变,明细固定为:业务线、明细类型、动作、渠道、名称、补充。明细保留完整国家、同名不同 ID 广告/广告组、出价字段、新增成效、KPI 原文及数据周期覆盖。最终布局统一列宽、表头、冻结首行、筛选、换行及行高;主表过长内容转入「完整说明」明细,明细过长按续行完整保存,禁止静默截断。
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
python3 scripts/summarize.py --request-dir <本次 requestId 目录>
|
|
38
|
-
python3 scripts/build_report.py --input <本次目录>/payload.json
|
|
39
|
-
```
|
|
36
|
+
## 输出与校验
|
|
40
37
|
|
|
41
|
-
|
|
38
|
+
先在本条临时目录生成一次最终样式,通过 ZIP、两张表、列名及逐单元格完整性校验后,以独占创建方式发布;再次回读验证成功才交付。最终目录必须在批次临时根外。
|
|
42
39
|
|
|
43
|
-
|
|
40
|
+
默认最终目录为桌面 `~/Desktop`,不存在时回退用户主目录,可用 `--output-dir` 指定。文件名为 `业务线策略_<ID>_<名称>_<随机标识>.xlsx`,同名目标拒绝覆盖。
|
|
44
41
|
|
|
45
|
-
|
|
46
|
-
{
|
|
47
|
-
"output_path": "可选,未传时默认写到用户桌面 业务线策略生成_YYYYMMDD_HHMMSS.xlsx",
|
|
48
|
-
"details": [
|
|
49
|
-
{"bizLine": "示例", "kind": "国家", "action": "新增", "channel": "TikTok", "name": "西撒哈拉", "extra": "消耗 $0"}
|
|
50
|
-
],
|
|
51
|
-
"rows": [
|
|
52
|
-
{
|
|
53
|
-
"group": "华东组",
|
|
54
|
-
"bizLine": "示例业务线",
|
|
55
|
-
"period": "本期:2026-09-08~2026-09-10\n上期:2026-09-06~2026-09-08",
|
|
56
|
-
"project": "项目A\n项目B\n项目C",
|
|
57
|
-
"budgetStrategy": "...",
|
|
58
|
-
"budgetLearning": "...",
|
|
59
|
-
"productStrategy": "...",
|
|
60
|
-
"productLearning": "...",
|
|
61
|
-
"audienceStrategy": "...",
|
|
62
|
-
"audienceLearning": "...",
|
|
63
|
-
"creativeStrategy": "...",
|
|
64
|
-
"creativeLearning": "..."
|
|
65
|
-
}
|
|
66
|
-
]
|
|
67
|
-
}
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
也支持 stdin:`python3 scripts/build_report.py --input -`(把 JSON 从 stdin 喂入)。
|
|
71
|
-
|
|
72
|
-
## 默认输出位置
|
|
73
|
-
|
|
74
|
-
用户未指定路径时,默认写到当前用户桌面(`~/Desktop`,找不到则退回主目录),文件名 `业务线策略生成_YYYYMMDD_HHMMSS.xlsx`,避免覆盖**其他请求**已经落盘的文件。用户明确要求其他路径 / 文件名时,通过 `--output` 或 payload 的 `output_path` 指定。
|
|
75
|
-
|
|
76
|
-
## 一次请求只留最终文件
|
|
77
|
-
|
|
78
|
-
时间戳文件名是为了不覆盖**别的请求**,不是让同一次请求导很多份。同一次用户请求:
|
|
79
|
-
|
|
80
|
-
1. payload 全部行定稿后再调用脚本,默认只成功写一次。
|
|
81
|
-
2. 把 stdout 里的 `output_path` 记进本次 `excels_written`。
|
|
82
|
-
3. 若必须重导:新文件成功后再删清单里的旧路径,可用 `scripts/cleanup.py --keep-excel <最终> --written-excel <旧1> --written-excel <旧2>`。
|
|
83
|
-
4. **不要**用桌面 glob(`*策略生成*.xlsx`)再「只留最新」;未出现在本次 `excels_written` 里的文件一律当别人的,不准删。
|
|
84
|
-
|
|
85
|
-
汇报给用户时给最终 Excel 路径,并附带本次 request 目录。原始 JSON 默认保留,见 [`runtime.md`](runtime.md)。
|
|
86
|
-
|
|
87
|
-
## 返回值与校验
|
|
88
|
-
|
|
89
|
-
脚本成功时输出 `{"ok": true, "output_path": "...", "row_count": N}`;失败(如 `rows` 为空)输出 `{"ok": false, "error": "..."}` 并以非 0 退出。Agent 应读取 `output_path` 回报给用户,不要凭假设的路径汇报。
|
|
90
|
-
|
|
91
|
-
生成后不做自动打开或回读校验(脚本没有 Excel 运行环境可用),Agent 汇报时按 `row_count` 与实际处理的业务线/项目数核对是否一致,不一致要说明原因(如某业务线被 7.1 判定为不可生成而跳过)。
|
|
42
|
+
每条业务线完成后只保留验证成功的最终文件,其他本次临时产物全部清理。汇报 `output_path` 及错误/残留(如有);不附带已经删除的 request 目录。完整生命周期见 [`runtime.md`](runtime.md)。
|
|
@@ -1,72 +1,37 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 执行、并发与清理
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
业务流程统一使用 `scripts/run_workflow.py`,低层 fetch/summarize/build 脚本仅供维护诊断,不作为日常生成入口。
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 并发与调用
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
- CLI
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
- 成功看信封 `ok == true` 或退出码 0。
|
|
13
|
-
- 回包必须写 UTF-8 文件再解析。合并文件是 JSON **数组**(以 `[` 开头),`json.loads` 要从第一个 `[` 或 `{` 里更靠前的那个开始,不能先 `find("{")`——否则会把第一行对象当全文,解析失败后当成空数据。
|
|
7
|
+
- 默认同时处理至多 3 条业务线,可显式设置 1 或 2。单条从查询到清理占一个名额,清理结束即补位。同一批次不能拆成多个入口绕过上限;不做跨会话全局调度。
|
|
8
|
+
- 单条内部接口、渠道和分页串行;只通过已安装 `sino-bc-cli`,不手写 HTTP。
|
|
9
|
+
- Windows 通过已安装 CLI 对应 Node 入口传参数数组,避免 cmd 重新解释 JSON。权限错误应报告,不当成未安装去自动下载。
|
|
10
|
+
- `--format json`,请求用 `--data`。成功必须同时满足退出码 0 和信封 `ok == true`;stderr 与 stdout 都直写本次文件。
|
|
11
|
+
- 列表行为 `data.result`,项目为 `data` 数组,维度/成效为 `data.data` 和 `data.total`。分页 `size=1000`、`PAGE_CAP=80`;总数未取全、提前空页、失败或总数变化均中止该条,不导出部分成功文件。
|
|
14
12
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
| 接口 | CLI 信封 `data` | 行数组 | 总数 |
|
|
18
|
-
| --- | --- | --- | --- |
|
|
19
|
-
| `+list` | 对象 | `data.result` | `data.total` |
|
|
20
|
-
| `+detail` | 业务线对象本身 | — | — |
|
|
21
|
-
| `+project-list` | **数组**(或对象里的 `result`) | `data` 或 `data.result` | `len` |
|
|
22
|
-
| 维度 / 成效 | 对象 | **`data.data`** | **`data.total`** |
|
|
23
|
-
|
|
24
|
-
维度/成效**不要**读 `data.result`。上一轮调试里认错字段会把 `got=0` 当成空页,1800+ 行广告只留下第 1 页。翻页:`page`/`size`(`size`≤1000),`acc < total` 就继续。`scripts/cli_json.py` 的 `unwrap_page` 已按上表处理。
|
|
25
|
-
|
|
26
|
-
## 本次请求的工作目录
|
|
27
|
-
|
|
28
|
-
每次用户请求开始时新建**唯一**目录,后续原始回包、payload 都只写这里,禁止写仓库、禁止和其他请求共用一个扁平目录:
|
|
13
|
+
## 目录归属
|
|
29
14
|
|
|
30
15
|
```text
|
|
31
|
-
%TEMP%/sino-bc-strategy
|
|
32
|
-
|
|
33
|
-
|
|
16
|
+
%TEMP%/sino-bc-strategy/batch-<随机标识>/
|
|
17
|
+
.strategy-owner.json
|
|
18
|
+
line-<业务线ID>-<随机标识>/
|
|
19
|
+
.strategy-owner.json
|
|
20
|
+
请求体、回包、stderr、merged JSON、payload、summary、临时 Excel
|
|
34
21
|
```
|
|
35
22
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
- `temp_dirs`:已创建的 `bl-<id>/` 路径
|
|
39
|
-
- `excels_written`:本次请求里 `build_report.py` 成功返回的每一个 `output_path`(按时间追加)
|
|
40
|
-
|
|
41
|
-
## 按业务线清理临时文件
|
|
23
|
+
每次现拉,新建目录,不复用历史缓存。所有本次临时文件写入本次目录;不向技能源码树写缓存。最终 Excel 写在批次临时根以外,文件名包含业务线 ID 和随机请求标识,独占创建、不覆盖已有文件。
|
|
42
24
|
|
|
43
|
-
|
|
25
|
+
## 逐条清理
|
|
44
26
|
|
|
45
|
-
|
|
27
|
+
成功路径:最终文件发布并回读验证 → 清理该业务线临时目录 → 输出完成结果。失败路径:保存可返回的错误摘要 → 关闭子进程 → 清理该业务线临时目录。所有任务停止后清理本次批次共享目录。
|
|
46
28
|
|
|
47
|
-
Excel
|
|
48
|
-
|
|
49
|
-
1. 按下面规则处理 Excel(只动本次清单的旧 xlsx)。
|
|
50
|
-
2. **保留**整个 `%TEMP%/sino-bc-strategy/<requestId>/`(原始回包 + `payload.json`),汇报时带上该路径。
|
|
51
|
-
3. 只有用户明确要求清缓存时才 `cleanup.py --request-dir`。不要删其他 `requestId`。
|
|
52
|
-
|
|
53
|
-
中途失败也保留本次 `requestId` 目录,便于对照回包排障;不要和下次请求共用同一目录。新请求必须新建 `requestId`。
|
|
54
|
-
|
|
55
|
-
可用本技能脚本:
|
|
56
|
-
|
|
57
|
-
```bash
|
|
58
|
-
python3 scripts/cleanup.py --request-dir <本次 requestId 目录>
|
|
59
|
-
python3 scripts/cleanup.py --keep-excel <最终xlsx> --written-excel <本次写过的xlsx1> --written-excel <xlsx2>
|
|
60
|
-
```
|
|
29
|
+
清理不限扩展名,包括 JSON、TXT、图片、日志副本、临时 Python、检查文件、缓存和所有临时 Excel;仅保留已验证的最终 Excel。清理范围以进程新建的目录、随机所有权标记及真实父路径为边界,不扫桌面、历史请求、其他任务或 CLI 全局审计日志,不动源码、技能和凭证。
|
|
61
30
|
|
|
62
|
-
|
|
31
|
+
符号链接和 Windows junction/reparse point 只移除本次目录中的链接本身,不能递归跟随目标。最终路径落入临时根时拒绝执行。文件占用或归属异常会返回残留路径并令批次非零退出,已验证最终文件仍保留。
|
|
63
32
|
|
|
64
|
-
|
|
33
|
+
Ctrl+C/SIGTERM 停止启动新任务,并等待在途 CLI 终止后清理;强杀/断电无法保证清理,不自动删除历史遗留目录。
|
|
65
34
|
|
|
66
|
-
|
|
67
|
-
- 若必须重导(脚本失败、列数不对、文案发现硬伤):记下新的 `output_path`,立刻删掉本次清单里**上一份** Excel。只删 `excels_written` 里的精确路径。
|
|
68
|
-
- **禁止**:按桌面文件名 glob(`业务线策略生成_*.xlsx`)再「只留最新」——会把**其他请求**的最终文件删掉。
|
|
69
|
-
- **禁止**:`Remove-Item -Force` 扫整个桌面时间戳范围。
|
|
70
|
-
- 其他请求、用户自己放在桌面的 xlsx,一律不动。
|
|
35
|
+
## 输出
|
|
71
36
|
|
|
72
|
-
|
|
37
|
+
控制台 JSON 行返回业务线 ID、阶段进度、各阶段耗时和完成结果;最终事件包含成功/失败数量及每条 `output_path` 或错误。任意生成/导出/清理失败时批次非零退出,其他成功文件保留。不额外保留性能报告、预览图或分析日志。
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# 业务线策略生成
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
为业务线策略生成技能规定可复用的分析、Excel 导出与批量执行行为,保证多条业务线运行时有明确的并发上限、独立结果和失败信息,并在每条任务结束后仅保留验证通过的最终文件,避免临时数据持续堆积或误删其他任务产物。
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
### Requirement: 统一生成入口与业务线隔离
|
|
10
|
+
|
|
11
|
+
技能 MUST 提供覆盖查询、拉数、分析、校验、导出和清理的统一入口。单业务线与多业务线 MUST 使用相同分析规则。输入 MUST 支持名称查找和显式业务线 ID 列表;批量候选 MUST 按 ID 去重。名称匹配不唯一时 MUST 明确报告歧义而非自动取第一条。业务请求 MUST 复用现有 CLI,MUST NOT 手写网关 HTTP 或绕过身份约束。
|
|
12
|
+
|
|
13
|
+
#### Scenario: 批量重复 ID 不重复生成
|
|
14
|
+
- **GIVEN** 输入列表包含相同业务线 ID 两次
|
|
15
|
+
- **WHEN** 执行统一入口
|
|
16
|
+
- **THEN** 该业务线只执行一次,拥有独立工作目录和唯一最终 Excel
|
|
17
|
+
|
|
18
|
+
#### Scenario: 名称存在歧义
|
|
19
|
+
- **GIVEN** 名称查询返回多条候选
|
|
20
|
+
- **WHEN** 解析单业务线目标
|
|
21
|
+
- **THEN** 返回候选与歧义错误,不启动这些候选的投放拉数任务
|
|
22
|
+
|
|
23
|
+
### Requirement: 单次执行最多并发三条业务线
|
|
24
|
+
|
|
25
|
+
一次技能调用 MUST 默认最多同时运行 3 条业务线任务,允许设置 1 至 3,超出范围 MUST 在启动业务任务前拒绝。一个业务线从拉数到清理结束 MUST 占用同一个并发名额。该业务线内部的 CLI 请求和分页 MUST 继续顺序执行,MUST NOT 叠加子级并发。待处理业务线在名额释放后继续启动。
|
|
26
|
+
|
|
27
|
+
#### Scenario: 十条业务线受并发上限约束
|
|
28
|
+
- **GIVEN** 请求生成 10 条不同业务线
|
|
29
|
+
- **WHEN** 默认执行批量入口
|
|
30
|
+
- **THEN** 任意时刻运行中的业务线数不超过 3,且有名额后继续处理队列
|
|
31
|
+
- **AND** 已完成业务线立即交付并清理,不等待全部 10 条结束
|
|
32
|
+
|
|
33
|
+
#### Scenario: 非法并发参数不发起请求
|
|
34
|
+
- **GIVEN** 并发参数为 0、4 或非整数
|
|
35
|
+
- **WHEN** 启动统一入口
|
|
36
|
+
- **THEN** 返回参数错误,不创建业务输出或调用业务接口
|
|
37
|
+
|
|
38
|
+
### Requirement: 分页与失败回包必须完整校验
|
|
39
|
+
|
|
40
|
+
每页 CLI 回包 MUST 直写文件并检查退出码、信封和分页总数。异常、解析失败、未到总数即遇空页、达到分页上限仍未取全,MUST 阻止该业务线生成成功报告。CLI stderr 错误 MUST 保留到本次任务结果摘要,而非当作无数据。其他业务线 MUST 可以继续执行。
|
|
41
|
+
|
|
42
|
+
#### Scenario: 后续页失败不导出部分结果
|
|
43
|
+
- **GIVEN** 首页成功且声明仍有下一页
|
|
44
|
+
- **WHEN** 后续页调用失败或返回空页但累计数量不足
|
|
45
|
+
- **THEN** 该业务线失败,不将不完整数据生成最终 Excel,其他业务线继续执行
|
|
46
|
+
|
|
47
|
+
### Requirement: 指标与周期口径固定
|
|
48
|
+
|
|
49
|
+
分析 MUST 从同粒度基础指标先求和再计算比率,分母为零或所需指标缺失时明确不可计算。安装 CPI MUST 标注为美元消耗除以平台安装量,MUST NOT 直接改称客户激活或留存成本。缺少同币种、同转化定义或留存数据时,客户 KPI MUST 标为不可判定,MUST NOT 使用固定 CPM 阈值替代。首期按可判定 KPI 输出,否则待观察;第二期按可计算核心指标环比判定,绝对变化不超过 10% 为稳定;成本下降、收益指标上升为改善。无可比核心指标时 MUST 待观察并说明原因。
|
|
50
|
+
|
|
51
|
+
请求周期与实际返回的数据覆盖范围 MUST 分别展示。数据未覆盖完整请求周期时 MUST 提示不完整,MUST NOT 将较短周期消耗下降直接解释为主动减预算。新增部分成效 MUST 单独聚合,MUST NOT 将业务线整体环比宣称为单一动作的因果效果。
|
|
52
|
+
|
|
53
|
+
#### Scenario: 人民币 CPA 与美元安装数据不可直接对标
|
|
54
|
+
- **GIVEN** 客户目标是人民币 CPA 与次日留存,接口返回美元消耗和平台安装量
|
|
55
|
+
- **WHEN** 缺少汇率、CPA 定义或留存数据
|
|
56
|
+
- **THEN** 仅计算已支持的观察指标,明确客户 KPI 暂不可判定,不生成 CPM 默认达标结论
|
|
57
|
+
|
|
58
|
+
#### Scenario: 本期六天与上期七天
|
|
59
|
+
- **GIVEN** 请求两个七天周期,但本期只返回六个日期
|
|
60
|
+
- **WHEN** 生成策略及 learning
|
|
61
|
+
- **THEN** 展示请求日期和实际覆盖日期,并提示消耗总额比较存在周期差异
|
|
62
|
+
|
|
63
|
+
### Requirement: 对象与明细按稳定标识统计
|
|
64
|
+
|
|
65
|
+
广告和广告组 MUST 按渠道及 ID 判断新增与本期未出现,MUST NOT 仅按名称去重。完整明细 MUST 保留同名不同 ID 的记录。两期集合差异 MUST NOT 被描述为已确认的创建或暂停操作。出价变化 MUST 按广告组日快照计算,并区分上调、下调、期内变化但首末持平;无值 MUST NOT 默认为零。国家全量与过消耗门槛的新增成效 MUST 可在明细追溯。
|
|
66
|
+
|
|
67
|
+
#### Scenario: 同名广告不可合并
|
|
68
|
+
- **GIVEN** 两个不同广告 ID 使用同一名称
|
|
69
|
+
- **WHEN** 统计新增广告并导出明细
|
|
70
|
+
- **THEN** 分别计数并输出 ID,不因名称相同而丢失其中一条
|
|
71
|
+
|
|
72
|
+
#### Scenario: 期内调价后恢复原价
|
|
73
|
+
- **GIVEN** 一个广告组期内出价变化但首末有效快照一致
|
|
74
|
+
- **WHEN** 生成出价摘要
|
|
75
|
+
- **THEN** 计为首末持平的期内变化,不归类为下调
|
|
76
|
+
|
|
77
|
+
### Requirement: 每条业务线只交付一份校验通过的最终 Excel
|
|
78
|
+
|
|
79
|
+
每条业务线 MUST 独立产出一份包含固定 12 列策略摘要及完整明细的 Excel。分析与排版 MUST 由固定脚本完成;正常流程 MUST 一次生成最终样式,MUST NOT 依赖 Agent 临时生成修订脚本或二次导入重排。文件发布前 MUST 检查 Excel 容器、两张表、列名及记录完整性。最终路径 MUST 位于待清理工作目录之外,名称 MUST 能区分业务线与请求,MUST NOT 覆盖已存在的其他文件。
|
|
80
|
+
|
|
81
|
+
#### Scenario: 并发生成不覆盖结果
|
|
82
|
+
- **GIVEN** 两条业务线在同一秒完成导出
|
|
83
|
+
- **WHEN** 发布最终文件
|
|
84
|
+
- **THEN** 获得两个不同且可读取的文件路径,各自包含对应业务线数据
|
|
85
|
+
|
|
86
|
+
#### Scenario: 校验失败不发布损坏文件
|
|
87
|
+
- **GIVEN** Excel 缺少明细表或明细行数不一致
|
|
88
|
+
- **WHEN** 执行发布前校验
|
|
89
|
+
- **THEN** 该任务失败,不对外声明最终 Excel 已生成
|
|
90
|
+
|
|
91
|
+
### Requirement: 任务结束后按归属清理全部临时产物
|
|
92
|
+
|
|
93
|
+
每条业务线任务完成后,MUST 清理本次任务创建的全部非最终产物,包括但不限于 JSON、请求/响应、日志副本、预览图片、临时脚本、临时 Excel、检查文件及运行时缓存。成功时 MUST 先确认最终文件已发布且验证通过,再清理;失败时 MUST 先保存可返回的错误摘要到内存/输出,再清理其临时和不完整产物。所有任务结束后 MUST 清理本次批次共享临时目录。MUST NOT 保留额外磁盘分析报告或将已删除目录作为交付物。
|
|
94
|
+
|
|
95
|
+
清理 MUST 以本次创建的独立目录及登记文件为边界,校验规范化绝对路径及所有权,MUST NOT 按扩展名或桌面 glob 广泛扫描。MUST NOT 删除历史请求、其他并行业务线、仓库源码、技能目录、平台凭证或 CLI 全局审计日志。符号链接、Windows junction/reparse point MUST NOT 被递归跟随到边界外。存在清理失败时 MUST 明确报告残留路径和原因,不宣称已完全清理。
|
|
96
|
+
|
|
97
|
+
#### Scenario: 成功后只保留最终文件
|
|
98
|
+
- **GIVEN** 本次业务线目录中存在 JSON、PNG、TXT、无扩展名文件和临时子目录
|
|
99
|
+
- **WHEN** 最终 Excel 发布并校验成功
|
|
100
|
+
- **THEN** 上述临时产物及业务线临时目录均被清理,最终 Excel 仍可读取
|
|
101
|
+
|
|
102
|
+
#### Scenario: 失败任务不残留临时文件
|
|
103
|
+
- **GIVEN** 拉数或分析失败且已创建部分回包和临时输出
|
|
104
|
+
- **WHEN** 该业务线任务结束
|
|
105
|
+
- **THEN** 返回业务线 ID、失败阶段和错误摘要,清理其拥有的临时产物,继续处理其他任务
|
|
106
|
+
|
|
107
|
+
#### Scenario: 清理链接时保护外部目录
|
|
108
|
+
- **GIVEN** 本次临时目录中有链接指向另一任务或用户目录
|
|
109
|
+
- **WHEN** 执行清理
|
|
110
|
+
- **THEN** 不遍历或删除链接目标,外部文件及其他任务目录保持不变
|
|
111
|
+
|
|
112
|
+
#### Scenario: 文件占用导致清理失败
|
|
113
|
+
- **GIVEN** 最终 Excel 已验证,但一个临时文件被占用且删除失败
|
|
114
|
+
- **WHEN** 工作流结束清理
|
|
115
|
+
- **THEN** 返回最终文件路径及清理失败状态和残留路径,批次以非成功状态结束
|
|
116
|
+
|
|
117
|
+
### Requirement: 批次结果可观察且失败互不掩盖
|
|
118
|
+
|
|
119
|
+
工作流 MUST 按业务线返回阶段状态、最终 Excel 路径或失败原因,并提供阶段耗时和批次成功/失败数量。结果 MUST 区分生成失败、导出失败、清理失败;任一任务失败时批次 MUST 非零退出,同时保留其他任务的成功文件。耗时结果 MUST 通过输出或最终 Excel 内已有适当区域提供,MUST NOT 为此保留额外临时报告。用户可处理的中断 MUST 停止启动新任务并等待/终止子进程后执行边界内清理;进程被强制杀死或断电的情形不得宣称已完成清理。
|
|
120
|
+
|
|
121
|
+
#### Scenario: 混合结果完整汇报
|
|
122
|
+
- **GIVEN** 三条业务线分别成功、拉数失败、清理失败
|
|
123
|
+
- **WHEN** 批次收尾
|
|
124
|
+
- **THEN** 输出三条各自结果与阶段耗时,保留成功的最终 Excel,批次非零退出
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
把业务线策略生成结果写成 .xlsx(业务线策略生成 skill 专用,纯标准库实现,无第三方依赖)。
|
|
5
5
|
|
|
6
6
|
职责边界:本脚本只负责「结构化数据 -> Excel 文件」的机械落盘,
|
|
7
|
-
|
|
7
|
+
不做数据拉取和业务判断;统一入口先通过固定汇总脚本生成内容。
|
|
8
8
|
|
|
9
9
|
用法:
|
|
10
10
|
python3 build_report.py --input payload.json [--output /path/to/file.xlsx]
|
|
@@ -18,7 +18,7 @@ payload.json 结构:
|
|
|
18
18
|
{
|
|
19
19
|
"bizLine": "业务线",
|
|
20
20
|
"kind": "国家|广告组|出价|兴趣词|关键词|广告",
|
|
21
|
-
"action": "
|
|
21
|
+
"action": "新增|本期未出现|上调|下调|当期",
|
|
22
22
|
"channel": "Meta|Google|TikTok",
|
|
23
23
|
"name": "名称",
|
|
24
24
|
"extra": "补充"
|
|
@@ -38,6 +38,11 @@ import json
|
|
|
38
38
|
import os
|
|
39
39
|
import sys
|
|
40
40
|
import zipfile
|
|
41
|
+
import math
|
|
42
|
+
import unicodedata
|
|
43
|
+
import uuid
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
from xml.etree import ElementTree as ET
|
|
41
46
|
from datetime import datetime
|
|
42
47
|
from xml.sax.saxutils import escape as xml_escape
|
|
43
48
|
|
|
@@ -70,7 +75,7 @@ CONTENT_KEYS = {key for key, _label in COLUMNS[4:]}
|
|
|
70
75
|
|
|
71
76
|
# 列宽:策略内容列最宽;项目名称列因单元格内多行罗列项目而适度加宽
|
|
72
77
|
COL_WIDTH_DEFAULT = 14
|
|
73
|
-
COL_WIDTH_CONTENT =
|
|
78
|
+
COL_WIDTH_CONTENT = 48
|
|
74
79
|
COL_WIDTH_MULTILINE = 22
|
|
75
80
|
MULTILINE_KEYS = {"period", "project"}
|
|
76
81
|
|
|
@@ -169,8 +174,9 @@ def build_shared_strings(rows, details=None):
|
|
|
169
174
|
[intern(str(row.get(key, "") or "")) for key, _label in DETAIL_COLUMNS]
|
|
170
175
|
)
|
|
171
176
|
|
|
177
|
+
# XML 会把原始 CR/CRLF 归一化为 LF;用字符引用保留客户文本,保证逐字回读。
|
|
172
178
|
items = "".join(
|
|
173
|
-
f'<si><t xml:space="preserve">{xml_escape(text)}</t></si>' for text in order
|
|
179
|
+
f'<si><t xml:space="preserve">{xml_escape(text, {chr(13): " "})}</t></si>' for text in order
|
|
174
180
|
)
|
|
175
181
|
sst_xml = (
|
|
176
182
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
@@ -180,7 +186,7 @@ def build_shared_strings(rows, details=None):
|
|
|
180
186
|
return sst_xml, header_indices, row_indices, detail_header, detail_rows
|
|
181
187
|
|
|
182
188
|
|
|
183
|
-
def build_sheet_xml(header_indices, row_indices, columns, content_keys=None, multiline_keys=None):
|
|
189
|
+
def build_sheet_xml(header_indices, row_indices, columns, content_keys=None, multiline_keys=None, values=None):
|
|
184
190
|
"""生成 worksheet xml:首行表头样式 s="1",其余行内容样式 s="2"(自动换行)"""
|
|
185
191
|
content_keys = content_keys or set()
|
|
186
192
|
multiline_keys = multiline_keys or set()
|
|
@@ -205,7 +211,14 @@ def build_sheet_xml(header_indices, row_indices, columns, content_keys=None, mul
|
|
|
205
211
|
for i, sst_index in enumerate(indices):
|
|
206
212
|
ref = f"{col_letter(i)}{row_num}"
|
|
207
213
|
cells.append(f'<c r="{ref}" s="{style_id}" t="s"><v>{sst_index}</v></c>')
|
|
208
|
-
|
|
214
|
+
# Excel 不会可靠地对程序生成的行自动增高,按中文显示宽度确定行高。
|
|
215
|
+
if row_num == 1:
|
|
216
|
+
height = 32
|
|
217
|
+
else:
|
|
218
|
+
record = (values or [{}] * len(row_indices))[row_num - 2]
|
|
219
|
+
lines = max((visual_lines(str(record.get(key) or ""), COL_WIDTH_CONTENT if key in content_keys else (COL_WIDTH_MULTILINE if key in multiline_keys else COL_WIDTH_DEFAULT)) for key, _ in columns), default=1)
|
|
220
|
+
height = min(409, max(30, lines * 15 + 10))
|
|
221
|
+
return f'<row r="{row_num}" ht="{height}" customHeight="1">{"".join(cells)}</row>'
|
|
209
222
|
|
|
210
223
|
rows_xml_parts = [row_xml(1, header_indices, 1)]
|
|
211
224
|
for offset, indices in enumerate(row_indices):
|
|
@@ -220,7 +233,7 @@ def build_sheet_xml(header_indices, row_indices, columns, content_keys=None, mul
|
|
|
220
233
|
'<sheetViews><sheetView workbookViewId="0">'
|
|
221
234
|
'<pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/>'
|
|
222
235
|
'</sheetView></sheetViews>'
|
|
223
|
-
f'{cols_xml}{sheet_data}'
|
|
236
|
+
f'{cols_xml}{sheet_data}<autoFilter ref="{dimension_ref}"/>'
|
|
224
237
|
'</worksheet>'
|
|
225
238
|
)
|
|
226
239
|
|
|
@@ -233,10 +246,64 @@ def default_output_path() -> str:
|
|
|
233
246
|
# 部分中文 Windows / OneDrive 环境桌面路径不同,找不到就退回主目录
|
|
234
247
|
desktop = home
|
|
235
248
|
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
236
|
-
return os.path.join(desktop, f"业务线策略生成_{stamp}.xlsx")
|
|
249
|
+
return os.path.join(desktop, f"业务线策略生成_{stamp}_{uuid.uuid4().hex[:8]}.xlsx")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def visual_lines(text, width):
|
|
253
|
+
"""中日韩字符占两格,保守估算换行后高度。"""
|
|
254
|
+
return sum(max(1, math.ceil(sum(2 if unicodedata.east_asian_width(c) in 'WF' else 1 for c in line) / max(1, width - 2))) for line in text.split('\n'))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def chunks(text, width, max_lines=20):
|
|
258
|
+
"""按可见行宽拆分,续行不丢字符,避免 Excel 行高及单元格字数上限。"""
|
|
259
|
+
result, part = [], ''
|
|
260
|
+
# 线性维护行宽,不对越来越长的片段重复扫描。
|
|
261
|
+
lines, used = 1, 0
|
|
262
|
+
for char in text:
|
|
263
|
+
size = 2 if unicodedata.east_asian_width(char) in 'WF' else 1
|
|
264
|
+
next_line = char == '\n' or used + size > width - 2
|
|
265
|
+
if (next_line and lines >= max_lines) or len(part) >= 16000:
|
|
266
|
+
result.append(part)
|
|
267
|
+
part, lines, used = '', 1, 0
|
|
268
|
+
if char == '\n':
|
|
269
|
+
lines, used = lines + 1, 0
|
|
270
|
+
elif used + size > width - 2:
|
|
271
|
+
lines, used = lines + 1, size
|
|
272
|
+
else:
|
|
273
|
+
used += size
|
|
274
|
+
part += char
|
|
275
|
+
return result + [part] if part or not result else result
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def prepare_layout(rows, details):
|
|
279
|
+
"""主表只放可见摘要,超长原文转完整明细;明细用续行保持全量。"""
|
|
280
|
+
rows = [dict(r) for r in rows]
|
|
281
|
+
details = [dict(r) for r in details or []]
|
|
282
|
+
for row in rows:
|
|
283
|
+
for key, label in COLUMNS:
|
|
284
|
+
text = str(row.get(key) or '')
|
|
285
|
+
width = COL_WIDTH_CONTENT if key in CONTENT_KEYS else (COL_WIDTH_MULTILINE if key in MULTILINE_KEYS else COL_WIDTH_DEFAULT)
|
|
286
|
+
parts = chunks(text, width, 18)
|
|
287
|
+
if len(parts) > 1:
|
|
288
|
+
details.append({'bizLine': row.get('bizLine', ''), 'kind': '完整说明', 'action': label, 'name': label, 'extra': text})
|
|
289
|
+
row[key] = parts[0] + '\n(完整内容见明细/完整说明)'
|
|
290
|
+
expanded = []
|
|
291
|
+
for record in details:
|
|
292
|
+
split = {key: chunks(str(record.get(key) or ''), COL_WIDTH_CONTENT if key in ('name', 'extra') else COL_WIDTH_DEFAULT, 18) for key, _ in DETAIL_COLUMNS}
|
|
293
|
+
count = max(map(len, split.values()))
|
|
294
|
+
for index in range(count):
|
|
295
|
+
# 续行有行号标识,身份短字段重复展示便于筛选;长字段逐段保留。
|
|
296
|
+
row = {key: (parts[index] if index < len(parts) else (parts[0] if len(parts) == 1 and key not in ('name', 'extra') else '')) for key, parts in split.items()}
|
|
297
|
+
if count > 1:
|
|
298
|
+
row['action'] += f' [续 {index + 1}/{count}]'
|
|
299
|
+
expanded.append(row)
|
|
300
|
+
return rows, expanded
|
|
237
301
|
|
|
238
302
|
|
|
239
303
|
def write_xlsx(rows, output_path: str, details=None) -> str:
|
|
304
|
+
if not rows:
|
|
305
|
+
raise ValueError('策略数据为空')
|
|
306
|
+
rows, details = prepare_layout(rows, details)
|
|
240
307
|
sst_xml, header_indices, row_indices, detail_header, detail_rows = build_shared_strings(
|
|
241
308
|
rows, details
|
|
242
309
|
)
|
|
@@ -246,6 +313,7 @@ def write_xlsx(rows, output_path: str, details=None) -> str:
|
|
|
246
313
|
COLUMNS,
|
|
247
314
|
content_keys=CONTENT_KEYS,
|
|
248
315
|
multiline_keys=MULTILINE_KEYS,
|
|
316
|
+
values=rows,
|
|
249
317
|
)
|
|
250
318
|
detail_xml = build_sheet_xml(
|
|
251
319
|
detail_header,
|
|
@@ -253,13 +321,14 @@ def write_xlsx(rows, output_path: str, details=None) -> str:
|
|
|
253
321
|
DETAIL_COLUMNS,
|
|
254
322
|
content_keys={"name", "extra"},
|
|
255
323
|
multiline_keys=set(),
|
|
324
|
+
values=details,
|
|
256
325
|
)
|
|
257
326
|
|
|
258
327
|
out_dir = os.path.dirname(output_path)
|
|
259
328
|
if out_dir and not os.path.isdir(out_dir):
|
|
260
329
|
os.makedirs(out_dir, exist_ok=True)
|
|
261
330
|
|
|
262
|
-
with zipfile.ZipFile(output_path, "
|
|
331
|
+
with zipfile.ZipFile(output_path, "x", zipfile.ZIP_DEFLATED) as zf:
|
|
263
332
|
zf.writestr("[Content_Types].xml", CONTENT_TYPES_XML)
|
|
264
333
|
zf.writestr("_rels/.rels", ROOT_RELS_XML)
|
|
265
334
|
zf.writestr("xl/workbook.xml", WORKBOOK_XML)
|
|
@@ -272,6 +341,26 @@ def write_xlsx(rows, output_path: str, details=None) -> str:
|
|
|
272
341
|
return output_path
|
|
273
342
|
|
|
274
343
|
|
|
344
|
+
def validate_xlsx(path, rows, details):
|
|
345
|
+
"""逐单元格回读,与布局后的完整输入比较,避免只检验文件存在。"""
|
|
346
|
+
ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
|
|
347
|
+
expected = prepare_layout(rows, details)
|
|
348
|
+
with zipfile.ZipFile(path) as archive:
|
|
349
|
+
if archive.testzip():
|
|
350
|
+
raise ValueError('Excel ZIP 校验失败')
|
|
351
|
+
workbook = ET.fromstring(archive.read('xl/workbook.xml'))
|
|
352
|
+
if [s.attrib['name'] for s in workbook.findall('s:sheets/s:sheet', ns)] != ['策略', '明细']:
|
|
353
|
+
raise ValueError('Excel 必须包含策略和明细两张表')
|
|
354
|
+
strings = [''.join(item.itertext()) for item in ET.fromstring(archive.read('xl/sharedStrings.xml'))]
|
|
355
|
+
for index, (records, columns) in enumerate(zip(expected, (COLUMNS, DETAIL_COLUMNS)), 1):
|
|
356
|
+
sheet = ET.fromstring(archive.read(f'xl/worksheets/sheet{index}.xml'))
|
|
357
|
+
actual = [[strings[int(cell.find('s:v', ns).text)] for cell in row] for row in sheet.findall('s:sheetData/s:row', ns)]
|
|
358
|
+
target = [[label for _, label in columns]] + [[str(r.get(key) or '') for key, _ in columns] for r in records]
|
|
359
|
+
if actual != target:
|
|
360
|
+
raise ValueError(f'Excel 第 {index} 张表内容或行列数不完整')
|
|
361
|
+
return {'rows': len(expected[0]), 'details': len(expected[1])}
|
|
362
|
+
|
|
363
|
+
|
|
275
364
|
def main() -> int:
|
|
276
365
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
277
366
|
parser.add_argument(
|