sino-bc-cli 1.8.26 → 1.8.27

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 (29) hide show
  1. package/README.md +6 -0
  2. package/dist/SKILL.md +10 -10
  3. package/dist/affordance/project-analysis.md +18 -0
  4. package/dist/affordance/user.md +8 -2
  5. package/dist/api-doc/oapi/project-analysis.md +86 -0
  6. package/dist/api-doc/oapi/user.md +19 -2
  7. package/dist/generated/api-catalog.json +779 -4
  8. package/dist/index.js +1 -1
  9. package/dist/skills/custom/biz-line-strategy-generate/SKILL.md +36 -17
  10. package/dist/skills/custom/biz-line-strategy-generate/references/api-flow.md +44 -22
  11. package/dist/skills/custom/biz-line-strategy-generate/references/four-strategies.md +18 -74
  12. package/dist/skills/custom/biz-line-strategy-generate/references/metrics.md +20 -32
  13. package/dist/skills/custom/biz-line-strategy-generate/references/output-excel.md +12 -7
  14. package/dist/skills/custom/biz-line-strategy-generate/references/runtime.md +41 -4
  15. package/dist/skills/custom/biz-line-strategy-generate/references/spec.md +176 -6
  16. package/dist/skills/custom/biz-line-strategy-generate/scripts/build_report.py +17 -6
  17. package/dist/skills/custom/biz-line-strategy-generate/scripts/cli_json.py +36 -17
  18. package/dist/skills/custom/biz-line-strategy-generate/scripts/fetch_data.py +44 -17
  19. package/dist/skills/custom/biz-line-strategy-generate/scripts/run_workflow.py +134 -37
  20. package/dist/skills/custom/biz-line-strategy-generate/scripts/strategy_output.py +277 -0
  21. package/dist/skills/custom/biz-line-strategy-generate/scripts/summarize.py +84 -146
  22. package/dist/skills/custom/biz-line-strategy-generate/scripts/test_database_output.py +401 -0
  23. package/dist/skills/custom/biz-line-strategy-generate/scripts/test_strategy_rules.py +18 -8
  24. package/dist/skills/custom/biz-line-strategy-generate/scripts/test_workflow.py +138 -8
  25. package/dist/skills/oapi/sino-bc-project-analysis/SKILL.md +13 -1
  26. package/dist/skills/oapi/sino-bc-project-analysis/references/analysis-workflow.md +4 -0
  27. package/dist/skills/oapi/sino-bc-project-analysis/references/filters-and-metrics.md +2 -2
  28. package/dist/skills/oapi/sino-bc-user/SKILL.md +6 -4
  29. package/package.json +1 -1
@@ -22,9 +22,10 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
22
22
 
23
23
  from build_report import default_output_path, validate_xlsx, write_xlsx
24
24
  from cleanup import OwnedDirectory, MARKER
25
- from cli_json import cli_context
26
- from fetch_data import argument_parser, fetch_business
25
+ from cli_json import cli_context, load_json_file
26
+ from fetch_data import argument_parser, fetch_business, resolve_periods
27
27
  from summarize import summarize_request
28
+ from strategy_output import (TagCache, GenerationOutcome, attach_tags, review_tags, ready_body, upload_excel, save_result)
28
29
 
29
30
  _output_lock = threading.Lock()
30
31
 
@@ -68,6 +69,9 @@ def execute_business(target, args, batch, cancel, progress=emit):
68
69
  current_stage = 'fetch'
69
70
  started = time.perf_counter()
70
71
  final = None
72
+ database = args.output_mode == 'database'
73
+ save_attempted = False
74
+ context = None
71
75
 
72
76
  def stage(name, function):
73
77
  nonlocal current_stage
@@ -83,25 +87,70 @@ def execute_business(target, args, batch, cancel, progress=emit):
83
87
 
84
88
  try:
85
89
  # 最终目录必须在整个批次临时树之外,而非仅在当前业务线之外。
86
- output_dir = Path(args.output_dir).resolve()
87
- if output_dir.is_relative_to(batch.path):
90
+ output_dir = Path(args.output_dir or Path(default_output_path()).parent).resolve() if not database else None
91
+ if output_dir is not None and output_dir.is_relative_to(batch.path):
88
92
  raise ValueError('最终 Excel 目录不能位于批次临时目录内')
