mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 03:30:48 +08:00
Make WhatsApp ops English-first for field deployments.
Default progress, quote wrappers, tool-error copy, and playbook recipes to English unless lang is explicitly zh. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
0ab5ab8c5a
commit
47fd83597e
11 changed files with 130 additions and 88 deletions
|
|
@ -89,7 +89,7 @@ def _build_admin_gateway_executor(
|
||||||
uid = str(user_id or "").strip() or None
|
uid = str(user_id or "").strip() or None
|
||||||
return build_gateway_executor(
|
return build_gateway_executor(
|
||||||
store,
|
store,
|
||||||
lang=str(lang or "zh"),
|
lang=str(lang or "en"),
|
||||||
specialist=specialist,
|
specialist=specialist,
|
||||||
viewer_user_id=uid,
|
viewer_user_id=uid,
|
||||||
viewer_username=str(viewer_username or "administrator").strip() or "administrator",
|
viewer_username=str(viewer_username or "administrator").strip() or "administrator",
|
||||||
|
|
@ -1301,6 +1301,27 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
gw_attachments = _channel_attachments_for_gateway(
|
||||||
|
list(inbound.attachments or [])
|
||||||
|
)
|
||||||
|
# Resolve channel lang before wrappers so WhatsApp field stays English-first.
|
||||||
|
from runtime.lang import resolve_runtime_lang
|
||||||
|
|
||||||
|
interaction_mode, selected_specialist, dispatch_lang = _resolve_channel_dispatch(
|
||||||
|
store, channel=inbound.channel, account=account
|
||||||
|
)
|
||||||
|
lang = (
|
||||||
|
dispatch_lang
|
||||||
|
if dispatch_lang in {"zh", "en"}
|
||||||
|
else resolve_runtime_lang(
|
||||||
|
store=store,
|
||||||
|
user_text=str(raw_user_text or text or ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# WhatsApp production is English-primary; never fall back to zh via empty lang.
|
||||||
|
if str(inbound.channel or "").strip().lower() == "whatsapp" and lang not in {"zh", "en"}:
|
||||||
|
lang = "en"
|
||||||
|
|
||||||
if inbound.is_group:
|
if inbound.is_group:
|
||||||
meta_for_group = meta_for_inbound
|
meta_for_group = meta_for_inbound
|
||||||
bot_reached = metadata_mentions_bot(meta_for_group) or mentions_include_bot(
|
bot_reached = metadata_mentions_bot(meta_for_group) or mentions_include_bot(
|
||||||
|
|
@ -1326,7 +1347,10 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
quoted_text=quoted_text,
|
quoted_text=quoted_text,
|
||||||
recent_messages=recent_messages,
|
recent_messages=recent_messages,
|
||||||
):
|
):
|
||||||
quoted_ctx = build_group_quoted_context_block(metadata=meta_for_group)
|
quoted_ctx = build_group_quoted_context_block(
|
||||||
|
metadata=meta_for_group,
|
||||||
|
lang=lang,
|
||||||
|
)
|
||||||
wa_acct = str(account_id or "").strip() or "wa-default"
|
wa_acct = str(account_id or "").strip() or "wa-default"
|
||||||
user_text = prepare_group_user_text_for_model(
|
user_text = prepare_group_user_text_for_model(
|
||||||
text=raw_user_text,
|
text=raw_user_text,
|
||||||
|
|
@ -1342,25 +1366,17 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
account_id=wa_acct,
|
account_id=wa_acct,
|
||||||
quoted_ctx=quoted_ctx,
|
quoted_ctx=quoted_ctx,
|
||||||
)
|
)
|
||||||
gw_attachments = _channel_attachments_for_gateway(
|
|
||||||
list(inbound.attachments or [])
|
|
||||||
)
|
|
||||||
if not user_text and gw_attachments:
|
if not user_text and gw_attachments:
|
||||||
user_text = "用户发送了附件,请根据附件内容回复。"
|
user_text = (
|
||||||
|
"用户发送了附件,请根据附件内容回复。"
|
||||||
|
if str(lang or "").startswith("zh")
|
||||||
|
else "User sent an attachment; please reply based on the attachment."
|
||||||
|
)
|
||||||
if user_text or gw_attachments:
|
if user_text or gw_attachments:
|
||||||
from runtime.gateway import OclawGateway
|
from runtime.gateway import OclawGateway
|
||||||
from runtime.types import StandardMessage
|
from runtime.types import StandardMessage
|
||||||
from runtime.lang import resolve_runtime_lang
|
|
||||||
from runtime.application.gateway.channel_turn_gate import get_channel_turn_gate
|
from runtime.application.gateway.channel_turn_gate import get_channel_turn_gate
|
||||||
|
|
||||||
interaction_mode, selected_specialist, dispatch_lang = _resolve_channel_dispatch(
|
|
||||||
store, channel=inbound.channel, account=account
|
|
||||||
)
|
|
||||||
lang = (
|
|
||||||
dispatch_lang
|
|
||||||
if dispatch_lang in {"zh", "en"}
|
|
||||||
else resolve_runtime_lang(store=store, user_text=user_text)
|
|
||||||
)
|
|
||||||
msg_metadata = {
|
msg_metadata = {
|
||||||
"tenant_id": tenant_id,
|
"tenant_id": tenant_id,
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
|
|
@ -1435,7 +1451,15 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
if isinstance(turn_job.get("msg_metadata"), dict)
|
if isinstance(turn_job.get("msg_metadata"), dict)
|
||||||
else dict(msg_metadata)
|
else dict(msg_metadata)
|
||||||
)
|
)
|
||||||
job_lang = str(turn_job.get("lang") or lang or "zh")
|
job_lang = str(
|
||||||
|
turn_job.get("lang")
|
||||||
|
or lang
|
||||||
|
or (
|
||||||
|
"en"
|
||||||
|
if str(inbound.channel or "").strip().lower() == "whatsapp"
|
||||||
|
else "zh"
|
||||||
|
)
|
||||||
|
)
|
||||||
job_inbound = turn_job.get("inbound") or inbound
|
job_inbound = turn_job.get("inbound") or inbound
|
||||||
progress_cb = None
|
progress_cb = None
|
||||||
tool_ui_cb = None
|
tool_ui_cb = None
|
||||||
|
|
|
||||||
|
|
@ -74,12 +74,12 @@ def normalize_tool_key(name: str) -> str:
|
||||||
return low.replace("_", "")
|
return low.replace("_", "")
|
||||||
|
|
||||||
|
|
||||||
def humanize_long_tool(*, tool_name: str, lang: str = "zh") -> str | None:
|
def humanize_long_tool(*, tool_name: str, lang: str = "en") -> str | None:
|
||||||
key = normalize_tool_key(tool_name)
|
key = normalize_tool_key(tool_name)
|
||||||
if not key:
|
if not key:
|
||||||
return None
|
return None
|
||||||
table = _LONG_TOOL_LABELS_EN if str(lang or "").startswith("en") else _LONG_TOOL_LABELS_ZH
|
# Field WhatsApp is English-first; only use Chinese when lang is explicitly zh.
|
||||||
# Keys in tables are already underscore-free lower names.
|
table = _LONG_TOOL_LABELS_ZH if str(lang or "").strip().lower().startswith("zh") else _LONG_TOOL_LABELS_EN
|
||||||
return table.get(key)
|
return table.get(key)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -107,19 +107,19 @@ def should_forward_progress_text(text: str) -> bool:
|
||||||
return len(t) >= 8
|
return len(t) >= 8
|
||||||
|
|
||||||
|
|
||||||
def humanize_progress_text(*, text: str, lang: str = "zh") -> str:
|
def humanize_progress_text(*, text: str, lang: str = "en") -> str:
|
||||||
t = str(text or "").strip()
|
t = str(text or "").strip()
|
||||||
|
is_zh = str(lang or "").strip().lower().startswith("zh")
|
||||||
m = _TOOLS_DONE_RE.search(t)
|
m = _TOOLS_DONE_RE.search(t)
|
||||||
if m:
|
if m:
|
||||||
if str(lang or "").startswith("en"):
|
if is_zh:
|
||||||
return "Tools finished; composing the reply…"
|
return "工具已完成,正在整理回复…"
|
||||||
return "工具已完成,正在整理回复…"
|
return "Tools finished; composing the reply…"
|
||||||
if t.lower().startswith("oclaw:"):
|
if t.lower().startswith("oclaw:"):
|
||||||
body = t.split(":", 1)[-1].strip()
|
body = t.split(":", 1)[-1].strip()
|
||||||
if str(lang or "").startswith("en"):
|
if is_zh:
|
||||||
return body or t
|
return f"处理中:{body}" if body else "处理中,请稍候…"
|
||||||
# Keep short Chinese-friendly wait copy for unknown oclaw:* lines.
|
return body or "Still working, please wait…"
|
||||||
return f"处理中:{body}" if body else "处理中,请稍候…"
|
|
||||||
return t
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -146,7 +146,7 @@ class WhatsappTurnProgressPublisher:
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
enqueue: Callable[[str, dict[str, Any] | None], None],
|
enqueue: Callable[[str, dict[str, Any] | None], None],
|
||||||
lang: str = "zh",
|
lang: str = "en",
|
||||||
is_group: bool = False,
|
is_group: bool = False,
|
||||||
inbound: Any = None,
|
inbound: Any = None,
|
||||||
min_interval_sec: float | None = None,
|
min_interval_sec: float | None = None,
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,7 @@ def _normalize_quoted_compare_text(text: str) -> str:
|
||||||
s = re.sub(r"^(?:@\S+\s+)+", "", s)
|
s = re.sub(r"^(?:@\S+\s+)+", "", s)
|
||||||
# Drop common group-ingest wrappers if somehow present.
|
# Drop common group-ingest wrappers if somehow present.
|
||||||
s = re.sub(r"^\[被引用消息\]\s*", "", s)
|
s = re.sub(r"^\[被引用消息\]\s*", "", s)
|
||||||
|
s = re.sub(r"^\[Quoted message\]\s*", "", s, flags=re.IGNORECASE)
|
||||||
s = re.sub(r"\s+", " ", s).strip()
|
s = re.sub(r"\s+", " ", s).strip()
|
||||||
return s[:400]
|
return s[:400]
|
||||||
|
|
||||||
|
|
@ -242,7 +243,7 @@ def should_inject_quoted_context(*, quoted_text: str, recent_messages: list[Any]
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def build_group_quoted_context_block(*, metadata: dict[str, Any] | None) -> str:
|
def build_group_quoted_context_block(*, metadata: dict[str, Any] | None, lang: str = "en") -> str:
|
||||||
info = extract_group_quoted_message(metadata=metadata)
|
info = extract_group_quoted_message(metadata=metadata)
|
||||||
quoted_text = str(info.get("quoted_text") or "").strip()
|
quoted_text = str(info.get("quoted_text") or "").strip()
|
||||||
if not quoted_text:
|
if not quoted_text:
|
||||||
|
|
@ -250,7 +251,9 @@ def build_group_quoted_context_block(*, metadata: dict[str, Any] | None) -> str:
|
||||||
quoted_participant = str(info.get("quoted_participant") or "").strip()
|
quoted_participant = str(info.get("quoted_participant") or "").strip()
|
||||||
quoted_push_name = str(info.get("quoted_push_name") or "").strip()
|
quoted_push_name = str(info.get("quoted_push_name") or "").strip()
|
||||||
speaker = quoted_push_name or quoted_participant or "unknown"
|
speaker = quoted_push_name or quoted_participant or "unknown"
|
||||||
return f"[被引用消息]\n{speaker}: {quoted_text}"
|
if str(lang or "").strip().lower().startswith("zh"):
|
||||||
|
return f"[被引用消息]\n{speaker}: {quoted_text}"
|
||||||
|
return f"[Quoted message]\n{speaker}: {quoted_text}"
|
||||||
|
|
||||||
|
|
||||||
def enrich_alert_group_question(*, user_text: str, quoted_alert: str) -> str:
|
def enrich_alert_group_question(*, user_text: str, quoted_alert: str) -> str:
|
||||||
|
|
@ -592,26 +595,26 @@ def prepare_group_user_text_for_model(
|
||||||
return prefix or body
|
return prefix or body
|
||||||
|
|
||||||
|
|
||||||
def build_group_focus_instruction(*, lang: str = "zh") -> str:
|
def build_group_focus_instruction(*, lang: str = "en") -> str:
|
||||||
if str(lang or "").strip().lower().startswith("en"):
|
if str(lang or "").strip().lower().startswith("zh"):
|
||||||
return (
|
return "[群聊规则:只回答当前发言人的问题;除非本条消息明确引用或承接前文,否则不要默认继承其他群成员的上下文。]"
|
||||||
"[Group chat rule: answer only the current sender's request. "
|
return (
|
||||||
"Do not assume context from other members unless this message explicitly quotes or references it.]"
|
"[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.]"
|
||||||
return "[群聊规则:只回答当前发言人的问题;除非本条消息明确引用或承接前文,否则不要默认继承其他群成员的上下文。]"
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_channel_file_delivery_instruction(*, lang: str = "zh") -> str:
|
def build_channel_file_delivery_instruction(*, lang: str = "en") -> str:
|
||||||
if str(lang or "").strip().lower().startswith("en"):
|
if str(lang or "").strip().lower().startswith("zh"):
|
||||||
return (
|
return (
|
||||||
"[Channel rule: to send any generated attachment back to the user on WhatsApp/WeChat "
|
"[渠道规则:若要把生成的附件发回用户(WhatsApp/微信,含文档/图片/视频),"
|
||||||
"(documents, images, videos), call save_deliverable_attachment with path or attachment_id. "
|
"必须调用 save_deliverable_attachment(path 或 attachment_id);"
|
||||||
"write_file, run_command, and image generation alone do not attach files to the outbound message.]"
|
"write_file、run_command、生图工具 alone 不会随消息发送附件。]"
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
"[渠道规则:若要把生成的附件发回用户(WhatsApp/微信,含文档/图片/视频),"
|
"[Channel rule: to send any generated attachment back to the user on WhatsApp/WeChat "
|
||||||
"必须调用 save_deliverable_attachment(path 或 attachment_id);"
|
"(documents, images, videos), call save_deliverable_attachment with path or attachment_id. "
|
||||||
"write_file、run_command、生图工具 alone 不会随消息发送附件。]"
|
"write_file, run_command, and image generation alone do not attach files to the outbound message.]"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,23 +27,23 @@ def format_unregistered_tool_error(
|
||||||
tool_name: str,
|
tool_name: str,
|
||||||
available: Iterable[str],
|
available: Iterable[str],
|
||||||
*,
|
*,
|
||||||
lang: str = "zh",
|
lang: str = "en",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
suggestions = suggest_tool_names(tool_name, available)
|
suggestions = suggest_tool_names(tool_name, available)
|
||||||
if str(lang or "").startswith("en"):
|
if str(lang or "").startswith("zh"):
|
||||||
err = f"Unregistered tool: {tool_name}"
|
|
||||||
hint = (
|
|
||||||
"Use one of suggestions, or refresh MCP tools for this specialist."
|
|
||||||
if suggestions
|
|
||||||
else "Tool is not in the current registry; check MCP enablement / specialist binding."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
err = f"未注册的工具: {tool_name}"
|
err = f"未注册的工具: {tool_name}"
|
||||||
hint = (
|
hint = (
|
||||||
"请改用 suggestions 中的工具名,或检查该专家的 MCP 绑定/启用状态。"
|
"请改用 suggestions 中的工具名,或检查该专家的 MCP 绑定/启用状态。"
|
||||||
if suggestions
|
if suggestions
|
||||||
else "当前工具目录中无此工具;请检查 MCP 是否启用及专家绑定。"
|
else "当前工具目录中无此工具;请检查 MCP 是否启用及专家绑定。"
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
err = f"Unregistered tool: {tool_name}"
|
||||||
|
hint = (
|
||||||
|
"Use one of suggestions, or refresh MCP tools for this specialist."
|
||||||
|
if suggestions
|
||||||
|
else "Tool is not in the current registry; check MCP enablement / specialist binding."
|
||||||
|
)
|
||||||
out: dict[str, Any] = {
|
out: dict[str, Any] = {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error_code": "tool_not_registered",
|
"error_code": "tool_not_registered",
|
||||||
|
|
|
||||||
|
|
@ -57,18 +57,18 @@ def format_invalid_arguments_error(
|
||||||
parameters: dict[str, Any],
|
parameters: dict[str, Any],
|
||||||
message: str,
|
message: str,
|
||||||
*,
|
*,
|
||||||
lang: str = "zh",
|
lang: str = "en",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Rich invalid-arg payload so the model can self-correct without blind retries."""
|
"""Rich invalid-arg payload so the model can self-correct without blind retries."""
|
||||||
props = parameters.get("properties") if isinstance(parameters.get("properties"), dict) else {}
|
props = parameters.get("properties") if isinstance(parameters.get("properties"), dict) else {}
|
||||||
required = [str(x) for x in (parameters.get("required") or []) if str(x)]
|
required = [str(x) for x in (parameters.get("required") or []) if str(x)]
|
||||||
example = _example_from_schema(parameters or {})
|
example = _example_from_schema(parameters or {})
|
||||||
if str(lang or "").startswith("en"):
|
if str(lang or "").startswith("zh"):
|
||||||
err = f"Invalid arguments: {message}"
|
|
||||||
hint = "Fix arguments to match the schema example; do not retry with the same payload."
|
|
||||||
else:
|
|
||||||
err = f"参数不合法: {message}"
|
err = f"参数不合法: {message}"
|
||||||
hint = "请按 example 修正参数后重试,不要用相同参数盲目重试。"
|
hint = "请按 example 修正参数后重试,不要用相同参数盲目重试。"
|
||||||
|
else:
|
||||||
|
err = f"Invalid arguments: {message}"
|
||||||
|
hint = "Fix arguments to match the schema example; do not retry with the same payload."
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error_code": "tool_invalid_arguments",
|
"error_code": "tool_invalid_arguments",
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ You are the ops specialist (network operations expert).
|
||||||
## WhatsApp interaction (mandatory)
|
## WhatsApp interaction (mandatory)
|
||||||
- Short ops intents follow `ops-netx-ume-playbook` WhatsApp recipes; target **≤3 tool calls** per user message. For Excel exports prefer `ume_alarm_xlsx_report`.
|
- Short ops intents follow `ops-netx-ume-playbook` WhatsApp recipes; target **≤3 tool calls** per user message. For Excel exports prefer `ume_alarm_xlsx_report`.
|
||||||
- Spreadsheet delivery: `ume_alarm_xlsx_report` or `write_xlsx(deliverable=true)` — never claim a file was sent without deliverable marking.
|
- Spreadsheet delivery: `ume_alarm_xlsx_report` or `write_xlsx(deliverable=true)` — never claim a file was sent without deliverable marking.
|
||||||
|
- **Field default is English**: WhatsApp channel dispatch defaults to `lang=en`; user-visible replies must contain **zero CJK**. Translate Chinese tool fields before display.
|
||||||
- Group chats default to **per-speaker session isolation** (members do not share dialogue memory within the same group).
|
- Group chats default to **per-speaker session isolation** (members do not share dialogue memory within the same group).
|
||||||
- Call `listCliTargets` at most once per session and reuse ids; batch `execManagedNe` commands; default `read_timeout_sec=60` — on timeout raise it, no blind retries.
|
- Call `listCliTargets` at most once per session and reuse ids; batch `execManagedNe` commands; default `read_timeout_sec=60` — on timeout raise it, no blind retries.
|
||||||
- Replies like `YES` / `confirm` / `继续` / `please continue`: continue the previous unfinished task — do **not** re-ask for confirmation or restart the query.
|
- Replies like `YES` / `confirm` / `继续` / `please continue`: continue the previous unfinished task — do **not** re-ask for confirmation or restart the query.
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
## WhatsApp 交互(强制)
|
## WhatsApp 交互(强制)
|
||||||
- 短句优先走 `ops-netx-ume-playbook` 的「WhatsApp 短指令配方」,控制在 ≤3 次工具调用;要 Excel 时优先 `ume_alarm_xlsx_report`。
|
- 短句优先走 `ops-netx-ume-playbook` 的「WhatsApp 短指令配方」,控制在 ≤3 次工具调用;要 Excel 时优先 `ume_alarm_xlsx_report`。
|
||||||
- 用户要表格/Excel:`ume_alarm_xlsx_report` 或 `write_xlsx(deliverable=true)`;禁止只写文件不投递。
|
- 用户要表格/Excel:`ume_alarm_xlsx_report` 或 `write_xlsx(deliverable=true)`;禁止只写文件不投递。
|
||||||
|
- **现场默认英文**:WhatsApp 渠道默认 `lang=en`;英文会话回复不得含汉字;工具中文字段先翻译再展示。
|
||||||
- 群聊默认按**发言人隔离会话**(同群不同人互不串上下文);勿假设「群共享一个对话记忆」。
|
- 群聊默认按**发言人隔离会话**(同群不同人互不串上下文);勿假设「群共享一个对话记忆」。
|
||||||
- `listCliTargets` 每会话最多查一次并复用 id;`execManagedNe` 合并 commands,超时调 `read_timeout_sec`(默认 60),禁止盲重试。
|
- `listCliTargets` 每会话最多查一次并复用 id;`execManagedNe` 合并 commands,超时调 `read_timeout_sec`(默认 60),禁止盲重试。
|
||||||
- 用户回复 `YES` / `confirm` / `确认` / `可以` / `继续` / `please continue`:直接承接上一未完成任务继续执行,**不要**再问一遍确认或重开查询。
|
- 用户回复 `YES` / `confirm` / `确认` / `可以` / `继续` / `please continue`:直接承接上一未完成任务继续执行,**不要**再问一遍确认或重开查询。
|
||||||
|
|
|
||||||
|
|
@ -52,23 +52,23 @@ description: 面向 ops 专家的 netx UME 运维作业手册。覆盖告警查
|
||||||
- **critical 口类/光路类告警**:抽 1–2 个 `ne_id` → `findTopologyPaths`(默认 `detail=summary`,看 `paths[].label`)→ 再决定是否 CLI。
|
- **critical 口类/光路类告警**:抽 1–2 个 `ne_id` → `findTopologyPaths`(默认 `detail=summary`,看 `paths[].label`)→ 再决定是否 CLI。
|
||||||
- **查网元身份**:`queryUmeNeInventory(keyword=host_name)`;完整 `raw_json` 用 `getUmeNe`。
|
- **查网元身份**:`queryUmeNeInventory(keyword=host_name)`;完整 `raw_json` 用 `getUmeNe`。
|
||||||
|
|
||||||
## WhatsApp 短指令配方(强制少工具)
|
## WhatsApp short-intent recipes (≤3 tools; English field primary)
|
||||||
|
|
||||||
群聊短句(中/英)优先走下列固定路径,**目标 ≤3 次工具调用**,不要先翻页/反复 listCliTargets。
|
Prefer these fixed paths for short group/DM asks (EN first; ZH aliases still work). **Target ≤3 tool calls** — do not paginate or re-list CLI targets.
|
||||||
|
|
||||||
| 用户说法(例) | 配方 |
|
| User says (examples) | Recipe |
|
||||||
|----------------|------|
|
|----------------------|--------|
|
||||||
| 断纤 / fiber cut / LOS / 光缆中断 | **优先** `ume_alarm_xlsx_report(mode=fiber_cut)`(一键 xlsx+投递);或 `queryUmeAlarmsRaw` 后摘要 |
|
| fiber cut / LOS / cable cut / 断纤 | Prefer `ume_alarm_xlsx_report(mode=fiber_cut)` (xlsx + deliver); or summarize via `queryUmeAlarmsRaw` |
|
||||||
| 离线 / 单板离线 / offline NE | **优先** `ume_alarm_xlsx_report(mode=offline)` |
|
| offline NE / board offline / 离线 | Prefer `ume_alarm_xlsx_report(mode=offline)` |
|
||||||
| Critical Top / 告警统计 | ① `aggregateUmeAlarms(severity=critical, top_ne=20)`;要文件:`ume_alarm_xlsx_report(mode=aggregate_by_host, severity=critical)` |
|
| Critical Top / alarm tally | ① `aggregateUmeAlarms(severity=critical, top_ne=20)`; for file: `ume_alarm_xlsx_report(mode=aggregate_by_host, severity=critical)` |
|
||||||
| 当前告警有多少 / tally | ① `runUmeDiagnostics` 或 `aggregateUmeAlarms`;② 直接报 by_severity + freshness |
|
| how many alarms / tally | ① `runUmeDiagnostics` or `aggregateUmeAlarms`; ② report by_severity + freshness |
|
||||||
| 导出 Excel / 发我表格 | `ume_alarm_xlsx_report` **或** `write_xlsx(..., deliverable=true)`;勿再拆成 3 步 |
|
| export Excel / send spreadsheet | `ume_alarm_xlsx_report` **or** `write_xlsx(..., deliverable=true)`; never split into 3 steps |
|
||||||
|
|
||||||
交付约定:
|
Delivery rules:
|
||||||
- `ume_alarm_xlsx_report` 默认 `deliverable=true`,WhatsApp 可直接收文件。
|
- `ume_alarm_xlsx_report` defaults `deliverable=true` so WhatsApp receives the file.
|
||||||
- 通用表格:`write_xlsx(..., deliverable=true)` 可跳过 `save_deliverable_attachment`。
|
- Generic sheets: `write_xlsx(..., deliverable=true)` can skip `save_deliverable_attachment`.
|
||||||
- 禁止用 `run_command`+openpyxl 造 xlsx。
|
- Do not build xlsx via `run_command` + openpyxl.
|
||||||
- 确认类短句(YES / confirm / 继续):承接上一任务继续,勿重新开查。
|
- Confirm short replies (`YES` / `confirm` / `继续`): continue the previous task; do not restart the query.
|
||||||
|
|
||||||
## 约束与护栏
|
## 约束与护栏
|
||||||
|
|
||||||
|
|
@ -86,7 +86,8 @@ description: 面向 ops 专家的 netx UME 运维作业手册。覆盖告警查
|
||||||
|
|
||||||
- 结论 + 工具证据 + 可执行下一步。
|
- 结论 + 工具证据 + 可执行下一步。
|
||||||
- 无工具证据不得臆测告警事实。
|
- 无工具证据不得臆测告警事实。
|
||||||
- 英文提问:回复不得含汉字;工具中文字段须译成英文。
|
- **现场默认英文**:用户用英文提问或渠道 lang=en 时,回复 **不得含汉字**;工具返回的中文字段必须译成英文后再展示。
|
||||||
|
- 仅当用户明确用中文提问且渠道为 zh 时,才用中文回复。
|
||||||
|
|
||||||
### 网元展示:以 host_name 为主键(强制)
|
### 网元展示:以 host_name 为主键(强制)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,14 @@ def test_channel_dispatch_defaults_to_expert_and_generalist() -> None:
|
||||||
assert lang == "auto"
|
assert lang == "auto"
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_dispatch_whatsapp_defaults_to_en() -> None:
|
||||||
|
store = _DummyStore()
|
||||||
|
interaction_mode, specialist, lang = _resolve_channel_dispatch(store, channel="whatsapp", account=None)
|
||||||
|
assert interaction_mode == "expert"
|
||||||
|
assert specialist == "generalist"
|
||||||
|
assert lang == "en"
|
||||||
|
|
||||||
|
|
||||||
def test_channel_dispatch_uses_global_settings() -> None:
|
def test_channel_dispatch_uses_global_settings() -> None:
|
||||||
store = _DummyStore(
|
store = _DummyStore(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -412,10 +412,10 @@ def test_prepare_group_user_text_for_model_shared_chat_prefix() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_build_group_focus_instruction() -> None:
|
def test_build_group_focus_instruction() -> None:
|
||||||
zh = build_group_focus_instruction()
|
en = build_group_focus_instruction()
|
||||||
en = build_group_focus_instruction(lang="en")
|
zh = build_group_focus_instruction(lang="zh")
|
||||||
assert "群聊规则" in zh
|
|
||||||
assert "current sender" in en
|
assert "current sender" in en
|
||||||
|
assert "群聊规则" in zh
|
||||||
|
|
||||||
|
|
||||||
def test_extract_group_quoted_message_and_build_context_block() -> None:
|
def test_extract_group_quoted_message_and_build_context_block() -> None:
|
||||||
|
|
@ -431,8 +431,10 @@ def test_extract_group_quoted_message_and_build_context_block() -> None:
|
||||||
assert info["quoted_text"] == "服务器刚刚 502 了"
|
assert info["quoted_text"] == "服务器刚刚 502 了"
|
||||||
assert info["quoted_push_name"] == "Alice"
|
assert info["quoted_push_name"] == "Alice"
|
||||||
block = build_group_quoted_context_block(metadata=meta)
|
block = build_group_quoted_context_block(metadata=meta)
|
||||||
assert "[被引用消息]" in block
|
assert "[Quoted message]" in block
|
||||||
assert "Alice" in block
|
assert "Alice" in block
|
||||||
|
zh_block = build_group_quoted_context_block(metadata=meta, lang="zh")
|
||||||
|
assert "[被引用消息]" in zh_block
|
||||||
|
|
||||||
|
|
||||||
def test_should_inject_quoted_context_dedupes_recent_message() -> None:
|
def test_should_inject_quoted_context_dedupes_recent_message() -> None:
|
||||||
|
|
@ -883,7 +885,7 @@ def test_inbound_group_injects_quoted_context_when_not_in_current_session(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
assert out.get("ok") is True
|
assert out.get("ok") is True
|
||||||
assert "[被引用消息]" in captured["text"]
|
assert "[Quoted message]" in captured["text"]
|
||||||
assert "看起来像 OSPF 邻居抖动" in captured["text"]
|
assert "看起来像 OSPF 邻居抖动" in captured["text"]
|
||||||
_ = tenant_id, user_id
|
_ = tenant_id, user_id
|
||||||
|
|
||||||
|
|
@ -965,6 +967,7 @@ def test_inbound_group_skips_quoted_context_when_already_in_current_session(
|
||||||
)
|
)
|
||||||
assert out.get("ok") is True
|
assert out.get("ok") is True
|
||||||
assert "[被引用消息]" not in captured["text"]
|
assert "[被引用消息]" not in captured["text"]
|
||||||
|
assert "[Quoted message]" not in captured["text"]
|
||||||
_ = tenant_id, user_id
|
_ = tenant_id, user_id
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,9 @@ class WhatsappProgressHelpersTests(unittest.TestCase):
|
||||||
self.assertTrue(should_forward_progress_text("oclaw: tools done (12000ms)"))
|
self.assertTrue(should_forward_progress_text("oclaw: tools done (12000ms)"))
|
||||||
self.assertTrue(should_forward_progress_text("oclaw: image specialist (legacy multimodal HTTP)…"))
|
self.assertTrue(should_forward_progress_text("oclaw: image specialist (legacy multimodal HTTP)…"))
|
||||||
|
|
||||||
def test_humanize_progress_text(self) -> None:
|
def test_humanize_defaults_to_english(self) -> None:
|
||||||
self.assertIn("整理", humanize_progress_text(text="oclaw: tools done (9000ms)", lang="zh"))
|
self.assertIn("CLI", str(humanize_long_tool(tool_name="mcp__netx__execManagedNe") or ""))
|
||||||
self.assertIn("composing", humanize_progress_text(text="oclaw: tools done (9000ms)", lang="en").lower())
|
self.assertIn("composing", humanize_progress_text(text="oclaw: tools done (9000ms)").lower())
|
||||||
|
|
||||||
|
|
||||||
class WhatsappTurnProgressPublisherTests(unittest.TestCase):
|
class WhatsappTurnProgressPublisherTests(unittest.TestCase):
|
||||||
|
|
@ -56,7 +56,7 @@ class WhatsappTurnProgressPublisherTests(unittest.TestCase):
|
||||||
|
|
||||||
pub = WhatsappTurnProgressPublisher(
|
pub = WhatsappTurnProgressPublisher(
|
||||||
enqueue=enqueue,
|
enqueue=enqueue,
|
||||||
lang="zh",
|
lang="en",
|
||||||
is_group=False,
|
is_group=False,
|
||||||
min_interval_sec=10.0,
|
min_interval_sec=10.0,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
|
|
@ -67,7 +67,7 @@ class WhatsappTurnProgressPublisherTests(unittest.TestCase):
|
||||||
|
|
||||||
pub.on_tool_ui("tool_use_call", {"tool_name": "mcp__netx__execManagedNe"})
|
pub.on_tool_ui("tool_use_call", {"tool_name": "mcp__netx__execManagedNe"})
|
||||||
self.assertEqual(len(sent), 1)
|
self.assertEqual(len(sent), 1)
|
||||||
self.assertIn("设备", sent[0][0])
|
self.assertIn("CLI", sent[0][0])
|
||||||
|
|
||||||
clock["t"] = 105.0
|
clock["t"] = 105.0
|
||||||
pub.on_tool_ui("tool_use_call", {"tool_name": "mcp__netx__queryUmeAlarms"})
|
pub.on_tool_ui("tool_use_call", {"tool_name": "mcp__netx__queryUmeAlarms"})
|
||||||
|
|
@ -82,7 +82,7 @@ class WhatsappTurnProgressPublisherTests(unittest.TestCase):
|
||||||
clock["t"] = 122.0
|
clock["t"] = 122.0
|
||||||
pub.on_progress("oclaw: tools done (15000ms)")
|
pub.on_progress("oclaw: tools done (15000ms)")
|
||||||
self.assertEqual(len(sent), 3)
|
self.assertEqual(len(sent), 3)
|
||||||
self.assertIn("整理", sent[2][0])
|
self.assertIn("composing", sent[2][0].lower())
|
||||||
|
|
||||||
def test_group_metadata_mentions_without_quote(self) -> None:
|
def test_group_metadata_mentions_without_quote(self) -> None:
|
||||||
sent: list[tuple[str, dict[str, Any] | None]] = []
|
sent: list[tuple[str, dict[str, Any] | None]] = []
|
||||||
|
|
@ -101,7 +101,7 @@ class WhatsappTurnProgressPublisherTests(unittest.TestCase):
|
||||||
|
|
||||||
pub = WhatsappTurnProgressPublisher(
|
pub = WhatsappTurnProgressPublisher(
|
||||||
enqueue=lambda t, m: sent.append((t, m)),
|
enqueue=lambda t, m: sent.append((t, m)),
|
||||||
lang="zh",
|
lang="en",
|
||||||
is_group=True,
|
is_group=True,
|
||||||
inbound=_Inbound(),
|
inbound=_Inbound(),
|
||||||
min_interval_sec=1.0,
|
min_interval_sec=1.0,
|
||||||
|
|
@ -109,6 +109,7 @@ class WhatsappTurnProgressPublisherTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
pub.on_tool_ui("tool_use_call", {"tool_name": "ume_alarm_xlsx_report"})
|
pub.on_tool_ui("tool_use_call", {"tool_name": "ume_alarm_xlsx_report"})
|
||||||
self.assertEqual(len(sent), 1)
|
self.assertEqual(len(sent), 1)
|
||||||
|
self.assertIn("Excel", sent[0][0])
|
||||||
meta = sent[0][1] or {}
|
meta = sent[0][1] or {}
|
||||||
self.assertTrue(meta.get("mention_jids"))
|
self.assertTrue(meta.get("mention_jids"))
|
||||||
self.assertNotIn("quote_stanza_id", meta)
|
self.assertNotIn("quote_stanza_id", meta)
|
||||||
|
|
@ -185,7 +186,7 @@ class WhatsappInboundProgressWiringTests(unittest.TestCase):
|
||||||
inbound_mod,
|
inbound_mod,
|
||||||
get_assistant_store=mock.MagicMock(return_value=self.store),
|
get_assistant_store=mock.MagicMock(return_value=self.store),
|
||||||
_build_admin_gateway_executor=mock.MagicMock(return_value=object()),
|
_build_admin_gateway_executor=mock.MagicMock(return_value=object()),
|
||||||
_resolve_channel_dispatch=mock.MagicMock(return_value=("expert", "ops", "zh")),
|
_resolve_channel_dispatch=mock.MagicMock(return_value=("expert", "ops", "en")),
|
||||||
), mock.patch("runtime.gateway.OclawGateway") as gw_cls, mock.patch(
|
), mock.patch("runtime.gateway.OclawGateway") as gw_cls, mock.patch(
|
||||||
"runtime.orchestration.group_ingest.should_process_group_inbound",
|
"runtime.orchestration.group_ingest.should_process_group_inbound",
|
||||||
return_value=True,
|
return_value=True,
|
||||||
|
|
@ -212,7 +213,7 @@ class WhatsappInboundProgressWiringTests(unittest.TestCase):
|
||||||
self.assertIn("inbound_reply", kinds)
|
self.assertIn("inbound_reply", kinds)
|
||||||
progress_rows = [p for p, k in zip(pending, kinds) if k == "inbound_progress"]
|
progress_rows = [p for p, k in zip(pending, kinds) if k == "inbound_progress"]
|
||||||
self.assertTrue(progress_rows)
|
self.assertTrue(progress_rows)
|
||||||
self.assertIn("设备", str(progress_rows[0].get("text") or ""))
|
self.assertIn("CLI", str(progress_rows[0].get("text") or ""))
|
||||||
progress_src = json.loads(str(progress_rows[0].get("source") or "{}"))
|
progress_src = json.loads(str(progress_rows[0].get("source") or "{}"))
|
||||||
self.assertTrue(progress_src.get("mention_jids"))
|
self.assertTrue(progress_src.get("mention_jids"))
|
||||||
self.assertFalse(progress_src.get("quote_stanza_id"))
|
self.assertFalse(progress_src.get("quote_stanza_id"))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue