feat: add prompt-first batches and pausable task guidance

This commit is contained in:
chenmingxuan committed 2026-09-23 22:59:48 +08:00
1 parent cec9898e4c
commit 057f0c3050
44 files changed
+1080 -191

No files matched your search

@@ -41,7 +41,7 @@ def register_submission(item: dict, case: dict | None = None, formats=None, **ex
def recover_batches():
"""Recovery never silently repeats a potentially side-effecting interrupted task."""
for job in store.jobs():
if job["status"] in {"running", "queued", "finalizing"}:
if job["status"] in {"running", "queued", "finalizing", "task_paused", "resuming"}:
job.update(
status=job.get("terminal_status", "interrupted"),
error=job.get("error")
@@ -64,7 +64,7 @@ def recover_batches():
status="paused", reason="服务已重启;请检查中断任务,点击继续执行未开始任务"
)
for job in store.jobs(batch["id"]):
if job["status"] in {"running", "queued", "finalizing"}:
if job["status"] in {"running", "queued", "finalizing", "task_paused", "resuming"}:
job.update(
status="interrupted", error="服务中断,需手动重跑", ended_at=time.time()
)
@@ -89,11 +89,15 @@ async def on_terminal(item: dict, status: str):
try:
job["status"] = status
report = await asyncio.to_thread(session_report, sid)
if report["verdict"] == "验证失败" or report["outcome"].get("task_status") in {
"blocked",
"partial",
"failed",
}:
if status != "interrupted" and (
report["verdict"] == "验证失败"
or report["outcome"].get("task_status")
in {
"blocked",
"partial",
"failed",
}
):
job["status"] = "failed"
job["error"] = "任务执行结束,但业务检查未通过"
# Persist terminal classification before export, but the coordinator waits for finalization.
@@ -121,11 +125,18 @@ async def tick_batches():
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次")
store.put("batch", batch["id"], batch)
for job in active:
limit = int(batch.get("timeout_seconds", 1800))
limit = int(
(job.get("case", {}).get("task_config") or {}).get("timeout_seconds")
or batch.get("timeout_seconds", 1800)
)
task_state = getattr(state, "active_runs", {}).get(str(job["session_id"])) or {}
excluded = float(task_state.get("non_execution_seconds") or 0)
if task_state.get("non_execution_started_at"):
excluded += time.time() - float(task_state["non_execution_started_at"])
if (
job["status"] != "finalizing"
and not job.get("timed_out")
and time.time() - job.get("started_at", time.time()) > limit
and time.time() - job.get("started_at", time.time()) - excluded > limit
):
job.update(error="任务达到批次配置的执行时限", timed_out=True)
store.save_job(job)
@@ -142,6 +153,11 @@ async def tick_batches():
job.update(status="skipped", error="本批次设置为失败即停止", ended_at=time.time())
store.save_job(job)
pending = []
if not pending and any(
j["status"] in {"task_paused", "resuming", "running", "queued", "finalizing"}
for j in jobs
):
continue
if not pending:
batch.update(status="completed", ended_at=time.time())
try:
@@ -156,7 +172,9 @@ async def tick_batches():
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次")
store.put("batch", batch["id"], batch)
continue
serial = batch.get("device_serial")
job = pending[0]
options = job["case"].get("task_config") or {}
serial = options.get("device_serial") or batch.get("device_serial")
devices = await device_pool.try_list_devices_async()
rejection = (
None
@@ -169,26 +187,28 @@ async def tick_batches():
continue
if (store.get("batch", batch["id"]) or {}).get("status") != "running":
continue
job = pending[0]
job.update(status="queued", started_at=time.time())
store.save_job(job)
try:
response = await task_queue_service.enqueue_tasks(
[case_goal(job["case"])],
profile=batch.get("profile", "flash"),
expected_output="用简体中文报告实际结果与证据。明确区分执行完成、验证通过、验证失败和前置条件未满足。"
+ job["case"].get("expected", ""),
profile=options.get("profile") or batch.get("profile", "flash"),
expected_output="用简体中文报告实际结果与证据。明确区分执行完成、验证通过、验证失败和前置条件未满足。",
enable_outputter=True,
device_serial=serial,
verification_level=options.get("verification_level"),
explorer_mode=options.get("explorer_mode"),
locked_app_package=options.get("locked_app_package"),
runtime_overrides=options,
session_id=job["session_id"],
ingress="batch",
)
if response.get("status") == "rejected" or not response.get("tasks"):
raise ValueError(response.get("error") or "队列拒绝接收任务")
bound = response["tasks"][0].get("device_serial")
if bound and not serial:
batch["device_serial"] = bound
store.put("batch", batch["id"], batch)
if bound:
job["device_serial"] = bound
store.save_job(job)
except Exception as exc:
job.update(status="pending", error=str(exc)[:500])
store.save_job(job)
@@ -129,6 +129,7 @@ def persist_environment(values: dict[str, str]) -> None:
class CapabilitySettings(BaseModel):
video_enabled: bool = False
recording_enabled: bool = False
outputter_enabled: bool = True
step_summarizer_enabled: bool = True
@@ -136,6 +137,7 @@ class CapabilitySettings(BaseModel):
def get_capability_settings() -> CapabilitySettings:
return CapabilitySettings(
video_enabled=settings.ARTEMIS_VIDEO_ENABLED,
recording_enabled=settings.ARTEMIS_RECORDING_ENABLED,
outputter_enabled=settings.ARTEMIS_OUTPUTTER_ENABLED,
step_summarizer_enabled=settings.ARTEMIS_STEP_SUMMARIZER_ENABLED,
)
@@ -144,6 +146,7 @@ def get_capability_settings() -> CapabilitySettings:
def save_capability_settings(update: CapabilitySettings) -> CapabilitySettings:
values = {
"ARTEMIS_VIDEO_ENABLED": update.video_enabled,
"ARTEMIS_RECORDING_ENABLED": update.recording_enabled,
"ARTEMIS_OUTPUTTER_ENABLED": update.outputter_enabled,
"ARTEMIS_STEP_SUMMARIZER_ENABLED": update.step_summarizer_enabled,
}
@@ -32,6 +32,8 @@ STATUS = {
"pending": "待执行",
"queued": "已排队",
"running": "执行中",
"task_paused": "等待指导",
"resuming": "等待设备恢复",
}
@@ -107,6 +109,11 @@ def session_report(session_id: str, db_path=None) -> dict:
"error": meta.get("error", ""),
"device": meta.get("device_serial") or text(session.get("device_info") or ""),
"tags": case.get("tags", ""),
"case_id": case.get("case_id", ""),
"category": case.get("category", ""),
"subcategory": case.get("subcategory", ""),
"preconditions": case.get("preconditions", ""),
"task_config": case.get("task_config") or {},
}
@@ -121,6 +128,11 @@ def time_text(value) -> str:
def metadata(report: dict) -> list[tuple]:
return [
("任务编号", report["id"]),
("用例编号", report.get("case_id") or "—"),
("检测项目", report.get("category") or "—"),
("测试子项", report.get("subcategory") or "—"),
("前置约束提取", report.get("preconditions") or "—"),
("任务配置", text(report.get("task_config") or {})),
("任务目标", report["goal"]),
("预期结果", report["expected"] or "未指定"),
("执行状态", STATUS.get(report["status"], report["status"])),
@@ -279,6 +291,88 @@ def export_session(session_id: str, formats: list[str] | None = None) -> list[Pa
return outputs
def session_screenshots(report: dict) -> list[tuple[Path, str]]:
"""Ordered, path-checked pre/post observations for each recorded step."""
screenshots: list[tuple[Path, str]] = []
for index, step in enumerate(report["steps"], 1):
for phase, key in (("操作前", "pre_image_name"), ("操作后", "post_image_name")):
source = image_path(step.get(key))
if source:
screenshots.append(
(
source,
f"{index:04d}_步骤{int(step.get('step_number') or index):04d}_{phase}{source.suffix.lower()}",
)
)
return screenshots
def session_videos(report: dict) -> list[tuple[Path, str]]:
"""Find only verified files attached to this session's recording directory."""
session = SessionRepository().get_session_by_id(report["id"]) or {}
recording = SessionRepository().get_video_recording_for_session(report["id"]) or {}
candidate = recording.get("local_video_path") or session.get("video_filepath")
if not candidate:
return []
source = Path(str(candidate)).resolve()
session_root = (TRACES_PATH / identifier(report["id"])).resolve()
if not source.is_relative_to(session_root):
return []
# A resumed recording lives under resume_NNNN; the initial recording is
# in its parent, with any later segments in sibling resume folders.
directory = source.parent
if directory.name.startswith("resume_"):
directory = directory.parent
if not directory.is_dir() or not directory.is_relative_to(session_root):
return []
paths = [
p
for p in directory.rglob("*.mp4")
if p.is_file()
and p.resolve().is_relative_to(directory.resolve())
and (p.parent == directory or p.parent.name.startswith("resume_"))
]
paths.sort(key=lambda p: (0 if p.parent == directory else 1, p.parent.name, p.name))
return [(path, f"{i:04d}_{path.parent.name}_{path.name}") for i, path in enumerate(paths, 1)]
def export_session_media(session_id: str, kind: str) -> Path:
if kind not in {"screenshots", "video", "all"}:
raise ValueError("不支持的下载类型")
with EXPORT_LOCK:
report = session_report(session_id)
target_dir = EXPORT_ROOT / "sessions" / identifier(session_id)
target_dir.mkdir(parents=True, exist_ok=True)
images = session_screenshots(report) if kind in {"screenshots", "all"} else []
videos = session_videos(report) if kind in {"video", "all"} else []
if kind == "video" and not videos:
raise ValueError("此任务没有已保存的录屏")
if kind == "screenshots" and not images:
raise ValueError("此任务没有可下载的步骤截图")
target = target_dir / f"{kind}.zip"
temporary = target_dir / f".{kind}.tmp"
manifest = {
"session_id": session_id,
"name": report["name"],
"status": report["status"],
"verdict": report["verdict"],
"screenshots": [name for _, name in images],
"videos": [name for _, name in videos],
"note": "录屏按时间顺序分段;暂停期间未占用设备,也未录制其他任务。",
}
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive:
if kind == "all":
for path in export_session(session_id, ["docx", "xlsx"]):
archive.write(path, path.name)
for source, name in images:
archive.write(source, f"步骤截图/{name}")
for source, name in videos:
archive.write(source, f"任务录屏/{name}")
archive.writestr("文件清单.json", json.dumps(manifest, ensure_ascii=False, indent=2))
temporary.replace(target)
return target
def export_batch(batch_id: str) -> Path:
with EXPORT_LOCK:
batch = store.get("batch", identifier(batch_id))
+100 -48
View File
@@ -1,4 +1,4 @@
"""Versioned Excel task template and bounded, non-executing import validation."""
"""Validate the prompt-first batch workbook without executing its contents."""
from io import BytesIO
import json
@@ -6,33 +6,53 @@ import os
import zipfile
from openpyxl import Workbook, load_workbook
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
from openpyxl.styles import Font, PatternFill, Alignment
from pydantic import BaseModel, Field, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
HEADERS = {
"任务名称": "name",
"任务目标": "goal",
"预期结果": "expected",
"目标应用": "app",
"前置条件": "preconditions",
"输入参数(JSON)": "parameters",
"标签": "tags",
}
HEADERS = (
"用例编号",
"检测项目",
"测试子项",
"测试用例",
"前置约束提取",
"Artemis提示词",
"任务配置(JSON)",
)
MAX_ROWS = int(os.getenv("ARTEMIS_IMPORT_MAX_ROWS", "1000"))
MAX_BYTES = int(os.getenv("ARTEMIS_IMPORT_MAX_BYTES", "10485760"))
class TaskOptions(BaseModel):
"""Allowlisted per-case overrides; credentials are never accepted in a workbook."""
model_config = ConfigDict(extra="forbid")
profile: str | None = Field(default=None, pattern="^(flash|pro)$")
model: str | None = Field(default=None, min_length=1, max_length=200)
reasoning_effort: str | None = Field(
default=None, pattern="^(default|none|minimal|low|medium|high|xhigh|max)$"
)
device_serial: str | None = Field(default=None, min_length=1, max_length=200)
verification_level: str | None = Field(default=None, pattern="^(off|final|checkpoints|strict)$")
explorer_mode: str | None = Field(default=None, pattern="^(flash|pro|ultra)$")
locked_app_package: str | None = Field(default=None, min_length=1, max_length=200)
record_video: bool | None = None
analyze_video: bool | None = None
request_timeout_seconds: float | None = Field(default=None, gt=0, le=3600)
max_retries: int | None = Field(default=None, ge=0, le=10)
retry_interval_seconds: float | None = Field(default=None, ge=0, le=300)
timeout_seconds: int | None = Field(default=None, ge=30, le=86400)
class TaskCase(BaseModel):
model_config = ConfigDict(extra="forbid")
case_id: str = Field(default="", max_length=200)
category: str = Field(default="", max_length=200)
subcategory: str = Field(default="", max_length=200)
name: str = Field(min_length=1, max_length=200)
goal: str = Field(min_length=1, max_length=10000)
expected: str = Field(default="", max_length=10000)
app: str = Field(default="", max_length=200)
preconditions: str = Field(default="", max_length=10000)
parameters: dict[str, str | int | float | bool] = Field(default_factory=dict)
tags: str = Field(default="", max_length=500)
goal: str = Field(min_length=1, max_length=10000)
task_config: TaskOptions = Field(default_factory=TaskOptions)
def template() -> bytes:
@@ -40,27 +60,54 @@ def template() -> bytes:
ws = wb.active
assert ws is not None
ws.title = "任务"
ws.append(list(HEADERS))
ws.append(HEADERS)
ws.freeze_panes = "A2"
ws.auto_filter.ref = "A1:G1"
for cell in ws[1]:
cell.font = Font(bold=True, color="FFFFFF")
cell.fill = PatternFill("solid", fgColor="245C78")
ws.column_dimensions[get_column_letter(int(cell.column or 1))].width = 32
ws.column_dimensions[get_column_letter(int(cell.column or 1))].width = (
65 if cell.column in {6, 7} else 28
)
help_sheet = wb.create_sheet("填写说明")
for line in [
"模板版本:1",
"每行一个独立任务;任务名称、任务目标必填。",
"预期结果决定要验证的业务条件。未填写时只报告任务完成情况。",
"前置条件描述实际用例起点;不会统一重启或清空应用数据。",
'输入参数使用 JSON 对象,例如 {"关键词":"天气"};目标可引用 {{关键词}}。',
"目标应用可填名称或包名;空白时由任务决定。",
"不要填写 Excel 公式、宏或外部链接。导入仅预览,点击开始后执行。",
]:
help_sheet.append([line])
help_sheet.column_dimensions["A"].width = 105
help_sheet.append(
["模板版本:2。任务表每行一项,Artemis提示词必填;只把这一列作为 AI 任务正文。"]
)
help_sheet.append(["前置约束提取及其他描述列仅作查看、搜索和报告元数据,不追加到提示词。"])
help_sheet.append(["任务配置(JSON)可留空,空字段继承批次或全局配置;禁止密钥与未知字段。"])
help_sheet.append(["示例仅供参考,不会导入执行。请在“任务”页填写自己的用例。"])
help_sheet.append(HEADERS)
help_sheet.append(
[
"示例-001",
"示例模块",
"示例检查",
"检查示例页面",
"设备已经打开目标应用",
"观察当前页面并报告实际结果,不要假设检查已通过。",
json.dumps(
{
"profile": "pro",
"model": "mimo-v2.6-flash",
"reasoning_effort": "high",
"verification_level": "final",
"record_video": True,
"analyze_video": False,
"request_timeout_seconds": 300,
"max_retries": 5,
"retry_interval_seconds": 10,
"timeout_seconds": 1800,
},
ensure_ascii=False,
),
]
)
for column in range(1, 8):
help_sheet.column_dimensions[get_column_letter(column)].width = 70 if column > 5 else 26
for row in help_sheet:
row[0].alignment = Alignment(wrap_text=True)
for cell in row:
cell.alignment = Alignment(wrap_text=True, vertical="top")
help_sheet.row_dimensions[6].height = 110
stream = BytesIO()
wb.save(stream)
return stream.getvalue()
@@ -90,7 +137,7 @@ def parse_workbook(data: bytes) -> dict:
rows = iter(wb["任务"].iter_rows())
headers = [str(cell.value or "").strip() for cell in next(rows, [])]
if headers != list(HEADERS):
raise ValueError("列名或顺序与模板不一致,请重新下载模板")
raise ValueError("列名或顺序与新版模板不一致,请重新下载模板")
tasks, errors = [], []
for number, row in enumerate(rows, 2):
if number > MAX_ROWS + 1:
@@ -100,28 +147,33 @@ def parse_workbook(data: bytes) -> dict:
try:
if any(cell.data_type == "f" for cell in row):
raise ValueError("不允许公式,请粘贴为纯文本")
values = [str(cell.value or "").strip() for cell in row[: len(HEADERS)]]
item = dict(zip(HEADERS.values(), values))
item["parameters"] = json.loads(item.get("parameters") or "{}")
case = TaskCase.model_validate(item)
values = [
str(cell.value if cell.value is not None else "").strip() for cell in row[:7]
]
config = json.loads(values[6] or "{}")
if not isinstance(config, dict):
raise ValueError("任务配置必须为 JSON 对象")
case = TaskCase.model_validate(
{
"case_id": values[0],
"category": values[1],
"subcategory": values[2],
"name": values[3] or values[2] or values[0] or values[5][:120],
"preconditions": values[4],
"goal": values[5],
"task_config": config,
}
)
tasks.append({"row": number, **case.model_dump()})
except (ValueError, TypeError) as exc:
errors.append({"row": number, "message": str(exc)[:500]})
if not tasks and not errors:
errors.append({"row": 2, "message": "没有任务,请填写模板后重新导入"})
return {"version": 1, "tasks": tasks, "errors": errors, "valid": bool(tasks) and not errors}
return {"version": 2, "tasks": tasks, "errors": errors, "valid": bool(tasks) and not errors}
finally:
wb.close()
def case_goal(case: dict) -> str:
goal = case["goal"]
for key, value in case.get("parameters", {}).items():
goal = goal.replace("{{" + key + "}}", str(value))
return (
f"任务名称:{case['name']}\n任务目标:{goal}\n目标应用:{case.get('app') or '按目标判断'}\n"
f"前置条件:{case.get('preconditions') or '根据实际用例判断合适起点'}\n"
"先观察手机并恢复到本用例需要的起始位置,记录准备过程。不要固定重启或清空数据。"
"如果无法建立前置条件,明确报告前置条件未满足,不继续正文操作。\n"
f"预期结果:{case.get('expected') or '仅确认任务完成情况,不宣称测试通过'}"
)
"""Execution gets the prompt verbatim; metadata stays outside model input."""
return case["goal"]
@@ -302,7 +302,7 @@ class TaskQueueService:
normalized = current_status.lower().strip() if isinstance(current_status, str) else None
if normalized == "success":
return "completed", True
if normalized in {"completed", "failed", "cancelled"}:
if normalized in {"completed", "failed", "cancelled", "interrupted"}:
return normalized, False
return ("completed" if returncode == 0 else "failed"), True
@@ -388,10 +388,9 @@ class TaskQueueService:
@classmethod
def _dispatch_pending_tasks(cls) -> None:
"""Launch every pending task admissible under the current concurrency limit."""
cls._sync_control_states()
limit = cls._concurrency_limit()
state.prune_finished_runs()
if limit == 1 and state.is_running:
return
# Dispatched-but-not-yet-spawned runs are only visible as queue items in
# "running" state, so admission must count those too -- active_runs alone
@@ -410,7 +409,9 @@ class TaskQueueService:
and item.get("pid") not in active_pids
for item in in_flight
)
capacity = limit - len(state.active_runs) - starting
capacity = (
limit - sum(not r.get("parked") for r in state.active_runs.values()) - starting
)
if capacity <= 0:
return
busy_devices = state.busy_device_ids | {
@@ -432,11 +433,15 @@ class TaskQueueService:
# A task without a resolved device may bind to any serial, so it
# only launches on an otherwise idle scheduler; the device lock
# then allocates freely without contending against active runs.
if device is None and (state.active_runs or in_flight or dispatched_any):
if device is None and (
any(not r.get("parked") for r in state.active_runs.values())
or in_flight
or dispatched_any
):
continue
if device is not None and target.lock_key in busy_devices:
continue
elif limit > 1 and device is not None and target.lock_key in busy_devices:
elif limit >= 1 and device is not None and target.lock_key in busy_devices:
# A second worker for this device would wait on its lock.
continue
@@ -454,6 +459,42 @@ class TaskQueueService:
if capacity <= 0:
break
@classmethod
def _sync_control_states(cls) -> None:
from artemis.runtime.task_control import read_state
from apps.admin_console.services.automation_store import store
for item in state.queue_items:
if not isinstance(item, dict):
continue
sid = str(item.get("session_id") or "")
run = state.active_runs.get(sid)
if not run:
continue
status = read_state(sid).get("status")
if status not in {"running", "task_paused", "resuming"}:
continue
old = item.get("status")
if status == old:
continue
parked = status in {"task_paused", "resuming"}
now = time.time()
if parked and not run.get("parked"):
run["non_execution_started_at"] = now
elif not parked and run.get("parked"):
run["non_execution_seconds"] = (
float(run.get("non_execution_seconds") or 0)
+ now
- float(run.pop("non_execution_started_at", now))
)
run["parked"] = parked
item["status"] = status
job = store.job(sid)
if job and job.get("status") != "finalizing":
job["status"] = status
store.save_job(job)
cls._broadcast_event("task_status", {"session_id": sid, "status": status})
@classmethod
def _begin_task_run(
cls,
@@ -531,6 +572,22 @@ class TaskQueueService:
env["ARTEMIS_SESSION_ID"] = str(sess_id)
env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend"))
env["ARTEMIS_TASK_WORKER"] = "1"
overrides = task_item.get("runtime_overrides") or {}
for option, variable in {
"model": "OPENAI_MODEL",
"reasoning_effort": "OPENAI_REASONING_EFFORT",
"request_timeout_seconds": "OPENAI_TIMEOUT_SECONDS",
"max_retries": "OPENAI_MAX_RETRIES",
"retry_interval_seconds": "OPENAI_RETRY_INTERVAL_SECONDS",
"record_video": "ARTEMIS_RECORDING_ENABLED",
"analyze_video": "ARTEMIS_VIDEO_ENABLED",
}.items():
if overrides.get(option) is not None:
env[variable] = (
str(overrides[option]).lower()
if isinstance(overrides[option], bool)
else str(overrides[option])
)
target.endpoint.apply_to_environment(env)
env[DeviceExecutionLock.LOCK_SCOPE_ENV] = target.lock_scope
queue_ticket = task_item.get("queue_ticket")
@@ -664,6 +721,12 @@ class TaskQueueService:
returncode,
manual_stop,
)
if (
state.is_shutting_down
and not manual_stop
and current_status not in {"completed", "success", "failed", "interrupted"}
):
new_status, should_persist = "interrupted", True
if should_persist:
if session_repo.update_session_status(sess_id, new_status, time.time()):
print(f"[QueueWorker] Updated session {sess_id} status to '{new_status}'")
@@ -1069,6 +1132,7 @@ class TaskQueueService:
conversation_id: str | None = None,
verification_level: str | None = None,
explorer_mode: str | None = None,
runtime_overrides: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Enqueues one or more goals and wakes up the background worker.
@@ -1123,6 +1187,8 @@ class TaskQueueService:
verification_level=verification_level,
explorer_mode=explorer_mode,
)
if runtime_overrides:
task_item["runtime_overrides"] = runtime_overrides
state.queue_items.append(task_item)
enqueued_tasks.append(task_item)
cls._broadcast_startup_progress(