sino-bc-cli 1.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/README.md +256 -0
  2. package/dist/affordance/auth.md +31 -0
  3. package/dist/affordance/bill.md +48 -0
  4. package/dist/affordance/biz-line-ai-strategy.md +35 -0
  5. package/dist/affordance/biz-line-metrics.md +47 -0
  6. package/dist/affordance/biz-line.md +84 -0
  7. package/dist/affordance/common-file.md +25 -0
  8. package/dist/affordance/dashboard.md +75 -0
  9. package/dist/affordance/dict.md +28 -0
  10. package/dist/affordance/document.md +67 -0
  11. package/dist/affordance/im.md +91 -0
  12. package/dist/affordance/invoice.md +49 -0
  13. package/dist/affordance/media.md +17 -0
  14. package/dist/affordance/organization.md +47 -0
  15. package/dist/affordance/project-analysis.md +95 -0
  16. package/dist/affordance/project.md +321 -0
  17. package/dist/affordance/quota.md +19 -0
  18. package/dist/affordance/reconciliation.md +41 -0
  19. package/dist/affordance/report.md +75 -0
  20. package/dist/affordance/sso-team-group.md +30 -0
  21. package/dist/affordance/task.md +148 -0
  22. package/dist/affordance/tenant.md +17 -0
  23. package/dist/affordance/user.md +41 -0
  24. package/dist/affordance/wallet.md +100 -0
  25. package/dist/api-doc/README.md +67 -0
  26. package/dist/api-doc/custom/template.md +0 -0
  27. package/dist/api-doc/oapi/auth.md +61 -0
  28. package/dist/api-doc/oapi/bill.md +130 -0
  29. package/dist/api-doc/oapi/biz-line-ai-strategy.md +119 -0
  30. package/dist/api-doc/oapi/biz-line-metrics.md +419 -0
  31. package/dist/api-doc/oapi/biz-line.md +382 -0
  32. package/dist/api-doc/oapi/common-file.md +50 -0
  33. package/dist/api-doc/oapi/dashboard.md +218 -0
  34. package/dist/api-doc/oapi/dict.md +49 -0
  35. package/dist/api-doc/oapi/document.md +182 -0
  36. package/dist/api-doc/oapi/im.md +278 -0
  37. package/dist/api-doc/oapi/invoice.md +129 -0
  38. package/dist/api-doc/oapi/media.md +17 -0
  39. package/dist/api-doc/oapi/organization.md +94 -0
  40. package/dist/api-doc/oapi/project-analysis.md +364 -0
  41. package/dist/api-doc/oapi/project.md +1114 -0
  42. package/dist/api-doc/oapi/quota.md +21 -0
  43. package/dist/api-doc/oapi/reconciliation.md +98 -0
  44. package/dist/api-doc/oapi/report.md +254 -0
  45. package/dist/api-doc/oapi/sso-team-group.md +54 -0
  46. package/dist/api-doc/oapi/task.md +528 -0
  47. package/dist/api-doc/oapi/tenant.md +17 -0
  48. package/dist/api-doc/oapi/user.md +98 -0
  49. package/dist/api-doc/oapi/wallet.md +275 -0
  50. package/dist/generated/api-catalog.json +39681 -0
  51. package/dist/generated/api-directory-index.json +1509 -0
  52. package/dist/index.js +2 -0
  53. package/dist/skill-manifest.json +336 -0
  54. package/dist/skills/biz-line-strategy-generate/SKILL.md +122 -0
  55. package/dist/skills/biz-line-strategy-generate/references/ai-strategy-prompt.md +50 -0
  56. package/dist/skills/biz-line-strategy-generate/references/api-flow.md +155 -0
  57. package/dist/skills/biz-line-strategy-generate/references/four-strategies.md +58 -0
  58. package/dist/skills/biz-line-strategy-generate/references/metrics.md +119 -0
  59. package/dist/skills/biz-line-strategy-generate/references/output-excel.md +63 -0
  60. package/dist/skills/biz-line-strategy-generate/references/runtime.md +127 -0
  61. package/dist/skills/biz-line-strategy-generate/references/spec.md +500 -0
  62. package/dist/skills/biz-line-strategy-generate/scripts/ai_strategy.py +89 -0
  63. package/dist/skills/biz-line-strategy-generate/scripts/build_report.py +629 -0
  64. package/dist/skills/biz-line-strategy-generate/scripts/cleanup.py +87 -0
  65. package/dist/skills/biz-line-strategy-generate/scripts/cli_json.py +705 -0
  66. package/dist/skills/biz-line-strategy-generate/scripts/fetch_data.py +449 -0
  67. package/dist/skills/biz-line-strategy-generate/scripts/run_workflow.py +402 -0
  68. package/dist/skills/biz-line-strategy-generate/scripts/strategy_output.py +556 -0
  69. package/dist/skills/biz-line-strategy-generate/scripts/submit_tags.py +32 -0
  70. package/dist/skills/biz-line-strategy-generate/scripts/summarize.py +932 -0
  71. package/dist/skills/biz-line-strategy-generate/scripts/test_ai_strategy.py +235 -0
  72. package/dist/skills/biz-line-strategy-generate/scripts/test_cli_json.py +97 -0
  73. package/dist/skills/biz-line-strategy-generate/scripts/test_database_output.py +904 -0
  74. package/dist/skills/biz-line-strategy-generate/scripts/test_pipeline.py +253 -0
  75. package/dist/skills/biz-line-strategy-generate/scripts/test_strategy_rules.py +224 -0
  76. package/dist/skills/biz-line-strategy-generate/scripts/test_tag_submission.py +238 -0
  77. package/dist/skills/biz-line-strategy-generate/scripts/test_workflow.py +1233 -0
  78. package/dist/skills/sino-bc-cli/SKILL.md +62 -0
  79. package/dist/skills/sino-bc-cli/domains/sino-bc-auth/SKILL.md +36 -0
  80. package/dist/skills/sino-bc-cli/domains/sino-bc-bill/SKILL.md +48 -0
  81. package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line/SKILL.md +58 -0
  82. package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line/references/common-mistakes.md +7 -0
  83. package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line/references/enums.md +13 -0
  84. package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line-ai-strategy/SKILL.md +46 -0
  85. package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line-metrics/SKILL.md +46 -0
  86. package/dist/skills/sino-bc-cli/domains/sino-bc-common-file/SKILL.md +44 -0
  87. package/dist/skills/sino-bc-cli/domains/sino-bc-dashboard/SKILL.md +50 -0
  88. package/dist/skills/sino-bc-cli/domains/sino-bc-dict/SKILL.md +48 -0
  89. package/dist/skills/sino-bc-cli/domains/sino-bc-document/SKILL.md +53 -0
  90. package/dist/skills/sino-bc-cli/domains/sino-bc-im/SKILL.md +62 -0
  91. package/dist/skills/sino-bc-cli/domains/sino-bc-invoice/SKILL.md +48 -0
  92. package/dist/skills/sino-bc-cli/domains/sino-bc-media/SKILL.md +40 -0
  93. package/dist/skills/sino-bc-cli/domains/sino-bc-organization/SKILL.md +46 -0
  94. package/dist/skills/sino-bc-cli/domains/sino-bc-project/SKILL.md +112 -0
  95. package/dist/skills/sino-bc-cli/domains/sino-bc-project-analysis/SKILL.md +61 -0
  96. package/dist/skills/sino-bc-cli/domains/sino-bc-project-analysis/references/analysis-workflow.md +21 -0
  97. package/dist/skills/sino-bc-cli/domains/sino-bc-project-analysis/references/filters-and-metrics.md +30 -0
  98. package/dist/skills/sino-bc-cli/domains/sino-bc-quota/SKILL.md +44 -0
  99. package/dist/skills/sino-bc-cli/domains/sino-bc-reconciliation/SKILL.md +47 -0
  100. package/dist/skills/sino-bc-cli/domains/sino-bc-report/SKILL.md +53 -0
  101. package/dist/skills/sino-bc-cli/domains/sino-bc-sso-team-group/SKILL.md +45 -0
  102. package/dist/skills/sino-bc-cli/domains/sino-bc-task/SKILL.md +73 -0
  103. package/dist/skills/sino-bc-cli/domains/sino-bc-task/references/state-machine.md +18 -0
  104. package/dist/skills/sino-bc-cli/domains/sino-bc-tenant/SKILL.md +34 -0
  105. package/dist/skills/sino-bc-cli/domains/sino-bc-user/SKILL.md +57 -0
  106. package/dist/skills/sino-bc-cli/domains/sino-bc-wallet/SKILL.md +55 -0
  107. package/dist/skills/sino-bc-cli/references/domain-index/module-8534789.md +53 -0
  108. package/dist/skills/sino-bc-cli/references/domain-index/module-8551546.md +70 -0
  109. package/dist/skills/sino-bc-cli/references/domain-index/module-8562638.md +13 -0
  110. package/dist/skills/sino-bc-cli/references/domain-index/module-8566367.md +13 -0
  111. package/dist/skills/sino-bc-cli/references/domain-index/module-8581524.md +17 -0
  112. package/dist/skills/sino-bc-cli/references/domain-index/module-8601540.md +13 -0
  113. package/dist/skills/sino-bc-cli/references/domain-index/module-8614891.md +33 -0
  114. package/dist/skills/sino-bc-cli/references/domain-index/module-8621792.md +17 -0
  115. package/dist/skills/sino-bc-shared/SKILL.md +31 -0
  116. package/dist/skills/sino-bc-shared/references/auth-recovery.md +36 -0
  117. package/dist/skills/sino-bc-shared/references/config-and-env.md +60 -0
  118. package/dist/skills/sino-bc-shared/references/output-contract.md +86 -0
  119. package/dist/skills/sino-bc-shared/references/skill-lifecycle.md +17 -0
  120. package/dist/skills/sino-bc-shared/references/tenant-context.md +21 -0
  121. package/dist/skills/sino-bc-shared/references/write-approval.md +16 -0
  122. package/package.json +70 -0
  123. package/scripts/postinstall.cjs +36 -0
