Isolate WhatsApp group turns by speaker and clarify SQL scope denials.

Always tag group senders, inject group-focus system hints, label queued follow-ups by speaker, and mark insufficient_scope failures as non-retryable with a user-facing hint.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-10 23:11:41 +08:00
parent d2693d1c63
commit e91e4fe773
9 changed files with 167 additions and 31 deletions

View file

@ -30,16 +30,30 @@ def merge_channel_pending_jobs(jobs: list[dict[str, Any]]) -> dict[str, Any]:
lang = str(rows[-1].get("lang") or "").strip().lower()
if lang.startswith("zh"):
header = "处理上一问期间又收到多条跟进,请一并回答:"
header = "处理上一问期间又收到多条跟进,请按发言人分别回答:"
else:
header = "Several follow-up questions arrived while the previous request was still running. Please answer them together:"
header = (
"Several follow-up questions arrived while the previous request was still running. "
"Answer each item for its own sender:"
)
parts: list[str] = []
attachments: list[dict[str, Any]] = []
for idx, job in enumerate(rows, start=1):
text = str(job.get("user_text") or "").strip()
md = job.get("msg_metadata") if isinstance(job.get("msg_metadata"), dict) else {}
sender = str(
md.get("group_sender_label")
or md.get("push_name")
or md.get("group_sender_id")
or md.get("external_user_id")
or ""
).strip()
if text:
parts.append(f"{idx}) {text}")
if sender and not text.lstrip().startswith(("[Sender:", "[发言:")):
parts.append(f"{idx}) [{sender}] {text}")
else:
parts.append(f"{idx}) {text}")
for att in job.get("attachments") or []:
if isinstance(att, dict):
attachments.append(att)

View file

