test_feishu_natural_commands.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. from __future__ import annotations
  2. import sys
  3. import unittest
  4. from datetime import datetime
  5. from pathlib import Path
  6. from unittest.mock import Mock, patch
  7. from zoneinfo import ZoneInfo
  8. HERE = Path(__file__).resolve().parent
  9. ROOT = HERE.parents[1]
  10. for path in (ROOT, HERE):
  11. if str(path) not in sys.path:
  12. sys.path.insert(0, str(path))
  13. from agent.tools.builtin.feishu.feishu_client import (
  14. ChatType,
  15. FeishuClient,
  16. FeishuMessageEvent,
  17. )
  18. from command_intent_parser import CommandIntentParser, IntentParserConfig
  19. from feishu_command_service import FeishuCommandService
  20. from operator_commands import (
  21. ACTION_DAY_PAUSE,
  22. ACTION_STOP,
  23. ACTION_TODAY_SPEND,
  24. SCOPE_ACCOUNTS,
  25. SCOPE_ALL,
  26. SCOPE_AUTOMATION,
  27. SCOPE_MISSING,
  28. CommandIntent,
  29. parse_deterministic_intent,
  30. )
  31. from operator_control import _execute_pause, pause_status_summary, preview_write_command
  32. from realtime_config import RealtimeControlConfig
  33. from run_scheduler import next_wake
  34. from tencent_client import TencentClient, TencentWriteRejectedError
  35. from today_spend_query import query_today_spend
  36. SHANGHAI = ZoneInfo("Asia/Shanghai")
  37. class NaturalCommandParsingTest(unittest.TestCase):
  38. def test_bare_pause_requires_scope(self) -> None:
  39. intent = parse_deterministic_intent("暂停")
  40. self.assertIsNotNone(intent)
  41. self.assertEqual(ACTION_DAY_PAUSE, intent.action)
  42. self.assertFalse(intent.complete)
  43. self.assertEqual(("scope",), intent.missing_fields)
  44. def test_stop_today_is_temporary_and_all_managed(self) -> None:
  45. intent = parse_deterministic_intent("停止今天所有账户的投放")
  46. self.assertEqual(ACTION_DAY_PAUSE, intent.action)
  47. self.assertEqual(SCOPE_ALL, intent.scope_type)
  48. self.assertTrue(intent.complete)
  49. def test_automation_accounts_means_all_managed(self) -> None:
  50. intent = parse_deterministic_intent("停止自动化账户")
  51. self.assertEqual(ACTION_STOP, intent.action)
  52. self.assertEqual(SCOPE_ALL, intent.scope_type)
  53. def test_model_cannot_invent_account_ids(self) -> None:
  54. parser = CommandIntentParser(IntentParserConfig(True, "test", 1, 0.8))
  55. intent = parser._validate_model_intent(
  56. {
  57. "action": "STOP",
  58. "scope_type": "ACCOUNTS",
  59. "missing_fields": [],
  60. "confidence": 0.99,
  61. },
  62. "把那个账户停掉",
  63. )
  64. self.assertEqual(SCOPE_MISSING, intent.scope_type)
  65. self.assertFalse(intent.complete)
  66. self.assertEqual((), intent.account_ids)
  67. def test_source_account_id_overrides_model_all_scope(self) -> None:
  68. parser = CommandIntentParser(IntentParserConfig(True, "test", 1, 0.8))
  69. intent = parser._validate_model_intent(
  70. {
  71. "action": "STOP",
  72. "scope_type": "ALL",
  73. "missing_fields": [],
  74. "confidence": 0.99,
  75. },
  76. "把86748335停掉",
  77. )
  78. self.assertEqual(SCOPE_ACCOUNTS, intent.scope_type)
  79. self.assertEqual((86748335,), intent.account_ids)
  80. def test_deterministic_action_is_not_overridden_by_model(self) -> None:
  81. parser = CommandIntentParser(IntentParserConfig(True, "test", 1, 0.8))
  82. with patch.object(
  83. parser,
  84. "_parse_with_model",
  85. return_value=CommandIntent(
  86. action=ACTION_STOP,
  87. scope_type=SCOPE_ALL,
  88. parse_source="llm",
  89. ),
  90. ):
  91. intent = parser.understand("今天让这批自动投放账户先歇一下")
  92. self.assertEqual(ACTION_DAY_PAUSE, intent.action)
  93. self.assertEqual(SCOPE_ALL, intent.scope_type)
  94. def test_incomplete_standard_command_survives_model_failure(self) -> None:
  95. parser = CommandIntentParser(IntentParserConfig(True, "test", 1, 0.8))
  96. with patch.object(
  97. parser,
  98. "_parse_with_model",
  99. side_effect=RuntimeError("model unavailable"),
  100. ):
  101. intent = parser.understand("暂停")
  102. self.assertEqual(ACTION_DAY_PAUSE, intent.action)
  103. self.assertEqual(("scope",), intent.missing_fields)
  104. def test_model_selects_automation_scope_for_today_spend(self) -> None:
  105. parser = CommandIntentParser(IntentParserConfig(True, "test", 1, 0.8))
  106. intent = parser._validate_model_intent(
  107. {
  108. "action": "TODAY_SPEND",
  109. "scope_type": "AUTOMATION",
  110. "missing_fields": [],
  111. "confidence": 0.99,
  112. },
  113. "查询今天自动化账户消耗",
  114. )
  115. self.assertEqual(ACTION_TODAY_SPEND, intent.action)
  116. self.assertEqual(SCOPE_AUTOMATION, intent.scope_type)
  117. self.assertTrue(intent.complete)
  118. class TodaySpendQueryTest(unittest.TestCase):
  119. class Tencent:
  120. def get_today_account_metrics(self, account_id, data_date):
  121. return {
  122. "cost_fen": account_id,
  123. "impressions": 100,
  124. "clicks": 10,
  125. "conversions": 2,
  126. }
  127. def test_all_scope_returns_summary_only(self) -> None:
  128. now = datetime(2026, 7, 30, 12, 0, tzinfo=SHANGHAI)
  129. with patch(
  130. "today_spend_query.load_all_spend_accounts",
  131. return_value=[{"account_id": 10000001}, {"account_id": 10000002}],
  132. ):
  133. summary = query_today_spend(
  134. CommandIntent(
  135. action=ACTION_TODAY_SPEND,
  136. scope_type=SCOPE_ALL,
  137. ).to_parsed_command(),
  138. now=now,
  139. tencent=self.Tencent(),
  140. )
  141. self.assertEqual(2, summary["account_count"])
  142. self.assertEqual(20000003, summary["cost_fen"])
  143. self.assertEqual(200, summary["impressions"])
  144. self.assertTrue(summary["complete"])
  145. class FeishuAuthorizationTest(unittest.TestCase):
  146. def setUp(self) -> None:
  147. self.service = FeishuCommandService.__new__(FeishuCommandService)
  148. self.service.allowed_chat_id = "oc_allowed"
  149. self.service.allowed_open_ids = {"ou_allowed"}
  150. def _event(self, *, mentioned: bool, chat_type: ChatType = ChatType.GROUP):
  151. return FeishuMessageEvent(
  152. message_id="om_1",
  153. chat_id="oc_allowed",
  154. chat_type=chat_type,
  155. content="暂停全部",
  156. content_type="text",
  157. sender_open_id="ou_allowed",
  158. mentioned_bot=mentioned,
  159. )
  160. def test_group_message_without_mention_is_ignored(self) -> None:
  161. self.assertFalse(self.service._authorized(self._event(mentioned=False)))
  162. def test_private_message_is_ignored(self) -> None:
  163. self.assertFalse(
  164. self.service._authorized(
  165. self._event(mentioned=True, chat_type=ChatType.P2P)
  166. )
  167. )
  168. class FeishuMentionDetectionTest(unittest.TestCase):
  169. def setUp(self) -> None:
  170. self.client = FeishuClient.__new__(FeishuClient)
  171. self.client._bot_open_id = "ou_bot"
  172. @staticmethod
  173. def _mention(open_id: str) -> Mock:
  174. mention = Mock()
  175. mention.id.open_id = open_id
  176. return mention
  177. def test_mentioning_another_user_is_not_bot_mention(self) -> None:
  178. self.assertFalse(
  179. self.client._check_bot_mentioned([self._mention("ou_other")])
  180. )
  181. def test_mentioning_bot_is_bot_mention(self) -> None:
  182. self.assertTrue(
  183. self.client._check_bot_mentioned([self._mention("ou_bot")])
  184. )
  185. def test_missing_bot_identity_fails_closed(self) -> None:
  186. self.client._bot_open_id = None
  187. with patch.object(
  188. self.client,
  189. "_load_bot_open_id",
  190. side_effect=RuntimeError("unavailable"),
  191. ):
  192. self.assertFalse(
  193. self.client._check_bot_mentioned([self._mention("ou_other")])
  194. )
  195. class PreviewSnapshotTest(unittest.TestCase):
  196. class FakeTencent:
  197. def get_ads(self, account_id: int):
  198. if account_id == 10000001:
  199. return [
  200. {
  201. "adgroup_id": 101,
  202. "adgroup_name": "active",
  203. "configured_status": "AD_STATUS_NORMAL",
  204. "begin_date": "2026-07-01",
  205. "end_date": "0",
  206. },
  207. {
  208. "adgroup_id": 102,
  209. "adgroup_name": "manual-off",
  210. "configured_status": "AD_STATUS_SUSPEND",
  211. },
  212. {
  213. "adgroup_id": 103,
  214. "adgroup_name": "already-deferred",
  215. "configured_status": "AD_STATUS_NORMAL",
  216. "begin_date": "2026-07-31",
  217. },
  218. ]
  219. return []
  220. def get_today_ad_metrics(self, account_id, adgroup_ids, data_date):
  221. return {
  222. 101: {
  223. "cost_fen": 12345,
  224. "impressions": 1000,
  225. "clicks": 20,
  226. "conversions": 3,
  227. }
  228. }
  229. def test_preview_uses_impacted_accounts_and_freezes_metrics(self) -> None:
  230. captured = {}
  231. def persist(record, items):
  232. captured["record"] = record
  233. captured["items"] = items
  234. return {**record}
  235. now = datetime(2026, 7, 30, 12, 0, tzinfo=SHANGHAI)
  236. intent = parse_deterministic_intent("暂停全部")
  237. with (
  238. patch(
  239. "operator_control.load_realtime_accounts",
  240. return_value=[
  241. {"account_id": 10000001, "audience_name": "auto"},
  242. {"account_id": 10000002, "audience_name": "pause-only"},
  243. ],
  244. ),
  245. patch("operator_control.load_operator_pauses", return_value=[]),
  246. patch("operator_control.find_pending_command_conflict", return_value=None),
  247. patch("operator_control.create_operator_command_with_items", side_effect=persist),
  248. patch("operator_control.advisory_lock") as preview_lock,
  249. ):
  250. preview_lock.return_value.__enter__.return_value = True
  251. command = preview_write_command(
  252. intent.to_parsed_command(),
  253. now=now,
  254. source_message_id="om_1",
  255. chat_id="oc_1",
  256. sender_open_id="ou_1",
  257. sender_name="operator",
  258. confirmation_ttl_minutes=10,
  259. start_hour=6,
  260. tencent=self.FakeTencent(),
  261. )
  262. self.assertEqual(1, command["preview_account_count"])
  263. self.assertEqual([10000001], command["target_account_ids"])
  264. self.assertEqual(1, command["preview_ad_count"])
  265. self.assertEqual(12345, command["preview_cost_fen"])
  266. self.assertEqual(101, captured["items"][0]["adgroup_id"])
  267. self.assertEqual(3, captured["items"][0]["preview_conversions"])
  268. self.assertEqual(
  269. "AD_STATUS_NORMAL",
  270. captured["items"][0]["target_status"],
  271. )
  272. class DayPauseExecutionTest(unittest.TestCase):
  273. class FakeTencent:
  274. def __init__(self) -> None:
  275. self.date_updates = []
  276. def get_ads(self, account_id: int):
  277. return [{
  278. "adgroup_id": 101,
  279. "adgroup_name": "active",
  280. "configured_status": "AD_STATUS_NORMAL",
  281. "begin_date": "2026-07-01",
  282. "end_date": "0",
  283. }]
  284. def update_ad_begin_dates(self, account_id, adgroup_ids, begin_date):
  285. self.date_updates.append((account_id, adgroup_ids, begin_date))
  286. def update_ad(self, *args, **kwargs):
  287. raise AssertionError("DAY_PAUSE must not update configured_status")
  288. def test_day_pause_only_moves_begin_date_to_tomorrow(self) -> None:
  289. client = self.FakeTencent()
  290. now = datetime(2026, 7, 30, 12, 0, tzinfo=SHANGHAI)
  291. with (
  292. patch(
  293. "operator_control.load_ad_states",
  294. return_value={101: {"operator_pause_mode": None}},
  295. ),
  296. patch("operator_control.set_operator_pause") as set_pause,
  297. patch("operator_control._record_item") as record_item,
  298. ):
  299. successes, failures, skipped = _execute_pause(
  300. {"command_id": "cmd_1", "action": ACTION_DAY_PAUSE},
  301. {"account_id": 10000001, "audience_name": "auto"},
  302. now=now,
  303. start_hour=6,
  304. client=client,
  305. items=[{"id": 1, "adgroup_id": 101}],
  306. )
  307. self.assertEqual((1, 0, 0), (successes, failures, skipped))
  308. self.assertEqual(
  309. [(10000001, [101], "2026-07-31")],
  310. client.date_updates,
  311. )
  312. self.assertEqual("UNTIL_NEXT_DELIVERY", set_pause.call_args.kwargs["mode"])
  313. self.assertEqual(
  314. "AD_STATUS_NORMAL",
  315. record_item.call_args.kwargs["target_status"],
  316. )
  317. def test_rejected_day_pause_clears_pending_pause_state(self) -> None:
  318. client = self.FakeTencent()
  319. now = datetime(2026, 7, 30, 12, 0, tzinfo=SHANGHAI)
  320. with (
  321. patch.object(
  322. client,
  323. "update_ad_begin_dates",
  324. side_effect=TencentWriteRejectedError("rejected"),
  325. ),
  326. patch(
  327. "operator_control.load_ad_states",
  328. return_value={101: {"operator_pause_mode": None}},
  329. ),
  330. patch("operator_control.set_operator_pause"),
  331. patch("operator_control.clear_operator_pause") as clear_pause,
  332. patch("operator_control._record_item"),
  333. ):
  334. result = _execute_pause(
  335. {"command_id": "cmd_1", "action": ACTION_DAY_PAUSE},
  336. {"account_id": 10000001, "audience_name": "auto"},
  337. now=now,
  338. start_hour=6,
  339. client=client,
  340. items=[{"id": 1, "adgroup_id": 101}],
  341. )
  342. self.assertEqual((0, 1, 0), result)
  343. clear_pause.assert_called_once_with(
  344. 10000001,
  345. 101,
  346. action="OPERATOR_PAUSE_FAILED",
  347. action_at=now,
  348. )
  349. def test_scheduler_waits_for_cpm_start_not_delivery_start(self) -> None:
  350. config = RealtimeControlConfig(start_hour=12, next_delivery_hour=6)
  351. now = datetime(2026, 7, 30, 1, 0, tzinfo=SHANGHAI)
  352. self.assertEqual(12, next_wake(now, config).hour)
  353. def test_expired_day_pause_is_not_reported_as_active(self) -> None:
  354. now = datetime(2026, 7, 31, 8, 0, tzinfo=SHANGHAI)
  355. with (
  356. patch(
  357. "operator_control.load_realtime_accounts",
  358. return_value=[{"account_id": 10000001}],
  359. ),
  360. patch(
  361. "operator_control.load_operator_pauses",
  362. return_value=[{
  363. "account_id": 10000001,
  364. "adgroup_id": 101,
  365. "operator_pause_mode": "UNTIL_NEXT_DELIVERY",
  366. "operator_resume_at": datetime(2026, 7, 31, 6, 0),
  367. }],
  368. ),
  369. ):
  370. summary = pause_status_summary(now)
  371. self.assertEqual(0, summary["total"])
  372. class TencentDateUpdateClassificationTest(unittest.TestCase):
  373. def test_tencent_rejection_uses_rejected_error(self) -> None:
  374. client = TencentClient()
  375. response = Mock(status_code=200, text='{"code": 1900001}')
  376. response.json.return_value = {
  377. "code": 1900001,
  378. "message": "rejected",
  379. }
  380. with (
  381. patch.object(client, "_common_params", return_value={}),
  382. patch.object(client, "_user_token", return_value="token"),
  383. patch.object(client.session, "post", return_value=response),
  384. self.assertRaises(TencentWriteRejectedError),
  385. ):
  386. client.update_ad_begin_dates(10000001, [101], "2026-07-31")
  387. if __name__ == "__main__":
  388. unittest.main()