sino-bc-cli 1.8.41 → 1.8.42
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/index.js +1 -1
- package/dist/release-info.json +2 -2
- package/dist/skill-manifest.json +24 -14
- package/dist/skills/biz-line-strategy-generate/SKILL.md +20 -17
- package/dist/skills/biz-line-strategy-generate/references/api-flow.md +16 -5
- package/dist/skills/biz-line-strategy-generate/references/read-tools.md +5 -2
- package/dist/skills/biz-line-strategy-generate/references/runtime.md +57 -18
- package/dist/skills/biz-line-strategy-generate/references/spec.md +56 -15
- package/dist/skills/biz-line-strategy-generate/scripts/ai_strategy.py +36 -22
- package/dist/skills/biz-line-strategy-generate/scripts/cleanup.py +9 -0
- package/dist/skills/biz-line-strategy-generate/scripts/resumable_delivery.py +184 -0
- package/dist/skills/biz-line-strategy-generate/scripts/resumable_requests.py +243 -0
- package/dist/skills/biz-line-strategy-generate/scripts/resumable_workflow.py +460 -0
- package/dist/skills/biz-line-strategy-generate/scripts/run_sharded.py +3 -0
- package/dist/skills/biz-line-strategy-generate/scripts/run_workflow.py +76 -17
- package/dist/skills/biz-line-strategy-generate/scripts/strategy_output.py +33 -15
- package/dist/skills/biz-line-strategy-generate/scripts/submit_ai_strategy.py +30 -0
- package/dist/skills/biz-line-strategy-generate/scripts/task_state.py +174 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_resource_cleanup.py +16 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_resumable_delivery.py +198 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_resumable_integration.py +301 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_resumable_requests.py +269 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_resumable_workflow.py +187 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_sharded.py +9 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_task_state.py +165 -0
- package/package.json +1 -1
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## Purpose
|
|
4
4
|
|
|
5
|
-
为业务线策略生成技能规定可复用的分析、Excel
|
|
5
|
+
为业务线策略生成技能规定可复用的分析、Excel 导出与批量执行行为,保证多条业务线运行时有明确的并发上限、独立结果和失败信息,并在确认结果后清理工作文件。可恢复任务保留必要状态和最终结果,等待、SIGTERM及写入结果未知时保留恢复材料,避免误删其他任务产物。
|
|
6
6
|
|
|
7
7
|
## Requirements
|
|
8
8
|
|
|
9
9
|
### Requirement: 统一生成入口与业务线隔离
|
|
10
10
|
|
|
11
|
-
技能 MUST 提供覆盖查询、拉数、分析、校验、导出和清理的统一入口。单业务线与多业务线 MUST 使用相同分析规则。输入 MUST 支持名称查找和显式业务线 ID 列表;批量候选 MUST 按 ID 去重。名称匹配不唯一时 MUST 明确报告歧义而非自动取第一条。业务请求 MUST 复用现有 CLI,MUST NOT 手写网关 HTTP
|
|
11
|
+
技能 MUST 提供覆盖查询、拉数、分析、校验、导出和清理的统一入口。单业务线与多业务线 MUST 使用相同分析规则。输入 MUST 支持名称查找和显式业务线 ID 列表;批量候选 MUST 按 ID 去重。名称匹配不唯一时 MUST 明确报告歧义而非自动取第一条。业务请求 MUST 复用现有 CLI,MUST NOT 手写网关 HTTP 或绕过身份约束。可恢复调用使用已确认的身份上下文,恢复时身份不一致 MUST 阻断,不把其他账号的凭证用于原任务。
|
|
12
12
|
|
|
13
13
|
#### Scenario: 批量重复 ID 不重复生成
|
|
14
14
|
- **GIVEN** 输入列表包含相同业务线 ID 两次
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
每片工作流MUST默认拉数(含业务预检、分页及固定分析)最多3条,标签/AI至导出/入库/清理最多3条,总在途(含等待)最多6条。--concurrency仅接受1/2/3,作为每阶段上限,总在途最多为两倍。初始MUST仅按拉数名额启动任务;任意一条准备好转入AI阶段时MUST立即归还拉数名额,调度器在本次循环补拉下一条,MUST NOT等待另外两条同时转阶段。AI已满时允许本条在总上限内持有独立数据等待,不另建无界审核队列;每条内部CLI请求及分页仍串行,MUST NOT简单将拉数并发改成6。
|
|
26
26
|
|
|
27
|
-
只有取得AI名额后MUST创建审核请求并启动7/15分钟阶段计时;交接等待MUST独立计入timings.wait_ai_slot
|
|
27
|
+
只有取得AI名额后MUST创建审核请求并启动7/15分钟阶段计时;交接等待MUST独立计入timings.wait_ai_slot。旧阻塞入口保留原有try/finally生命周期;可恢复入口MUST将阶段名额持久化,pending或SIGTERM退出不能释放业务在途名额。确定结束并清理后释放名额;阶段等待取消MUST不发审核请求。目录、身份、候选编号及结果独立,其他条目正常推进。Python线程池不保证宿主模型真正并行,只有实际分配SubAgent并观测到独立事件处理后才可声明Agent并行;无可比实测不得声明提速。
|
|
28
28
|
|
|
29
29
|
#### Scenario: 单条转阶段立即补拉
|
|
30
30
|
- **GIVEN** A/B/C正在拉数,B/C仍未完成
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
|
|
68
68
|
### Requirement: 指标与周期口径固定
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
可恢复入口 MUST 在创建时按上海日期解析并固定默认周期,旧阻塞入口保持执行机器当地日期:本期为上周一至周日,上期为上上周一至周日。MUST NOT 使用业务线返回的周期。用户指定日期 MUST 按期成对覆盖,未指定的期保持其自然周默认值;不合法日期 MUST 报错而非静默回退。查询、Excel 及数据库本期字段 MUST 使用同一解析结果。
|
|
71
71
|
|
|
72
72
|
#### Scenario: 自然周默认与业务线周期不同
|
|
73
73
|
- **GIVEN** 执行日期为 2026-09-22,业务线返回本期 2026-09-07~2026-09-13
|
|
@@ -127,7 +127,7 @@ Excel 写入与逐单元格回读校验 MUST 使用同一文本清洗口径:
|
|
|
127
127
|
|
|
128
128
|
### Requirement: 任务结束后按归属清理全部临时产物
|
|
129
129
|
|
|
130
|
-
|
|
130
|
+
以下普通结束清理不包括可恢复任务的状态、最终结果及未决恢复材料。每条业务线任务确定完成后,MUST 清理本次任务创建的全部工作产物,包括但不限于 JSON、请求/响应、日志副本、预览图片、临时脚本、临时 Excel、检查文件及运行时缓存。本地成功时 MUST 先确认最终文件已发布且验证通过,再清理;可恢复数据库模式 MUST 在临时 Excel 校验并上传成功后携带 storageKey 保存策略,确认并落盘策略ID后再清理该文件;旧阻塞入口保留上传后先删除临时Excel再保存的顺序;失败时 MUST 先保存可返回的错误摘要到内存/输出,再清理其临时和不完整产物。所有任务确定结束后 MUST 清理本次批次共享临时目录;可恢复任务状态与最终结果保留在清理范围外。MUST NOT 保留额外磁盘分析报告或将已删除目录作为交付物。
|
|
131
131
|
|
|
132
132
|
清理 MUST 以本次创建的独立目录及登记文件为边界,校验规范化绝对路径及所有权,MUST NOT 按扩展名或桌面 glob 广泛扫描。MUST NOT 删除历史请求、其他并行业务线、仓库源码、技能目录、平台凭证或 CLI 全局审计日志。符号链接、Windows junction/reparse point MUST NOT 被递归跟随到边界外。存在清理失败时 MUST 明确报告残留路径和原因,不宣称已完全清理。
|
|
133
133
|
|
|
@@ -153,7 +153,7 @@ Excel 写入与逐单元格回读校验 MUST 使用同一文本清洗口径:
|
|
|
153
153
|
|
|
154
154
|
### Requirement: 批次结果可观察且失败互不掩盖
|
|
155
155
|
|
|
156
|
-
工作流 MUST 按业务线返回阶段状态、本地最终 Excel 路径或数据库 aiStrategyId/storageKey,以及未生成或失败原因,并提供阶段耗时和批次成功/失败数量。结果 MUST 区分生成失败、导出失败、清理失败;重试结束后任一业务线最终失败时批次 MUST 非零退出,同时保留其他任务的成功文件。耗时结果 MUST 通过输出或最终 Excel 内已有适当区域提供,MUST NOT
|
|
156
|
+
工作流 MUST 按业务线返回阶段状态、本地最终 Excel 路径或数据库 aiStrategyId/storageKey,以及未生成或失败原因,并提供阶段耗时和批次成功/失败数量。结果 MUST 区分生成失败、导出失败、清理失败;重试结束后任一业务线最终失败时批次 MUST 非零退出,同时保留其他任务的成功文件。耗时结果 MUST 通过输出或最终 Excel 内已有适当区域提供,MUST NOT 为此保留额外临时报告。旧阻塞入口的用户可处理中断 MUST 停止启动新任务并等待/终止子进程后执行边界内清理;可恢复入口收到SIGTERM时停止启动并终止在途子进程,保留检查点与材料供下一次推进;进程被强制杀死或断电的情形不得宣称已完成清理。
|
|
157
157
|
|
|
158
158
|
#### Scenario: 混合结果完整汇报
|
|
159
159
|
- **GIVEN** 三条业务线分别成功、拉数失败、清理失败
|
|
@@ -162,7 +162,7 @@ Excel 写入与逐单元格回读校验 MUST 使用同一文本清洗口径:
|
|
|
162
162
|
|
|
163
163
|
### Requirement: 用户显式选择数据库输出
|
|
164
164
|
|
|
165
|
-
|
|
165
|
+
正式使用场景是数据库交付,Excel模式用于调试;命令默认仍仅交付本地 Excel,沿用桌面及显式目录选择。仅用户明确要求数据库输出时 MUST 上传 Excel 并保存 AI 策略;此模式 MUST NOT 在桌面发布文件。所有业务请求 MUST 走已有 CLI。
|
|
166
166
|
|
|
167
167
|
#### Scenario: 默认本地交付
|
|
168
168
|
- **GIVEN** 用户没有要求入库
|
|
@@ -184,7 +184,7 @@ Excel 写入与逐单元格回读校验 MUST 使用同一文本清洗口径:
|
|
|
184
184
|
|
|
185
185
|
### Requirement: 临时数据使用持久任务根
|
|
186
186
|
|
|
187
|
-
|
|
187
|
+
旧阻塞入口临时数据MUST位于工作根的strategy_task/YYYY-MM-DD/tmp/随机批次内;可恢复入口MUST使用strategy_task/resumable/task-随机标识及各业务线代次目录。工作根默认启动cwd,可用--task-root覆盖;相对路径按启动cwd解析,批次启动日期固定,跨午夜/重试不改变。空值、不可写或规范化后位于本专家Skill树内(含junction重定向)MUST失败,不回退系统TEMP。清理MUST复用原所有权边界,只清理本次随机批次/业务线,不扫历史或其他任务。
|
|
188
188
|
|
|
189
189
|
#### Scenario: 重试与历史目录隔离
|
|
190
190
|
- **WHEN** 本次任务跨午夜后重试,工作根内另有历史批次
|
|
@@ -285,10 +285,10 @@ READY MUST 包含四类策略正文、四类 learning、四类 effect、三类
|
|
|
285
285
|
|
|
286
286
|
### Requirement: 上传校验清理与保存顺序可追溯
|
|
287
287
|
|
|
288
|
-
数据库模式 MUST 先生成并完整校验 Excel,再调用 `common-file +upload`,serviceName MUST 为 sino-opti-hive。MUST 验证非空 storageKey
|
|
288
|
+
数据库模式 MUST 先生成并完整校验 Excel,再调用 `common-file +upload`,serviceName MUST 为 sino-opti-hive。MUST 验证非空 storageKey,并在 `biz-line-ai-strategy +ai-strategy-save` 的 fileStorageKey 传原值。可恢复入口保留临时Excel直到保存结果确认并持久化,旧阻塞入口仍在保存前删除。保存结果未知 MUST NOT 自动重试或追加失败状态覆盖;上传或保存失败 MUST 报告阶段与已有 key。清理 MUST 仅针对本次拥有的文件,失败 MUST 报告残留,MUST NOT 宣称完全成功。
|
|
289
289
|
|
|
290
290
|
#### Scenario: 上传成功后保存失败
|
|
291
|
-
- **GIVEN** 已取得 storageKey
|
|
291
|
+
- **GIVEN** 已取得 storageKey
|
|
292
292
|
- **WHEN** AI 策略保存失败或超时
|
|
293
293
|
- **THEN** 返回 key、保存失败或结果未知,不重新上传、不自动重试、不用 GENERATION_FAILED 覆盖潜在成功记录
|
|
294
294
|
|
|
@@ -351,7 +351,7 @@ READY MUST 包含四类策略正文、四类 learning、四类 effect、三类
|
|
|
351
351
|
|
|
352
352
|
### Requirement: 标签内容分析交接
|
|
353
353
|
|
|
354
|
-
标签选择MUST在导出/上传/保存前通过tag_review_required交接,绑定bizLineId、业务线attempt及本次reviewId,保留三类完整正文、事实和全部行业候选。请求MUST声明choice-key-v1、有效expiresAt,并为各候选提供绑定本次审核/策略类别/真实ID的choiceKey。Agent MUST在每次提交前重读对应请求,仅返回三类候选编号与理由,经submit_tags.py映射真实ID并自动组装身份;MUST NOT手写ID
|
|
354
|
+
标签选择MUST在导出/上传/保存前通过tag_review_required交接,绑定bizLineId、业务线attempt及本次reviewId,保留三类完整正文、事实和全部行业候选。请求MUST声明choice-key-v1、有效expiresAt,并为各候选提供绑定本次审核/策略类别/真实ID的choiceKey。Agent MUST在每次提交前重读对应请求,仅返回三类候选编号与理由,经submit_tags.py映射真实ID并自动组装身份;MUST NOT手写ID或将其他请求编号复制过来。编号错误、身份错配、过期、关闭、被下一轮替代、冲突响应及非法选择MUST被拒绝;可恢复协议同内容重复提交MUST幂等,旧阻塞协议仍拒绝重复响应,不覆盖、不部分发布、不重建已清理目录。消费端MUST再次验证编号到ID的映射,Excel与数据库复用同一组真实ID。
|
|
355
355
|
|
|
356
356
|
拒绝或超时MUST只重试标签选择,不重新拉数与分析。含首次最多2次、每次7分钟,旧请求结束时关闭;每次路径和reviewId独立。两次失败才逐场景使用真实唯一“其他”,没有唯一“其他”的场景标签为空、READY标签字段为[],正文和learning继续交付。原因、缺失场景及waitSeconds/阶段timings可见,MUST NOT称兜底为人工审核通过。取消、字典故障或提交IO故障不得自动归为内容不匹配。MUST保持拉数3/AI3/总6滑动补位和逐条清理,不另建无界队列。
|
|
357
357
|
|
|
@@ -361,7 +361,7 @@ READY MUST 包含四类策略正文、四类 learning、四类 effect、三类
|
|
|
361
361
|
- **THEN** 提交失败且B无新响应,不接受只因真实ID合法而跨请求选择
|
|
362
362
|
|
|
363
363
|
#### Scenario: 重读与独占发布
|
|
364
|
-
- **WHEN**
|
|
364
|
+
- **WHEN** 请求在映射期间被修改、超时、关闭或被替代,或另一个提交已写入不同响应
|
|
365
365
|
- **THEN** 拒绝提交,不覆盖、不留下半写JSON,不创建已清理目录
|
|
366
366
|
|
|
367
367
|
#### Scenario: 两次审核失败兜底
|
|
@@ -500,8 +500,49 @@ Excel 主表在项目名称后增加广告账号列(共16列)。列出业务
|
|
|
500
500
|
- **THEN** 八列表头与唯一数据行全部可从同一字符串池回读,加粗和换行保留,不存在 inlineStr 单元格
|
|
501
501
|
|
|
502
502
|
|
|
503
|
+
### Requirement: 可恢复任务通过短调用推进
|
|
504
|
+
|
|
505
|
+
Agent MUST 默认使用 `run_workflow.py --resumable` 创建一次任务,保存返回的 `taskPath`,随后通过 `--resume taskPath` 推进、`--status taskPath`只读查询。恢复或查询 MUST NOT接受新业务线、周期、输出模式等覆盖参数。旧阻塞入口继续兼容。待外部响应时 MUST 持久化请求并退出,不以后台守护进程等待;主Agent负责接手请求并再次推进。pending退出0只代表本次调用正常,MUST根据complete、ok及逐条结果判断业务是否完成。
|
|
506
|
+
|
|
507
|
+
#### Scenario: 等待请求不等于完成
|
|
508
|
+
- **WHEN** 返回complete=false且results中存在request
|
|
509
|
+
- **THEN** Agent处理对应请求后恢复同一任务,不重复创建任务,也不报告批次完成
|
|
510
|
+
|
|
511
|
+
### Requirement: 请求接收与消费可跨进程恢复
|
|
512
|
+
|
|
513
|
+
标签仍经submit_tags.py;AI请求MUST经submit_ai_strategy.py携带request/request-id并从stdin接收仅含text的JSON。两者MUST在任务状态锁内核对当前业务线代次、请求身份及期限,将接收凭据先落盘,再写响应副本。MUST NOT直接写响应文件或由Agent伪造接收时间。按时接收的响应可在期限后消费;同内容重复提交幂等,冲突拒绝。标签7分钟/2次、AI15分钟/3次及校验口径不变,恢复不重置计时和次数。
|
|
514
|
+
|
|
515
|
+
#### Scenario: 接收后进程中断
|
|
516
|
+
- **GIVEN** 请求接收凭据已落盘但响应副本尚未写完
|
|
517
|
+
- **WHEN** SIGTERM后再次推进
|
|
518
|
+
- **THEN** 从已确认的凭据继续消费,不因副本缺失或消费时已过期限丢弃有效提交
|
|
519
|
+
|
|
520
|
+
### Requirement: 隔日恢复刷新数据并保护未决写入
|
|
521
|
+
|
|
522
|
+
未完成数据代次跨上海日期时 MUST重新取数与分析,旧标签/AI响应失效;创建时业务周期和显式输入不变。消费响应、上传、首次READY保存及调试Excel发布前 MUST再次检查日期。同任务行业缓存同日恢复复用成功值与本轮错误,跨日清空,错误仍仅本轮缓存。已确认业务结果只收尾;上传或保存结果未知 MUST先核实,不能以跨日为由重放写入或覆盖旧意图。
|
|
523
|
+
|
|
524
|
+
#### Scenario: 上传后跨日
|
|
525
|
+
- **GIVEN** 已上传但尚未发出保存请求
|
|
526
|
+
- **WHEN** 恢复日期已改变
|
|
527
|
+
- **THEN** 不保存旧数据,重新取数并创建新代次;不擅自删除远端已上传文件
|
|
528
|
+
|
|
529
|
+
### Requirement: 保存确认及清理可独立恢复
|
|
530
|
+
|
|
531
|
+
上传/保存前 MUST持久化意图,返回storageKey或策略ID后立即落盘。中断恢复先读取本次完整成功CLI回包;保存仍未知时可查询AI策略列表,READY仅在业务线、周期、文件标识和全部保存内容唯一匹配时确认ID。无结果、查询失败、冲突及仅相同非READY状态/备注均不能证明本次写入结果。仅上传未保存不能靠AI策略列表确认。未知状态 MUST报告并保留材料,不自动重发或追加失败记录。
|
|
532
|
+
|
|
533
|
+
成功或确定失败结果 MUST先持久化,再清理工作文件。清理失败继续报告,恢复时仅重试清理,不重新生成、上传或保存。SIGTERM恢复不承诺解决单次取数或Excel处理本身超过宿主窗口;其他信号、强杀和断电不在本轮保证内。
|
|
534
|
+
|
|
535
|
+
#### Scenario: 保存成功后清理失败
|
|
536
|
+
- **GIVEN** AI策略ID及结果已持久化,清理失败
|
|
537
|
+
- **WHEN** 再次恢复
|
|
538
|
+
- **THEN** 只重试本次所属文件清理,保留原ID,不重复上传或保存
|
|
539
|
+
|
|
540
|
+
|
|
503
541
|
### Requirement: Strategy shard coordination preserves business execution
|
|
504
|
-
|
|
542
|
+
|
|
543
|
+
run_sharded仅提供旧阻塞分片协调,MUST NOT接受可恢复参数或声称持久恢复。默认可恢复执行沿用run_workflow --resumable,不能因宿主支持SubAgent而静默切回阻塞模式。
|
|
544
|
+
|
|
545
|
+
明确选择旧阻塞分片入口时,策略专家 SHALL 在实际支持SubAgent的宿主中由统一入口分配去重后的业务线,固定全部周期,令每片使用原有每阶段并发上限并由一个SubAgent独占接手事件。未指定片数时 SHALL 以三片为目标,实际片数不超过去重业务线数及已确认能接手的处理者数;用户明确指定的片数优先,但仍受这两个上限约束。每片默认并发 SHALL 为三,原有用户指定较低并发的能力保持。总在途上限 SHALL 为有效片数乘以每片并发乘以二。多片业务启动前 SHALL 确认所有处理者已成功创建并接受各自任务;创建失败时仅在业务开始前收缩、重新绑定并确认全部最终映射,并说明实际片数。仅确认宿主不支持SubAgent时 SHALL 使用原单入口由主Agent接手;能力不明先核实,名额为零或创建全部失败 SHALL 报告阻塞。支持但仅一片时 SHALL 仍创建一名子Agent,由主Agent启动run_sharded --shards 1。本片首轮全部执行清理后即可重试本片失败项,不等其他片;每片维护原行业字典缓存,最终结果按ID核对不漏不重。批次开始后 SHALL 不因处理者失联而静默改片、另起业务入口或重跑整片;无法确认原处理者可继续时 SHALL 沿原取消规则汇总,不自动派接替者并行处理同一片。每片处理者 SHALL 不嵌套增开Agent。
|
|
505
546
|
|
|
506
547
|
主Agent SHALL 实际调用宿主创建及任务派发工具,保存真实处理者标识与分片映射;可用名额 SHALL 按宿主配额扣除已占部分,不以Python线程代替独立Agent。创建成功且任务已派发是初次就绪证据,SHALL NOT 在启动入口前等待子Agent最终完成。子Agent SHALL 从after=0等待尚未创建的事件文件,不自行创建目录或启动业务。映射变化时 SHALL 在启动前重新派发并确认最终映射。子Agent正常完成时 SHALL 完整读取本片终态后回报身份、片号、ID结果/来源、交付、失败、重试和清理信息;主Agent SHALL 收齐回报并在入口退出后与plan、result及原清单核对,超大内容按既有工具恢复,未确认结果不得伪报完成。
|
|
507
548
|
|
|
@@ -585,13 +626,13 @@ Agent SHALL 从本条明细-only工作簿流式建立私有SQLite索引,完整
|
|
|
585
626
|
## 数量机械事实与引用
|
|
586
627
|
|
|
587
628
|
- AI请求前从本条已校验明细建一次索引,提供detailIndexPath和countFacts,重试复用;Agent不得重复prepare。明细并集不当本期,只有适用动作才推导本期/上期,数量占比明确分子分母并四舍五入两位;零分母不可计算。
|
|
588
|
-
-
|
|
629
|
+
- 旧阻塞响应保持bizLineId/attempt/requestId/text四字段;可恢复模式由提交脚本接收text并绑定请求身份。数量及数量比例在text中引用本次事实,接收端用自身可信副本渲染后执行原校验,不信任Agent修改过的请求或索引值。无效引用及已知明确裸计数形态只沿原AI重试,不重拉、不改标签或静默改数。
|
|
589
630
|
- 检查不涵盖任意自然语言、名称、未来建议、消耗比率或营销结论,不宣称全部数字正确;最终三表格式、明细、业务规则及提示词原文保持。
|
|
590
631
|
|
|
591
632
|
## 执行优化与完整恢复
|
|
592
633
|
|
|
593
634
|
- 生产新增Learning及出价输入最小快照 MUST 在本条分析期间私有落盘,不保留全部新增原行/日期快照及排序副本;原渠道→首次ID→同ID行序、日期字符串稳定排序、缺失和浮点结果 MUST 保持。结果对象集合仍占内存,不承诺全分析常量驻留;旧诊断接口兼容。
|
|
594
|
-
-
|
|
635
|
+
- 旧阻塞请求文件写入 MUST 不消耗原AI回包预算,写完后由事件提供ISO时间锚点;可恢复请求在发布前持久化epoch期限,写盘包含在该固定期限内,handoff直接读取请求期限且不传--issued-at。身份、各入口既有响应协议、原7/15分钟与重试规则不变。handoff MUST 实际呈现当前请求全文、数量/引用/输出约束及全部原统计,按requestId重新接手。request/stats片段 MUST 有来源绑定、无损UTF-8字节游标和真实响应预算,完成标记不替代中间内容完整接收;具体参数见 [read-tools.md](read-tools.md)。
|
|
595
636
|
- 原事件、默认读取与result.json兼容;显式summary及结果分页/原文分块 MUST 在预算内保留可恢复引用,不越过未完整收到的事件或ID。源/ID错误明确失败,not_ready需核对生产进程,结束后仍不可读报告协调异常;不据此重跑业务。全批ID、历史、文件、missing_ids/shard_errors及清理核对前不得删协调目录。
|
|
596
637
|
- 处理与清理双错 MUST 保留主因,纯清理失败同样非零且不重放。资源关闭后只有限重试EBUSY/ENOTEMPTY删除,不重试权限/内容错误;根标记删除后仅重试原rmdir,不重新扫描。目标全量回读通过而仅清理失败时保留有效路径,不将源校验通过当作目标有效。
|
|
597
638
|
- 确定不可生成状态在Excel也 MUST 不再整条拉三轮,仍保留失败语义。唯一一次复制前最终校验、交付目标全量回读及数据库上传前校验 MUST 保留,其余草稿/明细/XML/ZIP校验不减少。
|
|
@@ -14,12 +14,45 @@ class AiStrategyExhausted(RuntimeError):
|
|
|
14
14
|
"""AI 文案三次均未取得有效回包;批次不得再重跑整条业务线。"""
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
def validate_ai_text(value, *, require_learning=False, count_facts=None):
|
|
18
|
+
"""阻塞与可恢复交接使用同一文案及Learning校验。"""
|
|
19
|
+
if not isinstance(value, str) or not value.strip():
|
|
20
|
+
raise ValueError('AI 策略正文为空')
|
|
21
|
+
if count_facts is not None:
|
|
22
|
+
unreferenced = find_unreferenced_counts(value)
|
|
23
|
+
if unreferenced:
|
|
24
|
+
raise ValueError('数量事实请使用当前请求的机械引用,不能手写:' + unreferenced[0]['text'])
|
|
25
|
+
value = render_count_references(value, count_facts)
|
|
26
|
+
sections = prepare_ai_sections(value)
|
|
27
|
+
if require_learning:
|
|
28
|
+
for plain, _parts in sections[1:]:
|
|
29
|
+
split_ai_learning(plain)
|
|
30
|
+
return value
|
|
31
|
+
|
|
32
|
+
|
|
17
33
|
def _utc_text(moment=None):
|
|
18
34
|
if moment is None:
|
|
19
35
|
moment = datetime.now(timezone.utc)
|
|
20
36
|
return moment.isoformat(timespec='milliseconds').replace('+00:00', 'Z')
|
|
21
37
|
|
|
22
38
|
|
|
39
|
+
def prepare_ai_input(workbook):
|
|
40
|
+
"""同一明细索引和数量引用约定用于阻塞与可恢复请求。"""
|
|
41
|
+
prepared = prepare(workbook, workbook.parent / 'ai-detail.sqlite')
|
|
42
|
+
count_facts = prepared['countFacts']
|
|
43
|
+
return {
|
|
44
|
+
'detailIndexPath': prepared['index'], 'countFacts': count_facts,
|
|
45
|
+
'countReferenceContract': (
|
|
46
|
+
'既有数量和数量占比使用 {{数量|维度|渠道|口径}} 或 '
|
|
47
|
+
'{{数量占比|维度|渠道|分子口径|分母口径}} 引用;'
|
|
48
|
+
'跨渠道分母使用 {{数量占比|维度|分子渠道|分子口径|分母渠道|分母口径}},'
|
|
49
|
+
'分母渠道仅同渠道或全部渠道;渠道数仅可除渠道数。'
|
|
50
|
+
'明细并集包含新增、持续、暂停,不能当本期;本期和上期仅在事实提供时可用。'
|
|
51
|
+
'引用会由程序渲染,禁止自行合计/填写既有数量及其百分比;'
|
|
52
|
+
'消耗、效率和未来建议仍按原提示词分析。复用detailIndexPath,不重复prepare。'),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
23
56
|
def review_ai_strategy(root, workbook, prompt_path, cancel, progress, *,
|
|
24
57
|
biz_line_id, attempt, wait_seconds=900, require_learning=False):
|
|
25
58
|
"""只等待本业务线绑定的 Agent 回包;失败只重发 AI 请求。"""
|
|
@@ -34,7 +67,7 @@ def review_ai_strategy(root, workbook, prompt_path, cancel, progress, *,
|
|
|
34
67
|
raise InterruptedError('任务已取消')
|
|
35
68
|
|
|
36
69
|
# 只从已校验的本条明细建立一次索引,可信计数留在进程内;准备不消耗回包期限。
|
|
37
|
-
prepared =
|
|
70
|
+
prepared = prepare_ai_input(workbook)
|
|
38
71
|
count_facts = prepared['countFacts']
|
|
39
72
|
failures = []
|
|
40
73
|
for review_attempt in range(1, 4):
|
|
@@ -51,15 +84,7 @@ def review_ai_strategy(root, workbook, prompt_path, cancel, progress, *,
|
|
|
51
84
|
'bizLineId': biz_line_id, 'attempt': attempt, 'requestId': request_id,
|
|
52
85
|
'prompt': prompt, 'promptPath': str(prompt_path),
|
|
53
86
|
'workbookPath': str(workbook), 'worksheetName': '明细',
|
|
54
|
-
|
|
55
|
-
'countReferenceContract': (
|
|
56
|
-
'既有数量和数量占比使用 {{数量|维度|渠道|口径}} 或 '
|
|
57
|
-
'{{数量占比|维度|渠道|分子口径|分母口径}} 引用;'
|
|
58
|
-
'跨渠道分母使用 {{数量占比|维度|分子渠道|分子口径|分母渠道|分母口径}},'
|
|
59
|
-
'分母渠道仅同渠道或全部渠道;渠道数仅可除渠道数。'
|
|
60
|
-
'明细并集包含新增、持续、暂停,不能当本期;本期和上期仅在事实提供时可用。'
|
|
61
|
-
'引用会由程序渲染,禁止自行合计/填写既有数量及其百分比;'
|
|
62
|
-
'消耗、效率和未来建议仍按原提示词分析。复用detailIndexPath,不重复prepare。'),
|
|
87
|
+
**prepared,
|
|
63
88
|
}
|
|
64
89
|
if require_learning:
|
|
65
90
|
# 机械入库格式与独立分析提示词分开,不引入固定策略或额外投放数据。
|
|
@@ -101,18 +126,7 @@ def review_ai_strategy(root, workbook, prompt_path, cancel, progress, *,
|
|
|
101
126
|
type(reply['attempt']) is not int or reply['attempt'] != attempt or \
|
|
102
127
|
not isinstance(reply['requestId'], str) or reply['requestId'] != request_id:
|
|
103
128
|
raise ValueError('AI 策略回包身份与当前业务线不一致')
|
|
104
|
-
value = reply['text']
|
|
105
|
-
if not isinstance(value, str) or not value.strip():
|
|
106
|
-
raise ValueError('AI 策略正文为空')
|
|
107
|
-
# 同导出器共用机械分段校验,拒绝错列或超长回包后只重试 AI 阶段。
|
|
108
|
-
unreferenced = find_unreferenced_counts(value)
|
|
109
|
-
if unreferenced:
|
|
110
|
-
raise ValueError('数量事实请使用当前请求的机械引用,不能手写:' + unreferenced[0]['text'])
|
|
111
|
-
value = render_count_references(value, count_facts)
|
|
112
|
-
sections = prepare_ai_sections(value)
|
|
113
|
-
if require_learning:
|
|
114
|
-
for plain, _parts in sections[1:]:
|
|
115
|
-
split_ai_learning(plain)
|
|
129
|
+
value = validate_ai_text(reply['text'], require_learning=require_learning, count_facts=count_facts)
|
|
116
130
|
except (ValueError, UnicodeError) as exc:
|
|
117
131
|
reason = str(exc)
|
|
118
132
|
break
|
|
@@ -147,4 +147,13 @@ class OwnedDirectory:
|
|
|
147
147
|
except Exception as exc:
|
|
148
148
|
errors.append({'path': str(self.path), 'error': str(exc),
|
|
149
149
|
'error_type': type(exc).__name__, 'errno': getattr(exc, 'errno', None)})
|
|
150
|
+
# 根目录未删掉时恢复原归属,允许后续新进程仅继续收尾;不覆盖已有标记。
|
|
151
|
+
marker = self.path / MARKER
|
|
152
|
+
try:
|
|
153
|
+
with marker.open('x', encoding='utf-8') as handle:
|
|
154
|
+
json.dump({'token': self.token, 'path': str(self.path)}, handle)
|
|
155
|
+
except Exception as restore_error:
|
|
156
|
+
errors.append({'path': str(marker), 'error': str(restore_error),
|
|
157
|
+
'error_type': type(restore_error).__name__,
|
|
158
|
+
'errno': getattr(restore_error, 'errno', None)})
|
|
150
159
|
return {'ok': not errors, 'residual': errors}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""正式交付的写入意图与核实;调用方持有任务执行锁,本模块不重放未知写入。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from cli_json import load_json_file, require_ok, run_cli
|
|
9
|
+
from task_state import atomic_json
|
|
10
|
+
|
|
11
|
+
_QUERY_LIMIT = 200
|
|
12
|
+
_TAG_FIELDS = ('budgetStrategyTags', 'mediaStrategyTags', 'audienceStrategyTags')
|
|
13
|
+
_READY_FIELDS = ('bizLineId', 'periodStart', 'periodEnd', 'generateCode', 'fileStorageKey',
|
|
14
|
+
'budgetStrategySupplement', 'budgetStrategyEffect', 'budgetStrategyLearning',
|
|
15
|
+
'mediaStrategySupplement', 'mediaStrategyEffect', 'mediaStrategyLearning',
|
|
16
|
+
'audienceStrategySupplement', 'audienceStrategyEffect', 'audienceStrategyLearning',
|
|
17
|
+
'creativeStrategy', 'creativeStrategyEffect', 'creativeStrategyLearning', *_TAG_FIELDS)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UnknownWrite(RuntimeError):
|
|
21
|
+
"""写入可能成功,保留意图等待核实,不能按普通技术失败重新执行。"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _paths(root, kind):
|
|
25
|
+
if kind not in ('upload', 'save'):
|
|
26
|
+
raise ValueError('交付操作仅支持 upload 或 save')
|
|
27
|
+
root = Path(root)
|
|
28
|
+
if not root.is_dir():
|
|
29
|
+
raise ValueError('交付工作目录不存在')
|
|
30
|
+
return root / f'{kind}.intent.json', root / f'{kind}.out.json'
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _valid_result(kind, value):
|
|
34
|
+
if kind == 'save':
|
|
35
|
+
return type(value) is int and value > 0
|
|
36
|
+
return isinstance(value, str) and bool(value.strip()) and len(value) <= 512
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def read_intent(root, kind):
|
|
40
|
+
"""不存在与损坏严格区分,损坏不能被当作从未写入。"""
|
|
41
|
+
path, output = _paths(root, kind)
|
|
42
|
+
if not path.exists():
|
|
43
|
+
return None
|
|
44
|
+
try:
|
|
45
|
+
intent = json.loads(path.read_text(encoding='utf-8'))
|
|
46
|
+
except (ValueError, UnicodeError) as exc:
|
|
47
|
+
raise ValueError('交付意图损坏,拒绝重放') from exc
|
|
48
|
+
if not isinstance(intent, dict) or intent.get('version') != 1 or intent.get('kind') != kind or \
|
|
49
|
+
intent.get('output') != output.name or not isinstance(intent.get('body'), dict) or \
|
|
50
|
+
intent.get('status') not in ('pending', 'done'):
|
|
51
|
+
raise ValueError('交付意图格式或归属不匹配')
|
|
52
|
+
if intent['status'] == 'done' and not _valid_result(kind, intent.get('result')):
|
|
53
|
+
raise ValueError('交付成功结果损坏,拒绝重放')
|
|
54
|
+
return intent
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _complete(root, kind, intent, value):
|
|
58
|
+
if not _valid_result(kind, value):
|
|
59
|
+
raise UnknownWrite('交付返回值不完整,结果待核实')
|
|
60
|
+
path, _ = _paths(root, kind)
|
|
61
|
+
# 新字典避免落盘失败时调用方误认为内存中的 done 已提交。
|
|
62
|
+
try:
|
|
63
|
+
atomic_json(path, {**intent, 'status': 'done', 'result': value})
|
|
64
|
+
except Exception as exc:
|
|
65
|
+
# 外部写入已成功,检查点失败只能保持待核实,不能触发失败状态保存。
|
|
66
|
+
raise UnknownWrite(f'{kind} 已返回成功但结果记录失败,需恢复核实({type(exc).__name__})') from exc
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def perform_write(root, kind, body, operation, *, cancel=None, before_request=None):
|
|
71
|
+
"""首次写入前登记;已登记的调用只恢复,不再次调用 operation。"""
|
|
72
|
+
path, output = _paths(root, kind)
|
|
73
|
+
if not isinstance(body, dict):
|
|
74
|
+
raise ValueError('交付请求必须是对象')
|
|
75
|
+
intent = read_intent(root, kind)
|
|
76
|
+
if intent is not None:
|
|
77
|
+
if intent['body'] != body:
|
|
78
|
+
raise ValueError('交付请求与已登记意图不同,拒绝复用')
|
|
79
|
+
return recover_write(root, kind, cancel=cancel, before_request=before_request)
|
|
80
|
+
if output.exists():
|
|
81
|
+
raise ValueError('发现没有对应意图的旧交付回包,拒绝复用')
|
|
82
|
+
if cancel is not None and cancel.is_set():
|
|
83
|
+
raise InterruptedError('任务已中断')
|
|
84
|
+
if before_request is not None:
|
|
85
|
+
before_request()
|
|
86
|
+
intent = {'version': 1, 'kind': kind, 'body': json.loads(json.dumps(body)),
|
|
87
|
+
'output': output.name, 'status': 'pending'}
|
|
88
|
+
atomic_json(path, intent)
|
|
89
|
+
try:
|
|
90
|
+
value = operation()
|
|
91
|
+
except InterruptedError:
|
|
92
|
+
raise
|
|
93
|
+
except Exception as exc:
|
|
94
|
+
# 不把可能包含请求正文的底层错误写入恢复状态。
|
|
95
|
+
raise UnknownWrite(f'{kind} 未取得确定结果,保留意图待核实({type(exc).__name__})') from exc
|
|
96
|
+
return _complete(root, kind, intent, value)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _same_ready(row, body):
|
|
100
|
+
if body.get('generateCode') != 'READY' or any(key not in body for key in _READY_FIELDS):
|
|
101
|
+
return False
|
|
102
|
+
if not isinstance(body.get('fileStorageKey'), str) or not body['fileStorageKey'].strip():
|
|
103
|
+
return False
|
|
104
|
+
for key, expected in body.items():
|
|
105
|
+
remote = {'periodStart': 'weekStart', 'periodEnd': 'weekEnd'}.get(key, key)
|
|
106
|
+
if remote not in row:
|
|
107
|
+
return False
|
|
108
|
+
actual = row[remote]
|
|
109
|
+
if key in _TAG_FIELDS:
|
|
110
|
+
# 标签顺序无业务含义,但重复值不丢弃,以免集合比较掩盖差异。
|
|
111
|
+
if not isinstance(actual, list) or not isinstance(expected, list) or \
|
|
112
|
+
any(type(value) is not int for value in actual + expected) or sorted(actual) != sorted(expected):
|
|
113
|
+
return False
|
|
114
|
+
elif type(actual) is not type(expected) or actual != expected:
|
|
115
|
+
return False
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _query_saved(root, body, cancel, before_request):
|
|
120
|
+
if type(body.get('bizLineId')) is not int or not body.get('periodStart') or not body.get('periodEnd'):
|
|
121
|
+
raise ValueError('保存意图缺少业务线或周期')
|
|
122
|
+
query = {'bizLineId': body['bizLineId'], 'weekStart': body['periodStart'],
|
|
123
|
+
'weekEnd': body['periodEnd'], 'pageSize': 100}
|
|
124
|
+
found = set()
|
|
125
|
+
for page_num in range(1, _QUERY_LIMIT + 1):
|
|
126
|
+
if cancel is not None and cancel.is_set():
|
|
127
|
+
raise InterruptedError('任务已中断')
|
|
128
|
+
if before_request is not None:
|
|
129
|
+
before_request()
|
|
130
|
+
envelope = require_ok(run_cli(['biz-line-ai-strategy', '+list'],
|
|
131
|
+
{**query, 'pageNum': page_num},
|
|
132
|
+
Path(root) / f'verify-save-{page_num}.out.json'), '核实AI策略保存')
|
|
133
|
+
data = envelope.get('data')
|
|
134
|
+
if not isinstance(data, dict) or not isinstance(data.get('result'), list):
|
|
135
|
+
raise ValueError('AI策略查询未返回有效分页列表')
|
|
136
|
+
rows = data['result']
|
|
137
|
+
if not rows:
|
|
138
|
+
return next(iter(found)) if len(found) == 1 else None
|
|
139
|
+
for row in rows:
|
|
140
|
+
if not isinstance(row, dict):
|
|
141
|
+
raise ValueError('AI策略列表包含无效记录')
|
|
142
|
+
if _same_ready(row, body):
|
|
143
|
+
if not _valid_result('save', row.get('id')):
|
|
144
|
+
raise ValueError('匹配记录没有有效AI策略ID')
|
|
145
|
+
found.add(row['id'])
|
|
146
|
+
raise ValueError('AI策略核实达到分页上限仍未见空页,结果不完整')
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def recover_write(root, kind, *, cancel=None, before_request=None):
|
|
150
|
+
"""先恢复确切成功回包,保存未知再有限查询;没有意图返回 None。"""
|
|
151
|
+
intent = read_intent(root, kind)
|
|
152
|
+
if intent is None:
|
|
153
|
+
return None
|
|
154
|
+
if intent['status'] == 'done':
|
|
155
|
+
return intent['result']
|
|
156
|
+
if cancel is not None and cancel.is_set():
|
|
157
|
+
raise InterruptedError('任务已中断')
|
|
158
|
+
_, output = _paths(root, kind)
|
|
159
|
+
envelope = load_json_file(output)
|
|
160
|
+
if isinstance(envelope, dict) and envelope.get('ok') is True:
|
|
161
|
+
data = envelope.get('data')
|
|
162
|
+
value = data.get('storageKey') if kind == 'upload' and isinstance(data, dict) else data
|
|
163
|
+
if _valid_result(kind, value):
|
|
164
|
+
return _complete(root, kind, intent, value)
|
|
165
|
+
if kind == 'upload':
|
|
166
|
+
raise UnknownWrite('上传结果未知,尚无可用文件标识;不自动重传')
|
|
167
|
+
last_reason = '未找到唯一匹配记录'
|
|
168
|
+
for attempt in range(3):
|
|
169
|
+
if attempt:
|
|
170
|
+
if cancel is not None:
|
|
171
|
+
if cancel.wait(attempt):
|
|
172
|
+
raise InterruptedError('任务已中断')
|
|
173
|
+
else:
|
|
174
|
+
time.sleep(attempt)
|
|
175
|
+
try:
|
|
176
|
+
value = _query_saved(root, intent['body'], cancel, before_request)
|
|
177
|
+
except InterruptedError:
|
|
178
|
+
raise
|
|
179
|
+
except Exception as exc:
|
|
180
|
+
last_reason = f'查询未完整成功({type(exc).__name__})'
|
|
181
|
+
continue
|
|
182
|
+
if value is not None:
|
|
183
|
+
return _complete(root, kind, intent, value)
|
|
184
|
+
raise UnknownWrite(f'AI策略保存结果待核实:{last_reason};不自动重发保存')
|