mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 00:40:45 +08:00
Cut WA alarm re-query loops, auto-deliver xlsx, and add ops schedule templates.
TTL-cache UME alarm/diagnostics MCP calls, mark write_xlsx deliverable on WhatsApp unless explicitly false, and expose built-in recipe_template_id playbooks for daily tally / critical xlsx / license checks. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
7373e516f0
commit
d2693d1c63
9 changed files with 431 additions and 5 deletions
|
|
@ -2305,6 +2305,17 @@ def build_admin_router() -> APIRouter:
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@router.get("/admin/api/scheduled-jobs/meta/recipe-templates")
|
||||||
|
def api_scheduled_jobs_meta_recipe_templates(
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
from runtime.scheduler.recipe import list_ops_recipe_templates
|
||||||
|
|
||||||
|
store = get_assistant_store()
|
||||||
|
ctx = _resolve_auth(store, authorization)
|
||||||
|
_require_permission(ctx, "admin:read")
|
||||||
|
return {"ok": True, "items": list_ops_recipe_templates()}
|
||||||
|
|
||||||
@router.get("/admin/api/scheduled-jobs/meta/targets")
|
@router.get("/admin/api/scheduled-jobs/meta/targets")
|
||||||
def api_scheduled_jobs_meta_targets(
|
def api_scheduled_jobs_meta_targets(
|
||||||
tenant_id: str = Query(default=""),
|
tenant_id: str = Query(default=""),
|
||||||
|
|
@ -2370,13 +2381,43 @@ def build_admin_router() -> APIRouter:
|
||||||
prompt_text = str(payload.get("prompt_text") or "").strip()
|
prompt_text = str(payload.get("prompt_text") or "").strip()
|
||||||
schedule_kind = normalize_schedule_kind(payload.get("schedule_kind") or "cron")
|
schedule_kind = normalize_schedule_kind(payload.get("schedule_kind") or "cron")
|
||||||
schedule_expr = str(payload.get("schedule_expr") or "").strip()
|
schedule_expr = str(payload.get("schedule_expr") or "").strip()
|
||||||
if not name or not prompt_text or not schedule_expr:
|
if not name or not schedule_expr:
|
||||||
return {"ok": False, "error": "name, prompt_text, schedule_expr are required"}
|
return {"ok": False, "error": "name, schedule_expr are required"}
|
||||||
delivery = payload.get("delivery") if isinstance(payload.get("delivery"), dict) else None
|
delivery = payload.get("delivery") if isinstance(payload.get("delivery"), dict) else None
|
||||||
if delivery is None:
|
if delivery is None:
|
||||||
wa_chat = str((payload.get("whatsapp") or {}).get("chat_id") if isinstance(payload.get("whatsapp"), dict) else payload.get("whatsapp_chat_id") or "")
|
wa_chat = str((payload.get("whatsapp") or {}).get("chat_id") if isinstance(payload.get("whatsapp"), dict) else payload.get("whatsapp_chat_id") or "")
|
||||||
delivery = build_default_delivery(store=store, tenant_id=tenant_id, whatsapp_chat_id=wa_chat)
|
delivery = build_default_delivery(store=store, tenant_id=tenant_id, whatsapp_chat_id=wa_chat)
|
||||||
|
from runtime.scheduler.recipe import (
|
||||||
|
list_ops_recipe_templates,
|
||||||
|
normalize_recipe,
|
||||||
|
prompt_summary_from_recipe,
|
||||||
|
recipe_has_playbook,
|
||||||
|
resolve_ops_recipe_template,
|
||||||
|
)
|
||||||
|
|
||||||
recipe = payload.get("recipe") if isinstance(payload.get("recipe"), dict) else None
|
recipe = payload.get("recipe") if isinstance(payload.get("recipe"), dict) else None
|
||||||
|
if isinstance(recipe, dict):
|
||||||
|
recipe = normalize_recipe(recipe)
|
||||||
|
template_id = str(payload.get("recipe_template_id") or payload.get("template_id") or "").strip()
|
||||||
|
if template_id and not recipe_has_playbook(recipe):
|
||||||
|
tmpl = resolve_ops_recipe_template(template_id)
|
||||||
|
if tmpl is None:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": "unknown_recipe_template",
|
||||||
|
"template_id": template_id,
|
||||||
|
"available": list_ops_recipe_templates(),
|
||||||
|
}
|
||||||
|
recipe = tmpl
|
||||||
|
if recipe_has_playbook(recipe) and not prompt_text:
|
||||||
|
prompt_text = prompt_summary_from_recipe(recipe, fallback=name)
|
||||||
|
if not prompt_text:
|
||||||
|
return {"ok": False, "error": "name, prompt_text, schedule_expr are required"}
|
||||||
|
# WhatsApp field ops default to English when delivery targets WA and lang omitted.
|
||||||
|
lang = str(payload.get("lang") or "").strip()
|
||||||
|
if not lang:
|
||||||
|
ch = str((delivery or {}).get("channel") or "").strip().lower()
|
||||||
|
lang = "en" if ch in {"whatsapp", "wa"} else "zh"
|
||||||
row = store.scheduled_job_create(
|
row = store.scheduled_job_create(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
name=name,
|
name=name,
|
||||||
|
|
@ -2387,7 +2428,7 @@ def build_admin_router() -> APIRouter:
|
||||||
description=str(payload.get("description") or ""),
|
description=str(payload.get("description") or ""),
|
||||||
interaction_mode=normalize_interaction_mode(payload.get("interaction_mode") or "expert"),
|
interaction_mode=normalize_interaction_mode(payload.get("interaction_mode") or "expert"),
|
||||||
specialist=normalize_requested_specialist(payload.get("specialist") or "generalist"),
|
specialist=normalize_requested_specialist(payload.get("specialist") or "generalist"),
|
||||||
lang=str(payload.get("lang") or "zh"),
|
lang=lang,
|
||||||
delivery=delivery,
|
delivery=delivery,
|
||||||
recipe=recipe,
|
recipe=recipe,
|
||||||
source_session_id=str(payload.get("source_session_id") or "").strip() or None,
|
source_session_id=str(payload.get("source_session_id") or "").strip() or None,
|
||||||
|
|
|
||||||
|
|
@ -542,6 +542,51 @@ def _resolve_creator_specialist(ctx: ToolExecutionContext) -> str:
|
||||||
return "generalist"
|
return "generalist"
|
||||||
|
|
||||||
|
|
||||||
|
def _truthy_flag(raw: Any) -> bool | None:
|
||||||
|
"""Return True/False when the flag is explicit; None when unset."""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
if isinstance(raw, bool):
|
||||||
|
return raw
|
||||||
|
text = str(raw).strip().lower()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
if text in {"1", "true", "yes", "on"}:
|
||||||
|
return True
|
||||||
|
if text in {"0", "false", "no", "off"}:
|
||||||
|
return False
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_auto_mark_xlsx_deliverable(
|
||||||
|
*,
|
||||||
|
tool_name: str,
|
||||||
|
tool_args: dict[str, Any],
|
||||||
|
result: dict[str, Any],
|
||||||
|
inbound_metadata: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""On WhatsApp/WeChat, auto-mark successful write_xlsx as deliverable unless explicitly false."""
|
||||||
|
if not isinstance(result, dict) or result.get("ok") is False:
|
||||||
|
return result
|
||||||
|
if str(tool_name or "").strip() != "write_xlsx":
|
||||||
|
return result
|
||||||
|
channel = str(inbound_metadata.get("channel") or "").strip().lower()
|
||||||
|
if channel not in {"whatsapp", "wechat", "wecom"}:
|
||||||
|
return result
|
||||||
|
explicit = _truthy_flag(tool_args.get("deliverable")) if isinstance(tool_args, dict) else None
|
||||||
|
if explicit is False:
|
||||||
|
return result
|
||||||
|
if result.get("deliverable") is True:
|
||||||
|
return result
|
||||||
|
out = dict(result)
|
||||||
|
out["deliverable"] = True
|
||||||
|
out["auto_deliverable"] = True
|
||||||
|
out["hint"] = (
|
||||||
|
"Excel auto-marked deliverable for channel outbound — file will attach on WhatsApp/WeChat."
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ToolExecutionConfig:
|
class ToolExecutionConfig:
|
||||||
max_workers: int = 8
|
max_workers: int = 8
|
||||||
|
|
@ -788,6 +833,12 @@ class ToolExecutor:
|
||||||
else:
|
else:
|
||||||
result = _call()
|
result = _call()
|
||||||
out = normalize_tool_result(result)
|
out = normalize_tool_result(result)
|
||||||
|
out = _maybe_auto_mark_xlsx_deliverable(
|
||||||
|
tool_name=str(tc.name or ""),
|
||||||
|
tool_args=tool_args if isinstance(tool_args, dict) else {},
|
||||||
|
result=out,
|
||||||
|
inbound_metadata=ctx.inbound_metadata if isinstance(ctx.inbound_metadata, dict) else {},
|
||||||
|
)
|
||||||
dur_ms = int((time.perf_counter() - t0) * 1000)
|
dur_ms = int((time.perf_counter() - t0) * 1000)
|
||||||
try:
|
try:
|
||||||
_tool_exec_log.info(
|
_tool_exec_log.info(
|
||||||
|
|
|
||||||
|
|
@ -324,9 +324,113 @@ def load_recipe_from_job(job: Any) -> dict[str, Any]:
|
||||||
return normalize_recipe(data if isinstance(data, dict) else {})
|
return normalize_recipe(data if isinstance(data, dict) else {})
|
||||||
|
|
||||||
|
|
||||||
|
# Built-in ops playbooks for WhatsApp field schedules (English-first).
|
||||||
|
_OPS_RECIPE_TEMPLATE_ALIASES: dict[str, str] = {
|
||||||
|
"alarm_tally": "ume_alarm_tally_daily",
|
||||||
|
"alarm_tally_daily": "ume_alarm_tally_daily",
|
||||||
|
"critical_xlsx": "ume_critical_xlsx_daily",
|
||||||
|
"critical_xlsx_daily": "ume_critical_xlsx_daily",
|
||||||
|
"license_check": "ne_license_check_weekly",
|
||||||
|
"license_weekly": "ne_license_check_weekly",
|
||||||
|
}
|
||||||
|
|
||||||
|
OPS_RECIPE_TEMPLATES: dict[str, dict[str, Any]] = {
|
||||||
|
"ume_alarm_tally_daily": {
|
||||||
|
"version": 1,
|
||||||
|
"goal": "Post daily UME open-alarm tally to the ops WhatsApp group",
|
||||||
|
"steps": [
|
||||||
|
"Call runUmeDiagnostics or aggregateUmeAlarms for current open alarms",
|
||||||
|
"Summarize by_severity, top NEs, and freshness in concise English",
|
||||||
|
"Post a short WhatsApp update (skip xlsx unless counts are very large)",
|
||||||
|
],
|
||||||
|
"constraints": [
|
||||||
|
"Prefer English for WhatsApp field ops",
|
||||||
|
"Do not re-list CLI targets/inventory unless required",
|
||||||
|
"Do not blind-retry identical failing tool calls",
|
||||||
|
],
|
||||||
|
"success_criteria": [
|
||||||
|
"Group receives a severity tally that includes freshness",
|
||||||
|
],
|
||||||
|
"output": {"need_attachments": False},
|
||||||
|
},
|
||||||
|
"ume_critical_xlsx_daily": {
|
||||||
|
"version": 1,
|
||||||
|
"goal": "Send daily critical UME alarm Excel to the ops WhatsApp group",
|
||||||
|
"steps": [
|
||||||
|
"Call ume_alarm_xlsx_report(mode=aggregate_by_host, severity=critical, deliverable=true)",
|
||||||
|
"If that tool is unavailable: aggregateUmeAlarms then write_xlsx(deliverable=true)",
|
||||||
|
"Confirm the file is marked deliverable and summarize top hosts in English",
|
||||||
|
],
|
||||||
|
"constraints": [
|
||||||
|
"Prefer ume_alarm_xlsx_report over multi-step query+xlsx",
|
||||||
|
"Never claim a file was sent without deliverable marking",
|
||||||
|
"Prefer English for WhatsApp field ops",
|
||||||
|
],
|
||||||
|
"success_criteria": [
|
||||||
|
"WhatsApp group receives an xlsx attachment of critical alarms by host",
|
||||||
|
],
|
||||||
|
"output": {"need_attachments": True},
|
||||||
|
},
|
||||||
|
"ne_license_check_weekly": {
|
||||||
|
"version": 1,
|
||||||
|
"goal": "Weekly NE license/capacity check summary for ops WhatsApp",
|
||||||
|
"steps": [
|
||||||
|
"Resolve target NEs via listManagedNe or known constants (avoid repeated listCliTargets)",
|
||||||
|
"Run execManagedNe license/capacity show commands with read_timeout_sec>=60",
|
||||||
|
"Summarize near-limit or failed NEs in English; attach xlsx only if many rows",
|
||||||
|
],
|
||||||
|
"constraints": [
|
||||||
|
"Prefer English for WhatsApp field ops",
|
||||||
|
"On timeout/unreachable, classify failure and do not blind-retry identical args",
|
||||||
|
"Keep the group update short and actionable",
|
||||||
|
],
|
||||||
|
"success_criteria": [
|
||||||
|
"Group receives a license/capacity status summary for the target set",
|
||||||
|
],
|
||||||
|
"output": {"need_attachments": False},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_template_id(template_id: str) -> str:
|
||||||
|
tid = str(template_id or "").strip().lower().replace("-", "_")
|
||||||
|
return _OPS_RECIPE_TEMPLATE_ALIASES.get(tid, tid)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_ops_recipe_template(template_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Return a normalized recipe for a built-in ops template id, or None."""
|
||||||
|
tid = _normalize_template_id(template_id)
|
||||||
|
raw = OPS_RECIPE_TEMPLATES.get(tid)
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
recipe = normalize_recipe(raw)
|
||||||
|
src = dict(recipe.get("source") or {})
|
||||||
|
src["template_id"] = tid
|
||||||
|
recipe["source"] = src
|
||||||
|
return recipe
|
||||||
|
|
||||||
|
|
||||||
|
def list_ops_recipe_templates() -> list[dict[str, Any]]:
|
||||||
|
"""List built-in ops recipe templates (id + goal + attachment hint)."""
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for tid, raw in OPS_RECIPE_TEMPLATES.items():
|
||||||
|
recipe = normalize_recipe(raw)
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": tid,
|
||||||
|
"goal": str(recipe.get("goal") or ""),
|
||||||
|
"need_attachments": bool((recipe.get("output") or {}).get("need_attachments")),
|
||||||
|
"aliases": sorted(k for k, v in _OPS_RECIPE_TEMPLATE_ALIASES.items() if v == tid),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"COMPLEX_PROMPT_HINTS",
|
"COMPLEX_PROMPT_HINTS",
|
||||||
|
"OPS_RECIPE_TEMPLATES",
|
||||||
"compile_playbook_instruction",
|
"compile_playbook_instruction",
|
||||||
|
"list_ops_recipe_templates",
|
||||||
"load_recipe_from_job",
|
"load_recipe_from_job",
|
||||||
"looks_like_complex_schedule_prompt",
|
"looks_like_complex_schedule_prompt",
|
||||||
"normalize_recipe",
|
"normalize_recipe",
|
||||||
|
|
@ -336,4 +440,5 @@ __all__ = [
|
||||||
"recipe_has_playbook",
|
"recipe_has_playbook",
|
||||||
"recipe_is_empty",
|
"recipe_is_empty",
|
||||||
"recipe_missing_fields",
|
"recipe_missing_fields",
|
||||||
|
"resolve_ops_recipe_template",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ from runtime.scheduler.job_delete import (
|
||||||
resolve_scheduled_jobs_by_id,
|
resolve_scheduled_jobs_by_id,
|
||||||
)
|
)
|
||||||
from runtime.scheduler.recipe import (
|
from runtime.scheduler.recipe import (
|
||||||
|
list_ops_recipe_templates,
|
||||||
looks_like_complex_schedule_prompt,
|
looks_like_complex_schedule_prompt,
|
||||||
normalize_recipe,
|
normalize_recipe,
|
||||||
parse_recipe_arg,
|
parse_recipe_arg,
|
||||||
|
|
@ -21,6 +22,7 @@ from runtime.scheduler.recipe import (
|
||||||
prompt_summary_from_recipe,
|
prompt_summary_from_recipe,
|
||||||
recipe_has_playbook,
|
recipe_has_playbook,
|
||||||
recipe_missing_fields,
|
recipe_missing_fields,
|
||||||
|
resolve_ops_recipe_template,
|
||||||
)
|
)
|
||||||
from runtime.scheduler.service import run_scheduled_job_now
|
from runtime.scheduler.service import run_scheduled_job_now
|
||||||
from runtime.scheduler.system_timezone import default_system_timezone
|
from runtime.scheduler.system_timezone import default_system_timezone
|
||||||
|
|
@ -234,6 +236,18 @@ def schedule_create_tool() -> ToolSpec:
|
||||||
prompt_text = str(args.get("prompt_text") or "").strip()
|
prompt_text = str(args.get("prompt_text") or "").strip()
|
||||||
recipe_raw = parse_recipe_arg(args.get("recipe"))
|
recipe_raw = parse_recipe_arg(args.get("recipe"))
|
||||||
recipe = normalize_recipe(recipe_raw) if recipe_raw is not None else {}
|
recipe = normalize_recipe(recipe_raw) if recipe_raw is not None else {}
|
||||||
|
template_id = str(args.get("recipe_template_id") or args.get("template_id") or "").strip()
|
||||||
|
if template_id and not recipe_has_playbook(recipe):
|
||||||
|
tmpl = resolve_ops_recipe_template(template_id)
|
||||||
|
if tmpl is None:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": "unknown_recipe_template",
|
||||||
|
"template_id": template_id,
|
||||||
|
"available": list_ops_recipe_templates(),
|
||||||
|
"hint": "Use a known recipe_template_id (e.g. ume_alarm_tally_daily) or pass a full recipe.",
|
||||||
|
}
|
||||||
|
recipe = tmpl
|
||||||
if recipe_has_playbook(recipe):
|
if recipe_has_playbook(recipe):
|
||||||
prompt_text = prompt_summary_from_recipe(recipe, fallback=prompt_text or name)
|
prompt_text = prompt_summary_from_recipe(recipe, fallback=prompt_text or name)
|
||||||
if not prompt_text:
|
if not prompt_text:
|
||||||
|
|
@ -345,7 +359,9 @@ def schedule_create_tool() -> ToolSpec:
|
||||||
"Simple reminders may use prompt_text only. "
|
"Simple reminders may use prompt_text only. "
|
||||||
"Complex / multi-step / '刚才那件事做成定时' jobs MUST include a self-contained recipe "
|
"Complex / multi-step / '刚才那件事做成定时' jobs MUST include a self-contained recipe "
|
||||||
"(goal + >=2 concrete steps + success_criteria; no chat-dependent phrasing); "
|
"(goal + >=2 concrete steps + success_criteria; no chat-dependent phrasing); "
|
||||||
"call schedule_propose and get user confirmation first. "
|
"or pass recipe_template_id for a built-in ops playbook "
|
||||||
|
"(ume_alarm_tally_daily / ume_critical_xlsx_daily / ne_license_check_weekly). "
|
||||||
|
"Call schedule_propose and get user confirmation first when drafting custom recipes. "
|
||||||
"Delivery follows the current chat channel unless delivery is set explicitly."
|
"Delivery follows the current chat channel unless delivery is set explicitly."
|
||||||
),
|
),
|
||||||
parameters={
|
parameters={
|
||||||
|
|
@ -360,6 +376,14 @@ def schedule_create_tool() -> ToolSpec:
|
||||||
"description": "Short summary / reminder intent. For playbooks, prefer recipe.goal.",
|
"description": "Short summary / reminder intent. For playbooks, prefer recipe.goal.",
|
||||||
},
|
},
|
||||||
"recipe": _RECIPE_PARAM,
|
"recipe": _RECIPE_PARAM,
|
||||||
|
"recipe_template_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": (
|
||||||
|
"Built-in ops playbook id: ume_alarm_tally_daily, ume_critical_xlsx_daily, "
|
||||||
|
"ne_license_check_weekly (aliases: alarm_tally, critical_xlsx, license_check). "
|
||||||
|
"Used when recipe is omitted/incomplete."
|
||||||
|
),
|
||||||
|
},
|
||||||
"schedule_kind": {
|
"schedule_kind": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["cron", "once", "interval"],
|
"enum": ["cron", "once", "interval"],
|
||||||
|
|
|
||||||
|
|
@ -30,11 +30,17 @@ _MCP_TOOL_TIMEOUT_OVERRIDES_S: dict[str, float] = {
|
||||||
"aggregateUmeAlarms": 60.0,
|
"aggregateUmeAlarms": 60.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Read-mostly inventory/list tools that agents re-call in tight self-loops on WhatsApp.
|
# Read-mostly inventory/list/alarm tools that agents re-call in tight self-loops on WhatsApp.
|
||||||
_MCP_LIST_CACHE_TTL_S: dict[str, float] = {
|
_MCP_LIST_CACHE_TTL_S: dict[str, float] = {
|
||||||
"listCliTargets": 120.0,
|
"listCliTargets": 120.0,
|
||||||
"listManagedNe": 120.0,
|
"listManagedNe": 120.0,
|
||||||
"queryUmeNeInventory": 90.0,
|
"queryUmeNeInventory": 90.0,
|
||||||
|
# Short TTL: cut identical alarm/diagnostics re-query loops in the same turn.
|
||||||
|
"queryUmeAlarms": 45.0,
|
||||||
|
"queryUmeAlarmsRaw": 45.0,
|
||||||
|
"aggregateUmeAlarms": 45.0,
|
||||||
|
"aggregateUmeAlarmsRaw": 45.0,
|
||||||
|
"runUmeDiagnostics": 60.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
_MCP_LIST_CACHE_LOCK = threading.Lock()
|
_MCP_LIST_CACHE_LOCK = threading.Lock()
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,35 @@ class AdminScheduledJobsApiTests(unittest.TestCase):
|
||||||
self.assertEqual(str(job.get("schedule_expr") or ""), "7200")
|
self.assertEqual(str(job.get("schedule_expr") or ""), "7200")
|
||||||
self.assertEqual(str(job.get("prompt_text") or ""), "Stand up hourly")
|
self.assertEqual(str(job.get("prompt_text") or ""), "Stand up hourly")
|
||||||
|
|
||||||
|
def test_recipe_templates_meta_and_create(self) -> None:
|
||||||
|
token = self._login()
|
||||||
|
headers = {"authorization": f"Bearer {token}"}
|
||||||
|
meta = self.client.get("/admin/api/scheduled-jobs/meta/recipe-templates", headers=headers)
|
||||||
|
self.assertEqual(meta.status_code, 200, meta.text)
|
||||||
|
items = meta.json().get("items") or []
|
||||||
|
self.assertTrue(any(str(x.get("id") or "") == "ume_alarm_tally_daily" for x in items))
|
||||||
|
|
||||||
|
create = self.client.post(
|
||||||
|
"/admin/api/scheduled-jobs",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"name": "Daily alarm tally",
|
||||||
|
"schedule_kind": "cron",
|
||||||
|
"schedule_expr": "0 8 * * *",
|
||||||
|
"recipe_template_id": "alarm_tally",
|
||||||
|
"delivery": {"channel": "whatsapp", "chat_id": "ops@g.us"},
|
||||||
|
"specialist": "ops",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(create.status_code, 200, create.text)
|
||||||
|
body = create.json()
|
||||||
|
self.assertTrue(body.get("ok"), body)
|
||||||
|
job = body.get("job") or {}
|
||||||
|
self.assertEqual(str(job.get("lang") or ""), "en")
|
||||||
|
recipe = job.get("recipe") if isinstance(job.get("recipe"), dict) else {}
|
||||||
|
self.assertIn("UME", str(recipe.get("goal") or job.get("prompt_text") or ""))
|
||||||
|
self.assertEqual(str((recipe.get("source") or {}).get("template_id") or ""), "ume_alarm_tally_daily")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,64 @@ class McpTimeoutAndCacheTests(unittest.TestCase):
|
||||||
self.assertEqual(second.get("data", {}).get("items", [])[0]["ne_id"], "1")
|
self.assertEqual(second.get("data", {}).get("items", [])[0]["ne_id"], "1")
|
||||||
clear_list_cli_targets_cache()
|
clear_list_cli_targets_cache()
|
||||||
|
|
||||||
|
def test_alarm_query_ttl_cache(self) -> None:
|
||||||
|
clear_list_cli_targets_cache()
|
||||||
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as td:
|
||||||
|
store = SqliteStore(str(Path(td) / "ops.sqlite"))
|
||||||
|
store.upsert_mcp_server(
|
||||||
|
server_id="netx",
|
||||||
|
source_type="github",
|
||||||
|
source_ref="local",
|
||||||
|
entry_command="python",
|
||||||
|
entry_args=["-m", "netx_mcp"],
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
store.replace_mcp_server_tools(
|
||||||
|
server_id="netx",
|
||||||
|
tools=[
|
||||||
|
{
|
||||||
|
"tool_name": "queryUmeAlarms",
|
||||||
|
"description": "alarms",
|
||||||
|
"parameters": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool_name": "aggregateUmeAlarms",
|
||||||
|
"description": "agg",
|
||||||
|
"parameters": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tool_name": "runUmeDiagnostics",
|
||||||
|
"description": "diag",
|
||||||
|
"parameters": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
specs = {s.name: s for s in materialize_mcp_tools(store)}
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def fake_call_tool(self, tool_name, arguments=None): # type: ignore[no-untyped-def]
|
||||||
|
calls["n"] += 1
|
||||||
|
return {"ok": True, "data": {"tool": tool_name, "n": calls["n"]}}
|
||||||
|
|
||||||
|
with patch("runtime.tools.mcp.adapter.McpProcessRuntime.call_tool", fake_call_tool):
|
||||||
|
q = specs["mcp__netx__queryUmeAlarms"]
|
||||||
|
a = specs["mcp__netx__aggregateUmeAlarms"]
|
||||||
|
d = specs["mcp__netx__runUmeDiagnostics"]
|
||||||
|
q1 = q.handler({"severity": "critical"})
|
||||||
|
q2 = q.handler({"severity": "critical"})
|
||||||
|
a1 = a.handler({"top_ne": 10})
|
||||||
|
a2 = a.handler({"top_ne": 10})
|
||||||
|
d1 = d.handler({})
|
||||||
|
d2 = d.handler({})
|
||||||
|
self.assertEqual(calls["n"], 3)
|
||||||
|
self.assertFalse(q1.get("cache_hit"))
|
||||||
|
self.assertTrue(q2.get("cache_hit"))
|
||||||
|
self.assertTrue(a2.get("cache_hit"))
|
||||||
|
self.assertTrue(d2.get("cache_hit"))
|
||||||
|
self.assertEqual(a1.get("data", {}).get("tool"), "aggregateUmeAlarms")
|
||||||
|
self.assertEqual(d1.get("data", {}).get("tool"), "runUmeDiagnostics")
|
||||||
|
clear_list_cli_targets_cache()
|
||||||
|
|
||||||
|
|
||||||
class InvalidArgFormatTests(unittest.TestCase):
|
class InvalidArgFormatTests(unittest.TestCase):
|
||||||
def test_format_includes_example(self) -> None:
|
def test_format_includes_example(self) -> None:
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,14 @@ from pathlib import Path
|
||||||
|
|
||||||
from runtime.scheduler.recipe import (
|
from runtime.scheduler.recipe import (
|
||||||
compile_playbook_instruction,
|
compile_playbook_instruction,
|
||||||
|
list_ops_recipe_templates,
|
||||||
looks_like_complex_schedule_prompt,
|
looks_like_complex_schedule_prompt,
|
||||||
normalize_recipe,
|
normalize_recipe,
|
||||||
preview_markdown,
|
preview_markdown,
|
||||||
prompt_summary_from_recipe,
|
prompt_summary_from_recipe,
|
||||||
recipe_has_playbook,
|
recipe_has_playbook,
|
||||||
recipe_missing_fields,
|
recipe_missing_fields,
|
||||||
|
resolve_ops_recipe_template,
|
||||||
)
|
)
|
||||||
from runtime.scheduler.turn_text import build_scheduled_turn_instruction, scheduled_turn_system_suffix
|
from runtime.scheduler.turn_text import build_scheduled_turn_instruction, scheduled_turn_system_suffix
|
||||||
from runtime.tools.experts.productivity.schedule_tools import (
|
from runtime.tools.experts.productivity.schedule_tools import (
|
||||||
|
|
@ -67,6 +69,18 @@ class RecipeHelpersTests(unittest.TestCase):
|
||||||
self.assertTrue(looks_like_complex_schedule_prompt("继续刚才那个生成 PDF 流程"))
|
self.assertTrue(looks_like_complex_schedule_prompt("继续刚才那个生成 PDF 流程"))
|
||||||
self.assertFalse(looks_like_complex_schedule_prompt("提醒喝水"))
|
self.assertFalse(looks_like_complex_schedule_prompt("提醒喝水"))
|
||||||
|
|
||||||
|
def test_ops_recipe_templates(self) -> None:
|
||||||
|
items = list_ops_recipe_templates()
|
||||||
|
ids = {str(x.get("id") or "") for x in items}
|
||||||
|
self.assertIn("ume_alarm_tally_daily", ids)
|
||||||
|
self.assertIn("ume_critical_xlsx_daily", ids)
|
||||||
|
self.assertIn("ne_license_check_weekly", ids)
|
||||||
|
tmpl = resolve_ops_recipe_template("alarm_tally")
|
||||||
|
assert tmpl is not None
|
||||||
|
self.assertTrue(recipe_has_playbook(tmpl))
|
||||||
|
self.assertEqual((tmpl.get("source") or {}).get("template_id"), "ume_alarm_tally_daily")
|
||||||
|
self.assertIsNone(resolve_ops_recipe_template("nope"))
|
||||||
|
|
||||||
def test_turn_instruction_modes(self) -> None:
|
def test_turn_instruction_modes(self) -> None:
|
||||||
reminder = build_scheduled_turn_instruction(prompt_text="喝水", mode="scheduled", lang="zh")
|
reminder = build_scheduled_turn_instruction(prompt_text="喝水", mode="scheduled", lang="zh")
|
||||||
self.assertIn("提醒意图", reminder)
|
self.assertIn("提醒意图", reminder)
|
||||||
|
|
@ -183,6 +197,25 @@ class ScheduleRecipeToolTests(unittest.TestCase):
|
||||||
self.assertFalse(out.get("ok"))
|
self.assertFalse(out.get("ok"))
|
||||||
self.assertEqual(out.get("error"), "recipe_required")
|
self.assertEqual(out.get("error"), "recipe_required")
|
||||||
|
|
||||||
|
def test_create_from_ops_recipe_template(self) -> None:
|
||||||
|
out = schedule_create_tool().handler(
|
||||||
|
{
|
||||||
|
"tenant_id": self.tenant_id,
|
||||||
|
"owner_user_id": self.user_id,
|
||||||
|
"name": "Daily tally",
|
||||||
|
"recipe_template_id": "ume_alarm_tally_daily",
|
||||||
|
"schedule_kind": "cron",
|
||||||
|
"schedule_expr": "0 8 * * *",
|
||||||
|
"lang": "en",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(out.get("ok"), out)
|
||||||
|
job = out.get("job") or {}
|
||||||
|
recipe = job.get("recipe") or {}
|
||||||
|
self.assertTrue(recipe_has_playbook(recipe))
|
||||||
|
self.assertEqual((recipe.get("source") or {}).get("template_id"), "ume_alarm_tally_daily")
|
||||||
|
self.assertIn("UME", str(job.get("prompt_text") or ""))
|
||||||
|
|
||||||
def test_simple_reminder_still_works(self) -> None:
|
def test_simple_reminder_still_works(self) -> None:
|
||||||
out = schedule_create_tool().handler(
|
out = schedule_create_tool().handler(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
79
tests/test_wa_xlsx_auto_deliverable.py
Normal file
79
tests/test_wa_xlsx_auto_deliverable.py
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from runtime.chat.tool_runtime import ToolExecutionContext, ToolExecutor, _maybe_auto_mark_xlsx_deliverable
|
||||||
|
from runtime.tools.base import ToolRegistry, ToolSpec
|
||||||
|
from svc.llm.chat_models import LLMToolCall
|
||||||
|
from svc.persistence.sqlite_store import SqliteStore
|
||||||
|
|
||||||
|
|
||||||
|
def test_helper_marks_on_whatsapp() -> None:
|
||||||
|
out = _maybe_auto_mark_xlsx_deliverable(
|
||||||
|
tool_name="write_xlsx",
|
||||||
|
tool_args={},
|
||||||
|
result={"ok": True, "attachment_id": "a1", "name": "r.xlsx"},
|
||||||
|
inbound_metadata={"channel": "whatsapp"},
|
||||||
|
)
|
||||||
|
assert out.get("deliverable") is True
|
||||||
|
assert out.get("auto_deliverable") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_helper_respects_explicit_false() -> None:
|
||||||
|
out = _maybe_auto_mark_xlsx_deliverable(
|
||||||
|
tool_name="write_xlsx",
|
||||||
|
tool_args={"deliverable": False},
|
||||||
|
result={"ok": True, "attachment_id": "a1"},
|
||||||
|
inbound_metadata={"channel": "whatsapp"},
|
||||||
|
)
|
||||||
|
assert out.get("deliverable") is not True
|
||||||
|
assert out.get("auto_deliverable") is not True
|
||||||
|
|
||||||
|
|
||||||
|
def test_helper_skips_non_channel() -> None:
|
||||||
|
out = _maybe_auto_mark_xlsx_deliverable(
|
||||||
|
tool_name="write_xlsx",
|
||||||
|
tool_args={},
|
||||||
|
result={"ok": True, "attachment_id": "a1"},
|
||||||
|
inbound_metadata={"channel": "web"},
|
||||||
|
)
|
||||||
|
assert out.get("deliverable") is not True
|
||||||
|
|
||||||
|
|
||||||
|
def test_executor_auto_marks_write_xlsx_on_whatsapp(tmp_path: Path) -> None:
|
||||||
|
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||||
|
sess = store.create_session("t1")
|
||||||
|
|
||||||
|
def handler(_args: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"attachment_id": "att-xlsx",
|
||||||
|
"name": "report.xlsx",
|
||||||
|
"mime": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
}
|
||||||
|
|
||||||
|
reg = ToolRegistry(
|
||||||
|
[
|
||||||
|
ToolSpec(
|
||||||
|
name="write_xlsx",
|
||||||
|
description="xlsx",
|
||||||
|
parameters={"type": "object", "properties": {}},
|
||||||
|
handler=handler,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
ctx = ToolExecutionContext(
|
||||||
|
store=store,
|
||||||
|
tools=reg,
|
||||||
|
session_id=sess.id,
|
||||||
|
lang="en",
|
||||||
|
inbound_metadata={"channel": "whatsapp"},
|
||||||
|
turn_uuid="turn-xlsx-1",
|
||||||
|
)
|
||||||
|
out, _dur = ToolExecutor()._execute_tool(
|
||||||
|
ctx,
|
||||||
|
LLMToolCall(id="c1", name="write_xlsx", arguments={}),
|
||||||
|
)
|
||||||
|
assert out.get("ok") is True
|
||||||
|
assert out.get("deliverable") is True
|
||||||
|
assert out.get("auto_deliverable") is True
|
||||||
Loading…
Add table
Add a link
Reference in a new issue