sino-bc-cli 1.8.20 → 1.8.21

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 (22) hide show
  1. package/dist/SKILL.md +1 -1
  2. package/dist/index.js +1 -1
  3. package/dist/skills/custom/biz-line-strategy-generate/SKILL.md +20 -23
  4. package/dist/skills/custom/biz-line-strategy-generate/references/api-flow.md +5 -5
  5. package/dist/skills/custom/biz-line-strategy-generate/references/four-strategies.md +16 -14
  6. package/dist/skills/custom/biz-line-strategy-generate/references/metrics.md +29 -278
  7. package/dist/skills/custom/biz-line-strategy-generate/references/output-excel.md +8 -57
  8. package/dist/skills/custom/biz-line-strategy-generate/references/runtime.md +22 -57
  9. package/dist/skills/custom/biz-line-strategy-generate/references/spec.md +124 -0
  10. package/dist/skills/custom/biz-line-strategy-generate/scripts/build_report.py +98 -9
  11. package/dist/skills/custom/biz-line-strategy-generate/scripts/cleanup.py +71 -82
  12. package/dist/skills/custom/biz-line-strategy-generate/scripts/cli_json.py +125 -19
  13. package/dist/skills/custom/biz-line-strategy-generate/scripts/fetch_data.py +61 -32
  14. package/dist/skills/custom/biz-line-strategy-generate/scripts/run_workflow.py +189 -0
  15. package/dist/skills/custom/biz-line-strategy-generate/scripts/summarize.py +158 -148
  16. package/dist/skills/custom/biz-line-strategy-generate/scripts/test_strategy_rules.py +16 -2
  17. package/dist/skills/custom/biz-line-strategy-generate/scripts/test_workflow.py +345 -0
  18. package/package.json +1 -1
  19. package/dist/skills/custom/biz-line-strategy-generate/scripts/__pycache__/build_report.cpython-314.pyc +0 -0
  20. package/dist/skills/custom/biz-line-strategy-generate/scripts/__pycache__/cli_json.cpython-314.pyc +0 -0
  21. package/dist/skills/custom/biz-line-strategy-generate/scripts/__pycache__/fetch_data.cpython-314.pyc +0 -0
  22. package/dist/skills/custom/biz-line-strategy-generate/scripts/__pycache__/summarize.cpython-314.pyc +0 -0
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """业务线策略唯一生成入口:最多三个并发,逐条校验、交付和清理。"""
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
+ import tempfile
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, validate_xlsx, write_xlsx
24
+ from cleanup import OwnedDirectory, MARKER
25
+ from cli_json import cli_context
26
+ from fetch_data import argument_parser, fetch_business
27
+ from summarize import summarize_request
28
+
29
+ _output_lock = threading.Lock()
30
+
31
+
32
+ class PublishError(RuntimeError):
33
+ """发布失败且无法移除本次不完整目标时,保留精确残留路径。"""
34
+ def __init__(self, message, path):
35
+ super().__init__(message)
36
+ self.residual_path = str(path)
37
+
38
+
39
+ def emit(event):
40
+ """并发进度输出按 JSON 行加锁,结果无需额外磁盘日志。"""
41
+ with _output_lock:
42
+ print(json.dumps(event, ensure_ascii=False), flush=True)
43
+
44
+
45
+ def publish(temp, final, payload):
46
+ """先校验临时文件,再独占创建目标;任何中途错误仅清理自己创建的目标。"""
47
+ validate_xlsx(temp, payload['rows'], payload['details'])
48
+ created = False
49
+ try:
50
+ with final.open('xb') as output:
51
+ created = True
52
+ with temp.open('rb') as source:
53
+ shutil.copyfileobj(source, output)
54
+ validate_xlsx(final, payload['rows'], payload['details'])
55
+ except BaseException as original:
56
+ if created:
57
+ try:
58
+ final.unlink()
59
+ except OSError as cleanup_error:
60
+ raise PublishError(f'发布失败:{original};删除不完整目标失败:{cleanup_error}', final) from original
61
+ raise
62
+
63
+
64
+ def execute_business(target, args, batch, cancel, progress=emit):
65
+ """每个名额覆盖完整生命周期,清理结束后才返回调度器。"""
66
+ owned = OwnedDirectory.create(batch.path, f'line-{target or "named"}-')
67
+ result = {'id': target, 'ok': False, 'timings': {}, 'output_path': None}
68
+ current_stage = 'fetch'
69
+ started = time.perf_counter()
70
+ final = None
71
+
72
+ def stage(name, function):
73
+ nonlocal current_stage
74
+ current_stage = name
75
+ if cancel.is_set():
76
+ raise InterruptedError('任务已取消')
77
+ begin = time.perf_counter()
78
+ progress({'id': result['id'], 'stage': name, 'status': 'running'})
79
+ try:
80
+ return function()
81
+ finally:
82
+ result['timings'][name] = round(time.perf_counter() - begin, 3)
83
+
84
+ try:
85
+ # 最终目录必须在整个批次临时树之外,而非仅在当前业务线之外。
86
+ output_dir = Path(args.output_dir).resolve()
87
+ if output_dir.is_relative_to(batch.path):
88
+ raise ValueError('最终 Excel 目录不能位于批次临时目录内')
89
+ local_args = Namespace(**{**vars(args), 'id': target or 0, 'request_dir': str(owned.path)})
90
+ with cli_context(cancel, lambda text: progress({'id': result['id'], 'stage': 'fetch', 'detail': text})):
91
+ biz = stage('fetch', lambda: fetch_business(local_args))
92
+ result['id'], result['name'] = int(biz['id']), str(biz.get('name') or '')
93
+ payload, summary = stage('analyze', lambda: summarize_request(owned.path))
94
+ 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)
98
+ temp = owned.path / 'final.xlsx'
99
+ 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)
102
+ result['ok'] = True
103
+ except Exception as exc:
104
+ result.update(error=str(exc), failed_stage=current_stage)
105
+ if isinstance(exc, PublishError):
106
+ result['residual_output'] = exc.residual_path
107
+ finally:
108
+ begin = time.perf_counter()
109
+ cleanup = owned.cleanup(final)
110
+ result['timings']['cleanup'] = round(time.perf_counter() - begin, 3)
111
+ result['cleanup'] = cleanup
112
+ if not cleanup['ok']:
113
+ result.update(ok=False, cleanup_failed=True)
114
+ result['timings']['total'] = round(time.perf_counter() - started, 3)
115
+ return result
116
+
117
+
118
+ def run_batch(args, *, cancel=None, worker=execute_business, progress=emit, temp_parent=None):
119
+ """只提交可立即运行的三个任务;已完成任务交付后继续补位。"""
120
+ if args.concurrency not in (1, 2, 3):
121
+ raise ValueError('并发只能设置为 1、2 或 3')
122
+ if any(i <= 0 for i in args.id):
123
+ 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('用户指定的起止日期必须成对提供')
127
+ targets = list(dict.fromkeys(args.id)) if args.id else [None]
128
+ cancel = cancel or threading.Event()
129
+ batch = OwnedDirectory.create(Path(temp_parent or tempfile.gettempdir()) / 'sino-bc-strategy', 'batch-')
130
+ results, pending = [], {}
131
+ remaining = iter(targets)
132
+ started = time.perf_counter()
133
+ try:
134
+ 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:
140
+ break
141
+ pending[pool.submit(worker, target, args, batch, cancel, progress)] = target
142
+ if not pending:
143
+ 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
+ finally:
157
+ # 已失败的清理目录保留并报告,不由父级递归重试掩盖错误。
158
+ batch.validate()
159
+ children = [p for p in batch.path.iterdir() if p.name != MARKER]
160
+ cleanup = ({'ok': False, 'residual': [{'path': str(p), 'error': '业务线目录尚有残留'} for p in children]} if children else batch.cleanup())
161
+ success = sum(r['ok'] for r in results)
162
+ return {'ok': success == len(targets) and cleanup['ok'] and not cancel.is_set(), 'success': success,
163
+ 'failed': len(results) - success, 'results': results, 'cleanup': cleanup,
164
+ 'elapsed_seconds': round(time.perf_counter() - started, 3)}
165
+
166
+
167
+ def main():
168
+ parser = argument_parser(batch=True)
169
+ parser.description = __doc__
170
+ 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()
173
+ cancel = threading.Event()
174
+ # 可处理的 Ctrl+C/SIGTERM 只发信号,worker 关闭自己的 CLI 后清理。
175
+ previous = {sig: signal.signal(sig, lambda *_: cancel.set()) for sig in (signal.SIGINT, signal.SIGTERM)}
176
+ try:
177
+ result = run_batch(args, cancel=cancel)
178
+ emit({'event': 'batch_completed', **result})
179
+ return 0 if result['ok'] else 1
180
+ except ValueError as exc:
181
+ emit({'ok': False, 'error': str(exc)})
182
+ return 2
183
+ finally:
184
+ for sig, handler in previous.items():
185
+ signal.signal(sig, handler)
186
+
187
+
188
+ if __name__ == '__main__':
189
+ raise SystemExit(main())