@ -1376,6 +1376,7 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
tenant_id=str(tenant_id or ""),
account_id=wa_acct,
quoted_ctx=quoted_ctx,
lang=lang,
)
if not user_text and gw_attachments:
user_text = (
@ -1388,6 +1389,17 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
from runtime.types import StandardMessage
from runtime.application.gateway.channel_turn_gate import get_channel_turn_gate
raw_meta = (
meta_for_inbound.get("raw")
if isinstance(meta_for_inbound.get("raw"), dict)
else {}
)
sender_label = str(
raw_meta.get("pushName")
or meta_for_inbound.get("push_name")
or inbound.external_user_id
or ""
).strip()
msg_metadata = {
"tenant_id": tenant_id,
"user_id": user_id,
@ -1400,6 +1412,9 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
"external_user_id": inbound.external_user_id,
"external_chat_id": inbound.external_chat_id,
"group_sender_id": inbound.external_user_id,
"group_sender_label": sender_label,
"push_name": sender_label,
"group_session_scope": group_policy.session_scope,
"mentioned_jids": mention_jids_for_ctx,
"mention_names": mention_names_for_ctx,
"raw_inbound_text": raw_user_text,
@ -1436,12 +1451,20 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
busy_meta = build_whatsapp_group_progress_metadata(
inbound=inbound
)
busy_text = (
"还在处理上一条请求,这条会排队合并处理。"
if str(lang or "").startswith("zh")
else "Still working on your previous request; "
"I'll merge this follow-up next."
)
if str(lang or "").startswith("zh"):
busy_text = (
"本会话还有请求在处理,你这条已排队,完成后会继续回答。"
if bool(getattr(inbound, "is_group", False))
else "还在处理上一条请求,这条会排队合并处理。"
)
else:
busy_text = (
"Still working on an earlier request in this chat; "
"your message is queued and will be answered next."
if bool(getattr(inbound, "is_group", False))
else "Still working on your previous request; "
"I'll merge this follow-up next."
)
_enqueue_whatsapp_inbound_reply(
store,
inbound=inbound,

View file

@ -484,6 +484,15 @@ class OclawGateway:
return str(maybe_ops_short_intent_system_hint(text=str(msg.text or ""), lang=lang) or "").strip()
@staticmethod
def _group_focus_system_hint(msg: StandardMessage, lang: str) -> str:
md = msg.metadata if isinstance(msg.metadata, dict) else {}
if not bool(md.get("is_group")):
return ""
from runtime.orchestration.group_ingest import build_group_focus_instruction
return str(build_group_focus_instruction(lang=lang) or "").strip()
@staticmethod
def _tabular_query_system_hint(lang: str) -> str:
limits = OclawGateway._tabular_limits_from_config()
@ -1180,6 +1189,9 @@ class OclawGateway:
ch_hint = self._channel_file_delivery_system_hint(lang)
if ch_hint:
sys_prompt = f"{sys_prompt}\n\n{ch_hint}".strip()
focus_hint = self._group_focus_system_hint(msg, lang)
if focus_hint:
sys_prompt = f"{sys_prompt}\n\n{focus_hint}".strip()
if str(manager_specialist or requested_specialist or "").strip().lower() == "ops":
intent_hint = self._ops_short_intent_system_hint(msg, lang)
if intent_hint:

View file

@ -415,13 +415,20 @@ def should_process_group_inbound(
return not require_mention
def build_group_sender_context(*, metadata: dict[str, Any] | None, external_user_id: str) -> str:
def build_group_sender_context(
*,
metadata: dict[str, Any] | None,
external_user_id: str,
lang: str = "en",
) -> str:
meta = metadata if isinstance(metadata, dict) else {}
raw = meta.get("raw") if isinstance(meta.get("raw"), dict) else {}
push_name = str(raw.get("pushName") or meta.get("push_name") or meta.get("display_name") or "").strip()
sender = str(external_user_id or "").strip()
label = push_name or sender or "unknown"
return f"[发言: {label}]"
if str(lang or "").strip().lower().startswith("zh"):
return f"[发言: {label}]"
return f"[Sender: {label}]"
def _mention_tokens_for_jids(jids: list[str]) -> list[str]:
@ -565,6 +572,7 @@ def prepare_group_user_text_for_model(
tenant_id: str = "",
account_id: str = "",
quoted_ctx: str = "",
lang: str = "en",
) -> str:
body = str(text or "").strip()
body = strip_bot_mentions_from_text(text=body, bot_jid=bot_jid, metadata=metadata)
@ -582,10 +590,11 @@ def prepare_group_user_text_for_model(
account_id=account_id,
)
prefix_parts: list[str] = []
if normalize_group_session_scope(session_scope) == "chat":
prefix_parts.append(
build_group_sender_context(metadata=metadata, external_user_id=external_user_id)
)
# Always tag the speaker in groups (shared or per-user) so history/quotes cannot 串话.
_ = session_scope # retained for call-site compatibility / future policy forks
prefix_parts.append(
build_group_sender_context(metadata=metadata, external_user_id=external_user_id, lang=lang)
)
quote = str(quoted_ctx or "").strip()
if quote:
prefix_parts.append(quote)
@ -597,10 +606,15 @@ def prepare_group_user_text_for_model(
def build_group_focus_instruction(*, lang: str = "en") -> str:
if str(lang or "").strip().lower().startswith("zh"):
return "[群聊规则:只回答当前发言人的问题;除非本条消息明确引用或承接前文,否则不要默认继承其他群成员的上下文。]"
return (
"[群聊规则:只回答当前发言人(见 [发言: …])的问题;"
"除非本条消息明确引用或承接前文,否则不要默认继承其他群成员的上下文;"
"若排队合并了多条跟进,按条分别回应并 @ 对应发言人。]"
)
return (
"[Group chat rule: answer only the current sender's request. "
"Do not assume context from other members unless this message explicitly quotes or references it.]"
"[Group chat rule: answer only the current sender (see [Sender: …]). "
"Do not assume context from other members unless this message explicitly quotes or references it. "
"If several follow-ups were merged while busy, answer each item and @ the matching sender.]"
)

View file

@ -77,19 +77,29 @@ def enrich_mcp_scope_error(result: dict[str, Any]) -> dict[str, Any]:
out["error"] = f"insufficient_scope:{scope}" if scope else "insufficient_scope"
if scope == "sql:query":
out["hint"] = (
"Current netx token lacks sql:query. Prefer aggregateUmeAlarms / queryUmeAlarmsRaw / "
"ume_alarm_xlsx_report; ask an admin to grant sql:query only if SQL is required."
"Current netx token lacks sql:query. Do not retry sqlQueryUme. "
"Prefer aggregateUmeAlarms / queryUmeAlarmsRaw / ume_alarm_xlsx_report; "
"ask an admin to grant sql:query only if SQL is truly required."
)
out["fallback_tools"] = [
"mcp__netx__aggregateUmeAlarms",
"mcp__netx__queryUmeAlarmsRaw",
"ume_alarm_xlsx_report",
]
out["user_facing_hint"] = (
"SQL query is not enabled for this bot token. "
"I will use alarm aggregate/report tools instead, or an admin can grant sql:query."
)
else:
out["hint"] = (
f"Current netx token lacks scope {scope or '(unknown)'}. "
"Ask an admin to grant it on the netx API token, or use tools that do not need this scope."
"Do not retry the same tool; ask an admin to grant it, or use tools that do not need this scope."
)
out["user_facing_hint"] = (
f"Permission missing ({scope or 'scope'}). An admin needs to grant this on the netx API token."
)
out["failure_class"] = "auth"
out["retry_forbidden"] = True
return out