Hồi quy đã vá
-------------
F-12 Kéo–thả hoặc dán tệp vào ô chat ném NameError. R08 tách `_Input` sang
`chat_input_box.py` nhưng để `_paths_from_mime()` ở lại
`composer_widget.py`, nên hai hàm sự kiện Qt gọi một cái tên không tồn
tại. Bốn hàm dùng chung chuyển sang `composer_mime.py` — module thứ ba
là chỗ duy nhất không lặp lại được lỗi này. Đo lại: cả thả lẫn dán đều
gắn 1 tệp, khớp bản trước refactor.
F-01 Đổi provider thì bộ chọn model AI-Edit không làm gì. Hook cũ kiểm
`folder.ai_model_combo`, thuộc tính R08-T12 đã dời sang
`ai_panel.resolver`. Làm mới vô điều kiện, đúng như tab cũ: lần lấy đầu
tiên hỏng thì đổi provider chính là lúc phải thử lại.
F-07 Hàng chọn kỳ của Dashboard bị đẩy xuống dưới các thẻ số liệu. Hàng này
lọc CẢ BA thẻ con chứ không riêng biểu đồ, nên để nó nằm dưới là bắt
người dùng đọc con số trước khi thấy con số đó tính cho kỳ nào. Kèm
theo: `TokenUsageCardWidget` bị bỏ sót `setContentsMargins(0,0,0,0)`
mà hai thẻ con còn lại đã có, đẩy cả hàng thẻ lệch 9px.
`check_layout_geometry` nay khớp TỪNG BYTE với bản trước refactor.
F-11 Hai lớp khai trùng tên phương thức; Python giữ bản sau nên bản đầu là
mã chết. `co4e_tab.py::showEvent` bản đầu gọi `_narrow_guard.attach()`
và không bao giờ chạy.
Tách file (F-09)
----------------
Bốn file chạm trần 400 dòng, mỗi lần cắt ra một trách nhiệm thật:
graph_renderer.py -> graph_scene_builder.py + graph_export.py
co4e_workflow_service.py -> co4e_run_history.py
json_config_repository.py -> config_sections.py
agents_admin_tab.py -> shared/agent_kind_visuals.py
File cuối còn xoá 3 bản sao của hàm đã có trong `shared/formatters.py`,
giống hệt đến từng dòng — nay định dạng thời gian và avatar không lệch nhau
giữa các bảng Giám sát nữa.
Docstring
---------
41,6% -> 100% (3.478/3.478 định nghĩa production), kể cả module dormant và
phương thức dunder. Toàn bộ phần bổ sung viết bằng tiếng Việt; comment tiếng
Anh có sẵn giữ nguyên — dịch ngược là một đợt riêng.
Seam chưa nối dây (F-05)
------------------------
9 seam mang nhãn `SEAM · dựng <ngày>` kèm hai câu: được nối khi nào, và để
dormant thì hỏng gì. Ngày lấy từ lịch sử git, không phải hạn tự đặt. Gate O
đọc nhãn đó và nhắc khi quá 30 ngày.
859 test xanh · 4/4 cổng CASAN · 19/24 checker khớp từng byte bản cũ.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.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
|