sino-bc-cli 1.8.26 → 1.8.27
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 +6 -0
- package/dist/SKILL.md +10 -10
- package/dist/affordance/project-analysis.md +18 -0
- package/dist/affordance/user.md +8 -2
- package/dist/api-doc/oapi/project-analysis.md +86 -0
- package/dist/api-doc/oapi/user.md +19 -2
- package/dist/generated/api-catalog.json +779 -4
- package/dist/index.js +1 -1
- package/dist/skills/custom/biz-line-strategy-generate/SKILL.md +36 -17
- package/dist/skills/custom/biz-line-strategy-generate/references/api-flow.md +44 -22
- package/dist/skills/custom/biz-line-strategy-generate/references/four-strategies.md +18 -74
- package/dist/skills/custom/biz-line-strategy-generate/references/metrics.md +20 -32
- package/dist/skills/custom/biz-line-strategy-generate/references/output-excel.md +12 -7
- package/dist/skills/custom/biz-line-strategy-generate/references/runtime.md +41 -4
- package/dist/skills/custom/biz-line-strategy-generate/references/spec.md +176 -6
- package/dist/skills/custom/biz-line-strategy-generate/scripts/build_report.py +17 -6
- package/dist/skills/custom/biz-line-strategy-generate/scripts/cli_json.py +36 -17
- package/dist/skills/custom/biz-line-strategy-generate/scripts/fetch_data.py +44 -17
- package/dist/skills/custom/biz-line-strategy-generate/scripts/run_workflow.py +134 -37
- package/dist/skills/custom/biz-line-strategy-generate/scripts/strategy_output.py +277 -0
- package/dist/skills/custom/biz-line-strategy-generate/scripts/summarize.py +84 -146
- package/dist/skills/custom/biz-line-strategy-generate/scripts/test_database_output.py +401 -0
- package/dist/skills/custom/biz-line-strategy-generate/scripts/test_strategy_rules.py +18 -8
- package/dist/skills/custom/biz-line-strategy-generate/scripts/test_workflow.py +138 -8
- package/dist/skills/oapi/sino-bc-project-analysis/SKILL.md +13 -1
- package/dist/skills/oapi/sino-bc-project-analysis/references/analysis-workflow.md +4 -0
- package/dist/skills/oapi/sino-bc-project-analysis/references/filters-and-metrics.md +2 -2
- package/dist/skills/oapi/sino-bc-user/SKILL.md +6 -4
- package/package.json +1 -1
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
"""数据库输出离线回归:使用真实工作流和替代 CLI,不访问生产数据。"""
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
sys.dont_write_bytecode = True
|
|
10
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
11
|
+
|
|
12
|
+
from strategy_output import GenerationOutcome, precheck_projects, ready_body, save_result, validate_period
|
|
13
|
+
from strategy_output import load_tags, effect_code, TagCache, industry_condition
|
|
14
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
15
|
+
from run_workflow import run_batch, workflow_parser
|
|
16
|
+
from test_workflow import arguments, small_payload
|
|
17
|
+
from fetch_data import fetch_business, resolve_periods
|
|
18
|
+
from datetime import date
|
|
19
|
+
from argparse import Namespace
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DatabaseRules(unittest.TestCase):
|
|
23
|
+
"""明确业务分支及非幂等写入边界。"""
|
|
24
|
+
|
|
25
|
+
def test_content_review_handoff_and_real_tag_ids(self):
|
|
26
|
+
"""真实文件协议:名称不同也能由内容分析选择,Excel 名称与入库 ID 同源。"""
|
|
27
|
+
import threading
|
|
28
|
+
from strategy_output import review_tags, attach_tags
|
|
29
|
+
row = {key + 'Strategy': '上调 5 个广告组出价,分布在 EN(5个)'
|
|
30
|
+
for key in ('budget', 'product', 'audience')}
|
|
31
|
+
dictionaries = {key: [{'id': index, 'name': '竞价增强'}, {'id': index + 10, 'name': '其他'}]
|
|
32
|
+
for index, key in enumerate(('budget', 'product', 'audience'), 1)}
|
|
33
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
34
|
+
def respond(event):
|
|
35
|
+
self.assertEqual(event['event'], 'tag_review_required')
|
|
36
|
+
request = json.loads(Path(event['request_path']).read_text(encoding='utf-8'))
|
|
37
|
+
self.assertEqual(request['budget']['strategy'], row['budgetStrategy'])
|
|
38
|
+
self.assertEqual(request['budget']['labels'], dictionaries['budget'])
|
|
39
|
+
selection = {key: [{'id': labels[0]['id'], 'reason': '上调广告组出价对应竞价增强'}]
|
|
40
|
+
for key, labels in dictionaries.items()}
|
|
41
|
+
Path(event['response_path']).write_text(json.dumps(selection), encoding='utf-8')
|
|
42
|
+
choices = review_tags(row, dictionaries, {}, Path(tmp), threading.Event(), respond)
|
|
43
|
+
attach_tags(row, dictionaries, {}, choices)
|
|
44
|
+
self.assertEqual(row['budgetTagIds'], [1])
|
|
45
|
+
self.assertEqual(row['budgetTags'], '竞价增强')
|
|
46
|
+
|
|
47
|
+
def test_tag_review_rejects_foreign_ids_and_other_mixed_with_match(self):
|
|
48
|
+
"""不接受跨场景 ID、无理由、重复选择或具体标签同时带其他。"""
|
|
49
|
+
from strategy_output import reviewed_tags
|
|
50
|
+
labels = [{'id': 1, 'name': '拓展受众'}, {'id': 2, 'name': '其他'}]
|
|
51
|
+
for choices in ([], [{'id': 99, 'reason': '错误场景'}], [{'id': 1}],
|
|
52
|
+
[{'id': 1, 'reason': '新增受众'}, {'id': 2, 'reason': '其他'}],
|
|
53
|
+
[{'id': 1, 'reason': '新增受众'}, {'id': 1, 'reason': '重复'}]):
|
|
54
|
+
with self.subTest(choices=choices), self.assertRaises(ValueError):
|
|
55
|
+
reviewed_tags(labels, choices)
|
|
56
|
+
self.assertEqual(reviewed_tags(labels, [{'id': 2, 'reason': '正文未涉及受众拓展,其他标签均不适用'}]), [labels[1]])
|
|
57
|
+
|
|
58
|
+
def test_tag_review_cancellation(self):
|
|
59
|
+
"""等待分析时取消可立即清理退出,不阻塞下一批。"""
|
|
60
|
+
import threading
|
|
61
|
+
from strategy_output import review_tags
|
|
62
|
+
cancel = threading.Event()
|
|
63
|
+
cancel.set()
|
|
64
|
+
keys = ('budget', 'product', 'audience')
|
|
65
|
+
with tempfile.TemporaryDirectory() as tmp, self.assertRaises(InterruptedError):
|
|
66
|
+
review_tags({key + 'Strategy': '内容' for key in keys},
|
|
67
|
+
{key: [{'id': 1, 'name': '其他'}] for key in keys}, {}, Path(tmp), cancel, lambda _: None)
|
|
68
|
+
|
|
69
|
+
def test_all_zero_stops_before_channel_query(self):
|
|
70
|
+
with tempfile.TemporaryDirectory() as tmp, patch('strategy_output.run_cli', return_value={
|
|
71
|
+
'ok': True, 'data': {'result': [{'projectId': 1, 'lastWeekSpend': 0}], 'total': 1}
|
|
72
|
+
}) as api:
|
|
73
|
+
with self.assertRaises(GenerationOutcome) as caught:
|
|
74
|
+
precheck_projects(Path(tmp), 1)
|
|
75
|
+
self.assertEqual(caught.exception.code, 'NO_DELIVERY_DATA')
|
|
76
|
+
self.assertEqual(api.call_count, 1)
|
|
77
|
+
self.assertEqual(api.call_args.args[1], {'bizLineIdList': [1], 'pageNum': 1, 'pageSize': 100})
|
|
78
|
+
|
|
79
|
+
def test_explicit_skip_spend_still_checks_projects_and_channels(self):
|
|
80
|
+
"""显式跳过只放行消耗门槛,不放行漏项目或未知渠道。"""
|
|
81
|
+
project = {'projectId': 1, 'lastWeekSpend': 0, 'channelIdList': ['f']}
|
|
82
|
+
page = {'ok': True, 'data': {'result': [project], 'total': 1}}
|
|
83
|
+
channels = {'ok': True, 'data': [{'channelId': 'f', 'channelName': 'Meta'}]}
|
|
84
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
85
|
+
with patch('strategy_output.run_cli', side_effect=[page, channels]) as api:
|
|
86
|
+
precheck_projects(Path(tmp), 1, [1], skip_spend_check=True)
|
|
87
|
+
self.assertEqual(api.call_count, 2)
|
|
88
|
+
with patch('strategy_output.run_cli', return_value=page):
|
|
89
|
+
with self.assertRaisesRegex(RuntimeError, '未覆盖'):
|
|
90
|
+
precheck_projects(Path(tmp), 1, [1, 2], skip_spend_check=True)
|
|
91
|
+
with patch('strategy_output.run_cli', side_effect=[page, {'ok': True, 'data': []}]):
|
|
92
|
+
with self.assertRaises(GenerationOutcome) as caught:
|
|
93
|
+
precheck_projects(Path(tmp), 1, [1], skip_spend_check=True)
|
|
94
|
+
self.assertEqual(caught.exception.code, 'UNSUPPORTED_CHANNEL')
|
|
95
|
+
self.assertFalse(workflow_parser().parse_args(['--id', '1']).skip_project_spend_check)
|
|
96
|
+
self.assertTrue(workflow_parser().parse_args(['--id', '1', '--skip-project-spend-check']).skip_project_spend_check)
|
|
97
|
+
|
|
98
|
+
def test_later_page_positive_checks_all_channels(self):
|
|
99
|
+
pages = [
|
|
100
|
+
{'ok': True, 'data': {'result': [{'projectId': 1, 'lastWeekSpend': 0, 'channelIdList': ['f']}], 'total': 2}},
|
|
101
|
+
{'ok': True, 'data': {'result': [{'projectId': 2, 'lastWeekSpend': 2, 'channelIdList': ['g']}], 'total': 2}},
|
|
102
|
+
{'ok': True, 'data': [{'channelId': 'f', 'channelName': 'Facebook'}, {'channelId': 'g', 'channelName': 'Google'}]},
|
|
103
|
+
]
|
|
104
|
+
with tempfile.TemporaryDirectory() as tmp, patch('strategy_output.run_cli', side_effect=pages) as api:
|
|
105
|
+
precheck_projects(Path(tmp), 1)
|
|
106
|
+
self.assertEqual(api.call_count, 3)
|
|
107
|
+
self.assertEqual(api.call_args_list[1].args[1]['pageNum'], 2)
|
|
108
|
+
|
|
109
|
+
def test_missing_spend_and_failed_page_are_not_zero(self):
|
|
110
|
+
for env in ({'ok': True, 'data': {'result': [{'projectId': 1}], 'total': 1}}, {'ok': False, 'error': 'denied'}):
|
|
111
|
+
with self.subTest(env=env), tempfile.TemporaryDirectory() as tmp, patch('strategy_output.run_cli', return_value=env):
|
|
112
|
+
with self.assertRaises(RuntimeError) as caught:
|
|
113
|
+
precheck_projects(Path(tmp), 1)
|
|
114
|
+
self.assertNotEqual(getattr(caught.exception, 'code', None), 'NO_DELIVERY_DATA')
|
|
115
|
+
|
|
116
|
+
def test_default_service_filter_cannot_hide_linked_projects(self):
|
|
117
|
+
"""即使分页 total 完整,也要与业务线 projectIds 核对,不能遗漏其他服务项目。"""
|
|
118
|
+
with tempfile.TemporaryDirectory() as tmp, patch('strategy_output.run_cli', return_value={
|
|
119
|
+
'ok': True, 'data': {'result': [{'projectId': 1, 'lastWeekSpend': 0}], 'total': 1}
|
|
120
|
+
}):
|
|
121
|
+
with self.assertRaisesRegex(RuntimeError, '未覆盖业务线全部关联项目') as caught:
|
|
122
|
+
precheck_projects(Path(tmp), 1, [1, 2])
|
|
123
|
+
self.assertNotIsInstance(caught.exception, GenerationOutcome)
|
|
124
|
+
|
|
125
|
+
def test_calendar_periods_and_explicit_overrides(self):
|
|
126
|
+
"""周一、周日及跨年均取已结束周,用户可分别覆盖两期。"""
|
|
127
|
+
args = Namespace(curr_start='', curr_end='', prev_start='', prev_end='')
|
|
128
|
+
for anchor in (date(2026, 9, 21), date(2026, 9, 22), date(2026, 9, 27)):
|
|
129
|
+
self.assertEqual(resolve_periods(args, anchor), {
|
|
130
|
+
'curr': ('2026-09-14', '2026-09-20'), 'prev': ('2026-09-07', '2026-09-13')})
|
|
131
|
+
self.assertEqual(resolve_periods(args, date(2026, 1, 1)), {
|
|
132
|
+
'curr': ('2025-12-22', '2025-12-28'), 'prev': ('2025-12-15', '2025-12-21')})
|
|
133
|
+
args.curr_start, args.curr_end = '2026-09-01', '2026-09-05'
|
|
134
|
+
self.assertEqual(resolve_periods(args, date(2026, 9, 22))['curr'], ('2026-09-01', '2026-09-05'))
|
|
135
|
+
self.assertEqual(resolve_periods(args, date(2026, 9, 22))['prev'], ('2026-09-07', '2026-09-13'))
|
|
136
|
+
args.prev_start, args.prev_end = '2026-08-25', '2026-08-29'
|
|
137
|
+
self.assertEqual(resolve_periods(args)['prev'], ('2026-08-25', '2026-08-29'))
|
|
138
|
+
args.prev_end = ''
|
|
139
|
+
with self.assertRaises(ValueError):
|
|
140
|
+
resolve_periods(args)
|
|
141
|
+
|
|
142
|
+
def test_invalid_week_rejected(self):
|
|
143
|
+
validate_period('2026-09-07', '2026-09-13')
|
|
144
|
+
for bounds in [('2026-09-08', '2026-09-13'), ('2026-09-07', '2026-09-20'), ('2026-08-31', '2026-09-06')]:
|
|
145
|
+
with self.subTest(bounds=bounds), self.assertRaises(ValueError):
|
|
146
|
+
validate_period(*bounds)
|
|
147
|
+
|
|
148
|
+
def test_full_ready_mapping_and_effect_validation(self):
|
|
149
|
+
row = {}
|
|
150
|
+
for key in ('budget', 'product', 'audience', 'creative'):
|
|
151
|
+
row.update({key + 'Strategy': '正文', key + 'Learning': '独立结论', key + 'Effect': 3})
|
|
152
|
+
for key in ('budget', 'product', 'audience'):
|
|
153
|
+
row[key + 'TagIds'] = [21]
|
|
154
|
+
context = {'bizLineId': 1, 'periodStart': '2026-09-07', 'periodEnd': '2026-09-13'}
|
|
155
|
+
body = ready_body(context, row, 'key.xlsx')
|
|
156
|
+
self.assertEqual(body['mediaStrategyTags'], [21])
|
|
157
|
+
self.assertEqual(body['mediaStrategyEffect'], 3)
|
|
158
|
+
self.assertEqual(body['creativeStrategy'], '正文')
|
|
159
|
+
self.assertEqual(body['fileStorageKey'], 'key.xlsx')
|
|
160
|
+
row['creativeEffect'] = 0
|
|
161
|
+
with self.assertRaises(ValueError):
|
|
162
|
+
ready_body(context, row, 'key.xlsx')
|
|
163
|
+
|
|
164
|
+
def test_save_failure_never_retries(self):
|
|
165
|
+
with tempfile.TemporaryDirectory() as tmp, patch('strategy_output.run_cli', return_value={'ok': False, 'error': 'timeout'}) as api:
|
|
166
|
+
with self.assertRaises(RuntimeError):
|
|
167
|
+
save_result(Path(tmp), {'generateCode': 'NO_PROJECT'})
|
|
168
|
+
self.assertEqual(api.call_count, 1)
|
|
169
|
+
self.assertEqual(api.call_args.args[0], ['biz-line', '+ai-strategy-save'])
|
|
170
|
+
|
|
171
|
+
def test_dictionary_requests_and_empty_results(self):
|
|
172
|
+
with tempfile.TemporaryDirectory() as tmp, patch('strategy_output.run_cli', return_value={'ok': True, 'data': [
|
|
173
|
+
{'id': 1, 'name': '其他', 'isOther': 1, 'children': None}
|
|
174
|
+
]}) as api:
|
|
175
|
+
dictionaries = load_tags(Path(tmp), '11')
|
|
176
|
+
self.assertEqual(len(dictionaries), 3)
|
|
177
|
+
self.assertEqual([call.args[1]['sceneCode'] for call in api.call_args_list],
|
|
178
|
+
['BUDGET_STRATEGY', 'MEDIA_STRATEGY', 'AUDIENCE_STRATEGY'])
|
|
179
|
+
self.assertTrue(all(call.args[1]['filterCondition'] == '11' for call in api.call_args_list))
|
|
180
|
+
api.return_value = {'ok': True, 'data': []}
|
|
181
|
+
with self.assertRaises(ValueError):
|
|
182
|
+
load_tags(Path(tmp), '11')
|
|
183
|
+
|
|
184
|
+
def test_industry_cache_concurrent_reuse_and_isolation(self):
|
|
185
|
+
"""多行业并发时同一行业只查三次,且多个行业只取首个。"""
|
|
186
|
+
with tempfile.TemporaryDirectory() as tmp, patch('strategy_output.run_cli', return_value={
|
|
187
|
+
'ok': True, 'data': [{'id': 1, 'name': '其他'}]
|
|
188
|
+
}) as api:
|
|
189
|
+
cache = TagCache(Path(tmp))
|
|
190
|
+
lines = [{'adCategoryIndustryIds': ids} for ids in ([11, 22], [11], [22], [33], [22])]
|
|
191
|
+
with ThreadPoolExecutor(max_workers=3) as executor:
|
|
192
|
+
values = list(executor.map(cache.get, lines))
|
|
193
|
+
self.assertIs(values[0], values[1])
|
|
194
|
+
self.assertEqual(api.call_count, 9)
|
|
195
|
+
for industry in ('11', '22', '33'):
|
|
196
|
+
queries = [call.args[1] for call in api.call_args_list if call.args[1]['filterCondition'] == industry]
|
|
197
|
+
self.assertEqual(queries, [{'sceneCode': scene, 'filterCondition': industry} for scene in
|
|
198
|
+
('BUDGET_STRATEGY', 'MEDIA_STRATEGY', 'AUDIENCE_STRATEGY')])
|
|
199
|
+
self.assertEqual(len({str(call.args[2]) for call in api.call_args_list}), 9)
|
|
200
|
+
|
|
201
|
+
def test_invalid_industry_and_failure_cache(self):
|
|
202
|
+
"""不能改用第二个行业,失败仅缓存对应行业,不影响其他行业。"""
|
|
203
|
+
for ids in (None, [], [None, 11], [False], [-1], ['bad', 11]):
|
|
204
|
+
with self.assertRaises(ValueError):
|
|
205
|
+
industry_condition({'adCategoryIndustryIds': ids})
|
|
206
|
+
with tempfile.TemporaryDirectory() as tmp, patch('strategy_output.run_cli', return_value={'ok': True, 'data': []}) as api:
|
|
207
|
+
cache = TagCache(Path(tmp))
|
|
208
|
+
for _ in range(2):
|
|
209
|
+
with self.assertRaises((ValueError, RuntimeError)):
|
|
210
|
+
cache.get({'adCategoryIndustryIds': [11]})
|
|
211
|
+
self.assertEqual(api.call_count, 1)
|
|
212
|
+
api.return_value = {'ok': True, 'data': [{'id': 1, 'name': '其他'}]}
|
|
213
|
+
cache.get({'adCategoryIndustryIds': [22]})
|
|
214
|
+
self.assertEqual(api.call_count, 4)
|
|
215
|
+
|
|
216
|
+
def test_effects_and_parser(self):
|
|
217
|
+
self.assertEqual([effect_code(v) for v in ('有效', '无效', '待观察', '待观察(预警)')], [1, 2, 3, 3])
|
|
218
|
+
self.assertEqual(workflow_parser().parse_args(['--id', '1']).output_mode, 'excel')
|
|
219
|
+
self.assertEqual(workflow_parser().parse_args(['--id', '1', '--output-mode', 'database']).output_mode, 'database')
|
|
220
|
+
|
|
221
|
+
def test_real_fetch_zero_precheck_creates_context_before_stopping(self):
|
|
222
|
+
"""不替换 fetch_business,确认真实入口在广告拉数前结束且可写正确周期状态。"""
|
|
223
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
224
|
+
root = Path(tmp)
|
|
225
|
+
args = Namespace(**{**vars(arguments(root)), 'id': 1, 'request_dir': str(root), 'output_mode': 'database'})
|
|
226
|
+
with patch('fetch_data.date', wraps=date) as clock, patch('fetch_data.run_cli', return_value={'ok': True, 'data': {'result': [{
|
|
227
|
+
'id': 1, 'name': '匿名业务线', 'projectIds': [10],
|
|
228
|
+
'periodStart': '2026-09-07', 'periodEnd': '2026-09-13',
|
|
229
|
+
}], 'total': 1}}) as fetch_api, patch('strategy_output.run_cli', return_value={
|
|
230
|
+
'ok': True, 'data': {'result': [{'projectId': 10, 'lastWeekSpend': 0}], 'total': 1}
|
|
231
|
+
}):
|
|
232
|
+
clock.today.return_value = date(2026, 9, 22)
|
|
233
|
+
with self.assertRaises(GenerationOutcome) as outcome:
|
|
234
|
+
fetch_business(args)
|
|
235
|
+
self.assertEqual(outcome.exception.code, 'NO_DELIVERY_DATA')
|
|
236
|
+
self.assertEqual(fetch_api.call_count, 1)
|
|
237
|
+
self.assertEqual(json.loads((root / 'save-context.json').read_text())['periodStart'], '2026-09-14')
|
|
238
|
+
|
|
239
|
+
def test_fetch_queries_industry_tags_before_project_precheck(self):
|
|
240
|
+
"""真实 fetch 入口先解析业务线行业并查字典,再进入项目检查。"""
|
|
241
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
242
|
+
args = Namespace(**{**vars(arguments(Path(tmp))), 'id': 1, 'request_dir': tmp,
|
|
243
|
+
'output_mode': 'database', 'curr_start': '2026-09-07', 'curr_end': '2026-09-13',
|
|
244
|
+
'tag_cache': TagCache(Path(tmp) / 'tags')})
|
|
245
|
+
events = []
|
|
246
|
+
def lookup(*_):
|
|
247
|
+
events.append('biz')
|
|
248
|
+
return {'ok': True, 'data': {'result': [{'id': 1, 'projectIds': [10], 'adCategoryIndustryIds': [11, 22]}], 'total': 1}}
|
|
249
|
+
def tags(command, body, out):
|
|
250
|
+
events.append(body)
|
|
251
|
+
return {'ok': True, 'data': [{'id': 1, 'name': '其他'}]}
|
|
252
|
+
def precheck(*_, **kwargs):
|
|
253
|
+
events.append('projects')
|
|
254
|
+
raise GenerationOutcome('NO_DELIVERY_DATA', '全零')
|
|
255
|
+
with patch('fetch_data.run_cli', side_effect=lookup), patch('strategy_output.run_cli', side_effect=tags), patch('fetch_data.precheck_projects', side_effect=precheck):
|
|
256
|
+
with self.assertRaises(GenerationOutcome):
|
|
257
|
+
fetch_business(args)
|
|
258
|
+
self.assertEqual(events, ['biz',
|
|
259
|
+
{'sceneCode': 'BUDGET_STRATEGY', 'filterCondition': '11'},
|
|
260
|
+
{'sceneCode': 'MEDIA_STRATEGY', 'filterCondition': '11'},
|
|
261
|
+
{'sceneCode': 'AUDIENCE_STRATEGY', 'filterCondition': '11'}, 'projects'])
|
|
262
|
+
|
|
263
|
+
def test_invalid_output_options_do_not_create_files(self):
|
|
264
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
265
|
+
args = arguments(Path(tmp) / 'out')
|
|
266
|
+
args.output_mode = 'database'
|
|
267
|
+
with self.assertRaises(ValueError):
|
|
268
|
+
run_batch(args, temp_parent=Path(tmp))
|
|
269
|
+
args.output_dir, args.curr_start = None, '2026-09-07'
|
|
270
|
+
with self.assertRaises(ValueError):
|
|
271
|
+
run_batch(args, temp_parent=Path(tmp))
|
|
272
|
+
self.assertFalse(list(Path(tmp).iterdir()))
|
|
273
|
+
|
|
274
|
+
def test_unknown_channel_is_not_dropped(self):
|
|
275
|
+
with tempfile.TemporaryDirectory() as tmp, patch('strategy_output.run_cli', side_effect=[
|
|
276
|
+
{'ok': True, 'data': {'result': [{'projectId': 1, 'lastWeekSpend': 5, 'channelIdList': ['unknown']}], 'total': 1}},
|
|
277
|
+
{'ok': True, 'data': [{'channelId': 1, 'channelName': 'Facebook'}]},
|
|
278
|
+
]):
|
|
279
|
+
with self.assertRaises(GenerationOutcome) as outcome:
|
|
280
|
+
precheck_projects(Path(tmp), 1, [1])
|
|
281
|
+
self.assertEqual(outcome.exception.code, 'UNSUPPORTED_CHANNEL')
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class DatabaseWorkflow(unittest.TestCase):
|
|
285
|
+
"""实际工作流导出真实 xlsx,仅替换外部 CLI;检查调用顺序与文件生命周期。"""
|
|
286
|
+
|
|
287
|
+
def run_fixture(self, root, *, failure=None, outcome=None):
|
|
288
|
+
args = arguments(root / 'desktop')
|
|
289
|
+
args.output_dir, args.output_mode = None, 'database'
|
|
290
|
+
payload = small_payload()
|
|
291
|
+
for key in ('budget', 'product', 'audience', 'creative'):
|
|
292
|
+
payload['rows'][0][key + 'Effect'] = 3
|
|
293
|
+
calls = []
|
|
294
|
+
def fetch(local):
|
|
295
|
+
# 上下文写入时机与生产 fetch_business 一致。
|
|
296
|
+
(Path(local.request_dir) / 'save-context.json').write_text(json.dumps({
|
|
297
|
+
'bizLineId': 1, 'periodStart': '2026-09-07', 'periodEnd': '2026-09-13',
|
|
298
|
+
}))
|
|
299
|
+
if outcome:
|
|
300
|
+
raise GenerationOutcome(outcome, 'fixture reason')
|
|
301
|
+
return {'id': 1, 'name': '匿名样本', 'adCategoryIndustryIds': [11]}
|
|
302
|
+
def api(command, body, out):
|
|
303
|
+
calls.append((command, body))
|
|
304
|
+
if command == ['biz-line', '+categories']:
|
|
305
|
+
return {'ok': True, 'data': [{'id': len(calls), 'name': '其他'}]}
|
|
306
|
+
if command == ['common-file', '+upload']:
|
|
307
|
+
self.assertTrue(Path(body['file']).is_file())
|
|
308
|
+
self.assertEqual(body['serviceName'], 'sino-opti-hive')
|
|
309
|
+
if failure == 'upload':
|
|
310
|
+
return {'ok': False, 'error': 'upload failed'}
|
|
311
|
+
return {'ok': True, 'data': {'storageKey': '' if failure == 'empty_key' else 'saved.xlsx'}}
|
|
312
|
+
if command == ['biz-line', '+ai-strategy-save']:
|
|
313
|
+
if body['generateCode'] == 'READY':
|
|
314
|
+
self.assertFalse((out.parent / 'final.xlsx').exists())
|
|
315
|
+
self.assertEqual(body['fileStorageKey'], 'saved.xlsx')
|
|
316
|
+
self.assertEqual(body['mediaStrategyEffect'], 3)
|
|
317
|
+
if failure == 'save':
|
|
318
|
+
return {'ok': False, 'error': 'timeout'}
|
|
319
|
+
if failure == 'save_id':
|
|
320
|
+
return {'ok': True, 'data': None}
|
|
321
|
+
return {'ok': True, 'data': 123}
|
|
322
|
+
self.fail(f'未知请求 {command}')
|
|
323
|
+
def progress(event):
|
|
324
|
+
"""通过真实交接文件提供离线 Agent 选择,验证保存前确实完成内容分析交接。"""
|
|
325
|
+
if event.get('event') == 'tag_review_required':
|
|
326
|
+
request = json.loads(Path(event['request_path']).read_text(encoding='utf-8'))
|
|
327
|
+
selections = {key: [{'id': item['labels'][0]['id'], 'reason': '离线样本字典仅有其他'}]
|
|
328
|
+
for key, item in request.items()}
|
|
329
|
+
Path(event['response_path']).write_text(json.dumps(selections), encoding='utf-8')
|
|
330
|
+
with patch('run_workflow.fetch_business', fetch), patch('run_workflow.summarize_request', return_value=(payload, {
|
|
331
|
+
'detail_count': 1, 'curr_total': 20, 'tag_evidence': {},
|
|
332
|
+
})), patch('strategy_output.run_cli', api):
|
|
333
|
+
result = run_batch(args, progress=progress, temp_parent=root)
|
|
334
|
+
return result, calls
|
|
335
|
+
|
|
336
|
+
def test_success_upload_delete_save_without_desktop(self):
|
|
337
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
338
|
+
root = Path(tmp)
|
|
339
|
+
result, calls = self.run_fixture(root)
|
|
340
|
+
self.assertTrue(result['ok'])
|
|
341
|
+
self.assertEqual(result['results'][0]['aiStrategyId'], 123)
|
|
342
|
+
self.assertIsNone(result['results'][0]['output_path'])
|
|
343
|
+
self.assertEqual([c[0] for c in calls][-2:], [['common-file', '+upload'], ['biz-line', '+ai-strategy-save']])
|
|
344
|
+
self.assertFalse(list(root.rglob('*.xlsx')))
|
|
345
|
+
|
|
346
|
+
def test_upload_errors_save_failure_status(self):
|
|
347
|
+
for failure in ('upload', 'empty_key'):
|
|
348
|
+
with self.subTest(failure=failure), tempfile.TemporaryDirectory() as tmp:
|
|
349
|
+
result, calls = self.run_fixture(Path(tmp), failure=failure)
|
|
350
|
+
self.assertFalse(result['ok'])
|
|
351
|
+
self.assertEqual(calls[-1][1]['generateCode'], 'GENERATION_FAILED')
|
|
352
|
+
self.assertNotIn('fileStorageKey', calls[-1][1])
|
|
353
|
+
self.assertEqual(result['results'][0]['attempts'], 3)
|
|
354
|
+
self.assertEqual(sum(c[0] == ['common-file', '+upload'] for c in calls), 3)
|
|
355
|
+
|
|
356
|
+
def test_save_failure_preserves_key_and_never_writes_again(self):
|
|
357
|
+
for failure in ('save', 'save_id'):
|
|
358
|
+
with self.subTest(failure=failure), tempfile.TemporaryDirectory() as tmp:
|
|
359
|
+
result, calls = self.run_fixture(Path(tmp), failure=failure)
|
|
360
|
+
self.assertFalse(result['ok'])
|
|
361
|
+
self.assertEqual(sum(c[0] == ['biz-line', '+ai-strategy-save'] for c in calls), 1)
|
|
362
|
+
self.assertEqual(result['results'][0]['storageKey'], 'saved.xlsx')
|
|
363
|
+
self.assertTrue(result['results'][0]['save_result_unknown'])
|
|
364
|
+
self.assertFalse(list(Path(tmp).rglob('*.xlsx')))
|
|
365
|
+
|
|
366
|
+
def test_failed_business_status_save_is_not_replayed(self):
|
|
367
|
+
"""不可生成状态保存超时同样可能已落库,不整条重跑。"""
|
|
368
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
369
|
+
result, calls = self.run_fixture(Path(tmp), failure='save', outcome='NO_PROJECT')
|
|
370
|
+
self.assertFalse(result['ok'])
|
|
371
|
+
self.assertTrue(result['results'][0]['save_result_unknown'])
|
|
372
|
+
self.assertEqual(result['results'][0]['attempts'], 1)
|
|
373
|
+
self.assertEqual(len(calls), 1)
|
|
374
|
+
|
|
375
|
+
def test_business_outcomes_saved_without_excel_or_upload(self):
|
|
376
|
+
for code in ('NO_PROJECT', 'UNSUPPORTED_CHANNEL', 'NO_AD_ACCOUNT', 'NO_DELIVERY_DATA'):
|
|
377
|
+
with self.subTest(code=code), tempfile.TemporaryDirectory() as tmp:
|
|
378
|
+
result, calls = self.run_fixture(Path(tmp), outcome=code)
|
|
379
|
+
self.assertTrue(result['ok'])
|
|
380
|
+
self.assertEqual(len(calls), 1)
|
|
381
|
+
self.assertEqual(calls[0][1]['generateCode'], code)
|
|
382
|
+
self.assertFalse(list(Path(tmp).rglob('*.xlsx')))
|
|
383
|
+
|
|
384
|
+
def test_excel_delete_failure_is_reported(self):
|
|
385
|
+
"""模拟文件被占用,不能宣称清理成功;测试结束后由临时目录清理样本。"""
|
|
386
|
+
real_unlink = Path.unlink
|
|
387
|
+
def locked(path, *args, **kwargs):
|
|
388
|
+
if path.name == 'final.xlsx':
|
|
389
|
+
raise PermissionError('fixture file locked')
|
|
390
|
+
return real_unlink(path, *args, **kwargs)
|
|
391
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
392
|
+
with patch.object(Path, 'unlink', locked):
|
|
393
|
+
result, calls = self.run_fixture(Path(tmp))
|
|
394
|
+
self.assertFalse(result['ok'])
|
|
395
|
+
self.assertTrue(result['results'][0]['cleanup_failed'])
|
|
396
|
+
self.assertIn('fixture file locked', str(result))
|
|
397
|
+
self.assertEqual(calls[-1][1]['generateCode'], 'GENERATION_FAILED')
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
if __name__ == '__main__':
|
|
401
|
+
unittest.main()
|
|
@@ -58,13 +58,23 @@ def test_page_cap_covers_tiktok_bid_pages():
|
|
|
58
58
|
|
|
59
59
|
|
|
60
60
|
def test_period_verdict_follows_qoq_not_kpi():
|
|
61
|
-
#
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
61
|
+
# 四象限同时依赖达标与趋势;未知数据不能伪造有效或无效。
|
|
62
|
+
from cli_json import learning_text, amount_change
|
|
63
|
+
from strategy_output import effect_code
|
|
64
|
+
cases = [(True, '变好', '有效', '策略有效', 1), (True, '稳定', '有效', '策略有效', 1),
|
|
65
|
+
(True, '变差', '待观察(预警)', '警惕,再观察一周', 3),
|
|
66
|
+
(False, '变好', '待观察(潜力)', '再给一周机会', 3),
|
|
67
|
+
(False, '稳定', '无效', '需关停', 2), (False, '变差', '无效', '需关停', 2)]
|
|
68
|
+
for hit, trend, verdict, note, effect in cases:
|
|
69
|
+
actual = period_verdict(first_period=False, qoq_label=trend, kpi_usable=True, kpi_hit=hit)
|
|
70
|
+
assert actual == verdict
|
|
71
|
+
assert learning_text(actual) == f'结论:{verdict}\n说明:{note}'
|
|
72
|
+
assert effect_code(actual) == effect
|
|
73
|
+
for hit in (None, True, False):
|
|
74
|
+
assert period_verdict(first_period=False, qoq_label='无法对比', kpi_usable=True, kpi_hit=hit) == '待观察'
|
|
75
|
+
assert period_verdict(first_period=False, qoq_label='变好', kpi_usable=False) == '待观察'
|
|
76
|
+
assert amount_change(150, 100) == '环比 +50.00%'
|
|
77
|
+
assert '不可计算' in amount_change(100, 0)
|
|
68
78
|
|
|
69
79
|
|
|
70
80
|
def test_is_first_period_uses_prev_dates():
|
|
@@ -115,7 +125,7 @@ def test_naming_patterns_picks_short_tokens():
|
|
|
115
125
|
"KPOP测试-线下周边-803-东南亚仅马泰-自制-英语-登录-2609",
|
|
116
126
|
"彩妆测试-整体妆容-东南亚-自制-英语-install-260916",
|
|
117
127
|
]
|
|
118
|
-
text = naming_patterns(names)
|
|
128
|
+
text = naming_patterns(names, limit=10)
|
|
119
129
|
assert "KPOP" in text or "舞蹈" in text
|
|
120
130
|
assert "彩妆" in text or "整体妆容" in text
|
|
121
131
|
assert len(text) < 200
|