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.
Files changed (26) hide show
  1. package/dist/index.js +1 -1
  2. package/dist/release-info.json +2 -2
  3. package/dist/skill-manifest.json +24 -14
  4. package/dist/skills/biz-line-strategy-generate/SKILL.md +20 -17
  5. package/dist/skills/biz-line-strategy-generate/references/api-flow.md +16 -5
  6. package/dist/skills/biz-line-strategy-generate/references/read-tools.md +5 -2
  7. package/dist/skills/biz-line-strategy-generate/references/runtime.md +57 -18
  8. package/dist/skills/biz-line-strategy-generate/references/spec.md +56 -15
  9. package/dist/skills/biz-line-strategy-generate/scripts/ai_strategy.py +36 -22
  10. package/dist/skills/biz-line-strategy-generate/scripts/cleanup.py +9 -0
  11. package/dist/skills/biz-line-strategy-generate/scripts/resumable_delivery.py +184 -0
  12. package/dist/skills/biz-line-strategy-generate/scripts/resumable_requests.py +243 -0
  13. package/dist/skills/biz-line-strategy-generate/scripts/resumable_workflow.py +460 -0
  14. package/dist/skills/biz-line-strategy-generate/scripts/run_sharded.py +3 -0
  15. package/dist/skills/biz-line-strategy-generate/scripts/run_workflow.py +76 -17
  16. package/dist/skills/biz-line-strategy-generate/scripts/strategy_output.py +33 -15
  17. package/dist/skills/biz-line-strategy-generate/scripts/submit_ai_strategy.py +30 -0
  18. package/dist/skills/biz-line-strategy-generate/scripts/task_state.py +174 -0
  19. package/dist/skills/biz-line-strategy-generate/scripts/test_resource_cleanup.py +16 -0
  20. package/dist/skills/biz-line-strategy-generate/scripts/test_resumable_delivery.py +198 -0
  21. package/dist/skills/biz-line-strategy-generate/scripts/test_resumable_integration.py +301 -0
  22. package/dist/skills/biz-line-strategy-generate/scripts/test_resumable_requests.py +269 -0
  23. package/dist/skills/biz-line-strategy-generate/scripts/test_resumable_workflow.py +187 -0
  24. package/dist/skills/biz-line-strategy-generate/scripts/test_sharded.py +9 -0
  25. package/dist/skills/biz-line-strategy-generate/scripts/test_task_state.py +165 -0
  26. package/package.json +1 -1