89
93
  local_args = Namespace(**{**vars(args), 'id': target or 0, 'request_dir': str(owned.path)})
90
94
  with cli_context(cancel, lambda text: progress({'id': result['id'], 'stage': 'fetch', 'detail': text})):
91
95
  biz = stage('fetch', lambda: fetch_business(local_args))
92
96
  result['id'], result['name'] = int(biz['id']), str(biz.get('name') or '')
97
+ context = load_json_file(owned.path / 'save-context.json') if database else None
93
98
  payload, summary = stage('analyze', lambda: summarize_request(owned.path))
94
99
  result['detail_count'] = summary['detail_count']
95
- safe_name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', '_', result['name'])[:55].rstrip(' .') or '业务线'
96
- final = output_dir / f'业务线策略_{result["id"]}_{safe_name}_{owned.token[:12]}.xlsx'
97
- output_dir.mkdir(parents=True, exist_ok=True)
100
+ if database and summary['curr_total'] <= 0:
101
+ raise GenerationOutcome('NO_DELIVERY_DATA', '目标周期无投放消耗')
102
+ with cli_context(cancel, lambda text: progress({'id': result['id'], 'stage': 'tags', 'detail': text})):
103
+ dictionaries = args.tag_cache.get(biz)
104
+ evidence = summary.get('tag_evidence', {})
105
+ selections = stage('tag_review', lambda: review_tags(payload['rows'][0], dictionaries, evidence,
106
+ owned.path, cancel, lambda event: progress({'id': result['id'], **event})))
107
+ stage('tags', lambda: attach_tags(payload['rows'][0], dictionaries, evidence, selections))
108
+ if database:
109
+ if not context:
110
+ raise ValueError('缺少可保存业务线及周期上下文')
111
+ ready_body(context, payload['rows'][0], 'preflight')
98
112
  temp = owned.path / 'final.xlsx'
99
113
  stage('export', lambda: write_xlsx(payload['rows'], str(temp), payload['details']))
100
- stage('validate_publish', lambda: publish(temp, final, payload))
101
- result['output_path'] = str(final)
114
+ if database:
115
+ stage('validate', lambda: validate_xlsx(temp, payload['rows'], payload['details']))
116
+ with cli_context(cancel, lambda text: progress({'id': result['id'], 'stage': current_stage, 'detail': text})):
117
+ result['storageKey'] = stage('upload', lambda: upload_excel(owned.path, temp))
118
+ # 仅删除自己创建且归属已验证的临时文件,上传成功后不留桌面副本。
119
+ owned.validate()
120
+ stage('remove_excel', temp.unlink)
121
+ body = ready_body(context, payload['rows'][0], result['storageKey'])
122
+ if cancel.is_set():
123
+ raise InterruptedError('任务已取消')
124
+ save_attempted = True
125
+ result['aiStrategyId'] = stage('save', lambda: save_result(owned.path, body))
126
+ result.update(generateCode='READY', database_saved=True)
127
+ else:
128
+ safe_name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', '_', result['name'])[:55].rstrip(' .') or '业务线'
129
+ final = output_dir / f'业务线策略_{result["id"]}_{safe_name}_{owned.token[:12]}.xlsx'
130
+ output_dir.mkdir(parents=True, exist_ok=True)
131
+ stage('validate_publish', lambda: publish(temp, final, payload))
132
+ result['output_path'] = str(final)
102
133
  result['ok'] = True
103
134
  except Exception as exc:
104
135
  result.update(error=str(exc), failed_stage=current_stage)
