operator_commands.py 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. """Deterministic parsing for Feishu advertising control commands."""
  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_TODAY_SPEND = "TODAY_SPEND"
  10. ACTION_CONFIRM = "CONFIRM"
  11. ACTION_CANCEL = "CANCEL"
  12. ACTION_REJECT = "REJECT"
  13. WRITE_ACTIONS = {ACTION_DAY_PAUSE, ACTION_STOP, ACTION_RESUME}
  14. READ_ACTIONS = {ACTION_STATUS, ACTION_TODAY_SPEND}
  15. INTENT_ACTIONS = WRITE_ACTIONS | READ_ACTIONS
  16. SCOPE_ALL = "ALL"
  17. SCOPE_ACCOUNTS = "ACCOUNTS"
  18. SCOPE_AUTOMATION = "AUTOMATION"
  19. SCOPE_MISSING = "MISSING"
  20. _COMMAND_ID_RE = re.compile(r"\b(?:cmd|roi)_[0-9A-Za-z_-]+\b", re.IGNORECASE)
  21. _ACCOUNT_ID_RE = re.compile(r"(?<!\d)(\d{7,12})(?!\d)")
  22. @dataclass(frozen=True)
  23. class ParsedCommand:
  24. action: str
  25. scope_type: str = "ACCOUNTS"
  26. account_ids: tuple[int, ...] = ()
  27. command_id: str | None = None
  28. @dataclass(frozen=True)
  29. class CommandIntent:
  30. action: str | None
  31. scope_type: str = SCOPE_MISSING
  32. account_ids: tuple[int, ...] = ()
  33. missing_fields: tuple[str, ...] = ()
  34. confidence: float = 1.0
  35. parse_source: str = "deterministic"
  36. @property
  37. def complete(self) -> bool:
  38. if self.action == ACTION_STATUS:
  39. return True
  40. if self.action == ACTION_TODAY_SPEND:
  41. if self.scope_type in {SCOPE_ALL, SCOPE_AUTOMATION}:
  42. return True
  43. return self.scope_type == SCOPE_ACCOUNTS and bool(self.account_ids)
  44. if self.action not in WRITE_ACTIONS:
  45. return False
  46. if self.scope_type == SCOPE_ALL:
  47. return True
  48. return self.scope_type == SCOPE_ACCOUNTS and bool(self.account_ids)
  49. def to_parsed_command(self) -> ParsedCommand:
  50. if not self.complete or self.action is None:
  51. raise ValueError("命令意图尚未补充完整")
  52. return ParsedCommand(
  53. action=self.action,
  54. scope_type=self.scope_type,
  55. account_ids=self.account_ids,
  56. )
  57. def normalize_text(raw: str) -> str:
  58. text = str(raw or "").strip()
  59. translations = str.maketrans(
  60. {
  61. ",": ",",
  62. "、": ",",
  63. ";": ",",
  64. ";": ",",
  65. ":": " ",
  66. ":": " ",
  67. "\t": " ",
  68. "\r": " ",
  69. "\n": " ",
  70. }
  71. )
  72. return re.sub(r"\s+", " ", text.translate(translations)).strip()
  73. def extract_account_ids(raw: str) -> tuple[int, ...]:
  74. return tuple(
  75. dict.fromkeys(int(value) for value in _ACCOUNT_ID_RE.findall(normalize_text(raw)))
  76. )
  77. def parse_deterministic_intent(raw: str) -> CommandIntent | None:
  78. """Parse safe command forms, including incomplete commands for clarification."""
  79. text = normalize_text(raw)
  80. if not text:
  81. return None
  82. if "查看暂停状态" in text or "查询暂停状态" in text:
  83. return CommandIntent(action=ACTION_STATUS, scope_type=SCOPE_ALL)
  84. if (
  85. text.startswith(("今天", "暂停", "停止"))
  86. and any(value in text for value in ("今天", "到明天", "本日"))
  87. ):
  88. action = ACTION_DAY_PAUSE
  89. elif text.startswith(("停止", "持续停止", "永久停止")):
  90. action = ACTION_STOP
  91. elif text.startswith(("暂停", "今天暂停", "暂停到明天", "今天不投")):
  92. action = ACTION_DAY_PAUSE
  93. elif text.startswith(("恢复", "开启", "继续投放")):
  94. action = ACTION_RESUME
  95. else:
  96. return None
  97. if any(value in text for value in ("全部", "所有", "自动化账户", "纳管账户")):
  98. return CommandIntent(action=action, scope_type=SCOPE_ALL)
  99. account_ids = extract_account_ids(text)
  100. if account_ids:
  101. return CommandIntent(
  102. action=action,
  103. scope_type=SCOPE_ACCOUNTS,
  104. account_ids=account_ids,
  105. )
  106. return CommandIntent(
  107. action=action,
  108. missing_fields=("scope",),
  109. )
  110. def parse_scope_reply(raw: str) -> tuple[str, tuple[int, ...]] | None:
  111. """Parse a scope-only answer used to complete an active conversation draft."""
  112. text = normalize_text(raw)
  113. if not text:
  114. return None
  115. if any(value in text for value in ("全部", "所有", "自动化账户", "纳管账户")):
  116. return SCOPE_ALL, ()
  117. account_ids = extract_account_ids(text)
  118. if account_ids:
  119. return SCOPE_ACCOUNTS, account_ids
  120. return None
  121. def parse_command(raw: str) -> ParsedCommand | None:
  122. text = normalize_text(raw)
  123. if not text:
  124. return None
  125. lowered = text.lower()
  126. command_match = _COMMAND_ID_RE.search(text)
  127. if lowered.startswith("确认"):
  128. if not command_match:
  129. raise ValueError("确认命令缺少 command_id")
  130. return ParsedCommand(
  131. action=ACTION_CONFIRM,
  132. command_id=command_match.group(0).lower(),
  133. )
  134. if lowered.startswith("取消"):
  135. if not command_match:
  136. raise ValueError("取消命令缺少 command_id")
  137. return ParsedCommand(
  138. action=ACTION_CANCEL,
  139. command_id=command_match.group(0).lower(),
  140. )
  141. if lowered.startswith("拒绝"):
  142. if not command_match or not command_match.group(0).lower().startswith("roi_"):
  143. raise ValueError("拒绝命令缺少 roi_run_id")
  144. return ParsedCommand(
  145. action=ACTION_REJECT,
  146. command_id=command_match.group(0).lower(),
  147. )
  148. if "查看暂停状态" in text or "查询暂停状态" in text:
  149. return ParsedCommand(action=ACTION_STATUS, scope_type="ALL")
  150. if text.startswith(("停止", "持续停止", "永久停止")):
  151. action = ACTION_STOP
  152. elif text.startswith(("暂停", "今天暂停", "暂停到明天", "今天不投")):
  153. action = ACTION_DAY_PAUSE
  154. elif text.startswith(("恢复", "开启", "继续投放")):
  155. action = ACTION_RESUME
  156. else:
  157. return None
  158. if "全部" in text or "所有" in text:
  159. return ParsedCommand(action=action, scope_type="ALL")
  160. account_ids = extract_account_ids(text)
  161. if not account_ids:
  162. raise ValueError("命令中没有有效账户 ID,也没有指定“全部”")
  163. return ParsedCommand(
  164. action=action,
  165. scope_type="ACCOUNTS",
  166. account_ids=account_ids,
  167. )