@@ -0,0 +1,243 @@
1
+ """Agent交接的持久请求;等待只返回,不把进程存活当作请求存活。"""
2
+ from hashlib import sha256
3
+ import json
4
+ from pathlib import Path
5
+ import time
6
+ from uuid import uuid4
7
+
8
+ from ai_strategy import AiStrategyExhausted, prepare_ai_input, validate_ai_text
9
+ from cleanup import is_link
10
+ from strategy_output import (SCENES, TAG_REVIEW_ATTEMPTS, TAG_REVIEW_SECONDS, choice_key,
11
+ map_tag_choices, tag_review_fallback, tag_review_strategies)
12
+ from task_state import atomic_json, file_digest, read_task, shared_lock
13
+
14
+
15
+ class PendingRequest(Exception):
16
+ """正常等待Agent的非终态,调用方不能按业务失败保存或清理。"""
17
+ def __init__(self, event):
18
+ super().__init__('等待 Agent 提交响应')
19
+ self.event = event
20
+
21
+
22
+ def _read(path):
23
+ if is_link(path):
24
+ raise ValueError('请求状态不能是链接')
25
+ value = json.loads(path.read_text(encoding='utf-8'))
26
+ if not isinstance(value, dict):
27
+ raise ValueError('请求状态必须是对象')
28
+ return value
29
+
30
+
31
+ def _line(task_root, line_key, generation, root):
32
+ """仅在任务短锁内核验当前代次;清理/换代与提交共用这个边界。"""
33
+ task = read_task(task_root)
34
+ line = task.get('lines', {}).get(line_key)
35
+ if not isinstance(line, dict) or line.get('generation') != generation \
36
+ or line.get('status') in ('completed', 'failed', 'cancelled', 'cleanup', 'closed') \
37
+ or Path(line.get('workPath', '')).resolve() != root or not root.is_dir() or is_link(root):
38
+ raise ValueError('请求所属任务已结束、目录已清理或数据代次已变化')
39
+ if not root.is_relative_to(task_root) or root == task_root:
40
+ raise ValueError('请求目录不属于当前任务')
41
+ return line
42
+
43
+
44
+ def _digest(packet):
45
+ return sha256(json.dumps(packet, sort_keys=True, ensure_ascii=False).encode('utf-8')).hexdigest()
46
+
47
+
48
+ def _advance(kind, root, cancel, progress, context, packet_factory, validate, *, seconds, attempts, fallback=None):
49
+ root, task_root = Path(root).resolve(), Path(context['taskPath']).resolve()
50
+ state_path = root / f'{kind}.state.json'
51
+ events, pending, result, exhausted = [], None, None, False
52
+ with shared_lock(task_root):
53
+ _line(task_root, context['lineKey'], context['generation'], root)
54
+ if cancel.is_set():
55
+ raise InterruptedError('任务已取消')
56
+ state = _read(state_path) if state_path.exists() else {'version': 1, 'failures': []}
57
+ if state.get('version') != 1:
58
+ raise ValueError('请求状态版本不兼容')
59
+ if state.get('status') == 'accepted':
60
+ return state['value']
61
+ if state.get('status') == 'closed':
62
+ raise ValueError('请求已关闭')
63
+ current = state.get('current')
64
+ if current:
65
+ packet = _read(Path(current['event']['request_path']))
66
+ if _digest(packet) != current['digest']:
67
+ raise ValueError('请求内容与已登记状态不一致')
68
+ receipt = current.get('receipt')
69
+ reason = None
70
+ if receipt is not None:
71
+ if receipt['acceptedAt'] >= packet['expiresAt']:
72
+ raise ValueError('请求接收时间不在有效期内')
73
+ try:
74
+ result = validate(packet, receipt['body'])
75
+ except (ValueError, TypeError, KeyError, UnicodeError) as exc:
76
+ reason = str(exc)
77
+ else:
78
+ state.update(status='accepted', value=result, consumedAt=time.time())
79
+ atomic_json(state_path, state)
80
+ events.append({**current['event'], 'event': kind.replace('-', '_') + '_accepted',
81
+ 'acceptedAt': receipt['acceptedAt'], 'consumedAt': state['consumedAt']})
82
+ elif time.time() >= packet['expiresAt']:
83
+ reason = '等待 Agent 回包超时'
84
+ else:
85
+ pending = current['event']
86
+ if reason is not None:
87
+ state['failures'].append({'reviewAttempt': current['event']['reviewAttempt'], 'reason': reason})
88
+ events.append({**current['event'], 'event': kind.replace('-', '_') + '_rejected', 'reason': reason})
89
+ state.pop('current')
90
+ atomic_json(state_path, state)
91
+ if result is None and pending is None:
92
+ review_attempt = len(state['failures']) + 1
93
+ if review_attempt > attempts:
94
+ if fallback is None:
95
+ exhausted = True
96
+ else:
97
+ result, missing = fallback()
98
+ state.update(status='accepted', value=result, consumedAt=time.time(),
99
+ fallback={'event': 'tag_review_fallback', 'bizLineId': context['bizLineId'],
100
+ 'attempt': context['attempt'], 'reviewAttempts': attempts,
101
+ 'failures': state['failures'], 'missingOtherScenes': missing})
102
+ atomic_json(state_path, state)
103
+ else:
104
+ issued = time.time()
105
+ identifier = uuid4().hex
106
+ packet = {**packet_factory(identifier), **context, 'resumableProtocol': 1,
107
+ 'reviewAttempt': review_attempt, 'issuedAt': issued, 'expiresAt': issued + seconds}
108
+ request = root / f'{kind}.{review_attempt}.request.json'
109
+ response = root / f'{kind}.{review_attempt}.response.json'
110
+ packet['responsePath'] = str(response)
111
+ id_key = 'reviewId' if kind == 'tag-review' else 'requestId'
112
+ pending = {'event': kind.replace('-', '_') + '_required', **context, id_key: identifier,
113
+ 'reviewAttempt': review_attempt, 'issuedAt': issued, 'expiresAt': packet['expiresAt'],
114
+ 'request_path': str(request), 'response_path': str(response)}
115
+ atomic_json(request, packet)
116
+ state.update(status='pending', current={'event': pending, 'digest': _digest(packet)})
117
+ atomic_json(state_path, state)
118
+ events.append(pending)
119
+ # 回调可能立即提交,不能在任务短锁内调用。
120
+ for event in events:
121
+ progress(event)
122
+ if exhausted:
123
+ raise AiStrategyExhausted('AI 策略生成三次失败:' + ';'.join(x['reason'] for x in state['failures']))
124
+ if pending is not None:
125
+ raise PendingRequest(pending)
126
+ return result
127
+
128
+
129
+ def advance_tags(row, dictionaries, evidence, root, cancel, progress, *,
130
+ biz_line_id, attempt, task_root, line_key, generation):
131
+ """沿用原候选映射、两轮审核及其他标签兜底;本次只推进一个外部等待点。"""
132
+ strategies = tag_review_strategies(row, dictionaries, evidence)
133
+ def packet_factory(identifier):
134
+ return {'reviewId': identifier, 'submissionMode': 'choice-key-v1',
135
+ 'strategies': {key: {**value, 'labels': [
136
+ {**label, 'choiceKey': choice_key(identifier, key, label['id'])}
137
+ for label in value['labels']]} for key, value in strategies.items()}}
138
+ def validate(packet, body):
139
+ mapped = map_tag_choices(packet, body)
140
+ return {key: [{'id': item['id'], 'reason': item['reason']} for item in mapped[key]] for key in SCENES}
141
+ context = {'taskPath': str(Path(task_root).resolve()), 'lineKey': line_key, 'generation': generation,
142
+ 'bizLineId': biz_line_id, 'attempt': attempt}
143
+ result = _advance('tag-review', root, cancel, progress, context, packet_factory, validate,
144
+ seconds=TAG_REVIEW_SECONDS, attempts=TAG_REVIEW_ATTEMPTS,
145
+ fallback=lambda: tag_review_fallback(dictionaries))
146
+ # 兜底已接受但父工作流尚未记结果时中断,恢复仍须携带兜底说明。
147
+ with shared_lock(Path(task_root).resolve()):
148
+ fallback = _read(Path(root) / 'tag-review.state.json').get('fallback')
149
+ if fallback:
150
+ progress(fallback)
151
+ return result
152
+
153
+
154
+ def advance_ai(root, workbook, prompt_path, cancel, progress, *, biz_line_id, attempt,
155
+ task_root, line_key, generation, wait_seconds=900, require_learning=False):
156
+ """AI文案依旧由Agent生成;有效期和审核次数跨调用持久化。"""
157
+ if wait_seconds <= 0 or not Path(workbook).is_file():
158
+ raise ValueError('AI请求缺少有效工作簿或等待期限')
159
+ prompt = Path(prompt_path).read_text(encoding='utf-8')
160
+ if not prompt.strip():
161
+ raise ValueError('AI策略提示词为空')
162
+ if cancel.is_set():
163
+ raise InterruptedError('任务已取消')
164
+ # 索引准备在发出请求和短锁之前完成;后续调用、换轮复用同一已提交索引。
165
+ prepared_path = Path(root) / 'ai-input.json'
166
+ if prepared_path.exists():
167
+ record = _read(prepared_path)
168
+ prepared = record['prepared']
169
+ if file_digest(Path(workbook)) != record['workbookDigest'] or \
170
+ file_digest(Path(prepared['detailIndexPath'])) != record['indexDigest']:
171
+ raise ValueError('AI明细工作簿或索引与准备记录不一致')
172
+ else:
173
+ prepared = prepare_ai_input(Path(workbook))
174
+ atomic_json(prepared_path, {'prepared': prepared, 'workbookDigest': file_digest(Path(workbook)),
175
+ 'indexDigest': file_digest(Path(prepared['detailIndexPath']))})
176
+ def packet_factory(identifier):
177
+ packet = {'requestId': identifier, 'prompt': prompt, 'promptPath': str(prompt_path),
178
+ 'workbookPath': str(workbook), 'worksheetName': '明细',
179
+ 'waitSeconds': wait_seconds, 'deadlineSource': 'request', **prepared}
180
+ if require_learning:
181
+ packet['outputContract'] = ('四段策略各以独立末段 Learning(有效/无效/待观察):说明 结束;'
182
+ '每段只能有一个 Learning,正文和冒号后的说明均非空,可保留换行。')
183
+ return packet
184
+ def validate(_packet, body):
185
+ if not isinstance(body, dict) or set(body) != {'text'}:
186
+ raise ValueError('AI响应必须只包含text')
187
+ return validate_ai_text(body['text'], require_learning=require_learning, count_facts=prepared['countFacts'])
188
+ context = {'taskPath': str(Path(task_root).resolve()), 'lineKey': line_key, 'generation': generation,
189
+ 'bizLineId': biz_line_id, 'attempt': attempt}
190
+ return _advance('ai-strategy', root, cancel, progress, context, packet_factory, validate,
191
+ seconds=wait_seconds, attempts=3)
192
+
193
+
194
+ def _submit(request, identifier, body, kind):
195
+ request = Path(request).absolute()
196
+ initial = _read(request)
197
+ if initial.get('resumableProtocol') != 1:
198
+ raise ValueError('不是可恢复请求')
199
+ task_root = Path(initial['taskPath']).resolve()
200
+ root = request.parent.resolve()
201
+ with shared_lock(task_root):
202
+ packet = _read(request)
203
+ _line(task_root, packet['lineKey'], packet['generation'], root)
204
+ state_path = root / f'{kind}.state.json'
205
+ state = _read(state_path)
206
+ current = state.get('current')
207
+ id_key = 'reviewId' if kind == 'tag-review' else 'requestId'
208
+ if not current or state.get('status') not in ('pending', 'accepted') \
209
+ or current['event']['request_path'] != str(request) \
210
+ or packet.get(id_key) != identifier or current['digest'] != _digest(packet):
211
+ raise ValueError('请求已关闭、被替代或身份不一致')
212
+ receipt = current.get('receipt')
213
+ response = Path(current['event']['response_path'])
214
+ if receipt is not None:
215
+ if receipt['body'] == body:
216
+ if not response.exists():
217
+ atomic_json(response, {**receipt, 'bizLineId': packet['bizLineId'], id_key: identifier,
218
+ 'generation': packet['generation'], 'attempt': packet['attempt']})
219
+ return response
220
+ raise ValueError('请求已提交,禁止用不同内容覆盖')
221
+ if time.time() >= packet['expiresAt']:
222
+ raise ValueError('请求已过期')
223
+ if kind == 'tag-review':
224
+ map_tag_choices(packet, body)
225
+ elif not isinstance(body, dict) or set(body) != {'text'} or not isinstance(body['text'], str):
226
+ raise ValueError('AI响应必须只包含字符串text')
227
+ # 接收凭据是唯一提交点;先落盘凭据,副本写入前中断也能消费。
228
+ receipt = {'body': body, 'acceptedAt': time.time()}
229
+ if receipt['acceptedAt'] >= packet['expiresAt']:
230
+ raise ValueError('请求已过期')
231
+ current['receipt'] = receipt
232
+ atomic_json(state_path, state)
233
+ atomic_json(response, {**receipt, 'bizLineId': packet['bizLineId'], id_key: identifier,
234
+ 'generation': packet['generation'], 'attempt': packet['attempt']})
235
+ return response
236
+
237
+
238
+ def submit_tags_response(request, review_id, selection):
239
+ return _submit(request, review_id, selection, 'tag-review')
240
+
241
+
242
+ def submit_ai_response(request, request_id, body):
243
+ return _submit(request, request_id, body, 'ai-strategy')