136
+ # 保存请求发出后不补写失败状态;超时可能已成功,补写会替换有效记录。
137
+ if database and not save_attempted and not cancel.is_set():
138
+ context = context or load_json_file(owned.path / 'save-context.json')
139
+ if context:
140
+ code = exc.code if isinstance(exc, GenerationOutcome) else 'GENERATION_FAILED'
141
+ result.update(id=context['bizLineId'], generateCode=code)
142
+ try:
143
+ with cli_context(cancel, lambda _: None):
144
+ result['aiStrategyId'] = stage('save_status', lambda: save_result(owned.path, {
145
+ **context, 'generateCode': code, 'remark': str(exc),
146
+ }))
147
+ result['database_saved'] = True
148
+ # 不可生成是业务结果;技术失败即使状态保存成功仍返回失败。
149
+ result['ok'] = isinstance(exc, GenerationOutcome)
150
+ except Exception as save_error:
151
+ result.update(database_saved=False, save_error=str(save_error), save_result_unknown=True)
152
+ elif save_attempted:
153
+ result.update(database_saved=False, save_result_unknown=True)
105
154
  if isinstance(exc, PublishError):
106
155
  result['residual_output'] = exc.residual_path
107
156
  finally:
@@ -115,61 +164,109 @@ def execute_business(target, args, batch, cancel, progress=emit):
115
164
  return result
116
165
 
117
166
 
167
+ def retry_allowed(result):
168
+ """只重跑未交付的技术失败;保存未知、残留文件及已处理业务状态需单独报告。"""
169
+ return not (result.get('ok') or result.get('save_result_unknown')
170
+ or result.get('cleanup_failed') or result.get('residual_output')
171
+ or result.get('output_path')
172
+ or result.get('failed_stage') in ('cancelled', 'worker')
173
+ or (result.get('database_saved') and result.get('generateCode') != 'GENERATION_FAILED'))
174
+
175
+
118
176
  def run_batch(args, *, cancel=None, worker=execute_business, progress=emit, temp_parent=None):
119
- """只提交可立即运行的三个任务;已完成任务交付后继续补位。"""
177
+ """每轮最多三个并发,整轮结束才重跑失败项,每条含首次最多三次。"""
120
178
  if args.concurrency not in (1, 2, 3):
121
179
  raise ValueError('并发只能设置为 1、2 或 3')
180
+ if args.output_mode not in ('excel', 'database'):
181
+ raise ValueError('输出模式必须为 excel 或 database')
182
+ if args.output_mode == 'database' and args.output_dir:
183
+ raise ValueError('database 模式不接受 --output-dir,不向桌面发布 Excel')
122
184
  if any(i <= 0 for i in args.id):
123
185
  raise ValueError('业务线 ID 必须为正整数')
124
- for prefix in ('curr', 'prev'):
125
- if bool(getattr(args, f'{prefix}_start')) != bool(getattr(args, f'{prefix}_end')):
126
- raise ValueError('用户指定的起止日期必须成对提供')
186
+ # 批次开始时固定日期,避免任务跨越周一零点后使用不同的默认周。
187
+ periods = resolve_periods(args)
188
+ args = Namespace(**{**vars(args), **{
189
+ key + suffix: bounds[index]
190
+ for key, bounds in periods.items()
191
+ for index, suffix in enumerate(('_start', '_end'))
192
+ }})
127
193
  targets = list(dict.fromkeys(args.id)) if args.id else [None]
128
194
  cancel = cancel or threading.Event()
129
195
  batch = OwnedDirectory.create(Path(temp_parent or tempfile.gettempdir()) / 'sino-bc-strategy', 'batch-')
130
- results, pending = [], {}
131
- remaining = iter(targets)
196
+ # 共享字典独立归属;业务线清理不触碰它,批次结束单独清理。
197
+ tags_owned = OwnedDirectory.create(batch.path, 'tags-')
198
+ args = Namespace(**{**vars(args), 'tag_cache': TagCache(tags_owned.path)})
199
+ latest, history = {}, {target: [] for target in targets}
200
+ round_targets = targets
132
201
  started = time.perf_counter()
133
202
  try:
134
203
  with ThreadPoolExecutor(max_workers=args.concurrency) as pool:
135
- while True:
136
- while not cancel.is_set() and len(pending) < args.concurrency:
137
- try:
138
- target = next(remaining)
139
- except StopIteration:
204
+ for attempt in range(1, 4):
205
+ if attempt > 1:
206
+ if cancel.is_set():
140
207
  break