@@ -0,0 +1,556 @@
1
+ """策略工作流的标签、业务预检及入库计划;所有网络请求仍由 CLI 执行。"""
2
+ from datetime import date, datetime, timedelta, timezone
3
+ from contextlib import contextmanager
4
+ from hashlib import sha256
5
+ import json
6
+ import math
7
+ import os
8
+ from pathlib import Path
9
+ import re
10
+ import threading
11
+ import time
12
+ from uuid import uuid4
13
+
14
+ from cli_json import optional_num, require_ok, run_cli
15
+ from cleanup import is_link
16
+
17
+ # 策略键与服务字段集中映射;数据库正文取 AI sheet,标签绑定沿用审核结果。
18
+ STRATEGIES = {'budget': 'budget', 'product': 'media', 'audience': 'audience', 'creative': 'creative'}
19
+ SCENES = {'budget': 'BUDGET_STRATEGY', 'product': 'MEDIA_STRATEGY', 'audience': 'AUDIENCE_STRATEGY'}
20
+ RESULT_CODES = {'READY', 'NO_PROJECT', 'UNSUPPORTED_CHANNEL', 'NO_AD_ACCOUNT', 'NO_DELIVERY_DATA', 'GENERATION_FAILED'}
21
+ TAG_REVIEW_SECONDS = 420
22
+ TAG_REVIEW_ATTEMPTS = 2
23
+ TAG_SUBMIT_HINT = '请使用 scripts/submit_tags.py 提交 choiceKey、reason;id(整数)由脚本自动补齐,不要直接写 response.json。'
24
+
25
+
26
+ class IndustryConfigurationError(ValueError):
27
+ """缺少行业配置不能通过重拉成效修复,入库一次后不整条重试。"""
28
+
29
+
30
+ def industry_condition(biz):
31
+ """行业筛选严格取业务线第一个行业 ID,不使用后续行业或人工默认值兜底。"""
32
+ ids = biz.get('adCategoryIndustryIds')
33
+ if not isinstance(ids, list) or not ids or type(ids[0]) is not int or ids[0] <= 0:
34
+ raise IndustryConfigurationError('业务线 adCategoryIndustryIds 缺少有效的首个行业 ID,无法查询策略标签')
35
+ return str(ids[0])
36
+
37
+
38
+ class TagCache:
39
+ """同批次按行业缓存三类标签与错误;锁内加载避免并发重复查询。"""
40
+ def __init__(self, root):
41
+ self.root = root
42
+ self.lock = threading.Lock()
43
+ self.values, self.errors = {}, {}
44
+
45
+ def reset_errors(self):
46
+ """整轮结束后才清除失败缓存,成功行业字典继续在本批次复用。"""
47
+ with self.lock:
48
+ self.errors.clear()
49
+
50
+ def get(self, biz):
51
+ """同行业复用,不同行业使用独立文件目录;不跨批次复用过期字典。"""
52
+ condition = industry_condition(biz)
53
+ with self.lock:
54
+ if condition in self.errors:
55
+ raise RuntimeError(self.errors[condition])
56
+ if condition not in self.values:
57
+ try:
58
+ industry_root = self.root / ('industry-' + condition)
59
+ industry_root.mkdir(parents=True, exist_ok=True)
60
+ self.values[condition] = load_tags(industry_root, condition)
61
+ except Exception as exc:
62
+ self.errors[condition] = str(exc)
63
+ raise
64
+ return self.values[condition]
65
+
66
+
67
+ class GenerationOutcome(RuntimeError):
68
+ """预期不可生成结果,与传输错误分开,防止失败回包被解释成无数据。"""
69
+ def __init__(self, code, message):
70
+ super().__init__(message)
71
+ self.code = code
72
+
73
+
74
+ def validate_period(start, end, today=None):
75
+ """保存接口只接受已结束且受支持的完整自然周,不发送展示状态。"""
76
+ start, end = date.fromisoformat(start), date.fromisoformat(end)
77
+ if start.weekday() != 0 or end != start + timedelta(days=6):
78
+ raise ValueError('入库周期必须是同一自然周的周一至周日')
79
+ if end >= (today or date.today()):
80
+ raise ValueError('入库周期尚未结束')
81
+ if start < date(2026, 9, 7):
82
+ raise ValueError('入库周期早于当前契约支持的 2026-09-07')
83
+
84
+
85
+ def precheck_projects(root, biz_id, expected_ids=None, *, skip_spend_check=False):
86
+ """逐页核对项目覆盖和上周消耗;渠道由关联接口的广告账号单独校验。"""
87
+ total = None
88
+ count = 0
89
+ all_zero, positive = True, False
90
+ seen = set()
91
+ for page in range(1, 81):
92
+ env = require_ok(run_cli(['biz-line', '+queryByPage'], {
93
+ 'bizLineIdList': [biz_id], 'pageNum': page, 'pageSize': 100,
94
+ }, root / f'precheck.p{page}.out.json'), '项目预检')
95
+ data = env.get('data')
96
+ if not isinstance(data, dict) or not isinstance(data.get('result'), list) or type(data.get('total')) is not int or data['total'] < 0:
97
+ raise RuntimeError('项目预检分页格式无效')
98
+ if total is not None and total != data['total']:
99
+ raise RuntimeError('项目预检分页总数变化')
100
+ total = data['total']
101
+ rows = data['result']
102
+ for item in rows:
103
+ if not isinstance(item, dict) or item.get('projectId') is None or item['projectId'] in seen:
104
+ raise RuntimeError('项目预检含无效或重复项目')
105
+ seen.add(item['projectId'])
106
+ value = optional_num(item, 'lastWeekSpend')
107
+ if isinstance(item.get('lastWeekSpend'), bool):
108
+ value = None
109
+ all_zero = all_zero and value == 0
110
+ positive = positive or (value is not None and value > 0)
111
+ count += len(rows)
112
+ if count > total:
113
+ raise RuntimeError('项目预检累计数量超过总数')
114
+ if count == total:
115
+ break
116
+ if not rows or page == 80:
117
+ raise RuntimeError('项目预检分页未取全')
118
+ # 服务端默认只查代投服务,必须用业务线关联 ID 校验覆盖,防止漏项目误判全零。
119
+ if expected_ids is not None and {str(v) for v in seen} != {str(v) for v in expected_ids}:
120
+ raise RuntimeError('项目分页未覆盖业务线全部关联项目(接口默认服务范围为 1、85),不能判断无项目或消耗全零')
121
+ if not count:
122
+ raise GenerationOutcome('NO_PROJECT', '业务线未关联项目')
123
+ # 仅用户显式跳过消耗门槛;项目覆盖、后续关联账号渠道和目标周期检查仍保留。
124
+ if not skip_spend_check and all_zero:
125
+ raise GenerationOutcome('NO_DELIVERY_DATA', '全部关联项目上周消耗 lastWeekSpend 均为 0')
126
+ if not skip_spend_check and not positive:
127
+ raise RuntimeError('项目上周消耗缺失或异常,不能判断为全零')
128
+
129
+
130
+ def load_tags(root, condition):
131
+ """每批次串行获取三类字典,保留服务返回的 ID 与名称,不虚构枚举。"""
132
+ if not isinstance(condition, str) or not condition.isdecimal() or int(condition) <= 0:
133
+ raise ValueError('策略字典筛选值必须是业务线首个行业 ID 的字符串')
134
+ result = {}
135
+ for key, scene in SCENES.items():
136
+ # 三个场景共用该业务线首个行业 ID,保留真实字典 ID/name。
137
+ query = {'sceneCode': scene, 'filterCondition': condition}
138
+ env = require_ok(run_cli(['biz-line', '+categories'], query,
139
+ root / f'tags-{key}.out.json'), f'{scene} 字典')
140
+ data = env.get('data')
141
+ if not isinstance(data, list) or not data:
142
+ raise ValueError(f'{scene} 字典为空或格式无效,无法取得标签 ID,请核对服务端场景字典配置')
143
+ result[key] = flatten_tags(data)
144
+ return result
145
+
146
+
147
+ def flatten_tags(items):
148
+ """兼容字典 children 树,重复 ID 必须对应同名,避免输出与写入不一致。"""
149
+ found = {}
150
+ def visit(nodes):
151
+ for item in nodes:
152
+ if not isinstance(item, dict) or type(item.get('id')) is not int or item['id'] <= 0 or not isinstance(item.get('name'), str) or not item['name'].strip():
153
+ raise ValueError('策略字典缺少有效 ID/name')
154
+ if item['id'] in found and found[item['id']]['name'] != item['name']:
155
+ raise ValueError('策略字典同 ID 对应不同名称')
156
+ found[item['id']] = item
157
+ children = item.get('children')
158
+ if children is not None:
159
+ if not isinstance(children, list):
160
+ raise ValueError('策略字典 children 格式无效')
161
+ visit(children)
162
+ visit(items)
163
+ return list(found.values())
164
+
165
+
166
+ def reviewed_tags(labels, selections):
167
+ """校验 Agent 内容分析结果:只接受本场景真实 ID、有理由的非重复选择。"""
168
+ by_id = {item['id']: item for item in flatten_tags(labels)}
169
+ if not isinstance(selections, list) or not selections:
170
+ raise ValueError('每类策略必须提供至少一个标签选择及理由')
171
+ matched = []
172
+ for choice in selections:
173
+ if not isinstance(choice, dict) or type(choice.get('id')) is not int or choice['id'] not in by_id:
174
+ raise ValueError('标签选择包含不属于本行业本场景的 ID')
175
+ if not isinstance(choice.get('reason'), str) or not choice['reason'].strip():
176
+ raise ValueError('标签选择必须说明策略内容与标签的对应关系;其他须说明无适合项')
177
+ item = by_id[choice['id']]
178
+ if item in matched:
179
+ raise ValueError('标签选择包含重复 ID')
180
+ matched.append(item)
181
+ if len(matched) > 1 and any(item.get('isOther') == 1 or item['name'].strip() == '其他' for item in matched):
182
+ raise ValueError('有适合标签时不能同时选其他')
183
+ if any(item.get('isOther') == 1 or item['name'].strip() == '其他' for item in matched):
184
+ others = [item for item in by_id.values() if item.get('isOther') == 1 or item['name'].strip() == '其他']
185
+ if len(others) != 1:
186
+ raise ValueError('本场景缺少唯一其他标签,不能任意选择兜底')
187
+ if len(json.dumps([item['id'] for item in matched])) > 500:
188
+ raise ValueError('策略标签 ID 数组序列化后超过 500 字符')
189
+ return matched
190
+
191
+
192
+ def choice_key(review_id, strategy, tag_id):
193
+ """编号绑定本次随机审核和策略类别,同行业同ID也不能错投。"""
194
+ return strategy[0].upper() + '-' + sha256(f'{review_id}:{strategy}:{tag_id}'.encode('utf-8')).hexdigest()[:16]
195
+
196
+
197
+ @contextmanager
198
+ def tag_review_lock(root):
199
+ """提交、消费与关闭共用本条目录锁;目录清理后不重新创建。"""
200
+ root = Path(root).absolute()
201
+ if not root.is_dir() or is_link(root):
202
+ raise ValueError('标签请求目录不存在或路径已变化')
203
+ # Windows临时根可能使用8.3短路径,规范化后再创建本条锁。
204
+ root = root.resolve()
205
+ lock = root / '.tag-review.lock'
206
+ deadline = time.monotonic() + 5
207
+ while True:
208
+ try:
209
+ descriptor = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
210
+ break
211
+ except FileExistsError:
212
+ if time.monotonic() >= deadline:
213
+ raise ValueError('标签交接锁繁忙,禁止删除锁或覆盖响应')
214
+ time.sleep(0.02)
215
+ try:
216
+ yield
217
+ finally:
218
+ # 仅释放本次实际创建的锁,不清理其他提交者替换的文件。
219
+ original = os.fstat(descriptor)
220
+ os.close(descriptor)
221
+ try:
222
+ if os.path.samestat(original, lock.stat(follow_symlinks=False)):
223
+ lock.unlink()
224
+ except FileNotFoundError:
225
+ pass
226
+
227
+
228
+ def close_tag_review(request):
229
+ """期限结束或取消后保留关闭标记,迟到提交不能复活旧请求。"""
230
+ request = Path(request)
231
+ with tag_review_lock(request.parent):
232
+ closed = request.with_name(request.name.replace('.request.json', '.closed.json'))
233
+ if not closed.exists():
234
+ with closed.open('x', encoding='utf-8') as stream:
235
+ json.dump({'closed': True}, stream)
236
+
237
+
238
+ def read_tag_request(path, expected_review_id):
239
+ """提交只接受当前审核路径,拒绝过期、关闭、被替代和已有响应。"""
240
+ path = Path(path).absolute()
241
+ match = re.fullmatch(r'tag-review\.([12])\.request\.json', path.name)
242
+ if not match or is_link(path):
243
+ raise ValueError('标签请求路径无效')
244
+ raw = path.read_bytes()
245
+ packet = json.loads(raw.decode('utf-8-sig'))
246
+ if not isinstance(packet, dict) or type(packet.get('bizLineId')) is not int or packet['bizLineId'] <= 0 \
247
+ or type(packet.get('attempt')) is not int or packet['attempt'] <= 0 \
248
+ or not isinstance(expected_review_id, str) or not expected_review_id \
249
+ or packet.get('reviewId') != expected_review_id or packet.get('submissionMode') != 'choice-key-v1':
250
+ raise ValueError('标签请求身份或编号协议不一致')
251
+ expires = packet.get('expiresAt')
252
+ closed = path.with_name(path.name.replace('.request.json', '.closed.json'))
253
+ if type(expires) not in (int, float) or not math.isfinite(expires) or time.time() >= expires \
254
+ or closed.exists() or any((path.parent / f'tag-review.{later}.request.json').exists()
255
+ for later in range(int(match[1]) + 1, TAG_REVIEW_ATTEMPTS + 1)):
256
+ raise ValueError('标签请求已过期、关闭或被下一次审核替代')
257
+ response = path.with_name(path.name.replace('.request.json', '.response.json'))
258
+ if response.exists() or response.is_symlink():
259
+ raise ValueError('标签响应已存在,禁止覆盖')
260
+ return raw, packet, response
261
+
262
+
263
+ def validate_tag_choice_fields(item, location, *, include_id=False):
264
+ """提交端与消费端复用字段诊断,只细化错误提示,不改变标签合法性规则。"""
265
+ if not isinstance(item, dict):
266
+ raise ValueError(f'{location} 必须是标签选择对象;{TAG_SUBMIT_HINT}')
267
+ fields = ('id', 'choiceKey', 'reason') if include_id else ('choiceKey', 'reason')
268
+ missing = [field for field in fields if field not in item]
269
+ if missing:
270
+ raise ValueError(f'{location} 缺少字段:{", ".join(missing)};{TAG_SUBMIT_HINT}')
271
+ if include_id and type(item['id']) is not int:
272
+ raise ValueError(f'{location}.id 必须是整数,不能是布尔值或字符串;{TAG_SUBMIT_HINT}')
273
+ if not isinstance(item['choiceKey'], str) or not item['choiceKey'].strip():
274
+ raise ValueError(f'{location}.choiceKey 必须是本次候选的非空字符串编号;{TAG_SUBMIT_HINT}')
275
+ if not isinstance(item['reason'], str) or not item['reason'].strip():
276
+ raise ValueError(f'{location}.reason 必须是非空字符串,说明正文与标签的对应依据;{TAG_SUBMIT_HINT}')
277
+
278
+
279
+ def map_tag_choices(packet, selection):
280
+ """复用原ID/理由校验;输出携带编号,消费端再次验证对应关系。"""
281
+ if not isinstance(selection, dict) or set(selection) != {'selections'} \
282
+ or not isinstance(selection['selections'], dict) or set(selection['selections']) != set(SCENES) \
283
+ or not isinstance(packet.get('strategies'), dict) or set(packet['strategies']) != set(SCENES):
284
+ raise ValueError(f'提交必须只包含 selections.budget/product/audience 三类编号与理由;{TAG_SUBMIT_HINT}')
285
+ result = {}
286
+ for key in SCENES:
287
+ labels = flatten_tags(packet['strategies'][key]['labels'])
288
+ by_key = {item['choiceKey']: item for item in labels}
289
+ if len(by_key) != len(labels) or any(item['choiceKey'] != choice_key(packet['reviewId'], key, item['id'])
290
+ for item in labels):
291
+ raise ValueError('候选编号与当前请求或场景不一致')
292
+ choices = selection['selections'][key]
293
+ if not isinstance(choices, list) or not choices:
294
+ raise ValueError(f'{key} 必须是非空标签选择数组;{TAG_SUBMIT_HINT}')
295
+ result[key] = []
296
+ for index, item in enumerate(choices):
297
+ location = f'{key}[{index}]'
298
+ validate_tag_choice_fields(item, location)
299
+ if set(item) != {'choiceKey', 'reason'}:
300
+ extra = ', '.join(sorted(set(item) - {'choiceKey', 'reason'}))
301
+ raise ValueError(f'{location} 含多余字段:{extra},提交输入只允许 choiceKey、reason;{TAG_SUBMIT_HINT}')
302
+ if item['choiceKey'] not in by_key:
303
+ raise ValueError(f'{location}.choiceKey 不属于当前请求/场景;请重新读取本次 request.json;{TAG_SUBMIT_HINT}')
304
+ result[key].append({'id': by_key[item['choiceKey']]['id'], **item})
305
+ reviewed_tags(labels, result[key])
306
+ return result
307
+
308
+
309
+ def submit_tag_choices(request, review_id, selection):
310
+ """统一提交入口:回读、映射、再核对原文,完整落盘后独占原子发布。"""
311
+ request = Path(request).absolute()
312
+ with tag_review_lock(request.parent):
313
+ raw, packet, response = read_tag_request(request, review_id)
314
+ mapped = map_tag_choices(packet, selection)
315
+ body = {key: packet[key] for key in ('bizLineId', 'attempt', 'reviewId')}
316
+ body['selections'] = mapped
317
+ temp = response.with_name(response.name + '.' + uuid4().hex + '.tmp')
318
+ created = False
319
+ try:
320
+ with temp.open('x', encoding='utf-8') as stream:
321
+ created = True
322
+ json.dump(body, stream, ensure_ascii=False)
323
+ stream.flush()
324
+ os.fsync(stream.fileno())
325
+ latest, _, _ = read_tag_request(request, review_id)
326
+ if raw != latest:
327
+ raise ValueError('标签请求在提交期间发生变化')
328
+ os.link(temp, response)
329
+ finally:
330
+ if created:
331
+ temp.unlink(missing_ok=True)
332
+ return response
333
+
334
+
335
+ def review_tags(row, dictionaries, evidence, root, cancel, progress, *, biz_line_id, attempt):
336
+ """只重试已分析数据的标签选择;穷尽后按真实字典兜底。"""
337
+ if type(biz_line_id) is not int or biz_line_id <= 0 or type(attempt) is not int or attempt <= 0:
338
+ raise ValueError('标签审核缺少有效的业务线 ID 或尝试轮次')
339
+ # 在进入审核循环前验证字典,避免字典故障被误当成回包错误并触发兜底。
340
+ flat_dictionaries = {key: flatten_tags(dictionaries[key]) for key in SCENES}
341
+ strategies = {}
342
+ for key in SCENES:
343
+ labels = []
344
+ for item in flat_dictionaries[key]:
345
+ # 候选只传审核所需语义字段,原字典仍用于最终 ID/名称校验。
346
+ candidate = {'id': item['id'], 'name': item['name'],
347
+ 'isOther': 1 if item.get('isOther') == 1 or item['name'].strip() == '其他' else 0}
348
+ for field in ('description', 'itemCode'):
349
+ if isinstance(item.get(field), str) and item[field].strip():
350
+ candidate[field] = item[field].strip()
351
+ labels.append(candidate)
352
+ strategies[key] = {'strategy': row[key + 'Strategy'], 'labels': labels,
353
+ 'facts': evidence.get(key, [])}
354
+ failures = []
355
+ for review_attempt in range(1, TAG_REVIEW_ATTEMPTS + 1):
356
+ if cancel.is_set():
357
+ raise InterruptedError('任务已取消')
358
+ # 每次选择独立文件与审核 ID;旧回包即使晚到也不会被新一轮读取。
359
+ request = root / f'tag-review.{review_attempt}.request.json'
360
+ response = root / f'tag-review.{review_attempt}.response.json'
361
+ review_id = uuid4().hex
362
+ content = {'bizLineId': biz_line_id, 'attempt': attempt, 'reviewId': review_id,
363
+ 'expiresAt': time.time() + TAG_REVIEW_SECONDS, 'submissionMode': 'choice-key-v1',
364
+ 'strategies': {key: {**value, 'labels': [
365
+ {**label, 'choiceKey': choice_key(review_id, key, label['id'])}
366
+ for label in value['labels']]} for key, value in strategies.items()}}
367
+ request.write_text(json.dumps(content, ensure_ascii=False), encoding='utf-8')
368
+ issued_at = datetime.now(timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z')
369
+ started = time.monotonic()
370
+ try:
371
+ progress({'event': 'tag_review_required', 'bizLineId': biz_line_id, 'attempt': attempt,
372
+ 'reviewAttempt': review_attempt, 'reviewId': review_id, 'issuedAt': issued_at,
373
+ 'expiresAt': content['expiresAt'], 'request_path': str(request), 'response_path': str(response)})
374
+ selected, reason, kind = _wait_tag_response(request, response, content, cancel,
375
+ started + TAG_REVIEW_SECONDS)
376
+ finally:
377
+ close_tag_review(request)
378
+ if cancel.is_set():
379
+ raise InterruptedError('任务已取消')
380
+ if selected is not None:
381
+ progress({'event': 'tag_review_accepted', 'bizLineId': biz_line_id,
382
+ 'attempt': attempt, 'reviewAttempt': review_attempt, 'reviewId': review_id,
383
+ 'waitSeconds': round(time.monotonic() - started, 3)})
384
+ return selected
385
+ failures.append({'reviewAttempt': review_attempt, 'reason': reason, 'kind': kind})
386
+ progress({'event': 'tag_review_rejected', 'bizLineId': biz_line_id,
387
+ 'attempt': attempt, 'reviewAttempt': review_attempt, 'reviewId': review_id, 'reason': reason})
388
+ selections, missing_other = {}, []
389
+ for key in SCENES:
390
+ others = [item for item in flat_dictionaries[key]
391
+ if item.get('isOther') == 1 or item['name'].strip() == '其他']
392
+ if len(others) == 1:
393
+ selections[key] = [{'id': others[0]['id'],
394
+ 'reason': '两次标签审核被拒绝或超时,自动选择本场景其他标签'}]
395
+ else:
396
+ selections[key] = []
397
+ missing_other.append(key)
398
+ progress({'event': 'tag_review_fallback', 'bizLineId': biz_line_id, 'attempt': attempt,
399
+ 'reviewAttempts': TAG_REVIEW_ATTEMPTS, 'failures': failures, 'missingOtherScenes': missing_other})
400
+ return selections
401
+
402
+
403
+ def _wait_tag_response(request, response, content, cancel, deadline):
404
+ """消费端复核编号映射;成功仍返回原ID/理由供Excel和入库共用。"""
405
+ while not cancel.is_set():
406
+ with tag_review_lock(request.parent):
407
+ # 排队等待锁和映射校验同样计入7分钟,不另加分析时限。
408
+ if time.monotonic() >= deadline:
409
+ return None, '等待 Agent 分析策略标签达到 7 分钟上限', 'timeout'
410
+ if response.exists():
411
+ try:
412
+ if is_link(response):
413
+ raise ValueError('标签响应不能是链接')
414
+ reply = json.loads(response.read_text(encoding='utf-8-sig'))
415
+ if not isinstance(reply, dict) or set(reply) != {'bizLineId', 'attempt', 'reviewId', 'selections'}:
416
+ raise ValueError('标签审核响应身份或结构无效')
417
+ # 同行业共享候选 ID,必须先核对请求身份,再检查标签属于对应场景。
418
+ if type(reply['bizLineId']) is not int or reply['bizLineId'] != content['bizLineId'] or \
419
+ type(reply['attempt']) is not int or reply['attempt'] != content['attempt'] or \
420
+ not isinstance(reply['reviewId'], str) or reply['reviewId'] != content['reviewId']:
421
+ raise ValueError('标签审核响应身份与当前业务线、轮次或请求不一致')
422
+ selections = reply['selections']
423
+ if not isinstance(selections, dict) or set(selections) != set(SCENES):
424
+ raise ValueError('标签分析结果必须包含 budget/product/audience 三类')
425
+ for key, items in selections.items():
426
+ if not isinstance(items, list) or not items:
427
+ raise ValueError(f'{key} 必须是非空标签响应数组;{TAG_SUBMIT_HINT}')
428
+ for index, item in enumerate(items):
429
+ validate_tag_choice_fields(item, f'{key}[{index}]', include_id=True)
430
+ # 三类全部核实后才接受,不能部分采用一份被拒绝的回包。
431
+ numbered = {'selections': {key: [
432
+ {'choiceKey': item['choiceKey'], 'reason': item['reason']} for item in selections[key]]
433
+ for key in SCENES}}
434
+ mapped = map_tag_choices(content, numbered)
435
+ if mapped != selections:
436
+ raise ValueError(f'标签响应的 id/choiceKey 映射或字段与本次请求不一致;{TAG_SUBMIT_HINT}')
437
+ except KeyError as exc:
438
+ return None, f'标签响应或请求缺少字段:{exc.args[0]};{TAG_SUBMIT_HINT}', 'rejected'
439
+ except (ValueError, TypeError, UnicodeError) as exc:
440
+ return None, str(exc), 'rejected'
441
+ if time.monotonic() >= deadline:
442
+ return None, '等待 Agent 分析策略标签达到 7 分钟上限', 'timeout'
443
+ if cancel.is_set():
444
+ raise InterruptedError('任务已取消')
445
+ return {key: [{'id': item['id'], 'reason': item['reason']} for item in mapped[key]]
446
+ for key in SCENES}, '', ''
447
+ cancel.wait(min(0.2, max(0, deadline - time.monotonic())))
448
+ raise InterruptedError('任务已取消')
449
+
450
+
451
+ def attach_tags(row, dictionaries, evidence, selections):
452
+ """展示名称与保存 ID 同时生成,防止 Excel 与数据库各自重新匹配。"""
453
+ for key in SCENES:
454
+ if selections[key] == []:
455
+ others = [item for item in flatten_tags(dictionaries[key])
456
+ if item.get('isOther') == 1 or item['name'].strip() == '其他']
457
+ if len(others) == 1:
458
+ raise ValueError('存在唯一其他标签时不得保存空标签')
459
+ row[key + 'TagIds'] = []
460
+ row[key + 'Tags'] = ''
461
+ row[key + 'TagFallbackMissingOther'] = True
462
+ continue
463
+ matched = reviewed_tags(dictionaries[key], selections[key])
464
+ row[key + 'TagIds'] = [item['id'] for item in matched]
465
+ row[key + 'Tags'] = '\n'.join(item['name'] for item in matched)
466
+
467
+
468
+ def effect_code(verdict):
469
+ """以分析器的结构化结论编码,预警仍归入待观察。"""
470
+ if verdict == '有效':
471
+ return 1
472
+ if verdict == '无效':
473
+ return 2
474
+ if verdict in ('待观察', '待观察(预警)', '待观察(潜力)'):
475
+ return 3
476
+ raise ValueError(f'未知策略效果结论:{verdict}')
477
+
478
+
479
+ # 只识别独立末段标题,不从投放叙述里的“有效”词汇推断效果。
480
+ _AI_LEARNING = re.compile(
481
+ r'(?im)^[ \t]*(?:【Learning】[ \t]*\n[ \t]*(?:Learning[ \t]*)?|Learning[ \t]*)'
482
+ r'[((](有效|无效|待观察)[))][ \t]*[::]')
483
+ _AI_LEARNING_MARKER = re.compile(r'(?im)^[ \t]*(?:【Learning】|Learning\b)')
484
+ AI_HEADINGS = {'budget': '预算策略', 'product': '广告产品策略',
485
+ 'audience': '受众策略', 'creative': '创意策略'}
486
+
487
+
488
+ def split_ai_learning(value):
489
+ """机械拆分 AI 最后一段:保留正文与说明内部换行,标签精确转效果码。"""
490
+ if not isinstance(value, str) or not value.strip():
491
+ raise ValueError('AI 策略单元格为空')
492
+ normalized = value.replace('\r\n', '\n').replace('\r', '\n')
493
+ matches = list(_AI_LEARNING.finditer(normalized))
494
+ if len(matches) != 1:
495
+ raise ValueError('AI 策略缺少唯一合法 Learning(有效/无效/待观察):说明')
496
+ match = matches[0]
497
+ # 单独小节标题和其紧接的标签视为同一段;其他 Learning 标题一律拒绝。
498
+ if any(not match.start() <= marker.start() < match.end()
499
+ for marker in _AI_LEARNING_MARKER.finditer(normalized)):
500
+ raise ValueError('AI 策略存在重复或无效 Learning 段')
501
+ strategy = normalized[:match.start()].strip()
502
+ learning = normalized[match.end():].strip()
503
+ if not strategy or not learning:
504
+ raise ValueError('AI 策略正文或 Learning 说明不能为空')
505
+ # Learning 必须收尾,不能把其后重新出现的策略小节误存为效果说明。
506
+ if re.search(r'(?m)^[ \t]*(?:【(?:策略总结|调整方向|关键数据)】|(?:策略总结|调整方向|关键数据)[ \t]*[::])', learning):
507
+ raise ValueError('AI 策略 Learning 必须是最后一个小节')
508
+ return strategy, effect_code(match.group(1)), learning
509
+
510
+
511
+ def ready_body(context, ai_sheet, tag_row, storage_key):
512
+ """从实际 AI sheet 组装 READY;固定行仅提供本业务线已审核的标签 ID 绑定。"""
513
+ if not isinstance(storage_key, str) or not storage_key.strip() or len(storage_key) > 512:
514
+ raise ValueError('缺少有效 Excel storageKey')
515
+ body = {**context, 'generateCode': 'READY', 'fileStorageKey': storage_key}
516
+ for local, remote in STRATEGIES.items():
517
+ strategy, effect, learning = split_ai_learning(ai_sheet.get(AI_HEADINGS[local]))
518
+ destination = 'creativeStrategy' if local == 'creative' else remote + 'StrategySupplement'
519
+ body[destination] = strategy
520
+ body[remote + 'StrategyLearning'] = learning
521
+ body[remote + 'StrategyEffect'] = effect
522
+ if local in SCENES:
523
+ # 名称来自工作簿,只能绑定本条已审核 ID;同名也不重新查询行业字典。
524
+ names = ai_sheet.get(AI_HEADINGS[local] + '标签')
525
+ expected_names = tag_row.get(local + 'Tags')
526
+ if not isinstance(names, str) or names != expected_names:
527
+ raise ValueError(f'{local} AI sheet 标签与本业务线审核结果不一致')
528
+ tags = tag_row.get(local + 'TagIds')
529
+ empty_fallback = tags == [] and names == '' and tag_row.get(local + 'TagFallbackMissingOther') is True
530
+ if not isinstance(tags, list) or ((not tags or not names) and not empty_fallback) or \
531
+ any(type(v) is not int or v <= 0 for v in tags) or len(json.dumps(tags)) > 500:
532
+ raise ValueError(f'{local} 策略标签 ID 无效')
533
+ body[remote + 'StrategyTags'] = list(tags)
534
+ return body
535
+
536
+
537
+ def upload_excel(root, path):
538
+ """通用上传返回值已经被 CLI 解包为 data;不使用营销协同 file 域。"""
539
+ env = require_ok(run_cli(['common-file', '+upload'], {'file': str(path), 'serviceName': 'sino-opti-hive'},
540
+ root / 'upload.out.json'), 'Excel 上传')
541
+ data = env.get('data')
542
+ key = data.get('storageKey') if isinstance(data, dict) else None
543
+ if not isinstance(key, str) or not key.strip() or len(key) > 512:
544
+ raise RuntimeError('上传未返回有效 storageKey')
545
+ return key
546
+
547
+
548
+ def save_result(root, body):
549
+ """非幂等保存只尝试一次,错误或不完整回包交由调用方报告,不重试。"""
550
+ if body.get('generateCode') not in RESULT_CODES:
551
+ raise ValueError('禁止保存展示状态或未知 generateCode')
552
+ env = require_ok(run_cli(['biz-line-ai-strategy', '+ai-strategy-save'], body, root / 'save.out.json'), 'AI 策略保存失败或结果未知')
553
+ ident = env.get('data')
554
+ if type(ident) is not int or ident <= 0:
555
+ raise RuntimeError('保存未返回有效 aiStrategyId,结果未知,不自动重试')
556
+ return ident
@@ -0,0 +1,32 @@
1
+ """复用标签审核规则,一次提交本请求的候选编号,不由AI手写身份或真实ID。"""
2
+ import argparse
3
+ import json
4
+ from pathlib import Path
5
+ import sys
6
+
7
+ # 从安装目录独立调用也不产生解释器缓存。
8
+ sys.dont_write_bytecode = True
9
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
10
+ from strategy_output import submit_tag_choices
11
+
12
+
13
+ def main():
14
+ """固定UTF-8 stdin,主工作流接受事件才代表审核完成。"""
15
+ for stream in (sys.stdin, sys.stdout, sys.stderr):
16
+ if hasattr(stream, 'reconfigure'):
17
+ stream.reconfigure(encoding='utf-8')
18
+ parser = argparse.ArgumentParser(description=__doc__)
19
+ parser.add_argument('--request', required=True)
20
+ parser.add_argument('--review-id', required=True)
21
+ args = parser.parse_args()
22
+ try:
23
+ response = submit_tag_choices(args.request, args.review_id, json.load(sys.stdin))
24
+ print(json.dumps({'ok': True, 'response_path': str(response)}, ensure_ascii=False))
25
+ return 0
26
+ except (ValueError, OSError, KeyError, TypeError, UnicodeError) as exc:
27
+ print(json.dumps({'ok': False, 'error': str(exc)}, ensure_ascii=False), file=sys.stderr)
28
+ return 1
29
+
30
+
31
+ if __name__ == '__main__':
32
+ sys.exit(main())