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.
- package/README.md +256 -0
- package/dist/affordance/auth.md +31 -0
- package/dist/affordance/bill.md +48 -0
- package/dist/affordance/biz-line-ai-strategy.md +35 -0
- package/dist/affordance/biz-line-metrics.md +47 -0
- package/dist/affordance/biz-line.md +84 -0
- package/dist/affordance/common-file.md +25 -0
- package/dist/affordance/dashboard.md +75 -0
- package/dist/affordance/dict.md +28 -0
- package/dist/affordance/document.md +67 -0
- package/dist/affordance/im.md +91 -0
- package/dist/affordance/invoice.md +49 -0
- package/dist/affordance/media.md +17 -0
- package/dist/affordance/organization.md +47 -0
- package/dist/affordance/project-analysis.md +95 -0
- package/dist/affordance/project.md +321 -0
- package/dist/affordance/quota.md +19 -0
- package/dist/affordance/reconciliation.md +41 -0
- package/dist/affordance/report.md +75 -0
- package/dist/affordance/sso-team-group.md +30 -0
- package/dist/affordance/task.md +148 -0
- package/dist/affordance/tenant.md +17 -0
- package/dist/affordance/user.md +41 -0
- package/dist/affordance/wallet.md +100 -0
- package/dist/api-doc/README.md +67 -0
- package/dist/api-doc/custom/template.md +0 -0
- package/dist/api-doc/oapi/auth.md +61 -0
- package/dist/api-doc/oapi/bill.md +130 -0
- package/dist/api-doc/oapi/biz-line-ai-strategy.md +119 -0
- package/dist/api-doc/oapi/biz-line-metrics.md +419 -0
- package/dist/api-doc/oapi/biz-line.md +382 -0
- package/dist/api-doc/oapi/common-file.md +50 -0
- package/dist/api-doc/oapi/dashboard.md +218 -0
- package/dist/api-doc/oapi/dict.md +49 -0
- package/dist/api-doc/oapi/document.md +182 -0
- package/dist/api-doc/oapi/im.md +278 -0
- package/dist/api-doc/oapi/invoice.md +129 -0
- package/dist/api-doc/oapi/media.md +17 -0
- package/dist/api-doc/oapi/organization.md +94 -0
- package/dist/api-doc/oapi/project-analysis.md +364 -0
- package/dist/api-doc/oapi/project.md +1114 -0
- package/dist/api-doc/oapi/quota.md +21 -0
- package/dist/api-doc/oapi/reconciliation.md +98 -0
- package/dist/api-doc/oapi/report.md +254 -0
- package/dist/api-doc/oapi/sso-team-group.md +54 -0
- package/dist/api-doc/oapi/task.md +528 -0
- package/dist/api-doc/oapi/tenant.md +17 -0
- package/dist/api-doc/oapi/user.md +98 -0
- package/dist/api-doc/oapi/wallet.md +275 -0
- package/dist/generated/api-catalog.json +39681 -0
- package/dist/generated/api-directory-index.json +1509 -0
- package/dist/index.js +2 -0
- package/dist/skill-manifest.json +336 -0
- package/dist/skills/biz-line-strategy-generate/SKILL.md +122 -0
- package/dist/skills/biz-line-strategy-generate/references/ai-strategy-prompt.md +50 -0
- package/dist/skills/biz-line-strategy-generate/references/api-flow.md +155 -0
- package/dist/skills/biz-line-strategy-generate/references/four-strategies.md +58 -0
- package/dist/skills/biz-line-strategy-generate/references/metrics.md +119 -0
- package/dist/skills/biz-line-strategy-generate/references/output-excel.md +63 -0
- package/dist/skills/biz-line-strategy-generate/references/runtime.md +127 -0
- package/dist/skills/biz-line-strategy-generate/references/spec.md +500 -0
- package/dist/skills/biz-line-strategy-generate/scripts/ai_strategy.py +89 -0
- package/dist/skills/biz-line-strategy-generate/scripts/build_report.py +629 -0
- package/dist/skills/biz-line-strategy-generate/scripts/cleanup.py +87 -0
- package/dist/skills/biz-line-strategy-generate/scripts/cli_json.py +705 -0
- package/dist/skills/biz-line-strategy-generate/scripts/fetch_data.py +449 -0
- package/dist/skills/biz-line-strategy-generate/scripts/run_workflow.py +402 -0
- package/dist/skills/biz-line-strategy-generate/scripts/strategy_output.py +556 -0
- package/dist/skills/biz-line-strategy-generate/scripts/submit_tags.py +32 -0
- package/dist/skills/biz-line-strategy-generate/scripts/summarize.py +932 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_ai_strategy.py +235 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_cli_json.py +97 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_database_output.py +904 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_pipeline.py +253 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_strategy_rules.py +224 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_tag_submission.py +238 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_workflow.py +1233 -0
- package/dist/skills/sino-bc-cli/SKILL.md +62 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-auth/SKILL.md +36 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-bill/SKILL.md +48 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line/SKILL.md +58 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line/references/common-mistakes.md +7 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line/references/enums.md +13 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line-ai-strategy/SKILL.md +46 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line-metrics/SKILL.md +46 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-common-file/SKILL.md +44 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-dashboard/SKILL.md +50 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-dict/SKILL.md +48 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-document/SKILL.md +53 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-im/SKILL.md +62 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-invoice/SKILL.md +48 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-media/SKILL.md +40 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-organization/SKILL.md +46 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-project/SKILL.md +112 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-project-analysis/SKILL.md +61 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-project-analysis/references/analysis-workflow.md +21 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-project-analysis/references/filters-and-metrics.md +30 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-quota/SKILL.md +44 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-reconciliation/SKILL.md +47 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-report/SKILL.md +53 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-sso-team-group/SKILL.md +45 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-task/SKILL.md +73 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-task/references/state-machine.md +18 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-tenant/SKILL.md +34 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-user/SKILL.md +57 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-wallet/SKILL.md +55 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8534789.md +53 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8551546.md +70 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8562638.md +13 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8566367.md +13 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8581524.md +17 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8601540.md +13 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8614891.md +33 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8621792.md +17 -0
- package/dist/skills/sino-bc-shared/SKILL.md +31 -0
- package/dist/skills/sino-bc-shared/references/auth-recovery.md +36 -0
- package/dist/skills/sino-bc-shared/references/config-and-env.md +60 -0
- package/dist/skills/sino-bc-shared/references/output-contract.md +86 -0
- package/dist/skills/sino-bc-shared/references/skill-lifecycle.md +17 -0
- package/dist/skills/sino-bc-shared/references/tenant-context.md +21 -0
- package/dist/skills/sino-bc-shared/references/write-approval.md +16 -0
- package/package.json +70 -0
- 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())
|