141
- pending[pool.submit(worker, target, args, batch, cancel, progress)] = target
142
- if not pending:
208
+ args.tag_cache.reset_errors()
209
+ progress({'event': 'retry_round', 'attempt': attempt, 'targets': round_targets})
210
+ pending, remaining = {}, iter(round_targets)
211
+ while True:
212
+ while not cancel.is_set() and len(pending) < args.concurrency:
213
+ try:
214
+ target = next(remaining)
215
+ except StopIteration:
216
+ break
217
+ pending[pool.submit(worker, target, args, batch, cancel, progress)] = target
218
+ if not pending:
219
+ break
220
+ finished, _ = wait(pending, timeout=0.2, return_when=FIRST_COMPLETED)
221
+ for future in finished:
222
+ target = pending.pop(future)
223
+ try:
224
+ result = future.result()
225
+ except Exception as exc:
226
+ # worker 之外异常无法保证副作用状态,不能盲目整条重放。
227
+ result = {'id': target, 'ok': False, 'error': str(exc), 'failed_stage': 'worker'}
228
+ result['attempt'] = attempt
229
+ history[target].append(result.copy())
230
+ latest[target] = result
231
+ progress({'event': 'completed', **result})
232
+ # 取消时保留此前失败记录;首次未启动条目仍计入最终结果。
233
+ for target in remaining:
234
+ if target not in latest:
235
+ latest[target] = {'id': target, 'ok': False, 'failed_stage': 'cancelled',
236
+ 'error': '任务未启动,已取消'}
237
+ round_targets = [target for target in round_targets if retry_allowed(latest[target])]
238
+ if not round_targets or cancel.is_set():
143
239
  break
144
- finished, _ = wait(pending, timeout=0.2, return_when=FIRST_COMPLETED)
145
- for future in finished:
146
- target = pending.pop(future)
147
- try:
148
- result = future.result()
149
- except Exception as exc:
150
- result = {'id': target, 'ok': False, 'error': str(exc), 'failed_stage': 'worker'}
151
- results.append(result)
152
- progress({'event': 'completed', **result})
153
- # 未启动任务也要明确返回取消状态,不能从批次结果中消失。
154
- for target in remaining:
155
- results.append({'id': target, 'ok': False, 'failed_stage': 'cancelled', 'error': '任务未启动,已取消'})
156
240
  finally:
241
+ tags_cleanup = tags_owned.cleanup()
157
242
  # 已失败的清理目录保留并报告,不由父级递归重试掩盖错误。
158
243
  batch.validate()
159
244
  children = [p for p in batch.path.iterdir() if p.name != MARKER]
160
245
  cleanup = ({'ok': False, 'residual': [{'path': str(p), 'error': '业务线目录尚有残留'} for p in children]} if children else batch.cleanup())
246
+ if not tags_cleanup['ok']:
247
+ cleanup['ok'] = False
248
+ cleanup.setdefault('residual', []).extend(tags_cleanup.get('residual', []))
249
+ # 同一业务线仅按最终一次统计,历史失败供定位,不重复计入失败数。
250
+ results = [{**latest[target], 'attempts': len(history[target]), 'attempt_history': history[target]}
251
+ for target in targets]
161
252
  success = sum(r['ok'] for r in results)
162
253
  return {'ok': success == len(targets) and cleanup['ok'] and not cancel.is_set(), 'success': success,
163
254
  'failed': len(results) - success, 'results': results, 'cleanup': cleanup,
164
255
  'elapsed_seconds': round(time.perf_counter() - started, 3)}
165
256
 
166
257
 
167
- def main():
258
+ def workflow_parser():
259
+ """入口参数集中构建,便于验证默认本地输出和显式数据库输出。"""
168
260
  parser = argument_parser(batch=True)
169
261
  parser.description = __doc__
170
262
  parser.add_argument('--concurrency', type=int, choices=(1, 2, 3), default=3)
171
- parser.add_argument('--output-dir', default=str(Path(default_output_path()).parent))
172
- args = parser.parse_args()
263
+ parser.add_argument('--output-dir', default=None)
264
+ parser.add_argument('--output-mode', choices=('excel', 'database'), default='excel')
265
+ return parser
266
+
267
+
268
+ def main():
269
+ args = workflow_parser().parse_args()
173
270
  cancel = threading.Event()
174
271
  # 可处理的 Ctrl+C/SIGTERM 只发信号,worker 关闭自己的 CLI 后清理。
175
272
  previous = {sig: signal.signal(sig, lambda *_: cancel.set()) for sig in (signal.SIGINT, signal.SIGTERM)}
@@ -0,0 +1,277 @@
1
+ """策略工作流的标签、业务预检及入库计划;所有网络请求仍由 CLI 执行。"""
2
+ from datetime import date, timedelta
3
+ import json
4
+ import re
5
+ import threading
6
+ import time
7
+
8
+ from cli_json import optional_num, require_ok, run_cli
9
+
10
+ # 本地行字段与服务字段集中映射,Excel 和数据库共享同一份分析结果。
11
+ STRATEGIES = {'budget': 'budget', 'product': 'media', 'audience': 'audience', 'creative': 'creative'}
12
+ SCENES = {'budget': 'BUDGET_STRATEGY', 'product': 'MEDIA_STRATEGY', 'audience': 'AUDIENCE_STRATEGY'}
13
+ CHANNEL_NAMES = {'facebook', 'meta', 'fb', 'google', 'gg', 'tiktok', 'tik tok', 'tt'}
14
+ RESULT_CODES = {'READY', 'NO_PROJECT', 'UNSUPPORTED_CHANNEL', 'NO_AD_ACCOUNT', 'NO_DELIVERY_DATA', 'GENERATION_FAILED'}
15
+
16
+
17
+ def industry_condition(biz):
18
+ """行业筛选严格取业务线第一个行业 ID,不使用后续行业或人工默认值兜底。"""
19
+ ids = biz.get('adCategoryIndustryIds')
20
+ if not isinstance(ids, list) or not ids or type(ids[0]) is not int or ids[0] <= 0:
21
+ raise ValueError('业务线 adCategoryIndustryIds 缺少有效的首个行业 ID,无法查询策略标签')
22
+ return str(ids[0])
23
+
24
+
25
+ class TagCache:
26
+ """同批次按行业缓存三类标签与错误;锁内加载避免并发重复查询。"""
27
+ def __init__(self, root):
28
+ self.root = root
29
+ self.lock = threading.Lock()
30
+ self.values, self.errors = {}, {}
31
+
32
+ def reset_errors(self):
33
+ """整轮结束后才清除失败缓存,成功行业字典继续在本批次复用。"""
34
+ with self.lock:
35
+ self.errors.clear()
36
+
37
+ def get(self, biz):
38
+ """同行业复用,不同行业使用独立文件目录;不跨批次复用过期字典。"""
39
+ condition = industry_condition(biz)
40
+ with self.lock:
41
+ if condition in self.errors:
42
+ raise RuntimeError(self.errors[condition])
43
+ if condition not in self.values:
44
+ try:
45
+ industry_root = self.root / ('industry-' + condition)
46
+ industry_root.mkdir(parents=True, exist_ok=True)
47
+ self.values[condition] = load_tags(industry_root, condition)
48
+ except Exception as exc:
49
+ self.errors[condition] = str(exc)
50
+ raise
51
+ return self.values[condition]
52
+
53
+
54
+ class GenerationOutcome(RuntimeError):
55
+ """预期不可生成结果,与传输错误分开,防止失败回包被解释成无数据。"""
56
+ def __init__(self, code, message):
57
+ super().__init__(message)
58
+ self.code = code
59
+
60
+
61
+ def validate_period(start, end, today=None):
62
+ """保存接口只接受已结束且受支持的完整自然周,不发送展示状态。"""
63
+ start, end = date.fromisoformat(start), date.fromisoformat(end)
64
+ if start.weekday() != 0 or end != start + timedelta(days=6):
65
+ raise ValueError('入库周期必须是同一自然周的周一至周日')
66
+ if end >= (today or date.today()):
67
+ raise ValueError('入库周期尚未结束')
68
+ if start < date(2026, 9, 7):
69
+ raise ValueError('入库周期早于当前契约支持的 2026-09-07')
70
+
71
+
72
+ def precheck_projects(root, biz_id, expected_ids=None, *, skip_spend_check=False):
73
+ """逐页统计上周消耗及全部项目渠道,不缓存全量项目、不按消耗预筛。"""
74
+ total = None
75
+ count = 0
76
+ all_zero, positive, channels_complete = True, False, True
77
+ channels, seen = set(), set()
78
+ for page in range(1, 81):
79
+ env = require_ok(run_cli(['biz-line', '+queryByPage'], {
80
+ 'bizLineIdList': [biz_id], 'pageNum': page, 'pageSize': 100,
81
+ }, root / f'precheck.p{page}.out.json'), '项目预检')
82
+ data = env.get('data')
83
+ if not isinstance(data, dict) or not isinstance(data.get('result'), list) or type(data.get('total')) is not int or data['total'] < 0:
84
+ raise RuntimeError('项目预检分页格式无效')
85
+ if total is not None and total != data['total']:
86
+ raise RuntimeError('项目预检分页总数变化')
87
+ total = data['total']
88
+ rows = data['result']
89
+ for item in rows:
90
+ if not isinstance(item, dict) or item.get('projectId') is None or item['projectId'] in seen:
91
+ raise RuntimeError('项目预检含无效或重复项目')
92
+ seen.add(item['projectId'])
93
+ value = optional_num(item, 'lastWeekSpend')
94
+ if isinstance(item.get('lastWeekSpend'), bool):
95
+ value = None
96
+ all_zero = all_zero and value == 0
97
+ positive = positive or (value is not None and value > 0)
98
+ ids = item.get('channelIdList')
99
+ if not isinstance(ids, list) or not ids:
100
+ channels_complete = False
101
+ else:
102
+ channels.update(str(v).strip() for v in ids)
103
+ count += len(rows)
104
+ if count > total:
105
+ raise RuntimeError('项目预检累计数量超过总数')
106
+ if count == total:
107
+ break
108
+ if not rows or page == 80:
109
+ raise RuntimeError('项目预检分页未取全')
110
+ # 服务端默认只查代投服务,必须用业务线关联 ID 校验覆盖,防止漏项目误判全零。
111
+ if expected_ids is not None and {str(v) for v in seen} != {str(v) for v in expected_ids}:
112
+ raise RuntimeError('项目分页未覆盖业务线全部关联项目(接口默认服务范围为 1、85),不能判断无项目或消耗全零')
113
+ if not count:
114
+ raise GenerationOutcome('NO_PROJECT', '业务线未关联项目')
115
+ # 仅用户显式跳过时不按项目上周消耗拦截;项目完整性、渠道及后续目标周期检查仍保留。
116
+ if not skip_spend_check and all_zero:
117
+ raise GenerationOutcome('NO_DELIVERY_DATA', '全部关联项目上周消耗 lastWeekSpend 均为 0')
118
+ if not skip_spend_check and not positive:
119
+ raise RuntimeError('项目上周消耗缺失或异常,不能判断为全零')
120
+ env = require_ok(run_cli(['media', '+channels'], None, root / 'channels.out.json'), '渠道字典')
121
+ if not isinstance(env.get('data'), list):
122
+ raise RuntimeError('渠道字典格式无效')
123
+ supported = {str(item['channelId']) for item in env['data'] if isinstance(item, dict)
124
+ and item.get('channelId') is not None and str(item.get('channelName', '')).strip().lower() in CHANNEL_NAMES}
125
+ if not channels_complete or not channels or not channels <= supported:
126
+ raise GenerationOutcome('UNSUPPORTED_CHANNEL', '全部项目渠道必须完整且只包含 FB、GG、TT')
127
+
128
+
129
+ def load_tags(root, condition):
130
+ """每批次串行获取三类字典,保留服务返回的 ID 与名称,不虚构枚举。"""
131
+ if not isinstance(condition, str) or not condition.isdecimal() or int(condition) <= 0:
132
+ raise ValueError('策略字典筛选值必须是业务线首个行业 ID 的字符串')
133
+ result = {}
134
+ for key, scene in SCENES.items():
135
+ # 三个场景共用该业务线首个行业 ID,保留真实字典 ID/name。
136
+ query = {'sceneCode': scene, 'filterCondition': condition}
137
+ env = require_ok(run_cli(['biz-line', '+categories'], query,
138
+ root / f'tags-{key}.out.json'), f'{scene} 字典')
139
+ data = env.get('data')
140
+ if not isinstance(data, list) or not data:
141
+ raise ValueError(f'{scene} 字典为空或格式无效,无法取得标签 ID,请核对服务端场景字典配置')
142
+ result[key] = flatten_tags(data)
143
+ return result
144
+
145
+
146
+ def flatten_tags(items):
147
+ """兼容字典 children 树,重复 ID 必须对应同名,避免输出与写入不一致。"""
148
+ found = {}
149
+ def visit(nodes):
150
+ for item in nodes:
151
+ 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():
152
+ raise ValueError('策略字典缺少有效 ID/name')
153
+ if item['id'] in found and found[item['id']]['name'] != item['name']:
154
+ raise ValueError('策略字典同 ID 对应不同名称')
155
+ found[item['id']] = item
156
+ children = item.get('children')
157
+ if children is not None:
158
+ if not isinstance(children, list):
159
+ raise ValueError('策略字典 children 格式无效')
160
+ visit(children)
161
+ visit(items)
162
+ return list(found.values())
163
+
164
+
165
+ def reviewed_tags(labels, selections):
166
+ """校验 Agent 内容分析结果:只接受本场景真实 ID、有理由的非重复选择。"""
167
+ by_id = {item['id']: item for item in flatten_tags(labels)}
168
+ if not isinstance(selections, list) or not selections:
169
+ raise ValueError('每类策略必须提供至少一个标签选择及理由')
170
+ matched = []
171
+ for choice in selections:
172
+ if not isinstance(choice, dict) or type(choice.get('id')) is not int or choice['id'] not in by_id:
173
+ raise ValueError('标签选择包含不属于本行业本场景的 ID')
174
+ if not isinstance(choice.get('reason'), str) or not choice['reason'].strip():
175
+ raise ValueError('标签选择必须说明策略内容与标签的对应关系;其他须说明无适合项')
176
+ item = by_id[choice['id']]
177
+ if item in matched:
178
+ raise ValueError('标签选择包含重复 ID')
179
+ matched.append(item)
180
+ if len(matched) > 1 and any(item.get('isOther') == 1 or item['name'].strip() == '其他' for item in matched):
181
+ raise ValueError('有适合标签时不能同时选其他')
182
+ if any(item.get('isOther') == 1 or item['name'].strip() == '其他' for item in matched):
183
+ others = [item for item in by_id.values() if item.get('isOther') == 1 or item['name'].strip() == '其他']
184
+ if len(others) != 1:
185
+ raise ValueError('本场景缺少唯一其他标签,不能任意选择兜底')
186
+ if len(json.dumps([item['id'] for item in matched])) > 500:
187
+ raise ValueError('策略标签 ID 数组序列化后超过 500 字符')
188
+ return matched
189
+
190
+
191
+ def review_tags(row, dictionaries, evidence, root, cancel, progress):
192
+ """向当前 Agent 交接正文与候选标签,等待其语义分析结果;不自动退回其他。"""
193
+ request = root / 'tag-review.request.json'
194
+ response = root / 'tag-review.response.json'
195
+ content = {key: {'strategy': row[key + 'Strategy'], 'labels': dictionaries[key],
196
+ 'facts': evidence.get(key, [])} for key in SCENES}
197
+ request.write_text(json.dumps(content, ensure_ascii=False, indent=2), encoding='utf-8')
198
+ progress({'event': 'tag_review_required', 'request_path': str(request), 'response_path': str(response)})
199
+ deadline = time.monotonic() + 1800
200
+ while not cancel.is_set():
201
+ if response.exists():
202
+ selections = json.loads(response.read_text(encoding='utf-8-sig'))
203
+ if not isinstance(selections, dict) or set(selections) != set(SCENES):
204
+ raise ValueError('标签分析结果必须包含 budget/product/audience 三类')
205
+ # 返回前校验三类全部有效,避免部分赋值后继续导出。
206
+ for key in SCENES:
207
+ reviewed_tags(dictionaries[key], selections[key])
208
+ return selections
209
+ if time.monotonic() >= deadline:
210
+ raise TimeoutError('等待 Agent 分析策略标签超时,未自动选择其他')
211
+ cancel.wait(0.2)
212
+ raise InterruptedError('任务已取消')
213
+
214
+
215
+ def attach_tags(row, dictionaries, evidence, selections):
216
+ """展示名称与保存 ID 同时生成,防止 Excel 与数据库各自重新匹配。"""
217
+ for key in SCENES:
218
+ matched = reviewed_tags(dictionaries[key], selections[key])
219
+ row[key + 'TagIds'] = [item['id'] for item in matched]
220
+ row[key + 'Tags'] = '\n'.join(item['name'] for item in matched)
221
+
222
+
223
+ def effect_code(verdict):
224
+ """以分析器的结构化结论编码,预警仍归入待观察。"""
225
+ if verdict == '有效':
226
+ return 1
227
+ if verdict == '无效':
228
+ return 2
229
+ if verdict in ('待观察', '待观察(预警)', '待观察(潜力)'):
230
+ return 3
231
+ raise ValueError(f'未知策略效果结论:{verdict}')
232
+
233
+
234
+ def ready_body(context, row, storage_key):
235
+ """组装服务条件必填字段;上传前也可用占位 key 预检,避免产生无用上传。"""
236
+ if not isinstance(storage_key, str) or not storage_key.strip() or len(storage_key) > 512:
237
+ raise ValueError('缺少有效 Excel storageKey')
238
+ body = {**context, 'generateCode': 'READY', 'fileStorageKey': storage_key}
239
+ for local, remote in STRATEGIES.items():
240
+ for suffix, destination in [('Strategy', 'creativeStrategy' if local == 'creative' else remote + 'StrategySupplement'),
241
+ ('Learning', remote + 'StrategyLearning')]:
242
+ value = row.get(local + suffix)
243
+ if not isinstance(value, str) or not value.strip():
244
+ raise ValueError(f'{local + suffix} 不能为空')
245
+ body[destination] = value
246
+ effect = row.get(local + 'Effect')
247
+ if type(effect) is not int or effect not in (1, 2, 3):
248
+ raise ValueError(f'{local} 效果必须为 1/2/3')
249
+ body[remote + 'StrategyEffect'] = effect
250
+ if local in SCENES:
251
+ tags = row.get(local + 'TagIds')
252
+ if not isinstance(tags, list) or not tags or any(type(v) is not int or v <= 0 for v in tags) or len(json.dumps(tags)) > 500:
253
+ raise ValueError(f'{local} 策略标签 ID 无效')
254
+ body[remote + 'StrategyTags'] = tags
255
+ return body
256
+
257
+
258
+ def upload_excel(root, path):
259
+ """通用上传返回值已经被 CLI 解包为 data;不使用营销协同 file 域。"""
260
+ env = require_ok(run_cli(['common-file', '+upload'], {'file': str(path), 'serviceName': 'sino-opti-hive'},
261
+ root / 'upload.out.json'), 'Excel 上传')
262
+ data = env.get('data')
263
+ key = data.get('storageKey') if isinstance(data, dict) else None
264
+ if not isinstance(key, str) or not key.strip() or len(key) > 512:
265
+ raise RuntimeError('上传未返回有效 storageKey')
266
+ return key
267
+
268
+
269
+ def save_result(root, body):
270
+ """非幂等保存只尝试一次,错误或不完整回包交由调用方报告,不重试。"""
271
+ if body.get('generateCode') not in RESULT_CODES:
272
+ raise ValueError('禁止保存展示状态或未知 generateCode')
273
+ env = require_ok(run_cli(['biz-line', '+ai-strategy-save'], body, root / 'save.out.json'), 'AI 策略保存失败或结果未知')
274
+ ident = env.get('data')
275
+ if type(ident) is not int or ident <= 0:
276
+ raise RuntimeError('保存未返回有效 aiStrategyId,结果未知,不自动重试')
277
+ return ident