mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 00:40:45 +08:00
feat(scheduler): workflow recipes with confirm-before-create
Store self-contained playbook recipes on scheduled jobs, add schedule_propose draft gate, and run playbook instructions at fire time instead of only short reminders. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
ad5d1db3c6
commit
d036190195
10 changed files with 989 additions and 49 deletions
|
|
@ -2376,6 +2376,7 @@ def build_admin_router() -> APIRouter:
|
|||
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 "")
|
||||
delivery = build_default_delivery(store=store, tenant_id=tenant_id, whatsapp_chat_id=wa_chat)
|
||||
recipe = payload.get("recipe") if isinstance(payload.get("recipe"), dict) else None
|
||||
row = store.scheduled_job_create(
|
||||
tenant_id=tenant_id,
|
||||
name=name,
|
||||
|
|
@ -2388,6 +2389,7 @@ def build_admin_router() -> APIRouter:
|
|||
specialist=normalize_requested_specialist(payload.get("specialist") or "generalist"),
|
||||
lang=str(payload.get("lang") or "zh"),
|
||||
delivery=delivery,
|
||||
recipe=recipe,
|
||||
source_session_id=str(payload.get("source_session_id") or "").strip() or None,
|
||||
created_by_user_id=str(ctx.get("user_id") or ""),
|
||||
source="admin",
|
||||
|
|
|
|||
339
runtime/scheduler/recipe.py
Normal file
339
runtime/scheduler/recipe.py
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
COMPLEX_PROMPT_HINTS = (
|
||||
"刚才",
|
||||
"之前",
|
||||
"继续",
|
||||
"按刚才",
|
||||
"同样的",
|
||||
"那件事",
|
||||
"那套",
|
||||
"流程",
|
||||
"步骤",
|
||||
"生成",
|
||||
"报告",
|
||||
"文档",
|
||||
"pdf",
|
||||
"xlsx",
|
||||
"附件",
|
||||
"发群",
|
||||
"发给",
|
||||
"执行",
|
||||
"整理",
|
||||
"汇总",
|
||||
"拉取",
|
||||
"爬取",
|
||||
"监控",
|
||||
"same as",
|
||||
"continue",
|
||||
"as before",
|
||||
"workflow",
|
||||
"report",
|
||||
"generate",
|
||||
"attach",
|
||||
)
|
||||
|
||||
|
||||
def _as_str_list(raw: Any) -> list[str]:
|
||||
if raw is None:
|
||||
return []
|
||||
if isinstance(raw, str):
|
||||
item = raw.strip()
|
||||
return [item] if item else []
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
out: list[str] = []
|
||||
for item in raw:
|
||||
text = str(item or "").strip()
|
||||
if text:
|
||||
out.append(text)
|
||||
return out
|
||||
|
||||
|
||||
def _as_str_dict(raw: Any) -> dict[str, str]:
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
out: dict[str, str] = {}
|
||||
for key, value in raw.items():
|
||||
k = str(key or "").strip()
|
||||
if not k:
|
||||
continue
|
||||
out[k] = str(value if value is not None else "").strip()
|
||||
return out
|
||||
|
||||
|
||||
def parse_recipe_arg(raw: Any) -> dict[str, Any] | None:
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
return data if isinstance(data, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def normalize_recipe(raw: Any) -> dict[str, Any]:
|
||||
data = parse_recipe_arg(raw) or {}
|
||||
goal = str(data.get("goal") or "").strip()
|
||||
steps = _as_str_list(data.get("steps"))
|
||||
constraints = _as_str_list(data.get("constraints"))
|
||||
success_criteria = _as_str_list(data.get("success_criteria") or data.get("successCriteria"))
|
||||
inputs_raw = data.get("inputs") if isinstance(data.get("inputs"), dict) else {}
|
||||
constants = _as_str_dict(inputs_raw.get("constants"))
|
||||
from_context = _as_str_list(inputs_raw.get("from_context") or inputs_raw.get("fromContext"))
|
||||
output_raw = data.get("output") if isinstance(data.get("output"), dict) else {}
|
||||
source_raw = data.get("source") if isinstance(data.get("source"), dict) else {}
|
||||
version = int(data.get("version") or 1)
|
||||
recipe: dict[str, Any] = {
|
||||
"version": max(1, version),
|
||||
"goal": goal,
|
||||
"steps": steps,
|
||||
"constraints": constraints,
|
||||
"success_criteria": success_criteria,
|
||||
"inputs": {
|
||||
"constants": constants,
|
||||
"from_context": from_context,
|
||||
},
|
||||
"output": {
|
||||
"style": str(output_raw.get("style") or "channel_update").strip() or "channel_update",
|
||||
"need_attachments": bool(output_raw.get("need_attachments") or output_raw.get("needAttachments")),
|
||||
},
|
||||
"source": {
|
||||
"session_id": str(source_raw.get("session_id") or source_raw.get("sessionId") or "").strip(),
|
||||
"compiled_at": str(source_raw.get("compiled_at") or source_raw.get("compiledAt") or "").strip(),
|
||||
},
|
||||
}
|
||||
return recipe
|
||||
|
||||
|
||||
def recipe_is_empty(recipe: dict[str, Any] | None) -> bool:
|
||||
if not recipe:
|
||||
return True
|
||||
norm = normalize_recipe(recipe)
|
||||
return not (norm.get("goal") or norm.get("steps") or norm.get("success_criteria") or norm.get("constraints"))
|
||||
|
||||
|
||||
def recipe_has_playbook(recipe: dict[str, Any] | None) -> bool:
|
||||
if not recipe:
|
||||
return False
|
||||
norm = normalize_recipe(recipe)
|
||||
steps = list(norm.get("steps") or [])
|
||||
goal = str(norm.get("goal") or "").strip()
|
||||
return bool(goal) and len(steps) >= 2
|
||||
|
||||
|
||||
def recipe_missing_fields(recipe: dict[str, Any] | None) -> list[str]:
|
||||
norm = normalize_recipe(recipe or {})
|
||||
missing: list[str] = []
|
||||
if not str(norm.get("goal") or "").strip():
|
||||
missing.append("goal")
|
||||
steps = list(norm.get("steps") or [])
|
||||
if len(steps) < 2:
|
||||
missing.append("steps")
|
||||
if not list(norm.get("success_criteria") or []):
|
||||
missing.append("success_criteria")
|
||||
return missing
|
||||
|
||||
|
||||
def looks_like_complex_schedule_prompt(prompt_text: str, *, recipe: dict[str, Any] | None = None) -> bool:
|
||||
"""Heuristic: multi-step / referential prompts need a recipe."""
|
||||
if recipe_has_playbook(recipe):
|
||||
return True
|
||||
text = str(prompt_text or "").strip()
|
||||
if not text:
|
||||
return False
|
||||
low = text.lower()
|
||||
if any(hint in low or hint in text for hint in COMPLEX_PROMPT_HINTS):
|
||||
# Short pure reminders like "提醒喝水" should stay simple.
|
||||
if len(text) <= 12 and ("提醒" in text or "remind" in low) and "步骤" not in text:
|
||||
return False
|
||||
return True
|
||||
if len(text) >= 80:
|
||||
return True
|
||||
if text.count("\n") >= 2:
|
||||
return True
|
||||
if re.search(r"(1[\.\)]|第一步|step\s*1)", text, flags=re.I):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def prompt_summary_from_recipe(recipe: dict[str, Any] | None, *, fallback: str = "") -> str:
|
||||
norm = normalize_recipe(recipe or {})
|
||||
goal = str(norm.get("goal") or "").strip()
|
||||
if goal:
|
||||
return goal
|
||||
steps = list(norm.get("steps") or [])
|
||||
if steps:
|
||||
return steps[0]
|
||||
return str(fallback or "").strip()
|
||||
|
||||
|
||||
def preview_markdown(
|
||||
*,
|
||||
name: str,
|
||||
schedule_kind: str,
|
||||
schedule_expr: str,
|
||||
timezone_name: str,
|
||||
recipe: dict[str, Any],
|
||||
lang: str = "zh",
|
||||
) -> str:
|
||||
norm = normalize_recipe(recipe)
|
||||
is_en = str(lang or "").lower().startswith("en")
|
||||
lines: list[str] = []
|
||||
if is_en:
|
||||
lines.append("## Scheduled workflow draft (confirm before create)")
|
||||
lines.append(f"- **Name**: {name or '(untitled)'}")
|
||||
lines.append(f"- **Schedule**: `{schedule_kind}` `{schedule_expr}` ({timezone_name or 'system'})")
|
||||
lines.append(f"- **Goal**: {norm.get('goal') or '(missing)'}")
|
||||
lines.append("### Steps")
|
||||
for i, step in enumerate(list(norm.get("steps") or []), start=1):
|
||||
lines.append(f"{i}. {step}")
|
||||
constraints = list(norm.get("constraints") or [])
|
||||
if constraints:
|
||||
lines.append("### Constraints")
|
||||
for item in constraints:
|
||||
lines.append(f"- {item}")
|
||||
criteria = list(norm.get("success_criteria") or [])
|
||||
if criteria:
|
||||
lines.append("### Success criteria")
|
||||
for item in criteria:
|
||||
lines.append(f"- {item}")
|
||||
constants = dict((norm.get("inputs") or {}).get("constants") or {})
|
||||
if constants:
|
||||
lines.append("### Fixed inputs")
|
||||
for key, value in constants.items():
|
||||
lines.append(f"- `{key}`: {value}")
|
||||
lines.append("")
|
||||
lines.append("Reply **confirm** to create, or list the edits you want.")
|
||||
else:
|
||||
lines.append("## 定时工作流草稿(请确认后再创建)")
|
||||
lines.append(f"- **名称**:{name or '(未命名)'}")
|
||||
lines.append(f"- **时间**:`{schedule_kind}` `{schedule_expr}`({timezone_name or '系统时区'})")
|
||||
lines.append(f"- **目标**:{norm.get('goal') or '(缺失)'}")
|
||||
lines.append("### 步骤")
|
||||
for i, step in enumerate(list(norm.get("steps") or []), start=1):
|
||||
lines.append(f"{i}. {step}")
|
||||
constraints = list(norm.get("constraints") or [])
|
||||
if constraints:
|
||||
lines.append("### 约束")
|
||||
for item in constraints:
|
||||
lines.append(f"- {item}")
|
||||
criteria = list(norm.get("success_criteria") or [])
|
||||
if criteria:
|
||||
lines.append("### 成功标准")
|
||||
for item in criteria:
|
||||
lines.append(f"- {item}")
|
||||
constants = dict((norm.get("inputs") or {}).get("constants") or {})
|
||||
if constants:
|
||||
lines.append("### 固定输入")
|
||||
for key, value in constants.items():
|
||||
lines.append(f"- `{key}`:{value}")
|
||||
lines.append("")
|
||||
lines.append("请回复**确认**以创建,或说明要修改的地方。")
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
def compile_playbook_instruction(*, recipe: dict[str, Any], lang: str = "zh") -> str:
|
||||
norm = normalize_recipe(recipe)
|
||||
is_en = str(lang or "").lower().startswith("en")
|
||||
steps = list(norm.get("steps") or [])
|
||||
constraints = list(norm.get("constraints") or [])
|
||||
criteria = list(norm.get("success_criteria") or [])
|
||||
constants = dict((norm.get("inputs") or {}).get("constants") or {})
|
||||
from_context = list((norm.get("inputs") or {}).get("from_context") or [])
|
||||
need_attachments = bool((norm.get("output") or {}).get("need_attachments"))
|
||||
|
||||
if is_en:
|
||||
lines = [
|
||||
"[Scheduled playbook — internal instruction, not a user message]",
|
||||
f"Goal: {norm.get('goal') or '(unspecified)'}",
|
||||
"Execute this recurring playbook end-to-end. Use tools as needed.",
|
||||
"Do not reply with only a short reminder unless the playbook is truly reminder-only.",
|
||||
"Steps:",
|
||||
]
|
||||
for i, step in enumerate(steps, start=1):
|
||||
lines.append(f"{i}. {step}")
|
||||
if constraints:
|
||||
lines.append("Constraints:")
|
||||
lines.extend(f"- {c}" for c in constraints)
|
||||
if criteria:
|
||||
lines.append("Success criteria:")
|
||||
lines.extend(f"- {c}" for c in criteria)
|
||||
if constants:
|
||||
lines.append("Fixed inputs:")
|
||||
lines.extend(f"- {k}: {v}" for k, v in constants.items())
|
||||
if from_context:
|
||||
lines.append("Pull from context when needed:")
|
||||
lines.extend(f"- {item}" for item in from_context)
|
||||
if need_attachments:
|
||||
lines.append(
|
||||
"If files are produced, call save_deliverable_attachment so channel delivery includes them."
|
||||
)
|
||||
lines.append("Deliver a useful channel update that reflects completed work.")
|
||||
return "\n".join(lines)
|
||||
|
||||
lines = [
|
||||
"【定时工作流·内部指令,不是用户发言】",
|
||||
f"目标:{norm.get('goal') or '(未指定)'}",
|
||||
"请按下方 playbook 完整执行本轮定时任务;按需调用工具。",
|
||||
"除非任务本身只是提醒,否则不要只回一句短提醒。",
|
||||
"步骤:",
|
||||
]
|
||||
for i, step in enumerate(steps, start=1):
|
||||
lines.append(f"{i}. {step}")
|
||||
if constraints:
|
||||
lines.append("约束:")
|
||||
lines.extend(f"- {c}" for c in constraints)
|
||||
if criteria:
|
||||
lines.append("成功标准:")
|
||||
lines.extend(f"- {c}" for c in criteria)
|
||||
if constants:
|
||||
lines.append("固定输入:")
|
||||
lines.extend(f"- {k}:{v}" for k, v in constants.items())
|
||||
if from_context:
|
||||
lines.append("需要时从上下文获取:")
|
||||
lines.extend(f"- {item}" for item in from_context)
|
||||
if need_attachments:
|
||||
lines.append("若产生文件,必须调用 save_deliverable_attachment,渠道才会随消息发送附件。")
|
||||
lines.append("完成后向渠道发送能体现已完成工作的更新消息。")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def load_recipe_from_job(job: Any) -> dict[str, Any]:
|
||||
raw = getattr(job, "recipe_json", None)
|
||||
if raw is None and isinstance(job, dict):
|
||||
raw = job.get("recipe_json") or job.get("recipe")
|
||||
if isinstance(raw, dict):
|
||||
return normalize_recipe(raw)
|
||||
text = str(raw or "").strip()
|
||||
if not text:
|
||||
return normalize_recipe({})
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except Exception:
|
||||
return normalize_recipe({})
|
||||
return normalize_recipe(data if isinstance(data, dict) else {})
|
||||
|
||||
|
||||
__all__ = [
|
||||
"COMPLEX_PROMPT_HINTS",
|
||||
"compile_playbook_instruction",
|
||||
"load_recipe_from_job",
|
||||
"looks_like_complex_schedule_prompt",
|
||||
"normalize_recipe",
|
||||
"parse_recipe_arg",
|
||||
"preview_markdown",
|
||||
"prompt_summary_from_recipe",
|
||||
"recipe_has_playbook",
|
||||
"recipe_is_empty",
|
||||
"recipe_missing_fields",
|
||||
]
|
||||
|
|
@ -7,6 +7,7 @@ import uuid
|
|||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from runtime.scheduler.recipe import load_recipe_from_job, recipe_has_playbook
|
||||
from runtime.scheduler.session_resolver import resolve_scheduled_session, resolve_scheduled_viewer_username
|
||||
from runtime.scheduler.turn_text import build_scheduled_turn_instruction
|
||||
from runtime.worker import ensure_worker_started
|
||||
|
|
@ -76,7 +77,14 @@ def enqueue_scheduled_job_run(
|
|||
agent_run_id = uuid.uuid4().hex
|
||||
prompt_text = str(getattr(job, "prompt_text", "") or "").strip()
|
||||
lang = str(getattr(job, "lang", "") or "zh")
|
||||
user_text = build_scheduled_turn_instruction(prompt_text=prompt_text, mode=mode, lang=lang)
|
||||
recipe = load_recipe_from_job(job)
|
||||
playbook = recipe_has_playbook(recipe)
|
||||
user_text = build_scheduled_turn_instruction(
|
||||
prompt_text=prompt_text,
|
||||
mode=mode,
|
||||
lang=lang,
|
||||
recipe=recipe if playbook else None,
|
||||
)
|
||||
viewer_username = resolve_scheduled_viewer_username(
|
||||
store,
|
||||
tenant_id=tenant_id,
|
||||
|
|
@ -95,6 +103,7 @@ def enqueue_scheduled_job_run(
|
|||
"lang": lang,
|
||||
"text": user_text,
|
||||
"prompt_text": prompt_text,
|
||||
"recipe": recipe if playbook else {},
|
||||
"attachments": [],
|
||||
"metadata": {
|
||||
"scheduled_job_id": job_id,
|
||||
|
|
@ -103,6 +112,7 @@ def enqueue_scheduled_job_run(
|
|||
"selected_specialist": str(getattr(job, "specialist", "") or "generalist"),
|
||||
"scheduled_mode": mode,
|
||||
"scheduled_proactive": True,
|
||||
"scheduled_playbook": playbook,
|
||||
},
|
||||
"interaction_mode": str(getattr(job, "interaction_mode", "") or "expert"),
|
||||
"requested_specialist": str(getattr(job, "specialist", "") or "generalist"),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from runtime.scheduler.recipe import compile_playbook_instruction, recipe_has_playbook
|
||||
|
||||
|
||||
def format_scheduled_user_reminder(prompt_text: str) -> str:
|
||||
body = str(prompt_text or "").strip()
|
||||
|
|
@ -10,10 +14,19 @@ def format_scheduled_user_reminder(prompt_text: str) -> str:
|
|||
return f"⏰ 提醒:{body}"
|
||||
|
||||
|
||||
def build_scheduled_turn_instruction(*, prompt_text: str, mode: str, lang: str) -> str:
|
||||
"""Internal LLM instruction for proactive scheduled reminders (not user-facing)."""
|
||||
intent = str(prompt_text or "").strip()
|
||||
def build_scheduled_turn_instruction(
|
||||
*,
|
||||
prompt_text: str,
|
||||
mode: str,
|
||||
lang: str,
|
||||
recipe: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""Internal LLM instruction for proactive scheduled reminders/playbooks (not user-facing)."""
|
||||
_ = str(mode or "scheduled").strip()
|
||||
if recipe_has_playbook(recipe):
|
||||
return compile_playbook_instruction(recipe=recipe or {}, lang=lang)
|
||||
|
||||
intent = str(prompt_text or "").strip()
|
||||
is_en = str(lang or "").lower().startswith("en")
|
||||
if is_en:
|
||||
return (
|
||||
|
|
@ -30,8 +43,21 @@ def build_scheduled_turn_instruction(*, prompt_text: str, mode: str, lang: str)
|
|||
)
|
||||
|
||||
|
||||
def scheduled_turn_system_suffix(*, lang: str) -> str:
|
||||
def scheduled_turn_system_suffix(*, lang: str, playbook: bool = False) -> str:
|
||||
is_en = str(lang or "").lower().startswith("en")
|
||||
if playbook:
|
||||
if is_en:
|
||||
return (
|
||||
"\n\n[Scheduled playbook mode] You are executing a recurring workflow for the user. "
|
||||
"Follow the playbook steps, use tools as needed, and deliver a useful update "
|
||||
"(including save_deliverable_attachment for generated files). "
|
||||
"Do not pretend the user just messaged you."
|
||||
)
|
||||
return (
|
||||
"\n\n【定时工作流模式】你正在执行周期性工作流。"
|
||||
"按 playbook 步骤完成任务,按需调用工具;若生成文件须 save_deliverable_attachment。"
|
||||
"不要假装用户刚刚发了消息,不要只回一句空提醒。"
|
||||
)
|
||||
if is_en:
|
||||
return (
|
||||
"\n\n[Scheduled job mode] You are sending a proactive reminder to the user. "
|
||||
|
|
|
|||
|
|
@ -1,17 +1,27 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from runtime.scheduler.cron_service import build_delivery_for_session
|
||||
from runtime.scheduler.whatsapp_mentions import merge_whatsapp_mention_jids, merge_whatsapp_mention_names
|
||||
from runtime.scheduler.expressions import normalize_schedule_kind
|
||||
from runtime.scheduler.system_timezone import default_system_timezone
|
||||
from runtime.scheduler.recipe import (
|
||||
looks_like_complex_schedule_prompt,
|
||||
normalize_recipe,
|
||||
parse_recipe_arg,
|
||||
preview_markdown,
|
||||
prompt_summary_from_recipe,
|
||||
recipe_has_playbook,
|
||||
recipe_missing_fields,
|
||||
)
|
||||
from runtime.scheduler.service import run_scheduled_job_now
|
||||
from runtime.types import normalize_interaction_mode, normalize_requested_specialist
|
||||
from svc.persistence.assistant_store import get_assistant_store
|
||||
from runtime.scheduler.system_timezone import default_system_timezone
|
||||
from runtime.scheduler.whatsapp_mentions import merge_whatsapp_mention_jids, merge_whatsapp_mention_names
|
||||
from runtime.tools.base import ToolSpec
|
||||
from runtime.tools.context_inject import enrich_tool_arguments
|
||||
from runtime.types import normalize_interaction_mode, normalize_requested_specialist
|
||||
from svc.persistence.assistant_store import get_assistant_store
|
||||
|
||||
|
||||
def _require(s: str, name: str) -> str:
|
||||
|
|
@ -35,6 +45,124 @@ def _parse_delivery_arg(raw: Any) -> dict[str, Any] | None:
|
|||
return None
|
||||
|
||||
|
||||
def _scoped_args(store: Any, tool_name: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
return enrich_tool_arguments(
|
||||
store=store,
|
||||
session_id=str(args.get("session_id") or ""),
|
||||
tool_name=tool_name,
|
||||
arguments=args,
|
||||
)
|
||||
|
||||
|
||||
_RECIPE_PARAM = {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"Self-contained workflow recipe (playbook). Required for complex/multi-step jobs. "
|
||||
"Must be understandable WITHOUT prior chat context: no '继续刚才/按上面'; "
|
||||
"put concrete paths, commands, time windows, and params in goal/steps/inputs.constants. "
|
||||
"Fields: goal, steps (>=2), success_criteria, optional constraints/inputs/output/source."
|
||||
),
|
||||
"properties": {
|
||||
"version": {"type": "integer"},
|
||||
"goal": {"type": "string"},
|
||||
"steps": {"type": "array", "items": {"type": "string"}},
|
||||
"constraints": {"type": "array", "items": {"type": "string"}},
|
||||
"success_criteria": {"type": "array", "items": {"type": "string"}},
|
||||
"inputs": {"type": "object"},
|
||||
"output": {"type": "object"},
|
||||
"source": {"type": "object"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def schedule_propose_tool() -> ToolSpec:
|
||||
def handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
store = get_assistant_store()
|
||||
args = _scoped_args(store, "schedule_propose", args)
|
||||
name = str(args.get("name") or "").strip() or "Scheduled workflow"
|
||||
schedule_kind = normalize_schedule_kind(str(args.get("schedule_kind") or "cron"))
|
||||
schedule_expr = _require(str(args.get("schedule_expr") or ""), "schedule_expr")
|
||||
timezone_name = str(args.get("timezone") or default_system_timezone()).strip() or default_system_timezone()
|
||||
lang = str(args.get("lang") or "zh")
|
||||
session_id = str(args.get("session_id") or "").strip()
|
||||
recipe = normalize_recipe(parse_recipe_arg(args.get("recipe")))
|
||||
if session_id and not str((recipe.get("source") or {}).get("session_id") or "").strip():
|
||||
recipe["source"]["session_id"] = session_id
|
||||
if not str((recipe.get("source") or {}).get("compiled_at") or "").strip():
|
||||
recipe["source"]["compiled_at"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
missing = recipe_missing_fields(recipe)
|
||||
if missing:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "recipe_incomplete",
|
||||
"missing_fields": missing,
|
||||
"hint": (
|
||||
"Fill goal, at least 2 steps, and success_criteria from the recent conversation, "
|
||||
"then call schedule_propose again. Do not create the job yet."
|
||||
),
|
||||
"recipe": recipe,
|
||||
}
|
||||
|
||||
preview = preview_markdown(
|
||||
name=name,
|
||||
schedule_kind=schedule_kind,
|
||||
schedule_expr=schedule_expr,
|
||||
timezone_name=timezone_name,
|
||||
recipe=recipe,
|
||||
lang=lang,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"draft": True,
|
||||
"name": name,
|
||||
"schedule_kind": schedule_kind,
|
||||
"schedule_expr": schedule_expr,
|
||||
"timezone": timezone_name,
|
||||
"recipe": recipe,
|
||||
"prompt_text": prompt_summary_from_recipe(recipe, fallback=name),
|
||||
"preview_markdown": preview,
|
||||
"next_step": (
|
||||
"Show preview_markdown to the user. After they confirm (or request edits), "
|
||||
"call schedule_create with the same recipe and schedule fields."
|
||||
),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
||||
|
||||
return ToolSpec(
|
||||
name="schedule_propose",
|
||||
description=(
|
||||
"Draft a scheduled workflow recipe WITHOUT creating the job. "
|
||||
"Use when the user wants to schedule a multi-step task they just guided "
|
||||
"(e.g. '做成定时/每周跑刚才那套'). "
|
||||
"CRITICAL: the recipe must be self-contained — at fire time there is no prior chat; "
|
||||
"a new LLM must understand the task from recipe alone (no '继续刚才', embed paths/params in steps/constants). "
|
||||
"Compile goal/steps(>=2)/success_criteria/constraints from the conversation into `recipe`, "
|
||||
"then show preview_markdown and wait for confirmation before schedule_create."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tenant_id": {"type": "string", "description": "Auto-filled from session; do not guess."},
|
||||
"owner_user_id": {"type": "string", "description": "Auto-filled from session."},
|
||||
"session_id": {"type": "string", "description": "Auto-filled from session."},
|
||||
"name": {"type": "string"},
|
||||
"recipe": _RECIPE_PARAM,
|
||||
"schedule_kind": {"type": "string", "enum": ["cron", "once", "interval"]},
|
||||
"schedule_expr": {"type": "string"},
|
||||
"timezone": {"type": "string"},
|
||||
"lang": {"type": "string"},
|
||||
},
|
||||
"required": ["recipe", "schedule_kind", "schedule_expr"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
handler=handler,
|
||||
tags=frozenset({"productivity", "schedule"}),
|
||||
)
|
||||
|
||||
|
||||
def schedule_create_tool() -> ToolSpec:
|
||||
def handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
|
|
@ -51,7 +179,37 @@ def schedule_create_tool() -> ToolSpec:
|
|||
"owner_user_id",
|
||||
)
|
||||
name = _require(str(args.get("name") or ""), "name")
|
||||
prompt_text = _require(str(args.get("prompt_text") or ""), "prompt_text")
|
||||
prompt_text = str(args.get("prompt_text") or "").strip()
|
||||
recipe_raw = parse_recipe_arg(args.get("recipe"))
|
||||
recipe = normalize_recipe(recipe_raw) if recipe_raw is not None else {}
|
||||
if recipe_has_playbook(recipe):
|
||||
prompt_text = prompt_summary_from_recipe(recipe, fallback=prompt_text or name)
|
||||
if not prompt_text:
|
||||
raise ValueError("prompt_text is required")
|
||||
|
||||
needs_recipe = looks_like_complex_schedule_prompt(prompt_text, recipe=recipe)
|
||||
if needs_recipe and not recipe_has_playbook(recipe):
|
||||
missing = recipe_missing_fields(recipe) or ["goal", "steps", "success_criteria"]
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "recipe_required",
|
||||
"missing_fields": missing,
|
||||
"hint": (
|
||||
"This looks like a complex/multi-step job. Call schedule_propose first, "
|
||||
"show the draft to the user, then schedule_create with a full recipe "
|
||||
"(goal + >=2 steps + success_criteria). Do not store vague prompts like "
|
||||
"'继续刚才那个'."
|
||||
),
|
||||
}
|
||||
if recipe and not recipe_has_playbook(recipe) and recipe_missing_fields(recipe):
|
||||
# Explicit but incomplete recipe → reject rather than silently drop.
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "recipe_incomplete",
|
||||
"missing_fields": recipe_missing_fields(recipe),
|
||||
"hint": "Complete the recipe or omit it for a simple reminder.",
|
||||
}
|
||||
|
||||
schedule_kind = normalize_schedule_kind(str(args.get("schedule_kind") or "cron"))
|
||||
schedule_expr = _require(str(args.get("schedule_expr") or ""), "schedule_expr")
|
||||
delivery = _parse_delivery_arg(args.get("delivery"))
|
||||
|
|
@ -76,6 +234,15 @@ def schedule_create_tool() -> ToolSpec:
|
|||
specialist = normalize_requested_specialist(
|
||||
str(args.get("specialist") or args.get("selected_specialist") or "generalist")
|
||||
)
|
||||
session_id = str(args.get("session_id") or "").strip() or None
|
||||
if recipe_has_playbook(recipe):
|
||||
if session_id and not str((recipe.get("source") or {}).get("session_id") or "").strip():
|
||||
recipe["source"]["session_id"] = session_id
|
||||
if not str((recipe.get("source") or {}).get("compiled_at") or "").strip():
|
||||
recipe["source"]["compiled_at"] = datetime.now(timezone.utc).isoformat()
|
||||
else:
|
||||
recipe = {}
|
||||
|
||||
row = store.scheduled_job_create(
|
||||
tenant_id=tenant_id,
|
||||
name=name,
|
||||
|
|
@ -88,7 +255,8 @@ def schedule_create_tool() -> ToolSpec:
|
|||
specialist=specialist,
|
||||
lang=str(args.get("lang") or "zh"),
|
||||
delivery=delivery,
|
||||
source_session_id=str(args.get("session_id") or "").strip() or None,
|
||||
recipe=recipe,
|
||||
source_session_id=session_id,
|
||||
created_by_user_id=owner_user_id,
|
||||
source="chat",
|
||||
)
|
||||
|
|
@ -98,7 +266,14 @@ def schedule_create_tool() -> ToolSpec:
|
|||
|
||||
return ToolSpec(
|
||||
name="schedule_create",
|
||||
description="Create a scheduled job (cron, once, or interval). Delivery follows the current chat channel (WhatsApp vs WeChat) unless delivery is set explicitly. For WhatsApp group @mentions, set whatsapp_mention_jids with explicit JIDs.",
|
||||
description=(
|
||||
"Create a scheduled job after the user confirmed the draft. "
|
||||
"Simple reminders may use prompt_text only. "
|
||||
"Complex / multi-step / '刚才那件事做成定时' jobs MUST include a self-contained recipe "
|
||||
"(goal + >=2 concrete steps + success_criteria; no chat-dependent phrasing); "
|
||||
"call schedule_propose and get user confirmation first. "
|
||||
"Delivery follows the current chat channel unless delivery is set explicitly."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -106,7 +281,11 @@ def schedule_create_tool() -> ToolSpec:
|
|||
"owner_user_id": {"type": "string", "description": "Auto-filled from session."},
|
||||
"session_id": {"type": "string", "description": "Auto-filled from session."},
|
||||
"name": {"type": "string"},
|
||||
"prompt_text": {"type": "string"},
|
||||
"prompt_text": {
|
||||
"type": "string",
|
||||
"description": "Short summary / reminder intent. For playbooks, prefer recipe.goal.",
|
||||
},
|
||||
"recipe": _RECIPE_PARAM,
|
||||
"schedule_kind": {"type": "string", "enum": ["cron", "once", "interval"]},
|
||||
"schedule_expr": {"type": "string"},
|
||||
"timezone": {"type": "string", "description": "IANA timezone; defaults to the host system timezone."},
|
||||
|
|
@ -120,6 +299,10 @@ def schedule_create_tool() -> ToolSpec:
|
|||
"items": {"type": "string"},
|
||||
"description": "WhatsApp JIDs to @mention on delivery (e.g. 628...@s.whatsapp.net). Stored in delivery.whatsapp.mention_jids.",
|
||||
},
|
||||
"whatsapp_mention_names": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
"delivery": {"type": "object"},
|
||||
"description": {"type": "string"},
|
||||
},
|
||||
|
|
@ -167,15 +350,6 @@ def schedule_list_tool() -> ToolSpec:
|
|||
)
|
||||
|
||||
|
||||
def _scoped_args(store: Any, tool_name: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
return enrich_tool_arguments(
|
||||
store=store,
|
||||
session_id=str(args.get("session_id") or ""),
|
||||
tool_name=tool_name,
|
||||
arguments=args,
|
||||
)
|
||||
|
||||
|
||||
def schedule_update_tool() -> ToolSpec:
|
||||
def handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
|
|
@ -200,6 +374,21 @@ def schedule_update_tool() -> ToolSpec:
|
|||
delivery = _parse_delivery_arg(args.get("delivery"))
|
||||
if delivery is not None:
|
||||
patch["delivery"] = delivery
|
||||
recipe_raw = parse_recipe_arg(args.get("recipe"))
|
||||
if recipe_raw is not None:
|
||||
recipe = normalize_recipe(recipe_raw)
|
||||
if recipe_has_playbook(recipe):
|
||||
patch["recipe"] = recipe
|
||||
if "prompt_text" not in patch:
|
||||
patch["prompt_text"] = prompt_summary_from_recipe(recipe)
|
||||
elif recipe_missing_fields(recipe):
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "recipe_incomplete",
|
||||
"missing_fields": recipe_missing_fields(recipe),
|
||||
}
|
||||
else:
|
||||
patch["recipe"] = {}
|
||||
row = store.scheduled_job_update(tenant_id=tenant_id, job_id=job_id, patch=patch)
|
||||
if not row:
|
||||
return {"ok": False, "error": "job_not_found"}
|
||||
|
|
@ -209,7 +398,7 @@ def schedule_update_tool() -> ToolSpec:
|
|||
|
||||
return ToolSpec(
|
||||
name="schedule_update",
|
||||
description="Update a scheduled job.",
|
||||
description="Update a scheduled job (including recipe playbook fields).",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -217,6 +406,7 @@ def schedule_update_tool() -> ToolSpec:
|
|||
"job_id": {"type": "string"},
|
||||
"name": {"type": "string"},
|
||||
"prompt_text": {"type": "string"},
|
||||
"recipe": _RECIPE_PARAM,
|
||||
"schedule_kind": {"type": "string"},
|
||||
"schedule_expr": {"type": "string"},
|
||||
"timezone": {"type": "string"},
|
||||
|
|
@ -340,6 +530,7 @@ def schedule_run_now_tool() -> ToolSpec:
|
|||
|
||||
__all__ = [
|
||||
"schedule_create_tool",
|
||||
"schedule_propose_tool",
|
||||
"schedule_list_tool",
|
||||
"schedule_update_tool",
|
||||
"schedule_pause_tool",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from runtime.tools.experts.productivity.schedule_tools import (
|
|||
schedule_delete_tool,
|
||||
schedule_list_tool,
|
||||
schedule_pause_tool,
|
||||
schedule_propose_tool,
|
||||
schedule_resume_tool,
|
||||
schedule_run_now_tool,
|
||||
schedule_update_tool,
|
||||
|
|
@ -23,6 +24,7 @@ __all__ = [
|
|||
"schedule_delete_tool",
|
||||
"schedule_list_tool",
|
||||
"schedule_pause_tool",
|
||||
"schedule_propose_tool",
|
||||
"schedule_resume_tool",
|
||||
"schedule_run_now_tool",
|
||||
"schedule_update_tool",
|
||||
|
|
|
|||
|
|
@ -286,9 +286,16 @@ def _worker_loop(*, store: Any, worker_id: str, poll_interval_s: float) -> None:
|
|||
if not system_prompt:
|
||||
system_prompt = str(getattr(executor, "system_prompt", "") or "")
|
||||
if is_scheduled_turn:
|
||||
from runtime.scheduler.recipe import recipe_has_playbook
|
||||
from runtime.scheduler.turn_text import scheduled_turn_system_suffix
|
||||
|
||||
system_prompt = str(system_prompt or "") + scheduled_turn_system_suffix(lang=lang)
|
||||
playbook = bool((metadata or {}).get("scheduled_playbook")) or recipe_has_playbook(
|
||||
payload.get("recipe") if isinstance(payload.get("recipe"), dict) else None
|
||||
)
|
||||
system_prompt = str(system_prompt or "") + scheduled_turn_system_suffix(
|
||||
lang=lang,
|
||||
playbook=playbook,
|
||||
)
|
||||
|
||||
max_messages = int(store.get_setting("AIA_TURN_MAX_CONTEXT_MESSAGES") or 80)
|
||||
max_tool_rounds = int(store.get_setting("AIA_TURN_MAX_TOOL_ROUNDS") or 100)
|
||||
|
|
|
|||
114
skills/_workspace/public/scheduled-workflows/SKILL.md
Normal file
114
skills/_workspace/public/scheduled-workflows/SKILL.md
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
---
|
||||
name: scheduled-workflows
|
||||
description: "把复杂/多步工作固化成定时 Workflow Recipe:先 schedule_propose 出草稿给用户确认,再 schedule_create。Recipe 必须自包含——到点执行时没有原对话,LLM 仅凭 recipe 仍能正确完成任务。"
|
||||
---
|
||||
|
||||
# 定时工作流 — Scheduled Workflows
|
||||
|
||||
## 何时使用
|
||||
|
||||
用户想把**刚才引导完成的多步任务**做成周期执行(例如「做成每周一 9 点的定时」「按刚才那套流程每天跑」),或创建会产出文件/报告的定时任务。
|
||||
|
||||
简单一句提醒(「每三小时提醒我喝水」)**不要**用本流程,直接 `schedule_create` + 短 `prompt_text` 即可。
|
||||
|
||||
## 最重要:自包含(无上下文仍可执行)
|
||||
|
||||
到点触发时,worker **不会**把「当时那通聊天」当成任务说明。LLM 几乎只看到 `recipe` 编译出的内部指令(外加可能截断的会话噪声)。
|
||||
|
||||
因此写入的 recipe / 步骤文案必须满足:
|
||||
|
||||
> **一个陌生的 LLM 在没有原对话、没有「刚才」「那个」指代的情况下,仍能正确理解并完成任务。**
|
||||
|
||||
### 禁止写入(指代 / 依赖上文)
|
||||
|
||||
- 「继续刚才那个」「按上面流程」「和上次一样」
|
||||
- 「用用户给的那个文件」「那个模板」——却不写清路径/文件名/字段
|
||||
- 「参考本会话里刚生成的报告」——却不把关键结论、参数、路径抄进 recipe
|
||||
- 步骤短到只有动词:「处理数据」「发一下」
|
||||
|
||||
### 必须写入(自包含要素)
|
||||
|
||||
- **做什么**:具体目标与交付物(文件类型、发给谁、消息长什么样)
|
||||
- **怎么做**:可逐步执行的步骤(工具/命令/数据源/命名规则)
|
||||
- **用什么**:路径、URL、表名、模板内容或固定参数 → 放进 `inputs.constants`
|
||||
- **边界**:不要动什么、失败怎么处理 → `constraints`
|
||||
- **怎样算完成**:可检查的成功标准 → `success_criteria`
|
||||
|
||||
写完后自检一句:**「若只有这份 recipe、清空聊天记录,新模型能否一次做对?」** 不能则补字段,禁止提交。
|
||||
|
||||
## 任务模板(标准样例,照此展开)
|
||||
|
||||
新建复杂定时任务时,**按下面模板填满**再 `schedule_propose`。括号内是说明,写入时换成真实内容。
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"goal": "【一句话完整目标】例如:每周一 09:00 汇总上一自然周值班告警,生成 PDF 发到当前 WhatsApp 群并 @值班人",
|
||||
"steps": [
|
||||
"【步骤1·取数】说明数据从哪来:接口/命令/文件路径、时间范围如何算(上周一 00:00 至周日 23:59,时区 Asia/Shanghai)",
|
||||
"【步骤2·处理】说明过滤规则、分组字段、统计口径;需要生成的中间文件路径(如 data/workspace/tmp/duty_weekly.csv)",
|
||||
"【步骤3·产出】说明最终交付物格式与路径(如 data/workspace/tmp/duty_weekly_YYYYMMDD.pdf);标题/章节结构写清",
|
||||
"【步骤4·投递】调用 save_deliverable_attachment 发送 PDF;渠道消息须含:周期范围、总数、Top3 风险、是否需人工跟进;若需 @人写清展示名与用途"
|
||||
],
|
||||
"constraints": [
|
||||
"【边界】只读该数据源,不修改生产配置/历史文件",
|
||||
"【失败】取数失败则渠道只发失败原因摘要,不假装成功、不发空 PDF"
|
||||
],
|
||||
"success_criteria": [
|
||||
"群内收到本周 PDF 附件",
|
||||
"消息正文含统计周期与至少一条结论或风险点",
|
||||
"无未说明的错误/半成品路径糊弄用户"
|
||||
],
|
||||
"inputs": {
|
||||
"constants": {
|
||||
"timezone": "Asia/Shanghai",
|
||||
"data_source": "【写死:命令、API、或 workspace 固定路径】",
|
||||
"output_dir": "data/workspace/tmp",
|
||||
"report_title_template": "值班周报 {week_range}"
|
||||
},
|
||||
"from_context": []
|
||||
},
|
||||
"output": {
|
||||
"style": "channel_update",
|
||||
"need_attachments": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `from_context` 默认保持 `[]`。凡是执行要用的信息,**尽量写进 `constants` 或 steps 正文**,不要指望「到点还能翻到建任务时那通聊天」。
|
||||
- `goal` 也可作为 `prompt_text` 短摘要,但**不能只有 goal、steps 仍含糊**。
|
||||
|
||||
### 反例 → 正例
|
||||
|
||||
| 反例(依赖上下文) | 正例(自包含) |
|
||||
|-------------------|----------------|
|
||||
| 步骤:生成报告并发群 | 用 `data/workspace/templates/duty.md` 模板,把上周告警按 NE 聚合,写 PDF 到 `data/workspace/tmp/duty_weekly.pdf`,再 `save_deliverable_attachment` 发当前渠道 |
|
||||
| constants: `{}`,口头说「用刚才那个 CSV」 | `"source_csv": "data/workspace/uploads/alarms_export.csv"`(或写入导出口径与再拉取命令) |
|
||||
| success: 搞定 | 渠道出现 PDF;正文含「告警总数 / 严重占比」 |
|
||||
|
||||
## 标准流程(必须遵守)
|
||||
|
||||
1. **`schedule_propose`**(只草稿,不落库)
|
||||
按上方**任务模板**从对话整理自包含 `recipe`(禁止原样塞「继续刚才」)。
|
||||
2. **把 `preview_markdown` 发给用户**,并口头强调:到点不会带着本次聊天,请核对步骤是否自包含。
|
||||
3. 用户确认后 **`schedule_create`**,带上同一份 `recipe` + 时间字段。
|
||||
`prompt_text` = `recipe.goal`(完整句,不要用「同上」)。
|
||||
|
||||
禁止:复杂任务直接 `schedule_create(prompt_text="继续刚才那个")`。
|
||||
|
||||
## 与渠道附件
|
||||
|
||||
若周期任务会生成文件,设 `"output": {"need_attachments": true}`,步骤里写明 `save_deliverable_attachment`(见 **channel-file-delivery**)。
|
||||
|
||||
## 简单 vs 复杂
|
||||
|
||||
| 类型 | 做法 |
|
||||
|------|------|
|
||||
| 喝水/开会提醒 | `schedule_create`,仅短 `prompt_text`(本身已自包含) |
|
||||
| 多步流程 /「刚才那件事」 | 套用**任务模板** → `schedule_propose` → 确认 → `schedule_create(recipe=...)` |
|
||||
|
||||
## 修改已有任务
|
||||
|
||||
用 `schedule_update` 更新 `recipe`。改完仍做自检:无上下文能否执行;必要时 `schedule_run_now` 验一次。
|
||||
|
|
@ -20,6 +20,7 @@ CREATE TABLE IF NOT EXISTS scheduled_job (
|
|||
schedule_expr TEXT NOT NULL,
|
||||
timezone TEXT NOT NULL DEFAULT 'Asia/Shanghai',
|
||||
prompt_text TEXT NOT NULL,
|
||||
recipe_json TEXT NOT NULL DEFAULT '{}',
|
||||
interaction_mode TEXT NOT NULL DEFAULT 'expert',
|
||||
specialist TEXT NOT NULL DEFAULT 'generalist',
|
||||
lang TEXT NOT NULL DEFAULT 'zh',
|
||||
|
|
@ -81,6 +82,7 @@ class ScheduledJob:
|
|||
schedule_expr: str
|
||||
timezone: str
|
||||
prompt_text: str
|
||||
recipe_json: str
|
||||
interaction_mode: str
|
||||
specialist: str
|
||||
lang: str
|
||||
|
|
@ -95,6 +97,18 @@ class ScheduledJob:
|
|||
updated_at: str
|
||||
|
||||
|
||||
def _row_field(row: Any, key: str, default: str = "") -> str:
|
||||
try:
|
||||
if hasattr(row, "keys") and key in row.keys():
|
||||
return str(row[key] if row[key] is not None else default)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return str(row[key] if row[key] is not None else default)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScheduledJobRun:
|
||||
id: str
|
||||
|
|
@ -115,27 +129,28 @@ class ScheduledJobRun:
|
|||
|
||||
def _row_to_job(row: Any) -> ScheduledJob:
|
||||
return ScheduledJob(
|
||||
id=str(row["id"] or ""),
|
||||
tenant_id=str(row["tenant_id"] or ""),
|
||||
name=str(row["name"] or ""),
|
||||
description=str(row["description"] or ""),
|
||||
status=str(row["status"] or ""),
|
||||
schedule_kind=str(row["schedule_kind"] or ""),
|
||||
schedule_expr=str(row["schedule_expr"] or ""),
|
||||
timezone=str(row["timezone"] or default_system_timezone()),
|
||||
prompt_text=str(row["prompt_text"] or ""),
|
||||
interaction_mode=str(row["interaction_mode"] or "expert"),
|
||||
specialist=str(row["specialist"] or "generalist"),
|
||||
lang=str(row["lang"] or "zh"),
|
||||
delivery_json=str(row["delivery_json"] or "{}"),
|
||||
source_session_id=str(row["source_session_id"] or "") or None,
|
||||
created_by_user_id=str(row["created_by_user_id"] or ""),
|
||||
source=str(row["source"] or ""),
|
||||
next_run_at=str(row["next_run_at"] or "") or None,
|
||||
last_run_at=str(row["last_run_at"] or "") or None,
|
||||
last_run_status=str(row["last_run_status"] or ""),
|
||||
created_at=str(row["created_at"] or ""),
|
||||
updated_at=str(row["updated_at"] or ""),
|
||||
id=_row_field(row, "id"),
|
||||
tenant_id=_row_field(row, "tenant_id"),
|
||||
name=_row_field(row, "name"),
|
||||
description=_row_field(row, "description"),
|
||||
status=_row_field(row, "status"),
|
||||
schedule_kind=_row_field(row, "schedule_kind"),
|
||||
schedule_expr=_row_field(row, "schedule_expr"),
|
||||
timezone=_row_field(row, "timezone", default_system_timezone()) or default_system_timezone(),
|
||||
prompt_text=_row_field(row, "prompt_text"),
|
||||
recipe_json=_row_field(row, "recipe_json", "{}") or "{}",
|
||||
interaction_mode=_row_field(row, "interaction_mode", "expert") or "expert",
|
||||
specialist=_row_field(row, "specialist", "generalist") or "generalist",
|
||||
lang=_row_field(row, "lang", "zh") or "zh",
|
||||
delivery_json=_row_field(row, "delivery_json", "{}") or "{}",
|
||||
source_session_id=_row_field(row, "source_session_id") or None,
|
||||
created_by_user_id=_row_field(row, "created_by_user_id"),
|
||||
source=_row_field(row, "source"),
|
||||
next_run_at=_row_field(row, "next_run_at") or None,
|
||||
last_run_at=_row_field(row, "last_run_at") or None,
|
||||
last_run_status=_row_field(row, "last_run_status"),
|
||||
created_at=_row_field(row, "created_at"),
|
||||
updated_at=_row_field(row, "updated_at"),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -160,7 +175,7 @@ def _row_to_run(row: Any) -> ScheduledJobRun:
|
|||
|
||||
_JOB_SELECT = """
|
||||
SELECT id, tenant_id, name, description, status, schedule_kind, schedule_expr, timezone,
|
||||
prompt_text, interaction_mode, specialist, lang, delivery_json, source_session_id,
|
||||
prompt_text, recipe_json, interaction_mode, specialist, lang, delivery_json, source_session_id,
|
||||
created_by_user_id, source, next_run_at, last_run_at, last_run_status, created_at, updated_at
|
||||
FROM scheduled_job
|
||||
"""
|
||||
|
|
@ -172,6 +187,19 @@ class ScheduledJobStoreMixin:
|
|||
conn.execute(SCHEDULED_JOB_INDEX_DDL)
|
||||
conn.execute(SCHEDULED_JOB_RUN_DDL)
|
||||
conn.execute(SCHEDULED_JOB_RUN_INDEX_DDL)
|
||||
self._ensure_scheduled_job_recipe_column(conn)
|
||||
|
||||
def _ensure_scheduled_job_recipe_column(self, conn: Any) -> None:
|
||||
if bool(getattr(self, "_use_pg", False)):
|
||||
conn.execute(
|
||||
"ALTER TABLE scheduled_job ADD COLUMN IF NOT EXISTS recipe_json TEXT NOT NULL DEFAULT '{}'"
|
||||
)
|
||||
return
|
||||
cols = {row[1] for row in conn.execute("PRAGMA table_info(scheduled_job)").fetchall()}
|
||||
if "recipe_json" not in cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE scheduled_job ADD COLUMN recipe_json TEXT NOT NULL DEFAULT '{}'"
|
||||
)
|
||||
|
||||
def scheduled_job_create(
|
||||
self,
|
||||
|
|
@ -187,6 +215,7 @@ class ScheduledJobStoreMixin:
|
|||
specialist: str = "generalist",
|
||||
lang: str = "zh",
|
||||
delivery: dict[str, Any] | None = None,
|
||||
recipe: dict[str, Any] | None = None,
|
||||
source_session_id: str | None = None,
|
||||
created_by_user_id: str = "",
|
||||
source: str = "admin",
|
||||
|
|
@ -203,14 +232,15 @@ class ScheduledJobStoreMixin:
|
|||
from_dt=None,
|
||||
)
|
||||
delivery_json = json.dumps(delivery or {}, ensure_ascii=False)
|
||||
recipe_json = json.dumps(recipe or {}, ensure_ascii=False)
|
||||
with self._connect() as conn: # type: ignore[attr-defined]
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO scheduled_job
|
||||
(id, tenant_id, name, description, status, schedule_kind, schedule_expr, timezone,
|
||||
prompt_text, interaction_mode, specialist, lang, delivery_json, source_session_id,
|
||||
prompt_text, recipe_json, interaction_mode, specialist, lang, delivery_json, source_session_id,
|
||||
created_by_user_id, source, next_run_at, last_run_at, last_run_status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, '', ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, '', ?, ?)
|
||||
""",
|
||||
(
|
||||
jid,
|
||||
|
|
@ -222,6 +252,7 @@ class ScheduledJobStoreMixin:
|
|||
str(schedule_expr or "").strip(),
|
||||
tz,
|
||||
str(prompt_text or "").strip(),
|
||||
recipe_json,
|
||||
str(interaction_mode or "expert"),
|
||||
str(specialist or "generalist"),
|
||||
str(lang or "zh"),
|
||||
|
|
@ -302,6 +333,11 @@ class ScheduledJobStoreMixin:
|
|||
delivery_json = json.dumps(p["delivery"], ensure_ascii=False)
|
||||
elif "delivery_json" in p:
|
||||
delivery_json = str(p.get("delivery_json") or "{}")
|
||||
recipe_json = cur.recipe_json
|
||||
if "recipe" in p and isinstance(p.get("recipe"), dict):
|
||||
recipe_json = json.dumps(p["recipe"], ensure_ascii=False)
|
||||
elif "recipe_json" in p:
|
||||
recipe_json = str(p.get("recipe_json") or "{}")
|
||||
source_session_id = cur.source_session_id
|
||||
if "source_session_id" in p:
|
||||
raw_sid = str(p.get("source_session_id") or "").strip()
|
||||
|
|
@ -323,7 +359,7 @@ class ScheduledJobStoreMixin:
|
|||
conn.execute(
|
||||
"""
|
||||
UPDATE scheduled_job SET
|
||||
name = ?, description = ?, prompt_text = ?, interaction_mode = ?, specialist = ?,
|
||||
name = ?, description = ?, prompt_text = ?, recipe_json = ?, interaction_mode = ?, specialist = ?,
|
||||
lang = ?, schedule_kind = ?, schedule_expr = ?, timezone = ?, delivery_json = ?,
|
||||
source_session_id = ?, status = ?, next_run_at = ?, updated_at = ?
|
||||
WHERE id = ? AND tenant_id = ?
|
||||
|
|
@ -332,6 +368,7 @@ class ScheduledJobStoreMixin:
|
|||
name,
|
||||
description,
|
||||
prompt_text,
|
||||
recipe_json,
|
||||
interaction_mode,
|
||||
specialist,
|
||||
lang,
|
||||
|
|
@ -576,6 +613,13 @@ class ScheduledJobStoreMixin:
|
|||
delivery = raw
|
||||
except Exception:
|
||||
delivery = {}
|
||||
recipe: dict[str, Any] = {}
|
||||
try:
|
||||
raw_recipe = json.loads(job.recipe_json or "{}")
|
||||
if isinstance(raw_recipe, dict):
|
||||
recipe = raw_recipe
|
||||
except Exception:
|
||||
recipe = {}
|
||||
return {
|
||||
"id": job.id,
|
||||
"tenant_id": job.tenant_id,
|
||||
|
|
@ -586,6 +630,7 @@ class ScheduledJobStoreMixin:
|
|||
"schedule_expr": job.schedule_expr,
|
||||
"timezone": job.timezone,
|
||||
"prompt_text": job.prompt_text,
|
||||
"recipe": recipe,
|
||||
"interaction_mode": job.interaction_mode,
|
||||
"specialist": job.specialist,
|
||||
"lang": job.lang,
|
||||
|
|
|
|||
204
tests/test_schedule_recipe.py
Normal file
204
tests/test_schedule_recipe.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from runtime.scheduler.recipe import (
|
||||
compile_playbook_instruction,
|
||||
looks_like_complex_schedule_prompt,
|
||||
normalize_recipe,
|
||||
preview_markdown,
|
||||
prompt_summary_from_recipe,
|
||||
recipe_has_playbook,
|
||||
recipe_missing_fields,
|
||||
)
|
||||
from runtime.scheduler.turn_text import build_scheduled_turn_instruction, scheduled_turn_system_suffix
|
||||
from runtime.tools.experts.productivity.schedule_tools import (
|
||||
schedule_create_tool,
|
||||
schedule_propose_tool,
|
||||
)
|
||||
from svc.persistence.assistant_store import reset_assistant_store_singleton
|
||||
from svc.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class RecipeHelpersTests(unittest.TestCase):
|
||||
def test_normalize_and_missing_fields(self) -> None:
|
||||
recipe = normalize_recipe(
|
||||
{
|
||||
"goal": "发值班报告",
|
||||
"steps": ["拉数据"],
|
||||
"success_criteria": [],
|
||||
}
|
||||
)
|
||||
self.assertEqual(recipe["goal"], "发值班报告")
|
||||
self.assertEqual(recipe_missing_fields(recipe), ["steps", "success_criteria"])
|
||||
self.assertFalse(recipe_has_playbook(recipe))
|
||||
|
||||
def test_playbook_ready(self) -> None:
|
||||
recipe = normalize_recipe(
|
||||
{
|
||||
"goal": "发值班报告",
|
||||
"steps": ["拉数据", "写摘要", "发群"],
|
||||
"success_criteria": ["群内收到 PDF"],
|
||||
"output": {"need_attachments": True},
|
||||
}
|
||||
)
|
||||
self.assertTrue(recipe_has_playbook(recipe))
|
||||
self.assertEqual(recipe_missing_fields(recipe), [])
|
||||
preview = preview_markdown(
|
||||
name="值班报告",
|
||||
schedule_kind="cron",
|
||||
schedule_expr="0 9 * * 1",
|
||||
timezone_name="Asia/Shanghai",
|
||||
recipe=recipe,
|
||||
lang="zh",
|
||||
)
|
||||
self.assertIn("拉数据", preview)
|
||||
self.assertIn("确认", preview)
|
||||
instr = compile_playbook_instruction(recipe=recipe, lang="zh")
|
||||
self.assertIn("定时工作流", instr)
|
||||
self.assertIn("save_deliverable_attachment", instr)
|
||||
self.assertEqual(prompt_summary_from_recipe(recipe), "发值班报告")
|
||||
|
||||
def test_complex_prompt_heuristic(self) -> None:
|
||||
self.assertTrue(looks_like_complex_schedule_prompt("把刚才那件事做成每周一定时"))
|
||||
self.assertTrue(looks_like_complex_schedule_prompt("继续刚才那个生成 PDF 流程"))
|
||||
self.assertFalse(looks_like_complex_schedule_prompt("提醒喝水"))
|
||||
|
||||
def test_turn_instruction_modes(self) -> None:
|
||||
reminder = build_scheduled_turn_instruction(prompt_text="喝水", mode="scheduled", lang="zh")
|
||||
self.assertIn("提醒意图", reminder)
|
||||
recipe = {
|
||||
"goal": "发报告",
|
||||
"steps": ["A", "B"],
|
||||
"success_criteria": ["完成"],
|
||||
}
|
||||
playbook = build_scheduled_turn_instruction(
|
||||
prompt_text="发报告",
|
||||
mode="scheduled",
|
||||
lang="zh",
|
||||
recipe=recipe,
|
||||
)
|
||||
self.assertIn("步骤", playbook)
|
||||
self.assertIn("A", playbook)
|
||||
self.assertIn("工作流", scheduled_turn_system_suffix(lang="zh", playbook=True))
|
||||
self.assertIn("提醒", scheduled_turn_system_suffix(lang="zh", playbook=False))
|
||||
|
||||
|
||||
class ScheduleRecipeToolTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "recipe.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
os.environ["AIA_ASSISTANT_DB_BACKEND"] = "sqlite"
|
||||
reset_assistant_store_singleton()
|
||||
self.store = SqliteStore(str(self.db))
|
||||
t = self.store.create_tenant("Team")
|
||||
self.tenant_id = str(t["id"])
|
||||
user = self.store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="administrator",
|
||||
display_name="Admin",
|
||||
role="owner",
|
||||
password_hash="x",
|
||||
is_active=True,
|
||||
)
|
||||
self.user_id = str(user["id"])
|
||||
|
||||
def tearDown(self) -> None:
|
||||
reset_assistant_store_singleton()
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_propose_rejects_incomplete_recipe(self) -> None:
|
||||
out = schedule_propose_tool().handler(
|
||||
{
|
||||
"tenant_id": self.tenant_id,
|
||||
"owner_user_id": self.user_id,
|
||||
"name": "报告",
|
||||
"schedule_kind": "cron",
|
||||
"schedule_expr": "0 9 * * 1",
|
||||
"recipe": {"goal": "发报告", "steps": ["一步"]},
|
||||
}
|
||||
)
|
||||
self.assertFalse(out.get("ok"))
|
||||
self.assertEqual(out.get("error"), "recipe_incomplete")
|
||||
|
||||
def test_propose_and_create_playbook(self) -> None:
|
||||
recipe = {
|
||||
"goal": "每周发值班 PDF",
|
||||
"steps": ["拉取数据", "生成 PDF", "发群"],
|
||||
"success_criteria": ["群内收到 PDF"],
|
||||
"constraints": ["不改历史文件"],
|
||||
"output": {"need_attachments": True},
|
||||
}
|
||||
proposed = schedule_propose_tool().handler(
|
||||
{
|
||||
"tenant_id": self.tenant_id,
|
||||
"owner_user_id": self.user_id,
|
||||
"session_id": "sess-1",
|
||||
"name": "值班报告",
|
||||
"schedule_kind": "cron",
|
||||
"schedule_expr": "0 9 * * 1",
|
||||
"recipe": recipe,
|
||||
}
|
||||
)
|
||||
self.assertTrue(proposed.get("ok"), proposed)
|
||||
self.assertTrue(proposed.get("draft"))
|
||||
self.assertIn("preview_markdown", proposed)
|
||||
|
||||
created = schedule_create_tool().handler(
|
||||
{
|
||||
"tenant_id": self.tenant_id,
|
||||
"owner_user_id": self.user_id,
|
||||
"session_id": "sess-1",
|
||||
"name": "值班报告",
|
||||
"prompt_text": "每周发值班 PDF",
|
||||
"schedule_kind": "cron",
|
||||
"schedule_expr": "0 9 * * 1",
|
||||
"recipe": recipe,
|
||||
}
|
||||
)
|
||||
self.assertTrue(created.get("ok"), created)
|
||||
job = created.get("job") or {}
|
||||
self.assertEqual((job.get("recipe") or {}).get("goal"), "每周发值班 PDF")
|
||||
self.assertEqual(len((job.get("recipe") or {}).get("steps") or []), 3)
|
||||
|
||||
got = self.store.scheduled_job_get(job_id=str(job["id"]), tenant_id=self.tenant_id)
|
||||
assert got is not None
|
||||
self.assertIn("每周发值班 PDF", got.recipe_json)
|
||||
|
||||
def test_create_rejects_vague_complex_prompt(self) -> None:
|
||||
out = schedule_create_tool().handler(
|
||||
{
|
||||
"tenant_id": self.tenant_id,
|
||||
"owner_user_id": self.user_id,
|
||||
"name": "bad",
|
||||
"prompt_text": "继续刚才那个",
|
||||
"schedule_kind": "interval",
|
||||
"schedule_expr": "3600",
|
||||
}
|
||||
)
|
||||
self.assertFalse(out.get("ok"))
|
||||
self.assertEqual(out.get("error"), "recipe_required")
|
||||
|
||||
def test_simple_reminder_still_works(self) -> None:
|
||||
out = schedule_create_tool().handler(
|
||||
{
|
||||
"tenant_id": self.tenant_id,
|
||||
"owner_user_id": self.user_id,
|
||||
"name": "喝水",
|
||||
"prompt_text": "提醒喝水",
|
||||
"schedule_kind": "interval",
|
||||
"schedule_expr": "10800",
|
||||
}
|
||||
)
|
||||
self.assertTrue(out.get("ok"), out)
|
||||
job = out.get("job") or {}
|
||||
self.assertEqual(job.get("prompt_text"), "提醒喝水")
|
||||
self.assertEqual(job.get("recipe") or {}, {})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue