sino-bc-cli 1.8.41 → 1.8.42
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/dist/index.js +1 -1
- package/dist/release-info.json +2 -2
- package/dist/skill-manifest.json +24 -14
- package/dist/skills/biz-line-strategy-generate/SKILL.md +20 -17
- package/dist/skills/biz-line-strategy-generate/references/api-flow.md +16 -5
- package/dist/skills/biz-line-strategy-generate/references/read-tools.md +5 -2
- package/dist/skills/biz-line-strategy-generate/references/runtime.md +57 -18
- package/dist/skills/biz-line-strategy-generate/references/spec.md +56 -15
- package/dist/skills/biz-line-strategy-generate/scripts/ai_strategy.py +36 -22
- package/dist/skills/biz-line-strategy-generate/scripts/cleanup.py +9 -0
- package/dist/skills/biz-line-strategy-generate/scripts/resumable_delivery.py +184 -0
- package/dist/skills/biz-line-strategy-generate/scripts/resumable_requests.py +243 -0
- package/dist/skills/biz-line-strategy-generate/scripts/resumable_workflow.py +460 -0
- package/dist/skills/biz-line-strategy-generate/scripts/run_sharded.py +3 -0
- package/dist/skills/biz-line-strategy-generate/scripts/run_workflow.py +76 -17
- package/dist/skills/biz-line-strategy-generate/scripts/strategy_output.py +33 -15
- package/dist/skills/biz-line-strategy-generate/scripts/submit_ai_strategy.py +30 -0
- package/dist/skills/biz-line-strategy-generate/scripts/task_state.py +174 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_resource_cleanup.py +16 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_resumable_delivery.py +198 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_resumable_integration.py +301 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_resumable_requests.py +269 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_resumable_workflow.py +187 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_sharded.py +9 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_task_state.py +165 -0
- package/package.json +1 -1
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""正式入库恢复:离线验证写入意图、回包恢复和查询认领。"""
|
|
2
|
+
import json
|
|
3
|
+
import tempfile
|
|
4
|
+
import threading
|
|
5
|
+
import unittest
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import Mock, patch
|
|
8
|
+
|
|
9
|
+
from resumable_delivery import UnknownWrite, perform_write, read_intent, recover_write
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def ready_body():
|
|
13
|
+
body = {'bizLineId': 31, 'periodStart': '2026-10-12', 'periodEnd': '2026-10-18',
|
|
14
|
+
'generateCode': 'READY', 'fileStorageKey': 'test-file-key'}
|
|
15
|
+
for key in ('budget', 'media', 'audience', 'creative'):
|
|
16
|
+
body[key + 'Strategy' + ('' if key == 'creative' else 'Supplement')] = key + '正文'
|
|
17
|
+
body[key + 'StrategyEffect'] = 3
|
|
18
|
+
body[key + 'StrategyLearning'] = key + '学习'
|
|
19
|
+
if key != 'creative':
|
|
20
|
+
body[key + 'StrategyTags'] = [2, 1]
|
|
21
|
+
return body
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def stored_row(body, ident=91):
|
|
25
|
+
return {('weekStart' if key == 'periodStart' else 'weekEnd' if key == 'periodEnd' else key): value
|
|
26
|
+
for key, value in body.items()} | {'id': ident}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def page(rows, total=100):
|
|
30
|
+
return {'ok': True, 'data': {'result': rows, 'total': total}}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DeliveryTests(unittest.TestCase):
|
|
34
|
+
def setUp(self):
|
|
35
|
+
self.temp = tempfile.TemporaryDirectory()
|
|
36
|
+
self.root = Path(self.temp.name)
|
|
37
|
+
self.addCleanup(self.temp.cleanup)
|
|
38
|
+
|
|
39
|
+
def pending(self, kind, body):
|
|
40
|
+
with self.assertRaises(UnknownWrite):
|
|
41
|
+
perform_write(self.root, kind, body, Mock(side_effect=TimeoutError('模拟超时')))
|
|
42
|
+
|
|
43
|
+
def test_success_persists_id_before_return_and_never_replays(self):
|
|
44
|
+
body = ready_body()
|
|
45
|
+
operation = Mock(return_value=91)
|
|
46
|
+
self.assertEqual(perform_write(self.root, 'save', body, operation), 91)
|
|
47
|
+
self.assertEqual(read_intent(self.root, 'save')['status'], 'done')
|
|
48
|
+
self.assertEqual(perform_write(self.root, 'save', body, operation), 91)
|
|
49
|
+
operation.assert_called_once()
|
|
50
|
+
|
|
51
|
+
def test_upload_success_is_recovered_from_bound_output(self):
|
|
52
|
+
self.pending('upload', {'file': 'final.xlsx'})
|
|
53
|
+
(self.root / 'upload.out.json').write_text(json.dumps({'ok': True, 'data': {'storageKey': 'uploaded-key'}}))
|
|
54
|
+
self.assertEqual(recover_write(self.root, 'upload'), 'uploaded-key')
|
|
55
|
+
self.assertEqual(read_intent(self.root, 'upload')['status'], 'done')
|
|
56
|
+
|
|
57
|
+
def test_save_success_output_wins_over_query(self):
|
|
58
|
+
self.pending('save', ready_body())
|
|
59
|
+
(self.root / 'save.out.json').write_text(json.dumps({'ok': True, 'data': 91}))
|
|
60
|
+
with patch('resumable_delivery.run_cli') as cli:
|
|
61
|
+
self.assertEqual(recover_write(self.root, 'save'), 91)
|
|
62
|
+
cli.assert_not_called()
|
|
63
|
+
|
|
64
|
+
def test_upload_unknown_does_not_replay_or_query_ai(self):
|
|
65
|
+
self.pending('upload', {'file': 'final.xlsx'})
|
|
66
|
+
operation = Mock(return_value='unwanted')
|
|
67
|
+
with patch('resumable_delivery.run_cli') as cli, self.assertRaises(UnknownWrite):
|
|
68
|
+
perform_write(self.root, 'upload', {'file': 'final.xlsx'}, operation)
|
|
69
|
+
operation.assert_not_called()
|
|
70
|
+
cli.assert_not_called()
|
|
71
|
+
|
|
72
|
+
def test_query_uses_complete_pages_and_ignores_changing_total(self):
|
|
73
|
+
body = ready_body()
|
|
74
|
+
self.pending('save', body)
|
|
75
|
+
row = stored_row(body)
|
|
76
|
+
row['budgetStrategyTags'] = [1, 2]
|
|
77
|
+
with patch('resumable_delivery.run_cli', side_effect=[page([row], 1), page([], 7)]) as cli:
|
|
78
|
+
self.assertEqual(recover_write(self.root, 'save'), 91)
|
|
79
|
+
request = cli.call_args_list[0].args[1]
|
|
80
|
+
self.assertEqual(request['weekStart'], body['periodStart'])
|
|
81
|
+
self.assertNotIn('periodStart', request)
|
|
82
|
+
self.assertEqual(cli.call_count, 2)
|
|
83
|
+
|
|
84
|
+
def test_second_page_match_conflict_stays_unknown(self):
|
|
85
|
+
body = ready_body()
|
|
86
|
+
self.pending('save', body)
|
|
87
|
+
pages = [page([stored_row(body, 91)]), page([stored_row(body, 92)]), page([])] * 3
|
|
88
|
+
with patch('resumable_delivery.run_cli', side_effect=pages), patch('resumable_delivery.time.sleep'), \
|
|
89
|
+
self.assertRaises(UnknownWrite):
|
|
90
|
+
recover_write(self.root, 'save')
|
|
91
|
+
self.assertEqual(read_intent(self.root, 'save')['status'], 'pending')
|
|
92
|
+
|
|
93
|
+
def test_content_mismatch_is_not_success(self):
|
|
94
|
+
body = ready_body()
|
|
95
|
+
self.pending('save', body)
|
|
96
|
+
row = stored_row(body)
|
|
97
|
+
row['creativeStrategyLearning'] = '其他内容'
|
|
98
|
+
with patch('resumable_delivery.run_cli', side_effect=[page([row]), page([])] * 3), \
|
|
99
|
+
patch('resumable_delivery.time.sleep'), self.assertRaises(UnknownWrite):
|
|
100
|
+
recover_write(self.root, 'save')
|
|
101
|
+
|
|
102
|
+
def test_nonready_same_record_cannot_be_claimed(self):
|
|
103
|
+
body = {'bizLineId': 31, 'periodStart': '2026-10-12', 'periodEnd': '2026-10-18',
|
|
104
|
+
'generateCode': 'NO_AD_ACCOUNT', 'remark': '没有账号'}
|
|
105
|
+
self.pending('save', body)
|
|
106
|
+
with patch('resumable_delivery.run_cli', side_effect=[page([stored_row(body)]), page([])] * 3) as cli, \
|
|
107
|
+
patch('resumable_delivery.time.sleep'), self.assertRaises(UnknownWrite):
|
|
108
|
+
recover_write(self.root, 'save')
|
|
109
|
+
self.assertGreater(cli.call_count, 0)
|
|
110
|
+
|
|
111
|
+
def test_incomplete_or_failed_query_cannot_claim_seen_match(self):
|
|
112
|
+
body = ready_body()
|
|
113
|
+
self.pending('save', body)
|
|
114
|
+
with patch('resumable_delivery.run_cli', side_effect=[page([stored_row(body)]), RuntimeError('断网')] * 3), \
|
|
115
|
+
patch('resumable_delivery.time.sleep'), self.assertRaises(UnknownWrite):
|
|
116
|
+
recover_write(self.root, 'save')
|
|
117
|
+
|
|
118
|
+
def test_stale_output_without_intent_is_rejected(self):
|
|
119
|
+
(self.root / 'save.out.json').write_text(json.dumps({'ok': True, 'data': 91}))
|
|
120
|
+
operation = Mock(return_value=92)
|
|
121
|
+
with self.assertRaises(ValueError):
|
|
122
|
+
perform_write(self.root, 'save', ready_body(), operation)
|
|
123
|
+
operation.assert_not_called()
|
|
124
|
+
|
|
125
|
+
def test_changed_body_cannot_reuse_intent(self):
|
|
126
|
+
body = ready_body()
|
|
127
|
+
perform_write(self.root, 'save', body, Mock(return_value=91))
|
|
128
|
+
with self.assertRaises(ValueError):
|
|
129
|
+
perform_write(self.root, 'save', body | {'fileStorageKey': 'different'}, Mock())
|
|
130
|
+
|
|
131
|
+
def test_cancel_during_operation_keeps_intent_pending(self):
|
|
132
|
+
with self.assertRaises(InterruptedError):
|
|
133
|
+
perform_write(self.root, 'save', ready_body(), Mock(side_effect=InterruptedError('中断')))
|
|
134
|
+
self.assertEqual(read_intent(self.root, 'save')['status'], 'pending')
|
|
135
|
+
|
|
136
|
+
def test_success_output_recovers_after_checkpoint_write_failure(self):
|
|
137
|
+
body = ready_body()
|
|
138
|
+
from task_state import atomic_json
|
|
139
|
+
writes = []
|
|
140
|
+
def fail_done(path, value):
|
|
141
|
+
writes.append(value['status'])
|
|
142
|
+
if value['status'] == 'done':
|
|
143
|
+
raise OSError('模拟状态写入失败')
|
|
144
|
+
atomic_json(path, value)
|
|
145
|
+
def operation():
|
|
146
|
+
(self.root / 'save.out.json').write_text(json.dumps({'ok': True, 'data': 91}))
|
|
147
|
+
return 91
|
|
148
|
+
with patch('resumable_delivery.atomic_json', side_effect=fail_done), self.assertRaises(UnknownWrite):
|
|
149
|
+
perform_write(self.root, 'save', body, operation)
|
|
150
|
+
self.assertEqual(writes, ['pending', 'done'])
|
|
151
|
+
with patch('resumable_delivery.run_cli') as cli:
|
|
152
|
+
self.assertEqual(recover_write(self.root, 'save'), 91)
|
|
153
|
+
cli.assert_not_called()
|
|
154
|
+
|
|
155
|
+
def test_upload_checkpoint_failure_is_unknown_not_business_failure(self):
|
|
156
|
+
from task_state import atomic_json
|
|
157
|
+
def fail_done(path, value):
|
|
158
|
+
if value['status'] == 'done':
|
|
159
|
+
raise OSError('模拟上传成功记录落盘失败')
|
|
160
|
+
atomic_json(path, value)
|
|
161
|
+
operation = Mock(return_value='uploaded-key')
|
|
162
|
+
with patch('resumable_delivery.atomic_json', side_effect=fail_done), self.assertRaises(UnknownWrite):
|
|
163
|
+
perform_write(self.root, 'upload', {'file': 'final.xlsx'}, operation)
|
|
164
|
+
self.assertEqual(read_intent(self.root, 'upload')['status'], 'pending')
|
|
165
|
+
operation.assert_called_once()
|
|
166
|
+
|
|
167
|
+
def test_unterminated_pagination_does_not_claim_match(self):
|
|
168
|
+
body = ready_body()
|
|
169
|
+
self.pending('save', body)
|
|
170
|
+
with patch('resumable_delivery.run_cli', return_value=page([stored_row(body)])), \
|
|
171
|
+
patch('resumable_delivery._QUERY_LIMIT', 2), patch('resumable_delivery.time.sleep'), \
|
|
172
|
+
self.assertRaises(UnknownWrite):
|
|
173
|
+
recover_write(self.root, 'save')
|
|
174
|
+
|
|
175
|
+
def test_boolean_id_is_not_a_success(self):
|
|
176
|
+
with self.assertRaises(UnknownWrite):
|
|
177
|
+
perform_write(self.root, 'save', ready_body(), Mock(return_value=True))
|
|
178
|
+
self.assertEqual(read_intent(self.root, 'save')['status'], 'pending')
|
|
179
|
+
|
|
180
|
+
def test_identity_guard_runs_before_each_query_page(self):
|
|
181
|
+
body = ready_body()
|
|
182
|
+
self.pending('save', body)
|
|
183
|
+
guard = Mock()
|
|
184
|
+
with patch('resumable_delivery.run_cli', side_effect=[page([stored_row(body)]), page([])]):
|
|
185
|
+
self.assertEqual(recover_write(self.root, 'save', before_request=guard), 91)
|
|
186
|
+
self.assertEqual(guard.call_count, 2)
|
|
187
|
+
|
|
188
|
+
def test_cancel_before_query_does_not_make_http_calls(self):
|
|
189
|
+
self.pending('save', ready_body())
|
|
190
|
+
cancel = threading.Event()
|
|
191
|
+
cancel.set()
|
|
192
|
+
with patch('resumable_delivery.run_cli') as cli, self.assertRaises(InterruptedError):
|
|
193
|
+
recover_write(self.root, 'save', cancel=cancel)
|
|
194
|
+
cli.assert_not_called()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
if __name__ == '__main__':
|
|
198
|
+
unittest.main()
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""分阶段任务的组合回归:真实Excel及提交脚本,业务接口仅用匿名本地替代。"""
|
|
2
|
+
from contextlib import ExitStack, contextmanager
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import signal
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import unittest
|
|
12
|
+
from unittest.mock import patch
|
|
13
|
+
import zipfile
|
|
14
|
+
|
|
15
|
+
sys.dont_write_bytecode = True
|
|
16
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
17
|
+
|
|
18
|
+
from detail_store import write_detail_rows
|
|
19
|
+
from resumable_workflow import run_resumable
|
|
20
|
+
from run_workflow import run_batch, workflow_parser
|
|
21
|
+
from strategy_output import submit_tag_choices
|
|
22
|
+
from resumable_requests import submit_ai_response
|
|
23
|
+
from task_state import atomic_json, read_task
|
|
24
|
+
from test_workflow import small_payload
|
|
25
|
+
|
|
26
|
+
IDENTITY = {'authContextId': 'test-context', 'userType': 'sso', 'gatewayUrl': 'http://127.0.0.1'}
|
|
27
|
+
AI_TEXT = '一页速览\n匿名数据,仅供测试。\n' + '\n'.join(f'【{heading}】\n- 匿名策略内容{index}。\nLearning(待观察):样本不足,继续观察。'
|
|
28
|
+
for index, heading in enumerate(('预算策略', '广告产品策略', '受众策略', '创意策略')))
|
|
29
|
+
DICTIONARIES = {key: [{'id': index, 'name': key + '标签', 'isOther': 0},
|
|
30
|
+
{'id': index + 10, 'name': '其他', 'isOther': 1}]
|
|
31
|
+
for index, key in enumerate(('budget', 'product', 'audience'), 1)}
|
|
32
|
+
SCRIPTS = Path(__file__).resolve().parent
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def arguments(root, mode='excel', resume=None, ids=(11,)):
|
|
36
|
+
if resume:
|
|
37
|
+
return workflow_parser().parse_args(['--resume', str(resume)])
|
|
38
|
+
flags = ['--resumable', '--task-root', str(root), '--output-mode', mode,
|
|
39
|
+
'--curr-start', '2026-10-12', '--curr-end', '2026-10-18', '--concurrency', '1']
|
|
40
|
+
for ident in ids:
|
|
41
|
+
flags.extend(['--id', str(ident)])
|
|
42
|
+
if mode == 'excel':
|
|
43
|
+
flags.extend(['--output-dir', str(root / 'output')])
|
|
44
|
+
return workflow_parser().parse_args(flags)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@contextmanager
|
|
48
|
+
def fixture_apis(root, *, pause_fetch=False):
|
|
49
|
+
"""替代取数/分析和出站调用,阶段调度、请求、文件生成校验保持真实。"""
|
|
50
|
+
calls = {'fetch': 0, 'analyze': 0, 'upload': 0, 'save': 0, 'saved': []}
|
|
51
|
+
def fetch(args):
|
|
52
|
+
calls['fetch'] += 1
|
|
53
|
+
directory = Path(args.request_dir)
|
|
54
|
+
atomic_json(directory / 'save-context.json', {'bizLineId': args.id,
|
|
55
|
+
'periodStart': args.curr_start, 'periodEnd': args.curr_end})
|
|
56
|
+
if pause_fetch:
|
|
57
|
+
(root / 'fetch-entered').write_text(str(directory))
|
|
58
|
+
while not (root / 'continue-fetch').exists():
|
|
59
|
+
# 模拟真实取数所检查的工作流取消标记,不运行任何HTTP。
|
|
60
|
+
from cli_json import _local
|
|
61
|
+
if getattr(_local, 'cancel', None) and _local.cancel.is_set():
|
|
62
|
+
raise InterruptedError('模拟取数收到SIGTERM')
|
|
63
|
+
time.sleep(0.01)
|
|
64
|
+
return {'id': args.id, 'name': '匿名业务线', 'adCategoryIndustryIds': [1]}
|
|
65
|
+
def analyze(directory):
|
|
66
|
+
calls['analyze'] += 1
|
|
67
|
+
payload = small_payload()
|
|
68
|
+
details = write_detail_rows(directory, payload.pop('details'))
|
|
69
|
+
payload['detailSource'] = details.reference(directory)
|
|
70
|
+
summary = {'detail_count': len(details), 'curr_total': 10, 'tag_evidence': {}}
|
|
71
|
+
atomic_json(directory / 'payload.json', payload)
|
|
72
|
+
atomic_json(directory / 'summary.json', summary)
|
|
73
|
+
return {**payload, 'details': details}, summary
|
|
74
|
+
def upload(directory, workbook):
|
|
75
|
+
calls['upload'] += 1
|
|
76
|
+
assert Path(workbook).is_file()
|
|
77
|
+
atomic_json(directory / 'upload.out.json', {'ok': True, 'data': {'storageKey': 'sample-key'}})
|
|
78
|
+
return 'sample-key'
|
|
79
|
+
def save(directory, body):
|
|
80
|
+
calls['save'] += 1
|
|
81
|
+
calls['saved'].append(body)
|
|
82
|
+
atomic_json(directory / 'save.out.json', {'ok': True, 'data': 321})
|
|
83
|
+
return 321
|
|
84
|
+
with ExitStack() as stack:
|
|
85
|
+
stack.enter_context(patch('run_workflow.fetch_business', fetch))
|
|
86
|
+
stack.enter_context(patch('run_workflow.summarize_request', analyze))
|
|
87
|
+
stack.enter_context(patch('strategy_output.TagCache.get', return_value=DICTIONARIES))
|
|
88
|
+
stack.enter_context(patch('run_workflow.upload_excel', upload))
|
|
89
|
+
stack.enter_context(patch('run_workflow.save_result', save))
|
|
90
|
+
yield calls
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def choices(request):
|
|
94
|
+
return {'selections': {key: [{'choiceKey': item['labels'][0]['choiceKey'], 'reason': '匿名正文动作对应'}]
|
|
95
|
+
for key, item in request['strategies'].items()}}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def submit_event(event, *, script=False):
|
|
99
|
+
packet = json.loads(Path(event['request_path']).read_text())
|
|
100
|
+
if event['event'] == 'tag_review_required':
|
|
101
|
+
body = choices(packet)
|
|
102
|
+
command = [sys.executable, str(SCRIPTS / 'submit_tags.py'), '--request', event['request_path'],
|
|
103
|
+
'--review-id', event['reviewId']]
|
|
104
|
+
direct = lambda: submit_tag_choices(event['request_path'], event['reviewId'], body)
|
|
105
|
+
else:
|
|
106
|
+
body = {'text': AI_TEXT}
|
|
107
|
+
command = [sys.executable, str(SCRIPTS / 'submit_ai_strategy.py'), '--request', event['request_path'],
|
|
108
|
+
'--request-id', event['requestId']]
|
|
109
|
+
direct = lambda: submit_ai_response(event['request_path'], event['requestId'], body)
|
|
110
|
+
if script:
|
|
111
|
+
result = subprocess.run(command, input=json.dumps(body), capture_output=True, text=True, timeout=10)
|
|
112
|
+
if result.returncode:
|
|
113
|
+
raise AssertionError(result.stderr)
|
|
114
|
+
else:
|
|
115
|
+
direct()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class ResumableIntegrationTests(unittest.TestCase):
|
|
119
|
+
def advance(self, args, events=None, cancel=None):
|
|
120
|
+
return run_resumable(args, cancel or threading.Event(), (events if events is not None else []).append,
|
|
121
|
+
identity_check=lambda _: IDENTITY)
|
|
122
|
+
|
|
123
|
+
def test_excel_three_calls_and_submit_scripts_match_legacy_workbook(self):
|
|
124
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
125
|
+
root = Path(temporary)
|
|
126
|
+
with fixture_apis(root) as calls:
|
|
127
|
+
first = self.advance(arguments(root))
|
|
128
|
+
self.assertFalse(first['complete'])
|
|
129
|
+
self.assertEqual(first['results'][0]['request']['event'], 'tag_review_required')
|
|
130
|
+
task_path = first['taskPath']
|
|
131
|
+
submit_event(first['results'][0]['request'], script=True)
|
|
132
|
+
second = self.advance(arguments(root, resume=task_path))
|
|
133
|
+
self.assertEqual(second['results'][0]['request']['event'], 'ai_strategy_required', second)
|
|
134
|
+
submit_event(second['results'][0]['request'], script=True)
|
|
135
|
+
final = self.advance(arguments(root, resume=task_path))
|
|
136
|
+
self.assertTrue(final['ok'], final)
|
|
137
|
+
self.assertTrue(final['complete'])
|
|
138
|
+
self.assertEqual(calls['fetch'], 1)
|
|
139
|
+
self.assertEqual(calls['analyze'], 1)
|
|
140
|
+
self.assertEqual((calls['upload'], calls['save']), (0, 0))
|
|
141
|
+
output = Path(final['results'][0]['output_path'])
|
|
142
|
+
repeated = self.advance(arguments(root, resume=task_path))
|
|
143
|
+
self.assertEqual(repeated['results'][0]['output_path'], str(output))
|
|
144
|
+
self.assertEqual(len(list((root / 'output').glob('*.xlsx'))), 1)
|
|
145
|
+
|
|
146
|
+
def legacy_progress(event):
|
|
147
|
+
if event.get('event') == 'tag_review_required':
|
|
148
|
+
packet = json.loads(Path(event['request_path']).read_text())
|
|
149
|
+
submit_tag_choices(event['request_path'], event['reviewId'], choices(packet))
|
|
150
|
+
elif event.get('event') == 'ai_strategy_required':
|
|
151
|
+
packet = {key: event[key] for key in ('bizLineId', 'attempt', 'requestId')}
|
|
152
|
+
atomic_json(Path(event['response_path']), {**packet, 'text': AI_TEXT})
|
|
153
|
+
old_args = arguments(root / 'legacy')
|
|
154
|
+
old_args.resumable = False
|
|
155
|
+
legacy = run_batch(old_args, progress=legacy_progress)
|
|
156
|
+
self.assertTrue(legacy['ok'], legacy)
|
|
157
|
+
with zipfile.ZipFile(output) as actual, zipfile.ZipFile(legacy['results'][0]['output_path']) as old:
|
|
158
|
+
self.assertEqual(set(actual.namelist()), set(old.namelist()))
|
|
159
|
+
for name in actual.namelist():
|
|
160
|
+
self.assertEqual(actual.read(name), old.read(name), name)
|
|
161
|
+
|
|
162
|
+
def test_database_saves_once_and_keeps_id_after_repeated_resume(self):
|
|
163
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
164
|
+
root = Path(temporary)
|
|
165
|
+
with fixture_apis(root) as calls:
|
|
166
|
+
first = self.advance(arguments(root, 'database'))
|
|
167
|
+
submit_event(first['results'][0]['request'])
|
|
168
|
+
second = self.advance(arguments(root, resume=first['taskPath']))
|
|
169
|
+
submit_event(second['results'][0]['request'])
|
|
170
|
+
final = self.advance(arguments(root, resume=first['taskPath']))
|
|
171
|
+
self.assertTrue(final['ok'], final)
|
|
172
|
+
self.assertEqual(final['results'][0]['aiStrategyId'], 321)
|
|
173
|
+
self.assertEqual((calls['upload'], calls['save']), (1, 1))
|
|
174
|
+
self.assertEqual(calls['saved'][0]['fileStorageKey'], 'sample-key')
|
|
175
|
+
again = self.advance(arguments(root, resume=first['taskPath']))
|
|
176
|
+
self.assertTrue(again['ok'], again)
|
|
177
|
+
self.assertEqual((calls['upload'], calls['save']), (1, 1))
|
|
178
|
+
self.assertFalse(list(root.rglob('*.xlsx')))
|
|
179
|
+
|
|
180
|
+
def test_next_day_restarts_data_and_rejects_old_request(self):
|
|
181
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
182
|
+
root = Path(temporary)
|
|
183
|
+
with fixture_apis(root) as calls, patch('resumable_workflow.shanghai_date', return_value='2026-10-25'):
|
|
184
|
+
first = self.advance(arguments(root))
|
|
185
|
+
old_request = first['results'][0]['request']
|
|
186
|
+
with fixture_apis(root) as next_calls, patch('resumable_workflow.shanghai_date', return_value='2026-10-26'):
|
|
187
|
+
resumed = self.advance(arguments(root, resume=first['taskPath']))
|
|
188
|
+
self.assertEqual(resumed['results'][0]['generation'], 2, resumed)
|
|
189
|
+
self.assertEqual(next_calls['fetch'], 1)
|
|
190
|
+
self.assertNotEqual(resumed['results'][0]['request']['reviewId'], old_request['reviewId'])
|
|
191
|
+
with self.assertRaises((ValueError, FileNotFoundError)):
|
|
192
|
+
submit_event(old_request)
|
|
193
|
+
self.assertEqual(read_task(first['taskPath'])['inputs']['curr_start'], '2026-10-12')
|
|
194
|
+
|
|
195
|
+
def test_sigterm_during_fetch_preserves_task_for_a_new_process(self):
|
|
196
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
197
|
+
root = Path(temporary)
|
|
198
|
+
code = """import sys,signal,threading,json
|
|
199
|
+
from pathlib import Path
|
|
200
|
+
sys.path.insert(0,sys.argv[1])
|
|
201
|
+
from test_resumable_integration import arguments,fixture_apis,IDENTITY
|
|
202
|
+
from resumable_workflow import run_resumable
|
|
203
|
+
root=Path(sys.argv[2]); cancel=threading.Event()
|
|
204
|
+
signal.signal(signal.SIGTERM,lambda *_:cancel.set())
|
|
205
|
+
with fixture_apis(root,pause_fetch=True) as calls:
|
|
206
|
+
result=run_resumable(arguments(root,'database'),cancel,lambda _:None,identity_check=lambda _:IDENTITY)
|
|
207
|
+
(root/'child-result.json').write_text(json.dumps({'result':result,'calls':calls}))
|
|
208
|
+
"""
|
|
209
|
+
proc = subprocess.Popen([sys.executable, '-B', '-c', code, str(SCRIPTS), str(root)],
|
|
210
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
211
|
+
try:
|
|
212
|
+
deadline = time.monotonic() + 8
|
|
213
|
+
while not (root / 'fetch-entered').exists() and time.monotonic() < deadline:
|
|
214
|
+
if proc.poll() is not None:
|
|
215
|
+
self.fail(proc.communicate()[1])
|
|
216
|
+
time.sleep(0.01)
|
|
217
|
+
self.assertTrue((root / 'fetch-entered').exists())
|
|
218
|
+
proc.send_signal(signal.SIGTERM)
|
|
219
|
+
out, error = proc.communicate(timeout=8)
|
|
220
|
+
self.assertEqual(proc.returncode, 0, out + error)
|
|
221
|
+
saved = json.loads((root / 'child-result.json').read_text())
|
|
222
|
+
self.assertEqual(saved['result']['status'], 'interrupted', saved)
|
|
223
|
+
self.assertEqual(saved['calls']['save'], 0)
|
|
224
|
+
task_path = saved['result']['taskPath']
|
|
225
|
+
task = read_task(task_path)
|
|
226
|
+
self.assertTrue(Path(task['lines']['11']['workPath']).is_dir())
|
|
227
|
+
# 原进程已经结束;由当前独立进程继续相同任务。
|
|
228
|
+
with fixture_apis(root) as calls:
|
|
229
|
+
resumed = self.advance(arguments(root, resume=task_path))
|
|
230
|
+
self.assertEqual(resumed['results'][0]['request']['event'], 'tag_review_required', resumed)
|
|
231
|
+
self.assertEqual(calls['fetch'], 1)
|
|
232
|
+
self.assertEqual(calls['save'], 0)
|
|
233
|
+
finally:
|
|
234
|
+
if proc.poll() is None:
|
|
235
|
+
proc.kill()
|
|
236
|
+
proc.communicate(timeout=5)
|
|
237
|
+
|
|
238
|
+
def test_midnight_after_upload_does_not_save_old_data(self):
|
|
239
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
240
|
+
root = Path(temporary)
|
|
241
|
+
day = {'value': '2026-10-25'}
|
|
242
|
+
with fixture_apis(root) as calls, patch('resumable_workflow.shanghai_date', side_effect=lambda: day['value']):
|
|
243
|
+
first = self.advance(arguments(root, 'database'))
|
|
244
|
+
submit_event(first['results'][0]['request'])
|
|
245
|
+
second = self.advance(arguments(root, resume=first['taskPath']))
|
|
246
|
+
submit_event(second['results'][0]['request'])
|
|
247
|
+
import run_workflow
|
|
248
|
+
upload = run_workflow.upload_excel
|
|
249
|
+
def cross_midnight(directory, workbook):
|
|
250
|
+
value = upload(directory, workbook)
|
|
251
|
+
day['value'] = '2026-10-26'
|
|
252
|
+
return value
|
|
253
|
+
with patch('run_workflow.upload_excel', cross_midnight):
|
|
254
|
+
paused = self.advance(arguments(root, resume=first['taskPath']))
|
|
255
|
+
self.assertFalse(paused['complete'], paused)
|
|
256
|
+
self.assertEqual(calls['upload'], 1)
|
|
257
|
+
self.assertEqual(calls['save'], 0)
|
|
258
|
+
refreshed = self.advance(arguments(root, resume=first['taskPath']))
|
|
259
|
+
self.assertEqual(refreshed['results'][0]['generation'], 2, refreshed)
|
|
260
|
+
self.assertEqual(refreshed['results'][0]['request']['event'], 'tag_review_required')
|
|
261
|
+
self.assertEqual(calls['fetch'], 2)
|
|
262
|
+
self.assertEqual(calls['save'], 0)
|
|
263
|
+
|
|
264
|
+
def test_midnight_after_export_does_not_publish_old_data(self):
|
|
265
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
266
|
+
root = Path(temporary)
|
|
267
|
+
day = {'value': '2026-10-25'}
|
|
268
|
+
with fixture_apis(root), patch('resumable_workflow.shanghai_date', side_effect=lambda: day['value']):
|
|
269
|
+
first = self.advance(arguments(root))
|
|
270
|
+
submit_event(first['results'][0]['request'])
|
|
271
|
+
second = self.advance(arguments(root, resume=first['taskPath']))
|
|
272
|
+
submit_event(second['results'][0]['request'])
|
|
273
|
+
import run_workflow
|
|
274
|
+
export = run_workflow.write_xlsx
|
|
275
|
+
def cross_midnight(rows, filename, details, **kwargs):
|
|
276
|
+
value = export(rows, filename, details, **kwargs)
|
|
277
|
+
if Path(filename).name == 'final.xlsx':
|
|
278
|
+
day['value'] = '2026-10-26'
|
|
279
|
+
return value
|
|
280
|
+
with patch('run_workflow.write_xlsx', cross_midnight):
|
|
281
|
+
paused = self.advance(arguments(root, resume=first['taskPath']))
|
|
282
|
+
self.assertFalse(paused['complete'], paused)
|
|
283
|
+
self.assertFalse(list((root / 'output').glob('*.xlsx')))
|
|
284
|
+
refreshed = self.advance(arguments(root, resume=first['taskPath']))
|
|
285
|
+
self.assertEqual(refreshed['results'][0]['generation'], 2, refreshed)
|
|
286
|
+
self.assertEqual(refreshed['results'][0]['request']['event'], 'tag_review_required')
|
|
287
|
+
|
|
288
|
+
def test_pending_retains_ai_capacity_across_calls(self):
|
|
289
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
290
|
+
root = Path(temporary)
|
|
291
|
+
with fixture_apis(root) as calls:
|
|
292
|
+
first = self.advance(arguments(root, ids=(11, 12, 13)))
|
|
293
|
+
self.assertEqual(calls['fetch'], 2)
|
|
294
|
+
self.assertEqual(sum(x['request'] is not None and x['request']['event'] == 'tag_review_required'
|
|
295
|
+
for x in first['results']), 1)
|
|
296
|
+
self.advance(arguments(root, resume=first['taskPath']))
|
|
297
|
+
self.assertEqual(calls['fetch'], 2)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
if __name__ == '__main__':
|
|
301
|
+
unittest.main()
|