CI / test (push) Canceled after 0s
## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.com>
224 lines
10 KiB
Python
224 lines
10 KiB
Python
"""Schedule Task module — AI Create Task.
|
|
|
|
Turns a natural-language description ("Mỗi thứ 2 lúc 9h, dùng Co4E đọc dữ liệu
|
|
CAE, tạo báo cáo, rồi chuyển cho Cowork soạn email...") into a list of task
|
|
dicts, via the active provider. The result is a PREVIEW — the UI shows it and
|
|
only creates real tasks after the user confirms (spec §9.2).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from .tasks import (
|
|
INPUT_MODES, PRIORITIES, REPEAT_TYPES, RUN_NEXT_MODES, TASK_TYPES, new_task,
|
|
)
|
|
|
|
_SYSTEM = """You convert a user's natural-language request into scheduled tasks
|
|
for a desktop automation app. Reply with ONE JSON object only (no prose, no
|
|
markdown fences) shaped exactly like:
|
|
{"tasks": [{
|
|
"title": str,
|
|
"description": str,
|
|
"task_type": "cowork"|"co4e_code"|"script"|"manual",
|
|
"priority": "low"|"medium"|"high"|"critical",
|
|
"schedule": {"enabled": bool, "run_at": "YYYY-MM-DD HH:MM" or null,
|
|
"repeat_type": "none"|"daily"|"weekly"},
|
|
"input": {"mode": "empty"|"manual"|"previous_task_output", "manual_text": str or null},
|
|
"dependency": {"previous_task_id": "TASK_1" or null,
|
|
"run_next_mode": "none"|"run_after_success"|"run_always"|"run_after_manual_confirm",
|
|
"pass_output_to_next": bool}
|
|
}]}
|
|
Rules: use "co4e_code" for coding/data/file-processing work, "cowork" for
|
|
documents/emails/reports/chat-style work. Reference earlier tasks in the same
|
|
reply as "TASK_1", "TASK_2" (1-based order). If the user gives a schedule,
|
|
fill run_at with the NEXT occurrence from today. Keep 1-4 tasks."""
|
|
|
|
|
|
def _extract_json(text: str) -> Optional[dict]:
|
|
"""The first parseable {...} block in the model's reply."""
|
|
text = (text or "").strip()
|
|
fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
|
|
if fenced:
|
|
text = fenced.group(1)
|
|
start = text.find("{")
|
|
if start == -1:
|
|
return None
|
|
for end in range(len(text), start, -1):
|
|
try:
|
|
return json.loads(text[start:end])
|
|
except json.JSONDecodeError:
|
|
continue
|
|
return None
|
|
|
|
|
|
def _clamp(value, allowed, default):
|
|
"""Ép một giá trị về tập hợp lệ; ngoài tập thì lấy mặc định.
|
|
|
|
Cần vì model hay trả về giá trị gần đúng ('High' thay vì 'high').
|
|
"""
|
|
return value if value in allowed else default
|
|
|
|
|
|
def normalize_planned_tasks(payload: dict) -> List[Dict[str, Any]]:
|
|
"""Turn the model's JSON into real task dicts (all defaults filled) and
|
|
resolve TASK_n references into actual ids + back-links, so the chain works
|
|
both directions (prev's next_task_id AND next's previous_task_id)."""
|
|
raw = (payload or {}).get("tasks") or []
|
|
if not isinstance(raw, list) or not raw:
|
|
return []
|
|
tasks: List[Dict[str, Any]] = []
|
|
for item in raw[:8]:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
t = new_task(str(item.get("title") or "Untitled task"))
|
|
t["description"] = str(item.get("description") or "")
|
|
t["task_type"] = _clamp(item.get("task_type"), TASK_TYPES, "cowork")
|
|
t["priority"] = _clamp(item.get("priority"), PRIORITIES, "medium")
|
|
t["is_ai_generated"] = True
|
|
sched = item.get("schedule") or {}
|
|
t["schedule"]["enabled"] = bool(sched.get("enabled"))
|
|
t["schedule"]["run_at"] = sched.get("run_at") or None
|
|
t["schedule"]["repeat_type"] = _clamp(sched.get("repeat_type"), REPEAT_TYPES, "none")
|
|
if t["schedule"]["enabled"] and t["schedule"]["run_at"]:
|
|
t["status"] = "scheduled"
|
|
inp = item.get("input") or {}
|
|
t["input"]["mode"] = _clamp(inp.get("mode"), INPUT_MODES, "empty")
|
|
t["input"]["manual_text"] = inp.get("manual_text") or None
|
|
dep = item.get("dependency") or {}
|
|
t["dependency"]["run_next_mode"] = _clamp(dep.get("run_next_mode"),
|
|
RUN_NEXT_MODES, "none")
|
|
t["dependency"]["pass_output_to_next"] = bool(dep.get("pass_output_to_next"))
|
|
t["_prev_ref"] = dep.get("previous_task_id") # TASK_n, resolved below
|
|
tasks.append(t)
|
|
|
|
# Resolve TASK_n → actual ids; wire both directions of the chain.
|
|
for t in tasks:
|
|
ref = t.pop("_prev_ref", None)
|
|
if not ref:
|
|
continue
|
|
m = re.match(r"TASK_(\d+)$", str(ref).strip())
|
|
idx = int(m.group(1)) - 1 if m else -1
|
|
if 0 <= idx < len(tasks) and tasks[idx] is not t:
|
|
prev = tasks[idx]
|
|
t["dependency"]["previous_task_id"] = prev["task_id"]
|
|
t["input"]["previous_task_id"] = prev["task_id"]
|
|
prev["dependency"]["next_task_id"] = t["task_id"]
|
|
if t["dependency"]["run_next_mode"] != "none":
|
|
prev["dependency"]["run_next_mode"] = t["dependency"]["run_next_mode"]
|
|
if t["dependency"]["pass_output_to_next"] or t["input"]["mode"] == "previous_task_output":
|
|
prev["dependency"]["pass_output_to_next"] = True
|
|
t["input"]["mode"] = "previous_task_output"
|
|
return tasks
|
|
|
|
|
|
def generate_task_description(provider, title: str, cancel=None) -> str:
|
|
"""Best-effort ✨ helper: draft a task's description from its title.
|
|
Returns '' on any error so the editor never breaks."""
|
|
title = (title or "").strip()
|
|
if not title:
|
|
return ""
|
|
messages = [
|
|
{"role": "system", "content":
|
|
"You write the DESCRIPTION of a scheduled automation task. Given its title, "
|
|
"write 2-4 concise sentences describing exactly what the task should do "
|
|
"(inputs, action, expected output). Reply with ONLY the description text, "
|
|
"in the same language as the title."},
|
|
{"role": "user", "content": title},
|
|
]
|
|
try:
|
|
a = provider.chat(messages, tools=None, on_text=None, cancel=cancel)
|
|
except Exception: # noqa: BLE001
|
|
return ""
|
|
return (a.get("content") or "").strip()
|
|
|
|
|
|
def generate_task_input_text(provider, title: str, description: str, cancel=None) -> str:
|
|
"""Best-effort ✨ helper: draft the task's Prompt/Input text from its
|
|
title + description. Returns '' on any error so the editor never breaks."""
|
|
title = (title or "").strip()
|
|
if not title:
|
|
return ""
|
|
messages = [
|
|
{"role": "system", "content":
|
|
"You write the INPUT/PROMPT text for a scheduled automation task — extra "
|
|
"context, data, or instructions the agent will need beyond the title and "
|
|
"description. Given the task's title and description, write 2-4 concise "
|
|
"sentences. Reply with ONLY the prompt text, in the same language as the title."},
|
|
{"role": "user", "content": f"Title: {title}\nDescription: {description or '(none)'}"},
|
|
]
|
|
try:
|
|
a = provider.chat(messages, tools=None, on_text=None, cancel=cancel)
|
|
except Exception: # noqa: BLE001
|
|
return ""
|
|
return (a.get("content") or "").strip()
|
|
|
|
|
|
def generate_prompt_from_description(provider, description: str, cancel=None) -> str:
|
|
"""Best-effort ✨ helper: expand the task's DESCRIPTION into the ready-to-run
|
|
Prompt/Input text the agent will act on. The title is intentionally NOT used
|
|
— in Schedule Task the title is just the card's label, so the content comes
|
|
only from the description. Returns '' on empty input or any error so the
|
|
editor never breaks."""
|
|
description = (description or "").strip()
|
|
if not description:
|
|
return ""
|
|
messages = [
|
|
{"role": "system", "content":
|
|
"You turn a task DESCRIPTION into the ready-to-run PROMPT an AI agent will "
|
|
"execute for a scheduled automation task. Rewrite the description as clear, "
|
|
"actionable instructions (what to do, with which inputs, and the expected "
|
|
"output). Do NOT invent a topic from a title — use ONLY the description. "
|
|
"Reply with ONLY the prompt text, in the same language as the description."},
|
|
{"role": "user", "content": description},
|
|
]
|
|
try:
|
|
a = provider.chat(messages, tools=None, on_text=None, cancel=cancel)
|
|
except Exception: # noqa: BLE001
|
|
return ""
|
|
return (a.get("content") or "").strip()
|
|
|
|
|
|
def generate_agent_prompt(provider, name: str = "", role: str = "", hint: str = "", cancel=None) -> str:
|
|
"""Best-effort ✨ helper: draft the INSTRUCTIONS/prompt for a Co4E agent from
|
|
its name + role (+ optional hint) — used when the user hasn't attached a
|
|
skill and wants the agent's behaviour written for them. Returns '' on error."""
|
|
name = (name or "").strip()
|
|
role = (role or "").strip()
|
|
if not name and not role and not hint:
|
|
return ""
|
|
who = f"{name} ({role})" if role else name or role
|
|
messages = [
|
|
{"role": "system", "content":
|
|
"You write the INSTRUCTIONS (system prompt) for a specialized AI agent that runs as one "
|
|
"step in a workflow. Given the agent's name/role (and any hint), write clear, imperative "
|
|
"guidance: what this agent is responsible for, how it should work, and what its output "
|
|
"should be. A few concise sentences or short bullets. Reply with ONLY the instructions "
|
|
"text — no title, no preamble."},
|
|
{"role": "user", "content": f"Agent: {who}" + (f"\nHint: {hint}" if hint else "")},
|
|
]
|
|
try:
|
|
a = provider.chat(messages, tools=None, on_text=None, cancel=cancel)
|
|
except Exception: # noqa: BLE001 — generation must never break the dialog
|
|
return ""
|
|
return (a.get("content") or "").strip()
|
|
|
|
|
|
def plan_tasks(provider, description: str, cancel=None) -> List[Dict[str, Any]]:
|
|
"""description → normalized task dicts (NOT yet saved). Raises RuntimeError
|
|
when the model's reply has no parseable task JSON."""
|
|
from datetime import datetime
|
|
|
|
messages = [
|
|
{"role": "system", "content": _SYSTEM},
|
|
{"role": "user", "content": f"Today is {datetime.now().strftime('%Y-%m-%d %H:%M %A')}.\n"
|
|
f"Request: {description}"},
|
|
]
|
|
reply = provider.chat(messages, cancel=cancel)
|
|
payload = _extract_json(reply.get("content", ""))
|
|
tasks = normalize_planned_tasks(payload) if payload else []
|
|
if not tasks:
|
|
raise RuntimeError("AI reply did not contain a valid task list — try rephrasing.")
|
|
return tasks
|