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,402 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """业务线策略唯一入口:拉数与AI各最多三个滑动并发,逐条交付和清理。"""
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import re
9
+ import shutil
10
+ import signal
11
+ import sys
12
+ from datetime import date
13
+ import threading
14
+ import time
15
+ from argparse import Namespace
16
+ from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
17
+ from pathlib import Path
18
+
19
+ # 从任意 cwd 或安装目录启动均定位到技能自身;不在源码树生成缓存。
20
+ sys.dont_write_bytecode = True
21
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
22
+
23
+ from build_report import (default_output_path, read_ai_sheet, validate_detail_workbook, validate_xlsx,
24
+ write_detail_workbook, write_xlsx)
25
+ from ai_strategy import AiStrategyExhausted, review_ai_strategy
26
+ from cleanup import OwnedDirectory, MARKER
27
+ from cli_json import cli_context, load_json_file
28
+ from fetch_data import argument_parser, fetch_business, resolve_periods
29
+ from summarize import summarize_request
30
+ from strategy_output import (TagCache, GenerationOutcome, IndustryConfigurationError,
31
+ attach_tags, review_tags, ready_body, upload_excel, save_result)
32
+
33
+ _output_lock = threading.Lock()
34
+
35
+
36
+ class PipelineLease:
37
+ """一条业务线的阶段名额:拉数转AI后立即释放拉数名额,收尾才释放AI。"""
38
+
39
+ def __init__(self, fetch_slots, ai_slots):
40
+ # 拉数名额已由调度器非阻塞取得,不能在排队线程中再占一份。
41
+ self.fetch_slots, self.ai_slots = fetch_slots, ai_slots
42
+ self.phase = 'fetch'
43
+
44
+ def enter_ai(self, cancel):
45
+ """排队不生成审核请求,不消耗7/15分钟的审核预算。"""
46
+ if self.phase != 'fetch':
47
+ raise ValueError('业务线不能重复进入AI阶段')
48
+ self.phase = 'waiting_ai'
49
+ self.fetch_slots.release()
50
+ while not cancel.is_set():
51
+ if self.ai_slots.acquire(timeout=0.1):
52
+ self.phase = 'ai'
53
+ if cancel.is_set():
54
+ raise InterruptedError('任务已取消')
55
+ return
56
+ raise InterruptedError('等待AI阶段名额时任务已取消')
57
+
58
+ def close(self):
59
+ """worker返回或异常都只归还当前实际持有的名额,允许重复收尾。"""
60
+ phase, self.phase = self.phase, 'closed'
61
+ if phase == 'fetch':
62
+ self.fetch_slots.release()
63
+ elif phase == 'ai':
64
+ self.ai_slots.release()
65
+
66
+
67
+ def execute_admitted(worker, target, args, batch, cancel, progress):
68
+ """跨阶段调度不拆散原有单条try/finally;清理结束后才归还总在途名额。"""
69
+ try:
70
+ return worker(target, args, batch, cancel, progress)
71
+ finally:
72
+ args.pipeline_lease.close()
73
+
74
+
75
+ class PublishError(RuntimeError):
76
+ """发布失败且无法移除本次不完整目标时,保留精确残留路径。"""
77
+ def __init__(self, message, path):
78
+ super().__init__(message)
79
+ self.residual_path = str(path)
80
+
81
+
82
+ def emit(event):
83
+ """并发进度输出按 JSON 行加锁,结果无需额外磁盘日志。"""
84
+ with _output_lock:
85
+ print(json.dumps(event, ensure_ascii=False), flush=True)
86
+
87
+
88
+ def publish(temp, final, payload):
89
+ """先校验临时文件,再独占创建目标;任何中途错误仅清理自己创建的目标。"""
90
+ validate_xlsx(temp, payload['rows'], payload['details'], ai_strategy=payload.get('ai_strategy'))
91
+ created = False
92
+ try:
93
+ with final.open('xb') as output:
94
+ created = True
95
+ with temp.open('rb') as source:
96
+ shutil.copyfileobj(source, output)
97
+ validate_xlsx(final, payload['rows'], payload['details'], ai_strategy=payload.get('ai_strategy'))
98
+ except BaseException as original:
99
+ if created:
100
+ try:
101
+ final.unlink()
102
+ except OSError as cleanup_error:
103
+ raise PublishError(f'发布失败:{original};删除不完整目标失败:{cleanup_error}', final) from original
104
+ raise
105
+
106
+
107
+ def execute_business(target, args, batch, cancel, progress=emit):
108
+ """单条保持完整生命周期,只在固定分析后交接拉数/AI阶段名额。"""
109
+ owned = OwnedDirectory.create(batch.path, f'line-{target or "named"}-')
110
+ result = {'id': target, 'ok': False, 'timings': {}, 'output_path': None}
111
+ current_stage = 'fetch'
112
+ started = time.perf_counter()
113
+ final = None
114
+ database = args.output_mode == 'database'
115
+ save_attempted = False
116
+ context = None
117
+
118
+ def stage(name, function):
119
+ nonlocal current_stage
120
+ current_stage = name
121
+ if cancel.is_set():
122
+ raise InterruptedError('任务已取消')
123
+ begin = time.perf_counter()
124
+ progress({'id': result['id'], 'stage': name, 'status': 'running'})
125
+ try:
126
+ return function()
127
+ finally:
128
+ result['timings'][name] = round(time.perf_counter() - begin, 3)
129
+
130
+ try:
131
+ # 最终目录必须在整个批次临时树之外,而非仅在当前业务线之外。
132
+ output_dir = Path(args.output_dir or Path(default_output_path()).parent).resolve() if not database else None
133
+ if output_dir is not None and output_dir.is_relative_to(batch.path):
134
+ raise ValueError('最终 Excel 目录不能位于批次临时目录内')
135
+ local_args = Namespace(**{**vars(args), 'id': target or 0, 'request_dir': str(owned.path)})
136
+ with cli_context(cancel, lambda text: progress({'id': result['id'], 'stage': 'fetch', 'detail': text})):
137
+ biz = stage('fetch', lambda: fetch_business(local_args))
138
+ result['id'], result['name'] = int(biz['id']), str(biz.get('name') or '')
139
+ context = load_json_file(owned.path / 'save-context.json') if database else None
140
+ payload, summary = stage('analyze', lambda: summarize_request(owned.path))
141
+ result['detail_count'] = summary['detail_count']
142
+ if database and summary['curr_total'] <= 0:
143
+ raise GenerationOutcome('NO_DELIVERY_DATA', '目标周期无投放消耗')
144
+ lease = getattr(args, 'pipeline_lease', None)
145
+ if lease is not None:
146
+ # 某一条准备好就补拉下一条,不等待同批其他拉数全部完成。
147
+ stage('wait_ai_slot', lambda: lease.enter_ai(cancel))
148
+ with cli_context(cancel, lambda text: progress({'id': result['id'], 'stage': 'tags', 'detail': text})):
149
+ dictionaries = args.tag_cache.get(biz)
150
+ evidence = summary.get('tag_evidence', {})
151
+ def tag_progress(event):
152
+ # 兜底必须进入最终业务线结果,不能只留在瞬时进度事件里。
153
+ if event.get('event') == 'tag_review_fallback':
154
+ result['tag_review_fallback'] = {
155
+ 'reviewAttempts': event['reviewAttempts'],
156
+ 'failures': event['failures'],
157
+ 'missingOtherScenes': event['missingOtherScenes'],
158
+ }
159
+ progress({'id': result['id'], **event})
160
+ selections = stage('tag_review', lambda: review_tags(payload['rows'][0], dictionaries, evidence,
161
+ owned.path, cancel, tag_progress,
162
+ biz_line_id=result['id'], attempt=getattr(args, 'attempt', 1)))
163
+ stage('tags', lambda: attach_tags(payload['rows'][0], dictionaries, evidence, selections))
164
+ if database:
165
+ if not context:
166
+ raise ValueError('缺少可保存业务线及周期上下文')
167
+ # 先把固定策略与明细写成可回读草稿,再把实际工作簿交给 Agent 撰写第三张表。
168
+ draft = owned.path / 'draft.xlsx'
169
+ stage('export_draft', lambda: write_xlsx(payload['rows'], str(draft), payload['details']))
170
+ stage('validate_draft', lambda: validate_xlsx(draft, payload['rows'], payload['details']))
171
+ # AI 只接收明细-only 工作簿;固定策略仍保留在草稿和最终交付文件中。
172
+ ai_detail = owned.path / 'ai-detail.xlsx'
173
+ stage('export_ai_detail', lambda: write_detail_workbook(payload['details'], str(ai_detail)))
174
+ stage('validate_ai_detail', lambda: validate_detail_workbook(ai_detail, payload['details']))
175
+ prompt = Path(__file__).resolve().parent.parent / 'references' / 'ai-strategy-prompt.md'
176
+ ai_text = stage('ai_strategy', lambda: review_ai_strategy(
177
+ owned.path, ai_detail, prompt, cancel,
178
+ lambda event: progress({'id': result['id'], **event}),
179
+ biz_line_id=result['id'], attempt=getattr(args, 'attempt', 1), require_learning=database))
180
+ payload['ai_strategy'] = ai_text
181
+ temp = owned.path / 'final.xlsx'
182
+ stage('export', lambda: write_xlsx(payload['rows'], str(temp), payload['details'], ai_strategy=ai_text))
183
+ stage('validate', lambda: validate_xlsx(temp, payload['rows'], payload['details'], ai_strategy=ai_text))
184
+ if database:
185
+ # 上传前从最终工作簿取值并校验,删文件后只复用这份已核对的单元格。
186
+ ai_sheet = stage('read_ai_sheet', lambda: read_ai_sheet(temp))
187
+ stage('preflight', lambda: ready_body(context, ai_sheet, payload['rows'][0], 'preflight'))
188
+ with cli_context(cancel, lambda text: progress({'id': result['id'], 'stage': current_stage, 'detail': text})):
189
+ result['storageKey'] = stage('upload', lambda: upload_excel(owned.path, temp))
190
+ # 仅删除自己创建且归属已验证的临时文件,上传成功后不留桌面副本。
191
+ owned.validate()
192
+ stage('remove_excel', temp.unlink)
193
+ body = ready_body(context, ai_sheet, payload['rows'][0], result['storageKey'])
194
+ if cancel.is_set():
195
+ raise InterruptedError('任务已取消')
196
+ save_attempted = True
197
+ result['aiStrategyId'] = stage('save', lambda: save_result(owned.path, body))
198
+ result.update(generateCode='READY', database_saved=True)
199
+ else:
200
+ safe_name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', '_', result['name'])[:55].rstrip(' .') or '业务线'
201
+ final = output_dir / f'业务线策略_{result["id"]}_{safe_name}_{owned.token[:12]}.xlsx'
202
+ output_dir.mkdir(parents=True, exist_ok=True)
203
+ stage('validate_publish', lambda: publish(temp, final, payload))
204
+ result['output_path'] = str(final)
205
+ result['ok'] = True
206
+ except Exception as exc:
207
+ result.update(error=str(exc), failed_stage=current_stage)
208
+ if isinstance(exc, IndustryConfigurationError):
209
+ # 真实业务线缺行业是配置失败,保存一次状态后不再重拉数据。
210
+ result.update(retryable=False, failure_kind='industry_configuration')
211
+ if isinstance(exc, AiStrategyExhausted):
212
+ # 三次 AI 尝试已经穷尽,批次不得重新拉数制造额外三轮。
213
+ result['ai_strategy_exhausted'] = True
214
+ # 保存请求发出后不补写失败状态;超时可能已成功,补写会替换有效记录。
215
+ if database and not save_attempted and not cancel.is_set():
216
+ context = context or load_json_file(owned.path / 'save-context.json')
217
+ if context:
218
+ code = exc.code if isinstance(exc, GenerationOutcome) else 'GENERATION_FAILED'
219
+ result.update(id=context['bizLineId'], generateCode=code)
220
+ try:
221
+ with cli_context(cancel, lambda _: None):
222
+ result['aiStrategyId'] = stage('save_status', lambda: save_result(owned.path, {
223
+ **context, 'generateCode': code, 'remark': str(exc),
224
+ }))
225
+ result['database_saved'] = True
226
+ # 不可生成是业务结果;技术失败即使状态保存成功仍返回失败。
227
+ result['ok'] = isinstance(exc, GenerationOutcome)
228
+ except Exception as save_error:
229
+ result.update(database_saved=False, save_error=str(save_error), save_result_unknown=True)
230
+ elif save_attempted:
231
+ result.update(database_saved=False, save_result_unknown=True)
232
+ if isinstance(exc, PublishError):
233
+ result['residual_output'] = exc.residual_path
234
+ finally:
235
+ begin = time.perf_counter()
236
+ cleanup = owned.cleanup(final)
237
+ result['timings']['cleanup'] = round(time.perf_counter() - begin, 3)
238
+ result['cleanup'] = cleanup
239
+ if not cleanup['ok']:
240
+ result.update(ok=False, cleanup_failed=True)
241
+ result['timings']['total'] = round(time.perf_counter() - started, 3)
242
+ return result
243
+
244
+
245
+ def retry_allowed(result):
246
+ """只重跑未交付的技术失败;保存未知、残留文件及已处理业务状态需单独报告。"""
247
+ return not (result.get('retryable') is False or result.get('ok') or result.get('save_result_unknown')
248
+ or result.get('ai_strategy_exhausted')
249
+ or result.get('cleanup_failed') or result.get('residual_output')
250
+ or result.get('output_path')
251
+ or result.get('failed_stage') in ('cancelled', 'worker')
252
+ or (result.get('database_saved') and result.get('generateCode') != 'GENERATION_FAILED'))
253
+
254
+
255
+ def run_batch(args, *, cancel=None, worker=execute_business, progress=emit, temp_parent=None):
256
+ """拉数/AI各最多三个,总在途最多六个;整轮结束才重跑失败项。"""
257
+ if args.concurrency not in (1, 2, 3):
258
+ raise ValueError('并发只能设置为 1、2 或 3')
259
+ if args.output_mode not in ('excel', 'database'):
260
+ raise ValueError('输出模式必须为 excel 或 database')
261
+ if args.output_mode == 'database' and args.output_dir:
262
+ raise ValueError('database 模式不接受 --output-dir,不向桌面发布 Excel')
263
+ if any(i <= 0 for i in args.id):
264
+ raise ValueError('业务线 ID 必须为正整数')
265
+ # 覆盖必须绑定本批目标,重复或拼错ID在请求业务接口前失败。
266
+ override_ids = [ident for ident, _ in getattr(args, 'core_index', [])]
267
+ if len(set(override_ids)) != len(override_ids):
268
+ raise ValueError('同一业务线不能重复指定核心指标')
269
+ if args.id and set(override_ids) - set(args.id):
270
+ raise ValueError('核心指标覆盖ID不在本批业务线中')
271
+ # 批次开始时固定日期,避免任务跨越周一零点后使用不同的默认周。
272
+ task_date = date.today()
273
+ periods = resolve_periods(args, today=task_date)
274
+ args = Namespace(**{**vars(args), **{
275
+ key + suffix: bounds[index]
276
+ for key, bounds in periods.items()
277
+ for index, suffix in enumerate(('_start', '_end'))
278
+ }})
279
+ targets = list(dict.fromkeys(args.id)) if args.id else [None]
280
+ cancel = cancel or threading.Event()
281
+ # 只使用明确的持久工作根,日期固定在本批启动时,不回退系统TEMP。
282
+ configured_root = getattr(args, 'task_root', None)
283
+ if configured_root is not None and not str(configured_root).strip():
284
+ raise ValueError('--task-root不能为空')
285
+ work_root = Path(temp_parent) if temp_parent is not None else Path(configured_root or Path.cwd())
286
+ if not work_root.is_absolute():
287
+ work_root = Path.cwd() / work_root
288
+ task_tmp = (work_root.resolve() / 'strategy_task' / task_date.isoformat() / 'tmp').resolve()
289
+ if task_tmp.is_relative_to(Path(__file__).resolve().parent.parent):
290
+ raise ValueError('--task-root不能位于安装技能或源码目录内,请选择持久工作目录')
291
+ try:
292
+ batch = OwnedDirectory.create(task_tmp, 'batch-')
293
+ except OSError as exc:
294
+ raise ValueError(f'无法创建策略任务目录 {task_tmp}:{exc}') from exc
295
+ # 共享字典独立归属;业务线清理不触碰它,批次结束单独清理。
296
+ tags_owned = OwnedDirectory.create(batch.path, 'tags-')
297
+ args = Namespace(**{**vars(args), 'tag_cache': TagCache(tags_owned.path)})
298
+ latest, history = {}, {target: [] for target in targets}
299
+ round_targets = targets
300
+ started = time.perf_counter()
301
+ fetch_slots = threading.BoundedSemaphore(args.concurrency)
302
+ ai_slots = threading.BoundedSemaphore(args.concurrency)
303
+ in_flight_limit = args.concurrency * 2
304
+ try:
305
+ # 最初只分配拉数名额;线程按实际提交惰性启动,不一次启动六条拉数。
306
+ with ThreadPoolExecutor(max_workers=in_flight_limit) as pool:
307
+ for attempt in range(1, 4):
308
+ if attempt > 1:
309
+ if cancel.is_set():
310
+ break
311
+ args.tag_cache.reset_errors()
312
+ progress({'event': 'retry_round', 'attempt': attempt, 'targets': round_targets})
313
+ pending, remaining = {}, iter(round_targets)
314
+ while True:
315
+ while not cancel.is_set() and len(pending) < in_flight_limit:
316
+ if not fetch_slots.acquire(blocking=False):
317
+ break
318
+ try:
319
+ target = next(remaining)
320
+ except StopIteration:
321
+ fetch_slots.release()
322
+ break
323
+ # 每个 worker 持有自己的轮次快照,避免并发修改共享 args 导致错配。
324
+ lease = PipelineLease(fetch_slots, ai_slots)
325
+ worker_args = Namespace(**{**vars(args), 'attempt': attempt, 'pipeline_lease': lease})
326
+ try:
327
+ future = pool.submit(execute_admitted, worker, target, worker_args, batch, cancel, progress)
328
+ except BaseException:
329
+ lease.close()
330
+ raise
331
+ pending[future] = target
332
+ if not pending:
333
+ break
334
+ finished, _ = wait(pending, timeout=0.2, return_when=FIRST_COMPLETED)
335
+ for future in finished:
336
+ target = pending.pop(future)
337
+ try:
338
+ result = future.result()
339
+ except Exception as exc:
340
+ # worker 之外异常无法保证副作用状态,不能盲目整条重放。
341
+ result = {'id': target, 'ok': False, 'error': str(exc), 'failed_stage': 'worker'}
342
+ result['attempt'] = attempt
343
+ history[target].append(result.copy())
344
+ latest[target] = result
345
+ progress({'event': 'completed', **result})
346
+ # 取消时保留此前失败记录;首次未启动条目仍计入最终结果。
347
+ for target in remaining:
348
+ if target not in latest:
349
+ latest[target] = {'id': target, 'ok': False, 'failed_stage': 'cancelled',
350
+ 'error': '任务未启动,已取消'}
351
+ round_targets = [target for target in round_targets if retry_allowed(latest[target])]
352
+ if not round_targets or cancel.is_set():
353
+ break
354
+ finally:
355
+ tags_cleanup = tags_owned.cleanup()
356
+ # 已失败的清理目录保留并报告,不由父级递归重试掩盖错误。
357
+ batch.validate()
358
+ children = [p for p in batch.path.iterdir() if p.name != MARKER]
359
+ cleanup = ({'ok': False, 'residual': [{'path': str(p), 'error': '业务线目录尚有残留'} for p in children]} if children else batch.cleanup())
360
+ if not tags_cleanup['ok']:
361
+ cleanup['ok'] = False
362
+ cleanup.setdefault('residual', []).extend(tags_cleanup.get('residual', []))
363
+ # 同一业务线仅按最终一次统计,历史失败供定位,不重复计入失败数。
364
+ results = [{**latest[target], 'attempts': len(history[target]), 'attempt_history': history[target]}
365
+ for target in targets]
366
+ success = sum(r['ok'] for r in results)
367
+ return {'ok': success == len(targets) and cleanup['ok'] and not cancel.is_set(), 'success': success,
368
+ 'failed': len(results) - success, 'results': results, 'cleanup': cleanup,
369
+ 'elapsed_seconds': round(time.perf_counter() - started, 3)}
370
+
371
+
372
+ def workflow_parser():
373
+ """入口参数集中构建,便于验证默认本地输出和显式数据库输出。"""
374
+ parser = argument_parser(batch=True)
375
+ parser.description = __doc__
376
+ parser.add_argument('--concurrency', type=int, choices=(1, 2, 3), default=3,
377
+ help='拉数和AI阶段各自的并发上限;总在途最多为此值的两倍,默认3+3')
378
+ parser.add_argument('--output-dir', default=None)
379
+ parser.add_argument('--task-root', default=None, help='持久工作目录,默认启动cwd;临时文件在strategy_task/日期/tmp下')
380
+ parser.add_argument('--output-mode', choices=('excel', 'database'), default='excel')
381
+ return parser
382
+
383
+
384
+ def main():
385
+ args = workflow_parser().parse_args()
386
+ cancel = threading.Event()
387
+ # 可处理的 Ctrl+C/SIGTERM 只发信号,worker 关闭自己的 CLI 后清理。
388
+ previous = {sig: signal.signal(sig, lambda *_: cancel.set()) for sig in (signal.SIGINT, signal.SIGTERM)}
389
+ try:
390
+ result = run_batch(args, cancel=cancel)
391
+ emit({'event': 'batch_completed', **result})
392
+ return 0 if result['ok'] else 1
393
+ except ValueError as exc:
394
+ emit({'ok': False, 'error': str(exc)})
395
+ return 2
396
+ finally:
397
+ for sig, handler in previous.items():
398
+ signal.signal(sig, handler)
399
+
400
+
401
+ if __name__ == '__main__':
402
+ raise SystemExit(main())