operator_commands.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. """飞书广告控制命令的确定性解析。"""
  2. from __future__ import annotations
  3. import re
  4. from dataclasses import dataclass
  5. ACTION_DAY_PAUSE = "DAY_PAUSE"
  6. ACTION_STOP = "STOP"
  7. ACTION_RESUME = "RESUME"
  8. ACTION_STATUS = "STATUS"
  9. ACTION_ACCOUNT_DELIVERY_STATUS = "ACCOUNT_DELIVERY_STATUS"
  10. ACTION_TODAY_SPEND = "TODAY_SPEND"
  11. ACTION_CONFIRM = "CONFIRM"
  12. ACTION_CANCEL = "CANCEL"
  13. ACTION_REJECT = "REJECT"
  14. WRITE_ACTIONS = {ACTION_DAY_PAUSE, ACTION_STOP, ACTION_RESUME}
  15. READ_ACTIONS = {
  16. ACTION_STATUS,
  17. ACTION_ACCOUNT_DELIVERY_STATUS,
  18. ACTION_TODAY_SPEND,
  19. }
  20. INTENT_ACTIONS = WRITE_ACTIONS | READ_ACTIONS
  21. SCOPE_ALL = "ALL"
  22. SCOPE_ACCOUNTS = "ACCOUNTS"
  23. SCOPE_AUTOMATION = "AUTOMATION"
  24. SCOPE_MISSING = "MISSING"
  25. _COMMAND_ID_RE = re.compile(r"\b(?:cmd|roi)_[0-9A-Za-z_-]+\b", re.IGNORECASE)
  26. _ACCOUNT_ID_RE = re.compile(r"(?<!\d)(\d{7,12})(?!\d)")
  27. @dataclass(frozen=True)
  28. class ParsedCommand:
  29. action: str
  30. scope_type: str = "ACCOUNTS"
  31. account_ids: tuple[int, ...] = ()
  32. command_id: str | None = None
  33. @dataclass(frozen=True)
  34. class CommandIntent:
  35. action: str | None
  36. scope_type: str = SCOPE_MISSING
  37. account_ids: tuple[int, ...] = ()
  38. missing_fields: tuple[str, ...] = ()
  39. confidence: float = 1.0
  40. parse_source: str = "deterministic"
  41. @property
  42. def complete(self) -> bool:
  43. if self.action in {ACTION_STATUS, ACTION_ACCOUNT_DELIVERY_STATUS}:
  44. return True
  45. if self.action == ACTION_TODAY_SPEND:
  46. if self.scope_type in {SCOPE_ALL, SCOPE_AUTOMATION}:
  47. return True
  48. return self.scope_type == SCOPE_ACCOUNTS and bool(self.account_ids)
  49. if self.action not in WRITE_ACTIONS:
  50. return False
  51. if self.scope_type == SCOPE_ALL:
  52. return True
  53. return self.scope_type == SCOPE_ACCOUNTS and bool(self.account_ids)
  54. def to_parsed_command(self) -> ParsedCommand:
  55. if not self.complete or self.action is None:
  56. raise ValueError("命令意图尚未补充完整")
  57. return ParsedCommand(
  58. action=self.action,
  59. scope_type=self.scope_type,
  60. account_ids=self.account_ids,
  61. )
  62. def normalize_text(raw: str) -> str:
  63. text = str(raw or "").strip()
  64. translations = str.maketrans(
  65. {
  66. ",": ",",
  67. "、": ",",
  68. ";": ",",
  69. ";": ",",
  70. ":": " ",
  71. ":": " ",
  72. "\t": " ",
  73. "\r": " ",
  74. "\n": " ",
  75. }
  76. )
  77. return re.sub(r"\s+", " ", text.translate(translations)).strip()
  78. def extract_account_ids(raw: str) -> tuple[int, ...]:
  79. return tuple(
  80. dict.fromkeys(int(value) for value in _ACCOUNT_ID_RE.findall(normalize_text(raw)))
  81. )
  82. def _is_pause_status_query(text: str) -> bool:
  83. """识别原有运营暂停状态查询。"""
  84. return "查看暂停状态" in text or "查询暂停状态" in text
  85. def is_all_account_delivery_status_query(text: str) -> bool:
  86. """识别从大数据账号表读取全部广告投放状态的查询。"""
  87. if _is_pause_status_query(text):
  88. return False
  89. has_all_word = any(value in text for value in ("全部", "所有"))
  90. has_account_word = any(value in text for value in ("账户", "账号", "帐号"))
  91. query_verbs = ("查询", "查看", "看看", "看下", "看一下")
  92. return (
  93. has_all_word
  94. and has_account_word
  95. and "状态" in text
  96. and any(value in text for value in query_verbs)
  97. )
  98. def parse_deterministic_intent(raw: str) -> CommandIntent | None:
  99. """解析安全命令格式,包括需要继续补问的不完整命令。"""
  100. text = normalize_text(raw)
  101. if not text:
  102. return None
  103. if _is_pause_status_query(text):
  104. return CommandIntent(action=ACTION_STATUS, scope_type=SCOPE_ALL)
  105. if is_all_account_delivery_status_query(text):
  106. return CommandIntent(
  107. action=ACTION_ACCOUNT_DELIVERY_STATUS,
  108. scope_type=SCOPE_ALL,
  109. )
  110. if (
  111. text.startswith(("今天", "暂停", "停止"))
  112. and any(value in text for value in ("今天", "到明天", "本日"))
  113. ):
  114. action = ACTION_DAY_PAUSE
  115. elif text.startswith(("停止", "持续停止", "永久停止")):
  116. action = ACTION_STOP
  117. elif text.startswith(("暂停", "今天暂停", "暂停到明天", "今天不投")):
  118. action = ACTION_DAY_PAUSE
  119. elif text.startswith(("恢复", "开启", "继续投放")):
  120. action = ACTION_RESUME
  121. else:
  122. return None
  123. if any(value in text for value in ("全部", "所有", "自动化账户", "纳管账户")):
  124. return CommandIntent(action=action, scope_type=SCOPE_ALL)
  125. account_ids = extract_account_ids(text)
  126. if account_ids:
  127. return CommandIntent(
  128. action=action,
  129. scope_type=SCOPE_ACCOUNTS,
  130. account_ids=account_ids,
  131. )
  132. return CommandIntent(
  133. action=action,
  134. missing_fields=("scope",),
  135. )
  136. def parse_scope_reply(raw: str) -> tuple[str, tuple[int, ...]] | None:
  137. """解析只包含范围的回答,用于补全当前多轮会话草稿。"""
  138. text = normalize_text(raw)
  139. if not text:
  140. return None
  141. if any(value in text for value in ("全部", "所有", "自动化账户", "纳管账户")):
  142. return SCOPE_ALL, ()
  143. account_ids = extract_account_ids(text)
  144. if account_ids:
  145. return SCOPE_ACCOUNTS, account_ids
  146. return None
  147. def parse_command(raw: str) -> ParsedCommand | None:
  148. text = normalize_text(raw)
  149. if not text:
  150. return None
  151. lowered = text.lower()
  152. command_match = _COMMAND_ID_RE.search(text)
  153. if lowered.startswith("确认"):
  154. if not command_match:
  155. raise ValueError("确认命令缺少 command_id")
  156. return ParsedCommand(
  157. action=ACTION_CONFIRM,
  158. command_id=command_match.group(0).lower(),
  159. )
  160. if lowered.startswith("取消"):
  161. if not command_match:
  162. raise ValueError("取消命令缺少 command_id")
  163. return ParsedCommand(
  164. action=ACTION_CANCEL,
  165. command_id=command_match.group(0).lower(),
  166. )
  167. if lowered.startswith("拒绝"):
  168. if not command_match or not command_match.group(0).lower().startswith("roi_"):
  169. raise ValueError("拒绝命令缺少 roi_run_id")
  170. return ParsedCommand(
  171. action=ACTION_REJECT,
  172. command_id=command_match.group(0).lower(),
  173. )
  174. if _is_pause_status_query(text):
  175. return ParsedCommand(action=ACTION_STATUS, scope_type="ALL")
  176. if is_all_account_delivery_status_query(text):
  177. return ParsedCommand(
  178. action=ACTION_ACCOUNT_DELIVERY_STATUS,
  179. scope_type="ALL",
  180. )
  181. if text.startswith(("停止", "持续停止", "永久停止")):
  182. action = ACTION_STOP
  183. elif text.startswith(("暂停", "今天暂停", "暂停到明天", "今天不投")):
  184. action = ACTION_DAY_PAUSE
  185. elif text.startswith(("恢复", "开启", "继续投放")):
  186. action = ACTION_RESUME
  187. else:
  188. return None
  189. if "全部" in text or "所有" in text:
  190. return ParsedCommand(action=action, scope_type="ALL")
  191. account_ids = extract_account_ids(text)
  192. if not account_ids:
  193. raise ValueError("命令中没有有效账户 ID,也没有指定“全部”")
  194. return ParsedCommand(
  195. action=action,
  196. scope_type="ACCOUNTS",
  197. account_ids=account_ids,
  198. )