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,253 @@
|
|
|
1
|
+
"""使用真实单条工作流验证拉数/AI滑动交接,不请求生产接口。"""
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
import threading
|
|
7
|
+
import unittest
|
|
8
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
|
|
11
|
+
sys.dont_write_bytecode = True
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
13
|
+
from run_workflow import run_batch
|
|
14
|
+
from strategy_output import submit_tag_choices
|
|
15
|
+
from test_workflow import arguments, small_payload
|
|
16
|
+
from test_ai_strategy import AI_TEXT
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SlidingPipeline(unittest.TestCase):
|
|
20
|
+
"""用事件控制先后,不靠耗时猜测滑动补位是否发生。"""
|
|
21
|
+
|
|
22
|
+
def execute(self, root, targets, fetch, progress, cancel=None, concurrency=3):
|
|
23
|
+
"""仅替代业务拉数与固定分析,标签、AI文件协议、Excel和清理走真实实现。"""
|
|
24
|
+
args = arguments(root / 'output', targets)
|
|
25
|
+
args.concurrency = concurrency
|
|
26
|
+
dictionaries = {key: [{'id': 11, 'name': '其他', 'isOther': 1}]
|
|
27
|
+
for key in ('budget', 'product', 'audience')}
|
|
28
|
+
def analyze(directory):
|
|
29
|
+
payload = small_payload()
|
|
30
|
+
(directory / 'payload.json').write_text(json.dumps(payload), encoding='utf-8')
|
|
31
|
+
return payload, {'detail_count': 1, 'curr_total': 100}
|
|
32
|
+
with patch('run_workflow.fetch_business', side_effect=fetch), \
|
|
33
|
+
patch('run_workflow.summarize_request', side_effect=analyze), \
|
|
34
|
+
patch('strategy_output.TagCache.get', return_value=dictionaries):
|
|
35
|
+
return run_batch(args, progress=progress, cancel=cancel, temp_parent=root)
|
|
36
|
+
|
|
37
|
+
def reply(self, event):
|
|
38
|
+
"""只模拟AI决策,提交/身份校验及工作簿消费沿用生产入口。"""
|
|
39
|
+
if event.get('event') not in ('tag_review_required', 'ai_strategy_required'):
|
|
40
|
+
return
|
|
41
|
+
packet = json.loads(Path(event['request_path']).read_text(encoding='utf-8'))
|
|
42
|
+
if event['event'] == 'tag_review_required':
|
|
43
|
+
choices = {'selections': {key: [{'choiceKey': item['labels'][0]['choiceKey'], 'reason': '样本无适合具体标签'}]
|
|
44
|
+
for key, item in packet['strategies'].items()}}
|
|
45
|
+
submit_tag_choices(event['request_path'], event['reviewId'], choices)
|
|
46
|
+
else:
|
|
47
|
+
reply = {key: packet[key] for key in ('bizLineId', 'attempt', 'requestId')}
|
|
48
|
+
Path(event['response_path']).write_text(json.dumps({**reply, 'text': AI_TEXT}), encoding='utf-8')
|
|
49
|
+
|
|
50
|
+
def biz(self, ident):
|
|
51
|
+
"""真实上下文由当前目标生成,不从上一个业务线继承。"""
|
|
52
|
+
return {'id': ident, 'name': f'样本{ident}', 'adCategoryIndustryIds': [11]}
|
|
53
|
+
|
|
54
|
+
def test_one_line_enters_ai_refills_fetch_before_other_two_finish(self):
|
|
55
|
+
"""第一条开始审核时第四条已拉数,第二/三条仍被刻意阻塞。"""
|
|
56
|
+
first_three = threading.Barrier(3)
|
|
57
|
+
slow_fetches = threading.Event()
|
|
58
|
+
next_fetch = threading.Event()
|
|
59
|
+
release_ai = threading.Event()
|
|
60
|
+
seen = []
|
|
61
|
+
lock = threading.Lock()
|
|
62
|
+
def fetch(args):
|
|
63
|
+
with lock:
|
|
64
|
+
seen.append(args.id)
|
|
65
|
+
if args.id <= 3:
|
|
66
|
+
first_three.wait(timeout=5)
|
|
67
|
+
if args.id in (2, 3):
|
|
68
|
+
if not slow_fetches.wait(5):
|
|
69
|
+
raise RuntimeError('等待控制放行拉数超时')
|
|
70
|
+
if args.id == 4:
|
|
71
|
+
next_fetch.set()
|
|
72
|
+
return self.biz(args.id)
|
|
73
|
+
def progress(event):
|
|
74
|
+
if event.get('event') == 'ai_strategy_required' and event['id'] == 1:
|
|
75
|
+
if not release_ai.wait(5):
|
|
76
|
+
raise RuntimeError('等待控制放行AI超时')
|
|
77
|
+
self.reply(event)
|
|
78
|
+
with tempfile.TemporaryDirectory() as tmp, ThreadPoolExecutor(max_workers=1) as executor:
|
|
79
|
+
root = Path(tmp)
|
|
80
|
+
future = executor.submit(self.execute, root, [1, 2, 3, 4], fetch, progress)
|
|
81
|
+
try:
|
|
82
|
+
self.assertTrue(next_fetch.wait(3), '第一条转入AI后没有逐条补拉第四条')
|
|
83
|
+
self.assertFalse(slow_fetches.is_set())
|
|
84
|
+
self.assertFalse(release_ai.is_set())
|
|
85
|
+
with lock:
|
|
86
|
+
self.assertEqual(set(seen), {1, 2, 3, 4})
|
|
87
|
+
finally:
|
|
88
|
+
slow_fetches.set()
|
|
89
|
+
release_ai.set()
|
|
90
|
+
result = future.result(timeout=10)
|
|
91
|
+
self.assertTrue(result['ok'], result)
|
|
92
|
+
self.assertFalse(list(root.rglob('batch-*')))
|
|
93
|
+
|
|
94
|
+
def test_stage_caps_and_global_six_bound_queue_before_tag_request(self):
|
|
95
|
+
"""AI三条全部占用时,只预取另三条;等待者尚未生成审核请求。"""
|
|
96
|
+
self.check_capacity(3)
|
|
97
|
+
|
|
98
|
+
def test_smaller_concurrency_limits_both_stages_and_total(self):
|
|
99
|
+
"""参数1/2同样滑动,分别限制总在途2/4,不沿用固定六条。"""
|
|
100
|
+
for capacity in (1, 2):
|
|
101
|
+
with self.subTest(capacity=capacity):
|
|
102
|
+
self.check_capacity(capacity)
|
|
103
|
+
|
|
104
|
+
def check_capacity(self, capacity):
|
|
105
|
+
"""通过真实请求文件及拉数/AI阻塞观察阶段上限,不检查内部计数实现。"""
|
|
106
|
+
lock = threading.Lock()
|
|
107
|
+
first_fetches = threading.Barrier(capacity)
|
|
108
|
+
ready = threading.Event()
|
|
109
|
+
release_ai = threading.Event()
|
|
110
|
+
ai_started, waiting, fetched, tagged = set(), set(), set(), set()
|
|
111
|
+
fetch_active = fetch_peak = ai_active = ai_peak = 0
|
|
112
|
+
def fetch(args):
|
|
113
|
+
nonlocal fetch_active, fetch_peak
|
|
114
|
+
with lock:
|
|
115
|
+
fetched.add(args.id)
|
|
116
|
+
fetch_active += 1
|
|
117
|
+
fetch_peak = max(fetch_peak, fetch_active)
|
|
118
|
+
if args.id <= capacity:
|
|
119
|
+
first_fetches.wait(timeout=5)
|
|
120
|
+
with lock:
|
|
121
|
+
fetch_active -= 1
|
|
122
|
+
return self.biz(args.id)
|
|
123
|
+
def progress(event):
|
|
124
|
+
nonlocal ai_active, ai_peak
|
|
125
|
+
with lock:
|
|
126
|
+
if event.get('stage') == 'wait_ai_slot':
|
|
127
|
+
waiting.add(event['id'])
|
|
128
|
+
if event.get('event') == 'tag_review_required':
|
|
129
|
+
tagged.add(event['id'])
|
|
130
|
+
if event.get('event') == 'ai_strategy_required':
|
|
131
|
+
ai_started.add(event['id'])
|
|
132
|
+
ai_active += 1
|
|
133
|
+
ai_peak = max(ai_peak, ai_active)
|
|
134
|
+
if len(ai_started) >= capacity and len(waiting) >= capacity * 2:
|
|
135
|
+
ready.set()
|
|
136
|
+
if event.get('event') == 'ai_strategy_required':
|
|
137
|
+
if not release_ai.wait(5):
|
|
138
|
+
raise RuntimeError('等待控制放行AI超时')
|
|
139
|
+
with lock:
|
|
140
|
+
ai_active -= 1
|
|
141
|
+
self.reply(event)
|
|
142
|
+
with tempfile.TemporaryDirectory() as tmp, ThreadPoolExecutor(max_workers=1) as executor:
|
|
143
|
+
root = Path(tmp)
|
|
144
|
+
future = executor.submit(self.execute, root, list(range(1, 11)), fetch, progress,
|
|
145
|
+
concurrency=capacity)
|
|
146
|
+
try:
|
|
147
|
+
self.assertTrue(ready.wait(3), '没有形成3条AI+3条已预取等待')
|
|
148
|
+
with lock:
|
|
149
|
+
self.assertEqual(len(fetched), capacity * 2)
|
|
150
|
+
self.assertEqual(len(tagged), capacity)
|
|
151
|
+
self.assertEqual((fetch_peak, ai_peak), (capacity, capacity))
|
|
152
|
+
self.assertEqual(len(list(root.rglob('line-*'))), capacity * 2)
|
|
153
|
+
self.assertEqual(len(list(root.rglob('tag-review.1.request.json'))), capacity)
|
|
154
|
+
finally:
|
|
155
|
+
release_ai.set()
|
|
156
|
+
result = future.result(timeout=15)
|
|
157
|
+
self.assertTrue(result['ok'], result)
|
|
158
|
+
self.assertEqual(result['success'], 10)
|
|
159
|
+
self.assertLessEqual(fetch_peak, capacity)
|
|
160
|
+
self.assertLessEqual(ai_peak, capacity)
|
|
161
|
+
self.assertTrue(all('wait_ai_slot' in row['timings'] for row in result['results']))
|
|
162
|
+
self.assertFalse(list(root.rglob('batch-*')))
|
|
163
|
+
|
|
164
|
+
def test_fetch_retry_and_ai_exhaustion_release_slots_without_crossing_rounds(self):
|
|
165
|
+
"""拉数技术失败只在首轮结束后重试,AI三次失败不重拉,后续条目继续。"""
|
|
166
|
+
counts, completed, ai_attempts = {}, set(), []
|
|
167
|
+
lock = threading.Lock()
|
|
168
|
+
def fetch(args):
|
|
169
|
+
with lock:
|
|
170
|
+
counts[args.id] = counts.get(args.id, 0) + 1
|
|
171
|
+
if args.attempt > 1:
|
|
172
|
+
self.assertEqual(completed, {1, 2, 3, 4})
|
|
173
|
+
if args.id == 2 and args.attempt == 1:
|
|
174
|
+
raise OSError('本条首次拉数失败')
|
|
175
|
+
return self.biz(args.id)
|
|
176
|
+
def progress(event):
|
|
177
|
+
if event.get('event') == 'completed' and event['attempt'] == 1:
|
|
178
|
+
with lock:
|
|
179
|
+
completed.add(event['id'])
|
|
180
|
+
if event.get('event') == 'ai_strategy_required' and event['id'] == 1:
|
|
181
|
+
ai_attempts.append(event['reviewAttempt'])
|
|
182
|
+
packet = json.loads(Path(event['request_path']).read_text(encoding='utf-8'))
|
|
183
|
+
reply = {key: packet[key] for key in ('bizLineId', 'attempt', 'requestId')}
|
|
184
|
+
Path(event['response_path']).write_text(json.dumps({**reply, 'text': '缺少四段标题'}), encoding='utf-8')
|
|
185
|
+
else:
|
|
186
|
+
self.reply(event)
|
|
187
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
188
|
+
root = Path(tmp)
|
|
189
|
+
result = self.execute(root, [1, 2, 3, 4], fetch, progress, concurrency=1)
|
|
190
|
+
self.assertEqual(counts, {1: 1, 2: 2, 3: 1, 4: 1})
|
|
191
|
+
self.assertEqual(ai_attempts, [1, 2, 3])
|
|
192
|
+
self.assertEqual(result['success'], 3)
|
|
193
|
+
self.assertEqual([row['attempts'] for row in result['results']], [1, 2, 1, 1])
|
|
194
|
+
self.assertTrue(result['results'][0]['ai_strategy_exhausted'])
|
|
195
|
+
self.assertFalse(list(root.rglob('batch-*')))
|
|
196
|
+
|
|
197
|
+
def test_cancel_while_waiting_for_ai_does_not_issue_request_or_leak(self):
|
|
198
|
+
"""concurrency=1为一拉一AI,第二条排队取消后不能发审核或启动第三条。"""
|
|
199
|
+
cancel = threading.Event()
|
|
200
|
+
queued = threading.Event()
|
|
201
|
+
ai_started = threading.Event()
|
|
202
|
+
fetched, tagged = [], []
|
|
203
|
+
def fetch(args):
|
|
204
|
+
fetched.append(args.id)
|
|
205
|
+
return self.biz(args.id)
|
|
206
|
+
def progress(event):
|
|
207
|
+
if event.get('stage') == 'wait_ai_slot' and event['id'] == 2:
|
|
208
|
+
queued.set()
|
|
209
|
+
if event.get('event') == 'tag_review_required':
|
|
210
|
+
tagged.append(event['id'])
|
|
211
|
+
if event.get('event') == 'ai_strategy_required':
|
|
212
|
+
ai_started.set()
|
|
213
|
+
cancel.wait(5)
|
|
214
|
+
return
|
|
215
|
+
self.reply(event)
|
|
216
|
+
with tempfile.TemporaryDirectory() as tmp, ThreadPoolExecutor(max_workers=1) as executor:
|
|
217
|
+
root = Path(tmp)
|
|
218
|
+
future = executor.submit(self.execute, root, [1, 2, 3], fetch, progress, cancel, 1)
|
|
219
|
+
try:
|
|
220
|
+
self.assertTrue(ai_started.wait(3))
|
|
221
|
+
self.assertTrue(queued.wait(3), '第二条没有在第一条AI期间完成预取')
|
|
222
|
+
finally:
|
|
223
|
+
cancel.set()
|
|
224
|
+
result = future.result(timeout=10)
|
|
225
|
+
self.assertFalse(result['ok'])
|
|
226
|
+
self.assertEqual(fetched, [1, 2])
|
|
227
|
+
self.assertEqual(tagged, [1])
|
|
228
|
+
self.assertEqual([row['attempts'] for row in result['results']], [1, 1, 0])
|
|
229
|
+
self.assertTrue(result['cleanup']['ok'])
|
|
230
|
+
self.assertFalse(list(root.rglob('batch-*')))
|
|
231
|
+
|
|
232
|
+
def test_outer_worker_exception_releases_reserved_fetch_slot(self):
|
|
233
|
+
"""原单条try之外抛错也不能占住唯一拉数名额或被盲目重放。"""
|
|
234
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
235
|
+
root = Path(tmp)
|
|
236
|
+
args = arguments(root / 'output', [1, 2, 3])
|
|
237
|
+
args.concurrency = 1
|
|
238
|
+
seen = []
|
|
239
|
+
def worker(target, args, batch, cancel, progress):
|
|
240
|
+
seen.append(target)
|
|
241
|
+
if target == 1:
|
|
242
|
+
raise OSError('worker外层错误')
|
|
243
|
+
return {'id': target, 'ok': True}
|
|
244
|
+
result = run_batch(args, worker=worker, temp_parent=root, progress=lambda _: None)
|
|
245
|
+
self.assertEqual(seen, [1, 2, 3])
|
|
246
|
+
self.assertEqual(result['success'], 2)
|
|
247
|
+
self.assertEqual(result['results'][0]['failed_stage'], 'worker')
|
|
248
|
+
self.assertEqual(result['results'][0]['attempts'], 1)
|
|
249
|
+
self.assertTrue(result['cleanup']['ok'])
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
if __name__ == '__main__':
|
|
253
|
+
unittest.main()
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""不发网:会议口径 + 实跑踩坑(直写文件、出价统计、环比结论、双 sheet)。"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import zipfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
SCRIPTS = Path(__file__).resolve().parent
|
|
15
|
+
if str(SCRIPTS) not in sys.path:
|
|
16
|
+
sys.path.insert(0, str(SCRIPTS))
|
|
17
|
+
|
|
18
|
+
from cli_json import ( # noqa: E402
|
|
19
|
+
bid_adjust_stats,
|
|
20
|
+
budget_abs_share,
|
|
21
|
+
exec_cli_to_file,
|
|
22
|
+
is_first_period,
|
|
23
|
+
is_ghost_enum,
|
|
24
|
+
list_query_body,
|
|
25
|
+
naming_patterns,
|
|
26
|
+
period_verdict,
|
|
27
|
+
region_bucket,
|
|
28
|
+
summarize_countries,
|
|
29
|
+
)
|
|
30
|
+
from fetch_data import PAGE_CAP # noqa: E402
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_list_query_uses_data_body_not_flags():
|
|
34
|
+
named = list_query_body(name="华南-S级-rednote小红书")
|
|
35
|
+
assert named["name"] == "华南-S级-rednote小红书"
|
|
36
|
+
assert named["sortType"] == "PERIOD"
|
|
37
|
+
assert "idList" not in named
|
|
38
|
+
by_id = list_query_body(biz_id=1164)
|
|
39
|
+
assert by_id["idList"] == [1164]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_exec_cli_to_file_keeps_payload_over_64kb():
|
|
43
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
44
|
+
out = Path(tmp) / "big.out.json"
|
|
45
|
+
# 在子进程内构造大回包,避免 Windows argv 长度上限干扰本测试。
|
|
46
|
+
script = 'import json; print(json.dumps({"ok": True, "pad": "x" * 80000}))'
|
|
47
|
+
rc, raw = exec_cli_to_file([sys.executable, "-c", script], out)
|
|
48
|
+
assert rc == 0
|
|
49
|
+
assert out.stat().st_size > 65536
|
|
50
|
+
parsed = json.loads(raw)
|
|
51
|
+
assert parsed["ok"] is True
|
|
52
|
+
assert len(parsed["pad"]) == 80000
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_page_cap_covers_tiktok_bid_pages():
|
|
56
|
+
# 大业务线广告数据超过 10 万行,80 页上限会截断。
|
|
57
|
+
assert PAGE_CAP >= 200
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_period_verdict_follows_qoq_not_kpi():
|
|
61
|
+
# 有上期时仅以 2% 改善判断,与是否达到阈值无关。
|
|
62
|
+
from cli_json import learning_text, amount_change
|
|
63
|
+
from strategy_output import effect_code
|
|
64
|
+
cases = [(hit, trend, '有效' if trend == '变好' else '无效',
|
|
65
|
+
'策略有效' if trend == '变好' else '需关停', 1 if trend == '变好' else 2)
|
|
66
|
+
for hit in (True, False) for trend in ('变好', '稳定', '变差')]
|
|
67
|
+
for hit, trend, verdict, note, effect in cases:
|
|
68
|
+
actual = period_verdict(first_period=False, qoq_label=trend, kpi_usable=True, kpi_hit=hit)
|
|
69
|
+
assert actual == verdict
|
|
70
|
+
assert learning_text(actual) == f'结论:{verdict}\n说明:{note}'
|
|
71
|
+
assert effect_code(actual) == effect
|
|
72
|
+
for hit in (None, True, False):
|
|
73
|
+
assert period_verdict(first_period=False, qoq_label='无法对比', kpi_usable=True, kpi_hit=hit) == '待观察'
|
|
74
|
+
assert period_verdict(first_period=False, qoq_label='变好', kpi_usable=False) == '待观察'
|
|
75
|
+
assert amount_change(150, 100) == '环比 +50.00%'
|
|
76
|
+
assert '不可计算' in amount_change(100, 0)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_is_first_period_uses_prev_dates():
|
|
80
|
+
assert is_first_period({}) is True
|
|
81
|
+
assert is_first_period({"prevPeriodStart": "2026-09-07", "prevPeriodEnd": "2026-09-13"}) is False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_ghost_enum_zero_share_is_not_stop():
|
|
85
|
+
assert is_ghost_enum(0.0, 0.0) is True
|
|
86
|
+
assert is_ghost_enum(6.0, 0.0) is False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_region_bucket_non_sea_before_sea():
|
|
90
|
+
assert region_bucket("两性-恋爱攻略-全球非东南亚-自制-英语-install") == "other"
|
|
91
|
+
assert region_bucket("KPOP测试-舞蹈二创-东南亚仅马泰") == "sea"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_bid_adjust_stats_aggregates_and_lists_when_few():
|
|
95
|
+
items = [
|
|
96
|
+
{"name": "A", "from": 5.0, "to": 8.0, "times": 2},
|
|
97
|
+
{"name": "B", "from": 10.0, "to": 7.0, "times": 1},
|
|
98
|
+
{"name": "C", "from": 3.0, "to": 3.0, "times": 1},
|
|
99
|
+
]
|
|
100
|
+
stats = bid_adjust_stats(items)
|
|
101
|
+
assert stats["raise_n"] == 1
|
|
102
|
+
assert stats["lower_n"] == 1
|
|
103
|
+
assert stats["flat_n"] == 1
|
|
104
|
+
assert stats["median_abs"] == 3.0
|
|
105
|
+
assert stats["list_all"] is True
|
|
106
|
+
assert len(stats["details"]) == 3
|
|
107
|
+
many = [{"name": str(i), "from": 1.0, "to": 2.0, "times": 1} for i in range(12)]
|
|
108
|
+
crowded = bid_adjust_stats(many)
|
|
109
|
+
assert crowded["raise_n"] == 12
|
|
110
|
+
assert crowded["list_all"] is False
|
|
111
|
+
assert crowded["details"] == []
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_budget_abs_share_flags_contradiction():
|
|
115
|
+
line = budget_abs_share("FB", prev_abs=28886, curr_abs=25194, prev_share=8.8, curr_share=11.6)
|
|
116
|
+
assert "$28,886 → $25,194" in line
|
|
117
|
+
assert "8.8% → 11.6%" in line
|
|
118
|
+
assert "占比升但金额降" in line
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_naming_patterns_picks_short_tokens():
|
|
122
|
+
names = [
|
|
123
|
+
"KPOP测试-舞蹈二创-802-东南亚仅越南-自制-英语-激活-2609",
|
|
124
|
+
"KPOP测试-线下周边-803-东南亚仅马泰-自制-英语-登录-2609",
|
|
125
|
+
"彩妆测试-整体妆容-东南亚-自制-英语-install-260916",
|
|
126
|
+
]
|
|
127
|
+
text = naming_patterns(names, limit=10)
|
|
128
|
+
assert "KPOP" in text or "舞蹈" in text
|
|
129
|
+
assert "彩妆" in text or "整体妆容" in text
|
|
130
|
+
assert len(text) < 200
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_summarize_countries_aggregates_when_over_50():
|
|
134
|
+
countries = [f"国{i}" for i in range(60)]
|
|
135
|
+
main, extra = summarize_countries(countries)
|
|
136
|
+
assert "超过 50" in main or "区域" in main or "大洲" in main
|
|
137
|
+
assert len(extra) == 60
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_build_report_writes_strategy_and_detail_sheets():
|
|
141
|
+
from build_report import write_xlsx
|
|
142
|
+
|
|
143
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
144
|
+
out = os.path.join(tmp, "out.xlsx")
|
|
145
|
+
rows = [
|
|
146
|
+
{
|
|
147
|
+
"group": "APP-华南组",
|
|
148
|
+
"bizLine": "示例",
|
|
149
|
+
"period": "本期:2026-09-14~2026-09-20\n上期:2026-09-07~2026-09-13",
|
|
150
|
+
"project": "项目A",
|
|
151
|
+
"budgetStrategy": "【预算】",
|
|
152
|
+
"budgetLearning": "结论:有效",
|
|
153
|
+
"productStrategy": "【广告目标】",
|
|
154
|
+
"productLearning": "结论:待观察",
|
|
155
|
+
"audienceStrategy": "【国家】",
|
|
156
|
+
"audienceLearning": "结论:有效",
|
|
157
|
+
"creativeStrategy": "【广告】",
|
|
158
|
+
"creativeLearning": "结论:有效",
|
|
159
|
+
}
|
|
160
|
+
]
|
|
161
|
+
details = [
|
|
162
|
+
{
|
|
163
|
+
"bizLine": "示例",
|
|
164
|
+
"kind": "国家",
|
|
165
|
+
"action": "新增",
|
|
166
|
+
"channel": "TikTok",
|
|
167
|
+
"name": "西撒哈拉",
|
|
168
|
+
"extra": "消耗 $0",
|
|
169
|
+
}
|
|
170
|
+
]
|
|
171
|
+
write_xlsx(rows, out, details=details)
|
|
172
|
+
with zipfile.ZipFile(out) as zf:
|
|
173
|
+
names = zf.namelist()
|
|
174
|
+
assert "xl/worksheets/sheet1.xml" in names
|
|
175
|
+
assert "xl/worksheets/sheet2.xml" in names
|
|
176
|
+
wb = zf.read("xl/workbook.xml").decode("utf-8")
|
|
177
|
+
assert "策略" in wb
|
|
178
|
+
assert "明细" in wb
|
|
179
|
+
sheet2 = zf.read("xl/worksheets/sheet2.xml").decode("utf-8")
|
|
180
|
+
sst = zf.read("xl/sharedStrings.xml").decode("utf-8")
|
|
181
|
+
assert "西撒哈拉" in sst
|
|
182
|
+
assert "sheetData" in sheet2
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_build_report_preserves_carriage_returns():
|
|
186
|
+
"""客户 KPI 与广告名中的 CR/CRLF 必须逐字保留,不能被 XML 换行归一化。"""
|
|
187
|
+
from build_report import write_xlsx, validate_xlsx
|
|
188
|
+
|
|
189
|
+
text = "客户 KPI\r\n第二行\r第三行\n<&> & "
|
|
190
|
+
rows = [{"bizLine": "换行验证", "budgetLearning": text}]
|
|
191
|
+
details = [{"bizLine": "换行验证", "name": text, "extra": text * 100}]
|
|
192
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
193
|
+
path = Path(tmp) / "line-endings.xlsx"
|
|
194
|
+
write_xlsx(rows, str(path), details)
|
|
195
|
+
assert validate_xlsx(path, rows, details)["rows"] == 1
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
if __name__ == "__main__":
|
|
199
|
+
tests = [
|
|
200
|
+
test_list_query_uses_data_body_not_flags,
|
|
201
|
+
test_exec_cli_to_file_keeps_payload_over_64kb,
|
|
202
|
+
test_page_cap_covers_tiktok_bid_pages,
|
|
203
|
+
test_period_verdict_follows_qoq_not_kpi,
|
|
204
|
+
test_is_first_period_uses_prev_dates,
|
|
205
|
+
test_ghost_enum_zero_share_is_not_stop,
|
|
206
|
+
test_region_bucket_non_sea_before_sea,
|
|
207
|
+
test_bid_adjust_stats_aggregates_and_lists_when_few,
|
|
208
|
+
test_budget_abs_share_flags_contradiction,
|
|
209
|
+
test_naming_patterns_picks_short_tokens,
|
|
210
|
+
test_summarize_countries_aggregates_when_over_50,
|
|
211
|
+
test_build_report_writes_strategy_and_detail_sheets,
|
|
212
|
+
test_build_report_preserves_carriage_returns,
|
|
213
|
+
]
|
|
214
|
+
failed = 0
|
|
215
|
+
for fn in tests:
|
|
216
|
+
try:
|
|
217
|
+
fn()
|
|
218
|
+
print("ok", fn.__name__)
|
|
219
|
+
except Exception as e:
|
|
220
|
+
failed += 1
|
|
221
|
+
print("FAIL", fn.__name__, type(e).__name__, e)
|
|
222
|
+
if failed:
|
|
223
|
+
raise SystemExit(1)
|
|
224
|
+
print("DONE")
|