From 6c5536b7582253ffcd70501c009ab7b688c6e11a Mon Sep 17 00:00:00 2001 From: oliver Date: Mon, 10 Aug 2026 23:41:48 +0800 Subject: [PATCH] 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 --- .../application/gateway/inbound_service.py | 63 ++++++++++++++++ .../application/gateway/ops_short_intent.py | 71 +++++++++++++++++++ tests/test_group_ingest.py | 41 +++++++++++ tests/test_ops_short_intent_and_exec_hints.py | 26 +++++++ 4 files changed, 201 insertions(+) diff --git a/runtime/application/gateway/inbound_service.py b/runtime/application/gateway/inbound_service.py index 095e3199..5e637e2f 100644 --- a/runtime/application/gateway/inbound_service.py +++ b/runtime/application/gateway/inbound_service.py @@ -1081,6 +1081,69 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]: group_policy.require_mention, text[:120], ) + # Ops short-intents without @bot look "broken" in field groups — nudge once (throttled). + if str(inbound.channel or "").strip().lower() == "whatsapp" and bool( + group_policy.require_mention + ): + try: + from runtime.application.gateway.ops_short_intent import ( + build_group_mention_nudge_text, + detect_ops_short_intent, + should_send_group_mention_nudge, + ) + from runtime.application.gateway.whatsapp_progress import ( + build_whatsapp_group_progress_metadata, + ) + from runtime.extensions.whatsapp.tenant import resolve_whatsapp_tenant_id + + intent = detect_ops_short_intent(text) + if intent and should_send_group_mention_nudge( + account_id=account_id, + chat_id=str(inbound.external_chat_id or ""), + user_id=str(inbound.external_user_id or ""), + ): + nudge_lang = "en" + try: + cfg = store.get_whatsapp_access_config( + tenant_id=resolve_whatsapp_tenant_id(store, account_id=account_id), + account_id=account_id, + ) + nudge_lang = str((cfg or {}).get("lang") or "en") + except Exception: + nudge_lang = "en" + nudge_text = build_group_mention_nudge_text( + intent=intent, + lang=nudge_lang, + triggers=list(group_policy.triggers or []), + ) + nudge_meta = build_whatsapp_group_progress_metadata(inbound=inbound) + tenant_for_nudge = "" + try: + tenant_for_nudge = str( + resolve_whatsapp_tenant_id(store, account_id=account_id) or "" + ) + except Exception: + tenant_for_nudge = "" + _enqueue_whatsapp_inbound_reply( + store, + inbound=inbound, + account_id=account_id, + tenant_id=tenant_for_nudge, + reply_text=nudge_text, + reply_attachments=None, + reply_metadata=nudge_meta, + kind="inbound_progress", + ) + return { + "ok": True, + "replies": [], + "delivery": "mention_nudge", + "ops_intent": intent, + } + except Exception: + logging.getLogger(__name__).debug( + "whatsapp group mention nudge failed", exc_info=True + ) return {"ok": True, "replies": []} if str(inbound.channel or "").strip().lower() == "whatsapp": from runtime.application.gateway.whatsapp_inbound_access import handle_whatsapp_access diff --git a/runtime/application/gateway/ops_short_intent.py b/runtime/application/gateway/ops_short_intent.py index 1b29af1d..6936b501 100644 --- a/runtime/application/gateway/ops_short_intent.py +++ b/runtime/application/gateway/ops_short_intent.py @@ -3,6 +3,8 @@ from __future__ import annotations import re +import threading +import time from typing import Any _BOT_MENTION_RE = re.compile(r"@\S+") @@ -108,9 +110,78 @@ def maybe_ops_short_intent_system_hint(*, text: str, lang: str = "en") -> str: 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", ] diff --git a/tests/test_group_ingest.py b/tests/test_group_ingest.py index 00a3cbc9..7bfcfd38 100644 --- a/tests/test_group_ingest.py +++ b/tests/test_group_ingest.py @@ -653,6 +653,47 @@ def test_inbound_group_without_mention_is_silent(monkeypatch: pytest.MonkeyPatch ) assert out.get("ok") is True assert out.get("replies") == [] + assert out.get("delivery") != "mention_nudge" + + +def test_inbound_group_ops_without_mention_gets_throttled_nudge( + monkeypatch: pytest.MonkeyPatch, fresh_sqlite_store: SqliteStore +) -> None: + from runtime.application.gateway.ops_short_intent import reset_group_mention_nudge_throttle_for_tests + + store = fresh_sqlite_store + _setup_whatsapp_identity(store) + monkeypatch.setattr("svc.persistence.assistant_store.get_assistant_store", lambda: store) + reset_group_mention_nudge_throttle_for_tests() + + payload = { + "channel": "whatsapp", + "account_id": "wa-default", + "user_id": "111@s.whatsapp.net", + "chat_id": "120363012345678@g.us", + "text": "fiber cut sites", + "is_group": True, + "mentions": [], + "metadata": {"bot_jid": "999@s.whatsapp.net", "source": "test"}, + } + first = process_inbound_payload(payload) + assert first.get("delivery") == "mention_nudge" + assert first.get("ops_intent") == "fiber_cut" + assert first.get("replies") == [] + outbound = store.list_channel_outbound_messages(limit=20) if hasattr(store, "list_channel_outbound_messages") else [] + if not outbound: + # Fallback: inspect via raw SQL if list helper missing + with store._connect() as conn: # noqa: SLF001 + rows = conn.execute( + "SELECT text, source FROM channel_outbound_message ORDER BY created_at DESC LIMIT 5" + ).fetchall() + outbound = [{"text": r[0], "source": r[1]} for r in rows] + assert outbound + assert any("@mention" in str(o.get("text") or "").lower() or "@me" in str(o.get("text") or "").lower() for o in outbound) + + second = process_inbound_payload(payload) + assert second.get("delivery") != "mention_nudge" + assert second.get("replies") == [] def test_inbound_dm_still_processes_without_mention(monkeypatch: pytest.MonkeyPatch, fresh_sqlite_store: SqliteStore) -> None: diff --git a/tests/test_ops_short_intent_and_exec_hints.py b/tests/test_ops_short_intent_and_exec_hints.py index b05ab46d..4518b5ea 100644 --- a/tests/test_ops_short_intent_and_exec_hints.py +++ b/tests/test_ops_short_intent_and_exec_hints.py @@ -1,8 +1,11 @@ from __future__ import annotations from runtime.application.gateway.ops_short_intent import ( + build_group_mention_nudge_text, detect_ops_short_intent, maybe_ops_short_intent_system_hint, + reset_group_mention_nudge_throttle_for_tests, + should_send_group_mention_nudge, ) from runtime.tools.tool_error_hints import enrich_exec_managed_ne_error @@ -17,6 +20,29 @@ def test_detect_ops_short_intent_english_field() -> None: assert detect_ops_short_intent("hello there how are you doing today with something else") is None +def test_group_mention_nudge_text_english() -> None: + text = build_group_mention_nudge_text(intent="fiber_cut", lang="en", triggers=["/oclaw"]) + assert "@mentioned" in text.lower() or "@me" in text.lower() + assert "/oclaw" in text + assert "fiber" in text.lower() + + +def test_group_mention_nudge_throttle() -> None: + reset_group_mention_nudge_throttle_for_tests() + assert should_send_group_mention_nudge( + account_id="wa", chat_id="g1", user_id="u1", now=100.0, ttl_s=60.0 + ) + assert not should_send_group_mention_nudge( + account_id="wa", chat_id="g1", user_id="u1", now=130.0, ttl_s=60.0 + ) + assert should_send_group_mention_nudge( + account_id="wa", chat_id="g1", user_id="u2", now=130.0, ttl_s=60.0 + ) + assert should_send_group_mention_nudge( + account_id="wa", chat_id="g1", user_id="u1", now=170.0, ttl_s=60.0 + ) + + def test_ops_short_intent_hint_english_default() -> None: hint = maybe_ops_short_intent_system_hint(text="LOS on these sites", lang="en") assert "fiber" in hint.lower() or "LOS" in hint