oclaw/runtime/application/gateway/ops_short_intent.py
oliver 6c5536b758 Nudge WhatsApp group ops asks that skip @mention.
When require_mention drops an ops short-intent, send one throttled @sender tip instead of silent ignore; casual chat stays quiet.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 23:41:48 +08:00

187 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Detect ops short intents for WhatsApp/field English recipes."""
from __future__ import annotations
import re
import threading
import time
from typing import Any
_BOT_MENTION_RE = re.compile(r"@\S+")
# intent -> (en hint, zh hint)
_HINTS: dict[str, tuple[str, str]] = {
"fiber_cut": (
"[Ops short-intent: fiber/LOS. Prefer ume_alarm_xlsx_report(mode=fiber_cut) in ≤3 tool calls; "
"do not paginate or re-list CLI targets first.]",
"[短指令:断纤/LOS。优先 ume_alarm_xlsx_report(mode=fiber_cut),≤3 次工具;勿先翻页或反复 listCliTargets。]",
),
"offline": (
"[Ops short-intent: offline NE. Prefer ume_alarm_xlsx_report(mode=offline) in ≤3 tool calls.]",
"[短指令:离线网元。优先 ume_alarm_xlsx_report(mode=offline),≤3 次工具。]",
),
"alarm_tally": (
"[Ops short-intent: alarm tally/top. Prefer aggregateUmeAlarms or "
"ume_alarm_xlsx_report(mode=aggregate_by_host); ≤3 tool calls.]",
"[短指令:告警统计/Top。优先 aggregateUmeAlarms 或 ume_alarm_xlsx_report(mode=aggregate_by_host);≤3 次工具。]",
),
"excel_export": (
"[Ops short-intent: Excel export. Prefer ume_alarm_xlsx_report or write_xlsx(deliverable=true); "
"do not build xlsx via run_command.]",
"[短指令:导出 Excel。优先 ume_alarm_xlsx_report 或 write_xlsx(deliverable=true);禁止 run_command 造表。]",
),
"license": (
"[Ops short-intent: license/capacity. Prefer aggregateUmeAlarms / queryUmeAlarmsRaw with license keywords, "
"or ume_alarm_xlsx_report(mode=list, keyword=license); ≤3 tool calls — no CLI spam.]",
"[短指令:License/容量。优先 aggregateUmeAlarms / queryUmeAlarmsRaw(license 关键字)"
"或 ume_alarm_xlsx_report(mode=list, keyword=license);≤3 次工具,勿刷 CLI。]",
),
"congestion": (
"[Ops short-intent: bandwidth congestion. Prefer aggregateUmeAlarms / queryUmeAlarmsRaw "
"(bandwidth/utilization/congestion keywords) or ume_alarm_xlsx_report(mode=list); "
"≤3 tool calls — no CLI spam / no sqlQueryUme unless scoped.]",
"[短指令:带宽拥塞。优先 aggregateUmeAlarms / queryUmeAlarmsRaw(带宽/利用率/拥塞)"
"或 ume_alarm_xlsx_report(mode=list);≤3 次工具,勿刷 CLI / 勿先 sqlQueryUme。]",
),
"continue": (
"[Ops short-intent: continue/confirm. Resume the unfinished prior task immediately; "
"do not re-ask confirmation or restart the query from scratch.]",
"[短指令:继续/确认。立即承接上一未完成任务;勿再问确认或重开查询。]",
),
}
def normalize_ops_user_text(text: str) -> str:
s = str(text or "").strip()
if not s:
return ""
s = _BOT_MENTION_RE.sub(" ", s)
return re.sub(r"\s+", " ", s).strip().lower()
def detect_ops_short_intent(text: str) -> str | None:
"""Return a recipe key for ultra-short field asks, else None."""
t = normalize_ops_user_text(text)
if not t or len(t) > 160:
return None
if re.fullmatch(r"(yes|y|ok|okay|confirm|continue|please continue|go ahead|可以|确认|继续|好的|行)", t):
return "continue"
if any(k in t for k in ("fiber", "los", "cable cut", "optical", "断纤", "光缆", "光路")):
return "fiber_cut"
if any(k in t for k in ("offline", "board offline", "ne communication", "离线", "单板离线", "通信中断")):
return "offline"
if any(k in t for k in ("excel", "xlsx", "spreadsheet", "export", "send me the table", "表格", "导出")):
return "excel_export"
if any(k in t for k in ("congest", "bandwidth", "utilization", "拥塞", "带宽", "利用率", "端口忙")):
return "congestion"
if any(k in t for k in ("license", "licence", "capacity", "license到期")):
return "license"
if any(
k in t
for k in (
"alarm",
"critical",
"tally",
"top ne",
"how many",
"告警",
"统计",
"多少",
)
):
return "alarm_tally"
return None
def build_ops_short_intent_hint(*, intent: str, lang: str = "en") -> str:
key = str(intent or "").strip()
pair = _HINTS.get(key)
if not pair:
return ""
en, zh = pair
return zh if str(lang or "").strip().lower().startswith("zh") else en
def maybe_ops_short_intent_system_hint(*, text: str, lang: str = "en") -> str:
intent = detect_ops_short_intent(text)
if not intent:
return ""
return build_ops_short_intent_hint(intent=intent, lang=lang)
def build_group_mention_nudge_text(
*,
intent: str | None = None,
lang: str = "en",
triggers: list[str] | None = None,
) -> str:
"""Tell field users why a group ops ask was ignored (require @bot / trigger)."""
extra = ""
trigs = [str(x or "").strip() for x in (triggers or []) if str(x or "").strip()]
if trigs:
shown = ", ".join(trigs[:3])
if str(lang or "").strip().lower().startswith("zh"):
extra = f"或发送触发词({shown})"
else:
extra = f" or use a trigger ({shown})"
label = str(intent or "").strip().replace("_", " ")
if str(lang or "").strip().lower().startswith("zh"):
topic = f"(识别到:{label})" if label else ""
return (
f"群里需要先 @我{extra} 才会处理运维请求{topic}。"
"请带上 @ 后重发(断纤/离线/告警/Excel/license 等)。"
)
topic = f" (detected: {label})" if label else ""
return (
f"In this group I only answer when @mentioned{extra}{topic}. "
"Please re-send with @me for ops asks (fiber / offline / alarms / excel / license)."
)
_MENTION_NUDGE_LOCK = threading.Lock()
_MENTION_NUDGE_LAST: dict[str, float] = {}
_MENTION_NUDGE_TTL_S = 12 * 60.0
def should_send_group_mention_nudge(
*,
account_id: str,
chat_id: str,
user_id: str,
now: float | None = None,
ttl_s: float | None = None,
) -> bool:
"""Throttle one nudge per sender/chat for a few minutes."""
key = f"{str(account_id or '').strip()}|{str(chat_id or '').strip()}|{str(user_id or '').strip()}"
if not key.strip("|"):
return False
ts = float(now if now is not None else time.monotonic())
window = float(ttl_s if ttl_s is not None else _MENTION_NUDGE_TTL_S)
with _MENTION_NUDGE_LOCK:
prev = _MENTION_NUDGE_LAST.get(key)
if prev is not None and (ts - float(prev)) < window:
return False
_MENTION_NUDGE_LAST[key] = ts
if len(_MENTION_NUDGE_LAST) > 512:
cutoff = ts - window
stale = [k for k, v in _MENTION_NUDGE_LAST.items() if float(v) < cutoff]
for k in stale[:128]:
_MENTION_NUDGE_LAST.pop(k, None)
return True
def reset_group_mention_nudge_throttle_for_tests() -> None:
with _MENTION_NUDGE_LOCK:
_MENTION_NUDGE_LAST.clear()
__all__ = [
"build_group_mention_nudge_text",
"build_ops_short_intent_hint",
"detect_ops_short_intent",
"maybe_ops_short_intent_system_hint",
"normalize_ops_user_text",
"reset_group_mention_nudge_throttle_for_tests",
"should_send_group_mention_nudge",
]