feat: add favorite task templates and optimize evidence exports
This commit is contained in:
1 parent
e1ea099592
commit
a4a5430922
46 files changed
+1714
-188
No files matched your search
@@ -182,11 +182,7 @@ async def batch_archive(batch_id: str):
|
||||
if archive_service.status(batch_id, batch)["status"] == "preparing":
|
||||
raise HTTPException(409, "报告正在打包,请等待准备完成")
|
||||
path = archive_service.archive_path(batch_id)
|
||||
if (
|
||||
not path.is_file()
|
||||
or not batch.get("report_ready")
|
||||
and archive_service.status(batch_id, batch)["status"] != "ready"
|
||||
):
|
||||
if not path.is_file() or archive_service.status(batch_id, batch)["status"] != "ready":
|
||||
path = await asyncio.to_thread(report_export.export_batch, batch_id)
|
||||
return FileResponse(path, filename=f"批次报告-{batch_id[:8]}.zip")
|
||||
|
||||
@@ -205,8 +201,7 @@ async def download_report(
|
||||
):
|
||||
try:
|
||||
if fmt in {"screenshots", "video", "all"}:
|
||||
path = await asyncio.to_thread(report_export.export_session_media, session_id, fmt)
|
||||
label = report_export.archive_case_name(report_export.session_report(session_id))
|
||||
path, label = await asyncio.to_thread(report_export.session_download, session_id, fmt)
|
||||
return FileResponse(path, filename=f"{label}-{fmt}.zip")
|
||||
paths = await asyncio.to_thread(report_export.export_session, session_id, [fmt])
|
||||
return FileResponse(paths[0], filename=f"任务报告-{session_id[:8]}.{fmt}")
|
||||
@@ -224,7 +219,9 @@ def review_case(session_id: str, value: CaseReviewRequest):
|
||||
and archive_service.status(batch_id, require("batch", batch_id))["status"] == "preparing"
|
||||
):
|
||||
raise HTTPException(409, "批次报告正在打包,请完成后再保存人工复验")
|
||||
with report_export.EXPORT_LOCK:
|
||||
with report_export.export_lock(
|
||||
*(["batch:" + batch_id] if batch_id else []), "session:" + session_id
|
||||
):
|
||||
current = report(session_id)
|
||||
if current["status"] not in {
|
||||
"completed",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Favorite templates shared by the home page and MCP."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from apps.admin_console.services.favorites import (
|
||||
FavoriteCreate,
|
||||
FavoriteRun,
|
||||
FavoriteUpdate,
|
||||
favorites,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/automation/favorites", tags=["automation"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_favorites():
|
||||
return favorites.store.records("favorite", 10000)
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_favorite(value: FavoriteCreate):
|
||||
try:
|
||||
return favorites.create(value)
|
||||
except (KeyError, ValueError) as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
|
||||
|
||||
@router.patch("/{key}")
|
||||
def update_favorite(key: str, value: FavoriteUpdate):
|
||||
try:
|
||||
return favorites.update(key, value)
|
||||
except (KeyError, ValueError) as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
|
||||
|
||||
@router.delete("/{key}")
|
||||
def delete_favorite(key: str):
|
||||
try:
|
||||
favorites.delete(key)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/{key}/run")
|
||||
async def run_favorite(key: str, value: FavoriteRun):
|
||||
try:
|
||||
return await favorites.run(key, value)
|
||||
except (KeyError, ValueError) as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
@@ -277,6 +277,9 @@ app.include_router(system.router)
|
||||
from apps.admin_console.routers.automation import router as automation_router
|
||||
|
||||
app.include_router(automation_router)
|
||||
from apps.admin_console.routers.favorites import router as favorites_router
|
||||
|
||||
app.include_router(favorites_router)
|
||||
|
||||
from apps.admin_console.routers.plugins import router as plugins_router
|
||||
|
||||
|
||||
@@ -36,6 +36,22 @@ def register_submission(item: dict, case: dict | None = None, formats=None, **ex
|
||||
"status": "queued",
|
||||
"created_at": time.time(),
|
||||
"device_serial": item.get("device_serial"),
|
||||
"execution_config": item.get("execution_config"),
|
||||
"submission": {
|
||||
key: item.get(key)
|
||||
for key in (
|
||||
"goal",
|
||||
"profile",
|
||||
"device_serial",
|
||||
"runtime_overrides",
|
||||
"expected_output",
|
||||
"enable_outputter",
|
||||
"verification_level",
|
||||
"explorer_mode",
|
||||
"locked_app_package",
|
||||
"app_path",
|
||||
)
|
||||
},
|
||||
"formats": formats if formats is not None else store.preferences()["formats"],
|
||||
**extra,
|
||||
}
|
||||
@@ -391,7 +407,9 @@ def delete_batch(batch_id: str) -> bool:
|
||||
return False
|
||||
storage = StorageManager(store.path, report_export.TRACES_PATH)
|
||||
mcp_exports = report_export.EXPORT_ROOT.parent / "mcp_exports"
|
||||
with report_export.EXPORT_LOCK:
|
||||
with report_export.export_lock(
|
||||
"batch:" + batch_id, *("session:" + j["session_id"] for j in jobs)
|
||||
):
|
||||
for job in jobs:
|
||||
session_id = str(UUID(job["session_id"]))
|
||||
storage.delete_session(UUID(session_id))
|
||||
|
||||
@@ -13,6 +13,12 @@ def archive_path(batch_id: str):
|
||||
return report_export.EXPORT_ROOT / "batches" / batch_id / "批次报告.zip"
|
||||
|
||||
|
||||
def cached(batch_id: str, batch: dict) -> bool:
|
||||
return bool(batch.get("report_ready")) and report_export.reusable(
|
||||
archive_path(batch_id), report_export.BATCH_ARCHIVE_SIGNATURE, "completed"
|
||||
)
|
||||
|
||||
|
||||
def _snapshot(state: dict) -> dict:
|
||||
elapsed = max(0, (state.get("finished_at") or time.monotonic()) - state["started_at"])
|
||||
completed, total = state["completed"], state["total"]
|
||||
@@ -37,7 +43,7 @@ def status(batch_id: str, batch: dict) -> dict:
|
||||
state = _states.get(batch_id)
|
||||
if state:
|
||||
return _snapshot(state)
|
||||
if batch.get("report_ready") and archive_path(batch_id).is_file():
|
||||
if cached(batch_id, batch):
|
||||
return {
|
||||
"status": "ready",
|
||||
"stage": "报告包已就绪",
|
||||
@@ -63,7 +69,7 @@ def prepare(batch_id: str, batch: dict) -> dict:
|
||||
current = _states.get(batch_id)
|
||||
if current and current["status"] == "preparing":
|
||||
return _snapshot(current)
|
||||
if batch.get("report_ready") and archive_path(batch_id).is_file():
|
||||
if cached(batch_id, batch):
|
||||
_states.pop(batch_id, None)
|
||||
return status_from_cache()
|
||||
state = {
|
||||
@@ -81,9 +87,9 @@ def prepare(batch_id: str, batch: dict) -> dict:
|
||||
|
||||
def run() -> None:
|
||||
try:
|
||||
with report_export.EXPORT_LOCK:
|
||||
with report_export.export_lock("batch:" + batch_id):
|
||||
latest = report_export.store.get("batch", batch_id)
|
||||
if not (latest and latest.get("report_ready") and archive_path(batch_id).is_file()):
|
||||
if not (latest and cached(batch_id, latest)):
|
||||
report_export.export_batch(batch_id, progress=update)
|
||||
with _lock:
|
||||
state.update(status="ready", stage="报告包已就绪", finished_at=time.monotonic())
|
||||
|
||||
@@ -105,14 +105,16 @@ def plugin_files(session_id: str) -> list[tuple[Path, str]]:
|
||||
return list(files.items())
|
||||
|
||||
|
||||
def export_index(session_id: str, report: dict, android_videos: list) -> dict:
|
||||
def export_index(
|
||||
session_id: str, report: dict, android_videos: list, *, images=None, artifacts=None, extras=None
|
||||
) -> dict:
|
||||
from apps.admin_console.services.report_export import session_artifacts, session_screenshots
|
||||
from apps.admin_console.services.exploration_evidence import (
|
||||
exploration_index,
|
||||
demonstration_sources,
|
||||
)
|
||||
|
||||
extras = plugin_files(session_id)
|
||||
extras = plugin_files(session_id) if extras is None else extras
|
||||
recordings = plugin_sources(session_id) + demonstration_sources(session_id)
|
||||
root = task_root(session_id)
|
||||
paths = {path.resolve(): name for path, name in extras}
|
||||
@@ -120,10 +122,10 @@ def export_index(session_id: str, report: dict, android_videos: list) -> dict:
|
||||
for segment in source["segments"]:
|
||||
segment["file"] = paths.get((root / segment["file"]).resolve())
|
||||
segment.pop("url", None)
|
||||
images = session_screenshots(report)
|
||||
artifacts = session_artifacts(report)
|
||||
images = session_screenshots(report) if images is None else images
|
||||
artifacts = session_artifacts(report) if artifacts is None else artifacts
|
||||
files = [(path, "步骤截图/" + name) for path, name in images]
|
||||
files += [(path, "任务录屏/" + name) for path, name in android_videos]
|
||||
files += [(path, "任务录屏/安卓/" + name) for path, name in android_videos]
|
||||
files += [(path, "任务文件/安卓/" + name) for path, name in artifacts]
|
||||
files += extras
|
||||
calls = []
|
||||
@@ -151,7 +153,7 @@ def export_index(session_id: str, report: dict, android_videos: list) -> dict:
|
||||
"steps": report.get("steps", []),
|
||||
"calls": calls,
|
||||
"recordings": recordings,
|
||||
"android_videos": ["任务录屏/" + name for _, name in android_videos],
|
||||
"android_videos": ["任务录屏/安卓/" + name for _, name in android_videos],
|
||||
"image_references": {path.stem: "步骤截图/" + name for path, name in images},
|
||||
"files": [{"path": name, "bytes": path.stat().st_size} for path, name in files],
|
||||
"note": "工具执行完成不等于用例通过。录像保留真实时间与缺口;各来源截图不能相互替代。",
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Credential-free execution settings persisted for repeatable favorites."""
|
||||
|
||||
from apps.admin_console.services.task_import import TaskOptions
|
||||
from artemis.config import load_agent_config, parse_llm_config, verification_level_for_checker
|
||||
from artemis.config.settings import settings
|
||||
|
||||
|
||||
def execution_config(item: dict) -> dict:
|
||||
agent = load_agent_config()
|
||||
model = parse_llm_config().operator
|
||||
options = {
|
||||
"profile": item.get("profile") or "flash",
|
||||
"model": model.model,
|
||||
"reasoning_effort": model.reasoning_effort or "default",
|
||||
"request_timeout_seconds": model.timeout,
|
||||
"max_retries": model.max_retries,
|
||||
"retry_interval_seconds": settings.OPENAI_RETRY_INTERVAL_SECONDS,
|
||||
"record_video": settings.ARTEMIS_RECORDING_ENABLED,
|
||||
"recording": agent.recording.model_dump(),
|
||||
"analyze_video": agent.video_analyzer.enable_ledger,
|
||||
"timeout_seconds": settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
|
||||
"verification_level": verification_level_for_checker(agent.checker),
|
||||
"explorer_mode": agent.explorer.resolve(profile=item.get("profile") or "flash"),
|
||||
"observation_timeout_seconds": settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS,
|
||||
"observation_max_attempts": settings.ARTEMIS_OBSERVATION_MAX_ATTEMPTS,
|
||||
"device_command_timeout_seconds": settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
|
||||
"action_failure_attempts": settings.ARTEMIS_ACTION_FAILURE_ATTEMPTS,
|
||||
"worker_cleanup_timeout_seconds": settings.ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS,
|
||||
}
|
||||
for key in ("device_serial", "verification_level", "explorer_mode", "locked_app_package"):
|
||||
if item.get(key) is not None:
|
||||
options[key] = item[key]
|
||||
overrides = item.get("runtime_overrides") or {}
|
||||
options["recording"] = {**options["recording"], **(overrides.get("recording") or {})}
|
||||
options.update({k: v for k, v in overrides.items() if k != "recording" and v is not None})
|
||||
return {
|
||||
"runtime_overrides": TaskOptions.model_validate(options).model_dump(exclude_none=True),
|
||||
"expected_output": item.get("expected_output"),
|
||||
"enable_outputter": item.get("enable_outputter")
|
||||
if item.get("enable_outputter") is not None
|
||||
else agent.outputter.enabled,
|
||||
"app_path": item.get("app_path"),
|
||||
}
|
||||
|
||||
|
||||
def persist_execution_config(item: dict) -> None:
|
||||
from apps.admin_console.services.automation_store import store
|
||||
|
||||
snapshot = execution_config(item)
|
||||
item["execution_config"] = snapshot
|
||||
if job := store.job(str(item["session_id"])):
|
||||
job["execution_config"] = snapshot
|
||||
store.save_job(job)
|
||||
@@ -32,7 +32,7 @@ def delete_exploration(repository: ExplorationStore, exploration_id: str) -> lis
|
||||
# Validation sessions and batch copies remain ordinary Flash/Pro history.
|
||||
deleted = [run["id"] for run in runs if run["kind"] == "exploration"]
|
||||
storage = StorageManager(repository.database.path, report_export.TRACES_PATH)
|
||||
with report_export.EXPORT_LOCK:
|
||||
with report_export.export_lock(*("session:" + run_id for run_id in deleted)):
|
||||
for run_id in deleted:
|
||||
safe_id = str(UUID(run_id))
|
||||
storage.delete_session(UUID(safe_id))
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Per-object export coordination and content-aware artifact reuse."""
|
||||
|
||||
from contextlib import contextmanager, ExitStack
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import threading
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
_guard = threading.Lock()
|
||||
_locks: WeakValueDictionary = WeakValueDictionary()
|
||||
TERMINAL = {"completed", "success", "failed", "cancelled", "interrupted", "skipped"}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def export_lock(*keys: str):
|
||||
# Retain strong references until every lock is released. Unrelated exports
|
||||
# run concurrently; nested session exports can reacquire their own RLock.
|
||||
with _guard:
|
||||
locks = []
|
||||
for key in sorted(set(keys)):
|
||||
lock = _locks.get(key)
|
||||
if lock is None:
|
||||
lock = threading.RLock()
|
||||
_locks[key] = lock
|
||||
locks.append(lock)
|
||||
with ExitStack() as stack:
|
||||
for lock in locks:
|
||||
stack.enter_context(lock)
|
||||
yield
|
||||
|
||||
|
||||
def fingerprint(report: dict, files=(), extra=None) -> str:
|
||||
metadata = []
|
||||
for path, name in files:
|
||||
stat = path.stat()
|
||||
metadata.append((name, str(path), stat.st_size, stat.st_mtime_ns))
|
||||
payload = json.dumps([2, report, metadata, extra], sort_keys=True, default=str)
|
||||
return hashlib.sha256(payload.encode()).hexdigest()
|
||||
|
||||
|
||||
def reusable(path: Path, signature: str, status: str) -> bool:
|
||||
if status not in TERMINAL or not path.is_file():
|
||||
return False
|
||||
try:
|
||||
return path.with_suffix(path.suffix + ".fingerprint").read_text() == signature
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def remember(path: Path, signature: str) -> None:
|
||||
marker = path.with_suffix(path.suffix + ".fingerprint")
|
||||
temporary = marker.with_suffix(".tmp")
|
||||
temporary.write_text(signature, encoding="ascii")
|
||||
temporary.replace(marker)
|
||||
|
||||
|
||||
def compression(path: Path) -> int:
|
||||
import zipfile
|
||||
|
||||
return (
|
||||
zipfile.ZIP_STORED
|
||||
if path.suffix.lower()
|
||||
in {".mp4", ".mkv", ".webm", ".docx", ".xlsx", ".png", ".jpg", ".jpeg", ".zip"}
|
||||
else zipfile.ZIP_DEFLATED
|
||||
)
|
||||
@@ -0,0 +1,406 @@
|
||||
"""Independent favorite snapshots dispatched through the existing task services."""
|
||||
|
||||
from copy import deepcopy
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from typing import Literal
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from apps.admin_console.database.repositories.session_repository import SessionRepository
|
||||
from apps.admin_console.services.automation_store import AutomationStore, identifier, store
|
||||
from apps.admin_console.services.execution_config import execution_config
|
||||
from apps.admin_console.services.model_service import model_service
|
||||
from apps.admin_console.services.task_import import TaskCase, TaskOptions, execution_title
|
||||
from artemis.config.exploration import ExplorationConfig
|
||||
from artemis.exploration.case_prompt import require_resolved_case
|
||||
from artemis.exploration.store import ExplorationStore
|
||||
|
||||
|
||||
class FavoriteCreate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
source_kind: Literal["task", "batch", "exploration", "exploration_case"] = "task"
|
||||
source_id: str = Field(pattern=r"^[A-Za-z0-9_-]{1,100}$")
|
||||
version: int | None = Field(default=None, ge=1)
|
||||
name: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
|
||||
|
||||
class FavoriteUpdate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
name: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
task_config: TaskOptions | None = None
|
||||
case_configs: dict[int, TaskOptions] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class FavoriteRun(FavoriteUpdate):
|
||||
request_id: str = Field(pattern=r"^[A-Za-z0-9_-]{1,100}$")
|
||||
|
||||
|
||||
def _merge_options(target: dict, updates: dict) -> None:
|
||||
if isinstance(updates.get("recording"), dict):
|
||||
updates = {
|
||||
**updates,
|
||||
"recording": {**(target.get("recording") or {}), **updates["recording"]},
|
||||
}
|
||||
target.update(updates)
|
||||
|
||||
|
||||
class FavoriteService:
|
||||
def __init__(self, storage: AutomationStore = store):
|
||||
self.store = storage
|
||||
self._run_locks: WeakValueDictionary = WeakValueDictionary()
|
||||
|
||||
def get(self, key: str) -> dict:
|
||||
value = self.store.get("favorite", identifier(key))
|
||||
if value is None:
|
||||
raise KeyError("收藏不存在")
|
||||
return value
|
||||
|
||||
def _task(self, session_id: str, *, fallback: dict | None = None) -> tuple[dict, list[str]]:
|
||||
job = self.store.job(session_id) or {}
|
||||
if snapshot := job.get("execution_config"):
|
||||
return deepcopy(snapshot), []
|
||||
row = SessionRepository(self.store.path).get_session_by_id(session_id) or {}
|
||||
submission = job.get("submission") or fallback or {}
|
||||
options = {
|
||||
**(job.get("case") or {}).get("task_config", {}),
|
||||
**(submission.get("runtime_overrides") or {}),
|
||||
}
|
||||
options = {key: value for key, value in options.items() if value is not None}
|
||||
try:
|
||||
device = row.get("device_info") or {}
|
||||
device = json.loads(device) if isinstance(device, str) else device
|
||||
except (ValueError, TypeError):
|
||||
device = {}
|
||||
device = device if isinstance(device, dict) else {}
|
||||
profile = (
|
||||
options.get("profile")
|
||||
or submission.get("profile")
|
||||
or model_service.resolve_session_profile(row)
|
||||
or "flash"
|
||||
)
|
||||
options["profile"] = profile
|
||||
serial = (
|
||||
options.get("device_serial")
|
||||
or submission.get("device_serial")
|
||||
or job.get("device_serial")
|
||||
or device.get("serial")
|
||||
)
|
||||
if serial:
|
||||
options["device_serial"] = serial
|
||||
if serial == "computer:local" or device.get("execution_target") == "computer":
|
||||
options["execution_target"] = "computer"
|
||||
# Historical diagnostics describe the actual model, unlike display metadata
|
||||
# which may reflect today's defaults. Read only execution model roles.
|
||||
with self.store.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT payload FROM traces WHERE session_id=? AND type='llm_diagnostic' ORDER BY timestamp DESC",
|
||||
(session_id,),
|
||||
).fetchall()
|
||||
for trace in rows:
|
||||
try:
|
||||
data = json.loads(trace[0])
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if str(data.get("role", "")).lower() in {
|
||||
"operator",
|
||||
"flashrunner",
|
||||
"reactiverunner",
|
||||
"planner",
|
||||
}:
|
||||
for key in ("model", "reasoning_effort"):
|
||||
if data.get(key):
|
||||
options.setdefault(key, data[key])
|
||||
if options.get("model"):
|
||||
break
|
||||
missing = [
|
||||
key
|
||||
for key in (
|
||||
"model",
|
||||
"reasoning_effort",
|
||||
"record_video",
|
||||
"recording",
|
||||
"analyze_video",
|
||||
"request_timeout_seconds",
|
||||
"max_retries",
|
||||
"retry_interval_seconds",
|
||||
"timeout_seconds",
|
||||
)
|
||||
if key not in options
|
||||
]
|
||||
snapshot = execution_config(
|
||||
{**submission, "profile": profile, "runtime_overrides": options}
|
||||
)
|
||||
return snapshot, missing
|
||||
|
||||
def create(self, value: FavoriteCreate) -> dict:
|
||||
token = f"{value.source_kind}:{value.source_id}:{value.version or ''}"
|
||||
key = hashlib.sha256(token.encode()).hexdigest()[:32]
|
||||
if existing := self.store.get("favorite", key):
|
||||
return existing
|
||||
snapshot, missing, kind, version = {}, [], value.source_kind, None
|
||||
source_id = value.source_id
|
||||
repo = ExplorationStore(self.store)
|
||||
if kind == "task":
|
||||
run = repo.run(source_id)
|
||||
if run and run["kind"] == "exploration":
|
||||
source_id = run["exploration_id"]
|
||||
kind = "exploration_case" if repo.case(source_id) else "exploration"
|
||||
if kind in {"exploration", "exploration_case"}:
|
||||
session = repo.get(source_id)
|
||||
options = deepcopy(session["task_config"])
|
||||
if kind == "exploration":
|
||||
snapshot = {
|
||||
"goal": session["goal"],
|
||||
"config": session["config"],
|
||||
"task_config": options,
|
||||
}
|
||||
name = session["goal"][:120]
|
||||
else:
|
||||
case = repo.case(source_id, value.version)
|
||||
if not case:
|
||||
raise KeyError("确认用例不存在")
|
||||
version = case["version"]
|
||||
validations = [
|
||||
r
|
||||
for r in repo.runs(source_id)
|
||||
if r["kind"] == "validation" and r.get("case_version") == version
|
||||
]
|
||||
if validations:
|
||||
config, missing = self._task(
|
||||
validations[-1]["id"],
|
||||
fallback={"runtime_overrides": validations[-1]["task_config"]},
|
||||
)
|
||||
else:
|
||||
options = {
|
||||
k: v
|
||||
for k, v in options.items()
|
||||
if k not in {"profile", "model", "reasoning_effort", "timeout_seconds"}
|
||||
}
|
||||
config = execution_config({"profile": "flash", "runtime_overrides": options})
|
||||
missing = ["首次用例复跑,默认 Flash"]
|
||||
snapshot = {"goal": require_resolved_case(case), **config}
|
||||
name = case["name"]
|
||||
elif kind == "batch":
|
||||
batch = self.store.get("batch", source_id)
|
||||
if not batch or batch.get("status") == "deleting":
|
||||
raise KeyError("批次不存在")
|
||||
cases, seen = [], set()
|
||||
for job in self.store.jobs(source_id):
|
||||
position = job.get("case_position", job["position"])
|
||||
if position in seen:
|
||||
continue
|
||||
seen.add(position)
|
||||
case = deepcopy(job["case"])
|
||||
config, inherited = self._task(
|
||||
job["session_id"],
|
||||
fallback={
|
||||
"profile": batch["profile"],
|
||||
"device_serial": batch.get("device_serial"),
|
||||
"runtime_overrides": {
|
||||
"timeout_seconds": batch.get("timeout_seconds"),
|
||||
**case.get("task_config", {}),
|
||||
},
|
||||
},
|
||||
)
|
||||
case["task_config"] = config["runtime_overrides"]
|
||||
cases.append(TaskCase.model_validate(case).model_dump(exclude_none=True))
|
||||
missing.extend(inherited)
|
||||
if not cases:
|
||||
raise ValueError("批次没有可收藏的用例")
|
||||
snapshot = {
|
||||
key: batch[key]
|
||||
for key in (
|
||||
"name",
|
||||
"formats",
|
||||
"profile",
|
||||
"device_serial",
|
||||
"timeout_seconds",
|
||||
"stop_on_failure",
|
||||
"repeat_count",
|
||||
)
|
||||
if key in batch
|
||||
}
|
||||
snapshot["cases"] = cases
|
||||
name = batch["name"]
|
||||
else:
|
||||
row = SessionRepository(self.store.path).get_session_by_id(source_id) or {}
|
||||
job = self.store.job(source_id) or {}
|
||||
goal = (
|
||||
(job.get("case") or {}).get("goal")
|
||||
or row.get("initial_goal")
|
||||
or (job.get("submission") or {}).get("goal")
|
||||
)
|
||||
if not goal:
|
||||
raise KeyError("任务不存在或没有可复用的提示词")
|
||||
config, missing = self._task(source_id)
|
||||
snapshot = {"goal": goal, **config}
|
||||
name = execution_title(job) or goal[:120]
|
||||
favorite = {
|
||||
"id": key,
|
||||
"name": (value.name or name).strip(),
|
||||
"kind": kind,
|
||||
"source_kind": value.source_kind,
|
||||
"source_id": value.source_id,
|
||||
"case_version": version,
|
||||
"snapshot": snapshot,
|
||||
"inherited_fields": sorted(set(missing)),
|
||||
"created_at": time.time(),
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
if not favorite["name"]:
|
||||
raise ValueError("收藏名称不能为空")
|
||||
return self.store.put("favorite", key, favorite)
|
||||
|
||||
def updated(self, favorite: dict, value: FavoriteUpdate) -> dict:
|
||||
result = deepcopy(favorite)
|
||||
if value.name is not None:
|
||||
if not value.name.strip():
|
||||
raise ValueError("收藏名称不能为空")
|
||||
result["name"] = value.name.strip()
|
||||
snapshot = result["snapshot"]
|
||||
options = value.task_config.model_dump(exclude_none=True) if value.task_config else {}
|
||||
if result["kind"] == "batch":
|
||||
for index, case in enumerate(snapshot["cases"]):
|
||||
if override := value.case_configs.get(index):
|
||||
_merge_options(case["task_config"], override.model_dump(exclude_none=True))
|
||||
_merge_options(case["task_config"], options)
|
||||
if set(value.case_configs) - set(range(len(snapshot["cases"]))):
|
||||
raise ValueError("批次用例下标不存在")
|
||||
if options.get("device_serial"):
|
||||
snapshot["device_serial"] = options["device_serial"]
|
||||
elif result["kind"] == "exploration":
|
||||
config = snapshot["config"]
|
||||
config.update(
|
||||
{
|
||||
k: options[k]
|
||||
for k in ("model", "reasoning_effort", "timeout_seconds")
|
||||
if k in options
|
||||
}
|
||||
)
|
||||
snapshot["config"] = ExplorationConfig.model_validate(config).model_dump()
|
||||
_merge_options(snapshot["task_config"], options)
|
||||
else:
|
||||
_merge_options(snapshot["runtime_overrides"], options)
|
||||
return result
|
||||
|
||||
def update(self, key: str, value: FavoriteUpdate) -> dict:
|
||||
favorite = self.updated(self.get(key), value)
|
||||
favorite["updated_at"] = time.time()
|
||||
return self.store.put("favorite", key, favorite)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
with self.store.connection() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM automation_records WHERE kind='favorite' AND id=?", (identifier(key),)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
async def run(self, key: str, value: FavoriteRun) -> dict:
|
||||
lock = self._run_locks.setdefault(value.request_id, asyncio.Lock())
|
||||
async with lock:
|
||||
return await self._run(key, value)
|
||||
|
||||
async def _run(self, key: str, value: FavoriteRun) -> dict:
|
||||
key = identifier(key)
|
||||
request = {"favorite_id": key, **value.model_dump(exclude_none=True)}
|
||||
signature = hashlib.sha256(json.dumps(request, sort_keys=True).encode()).hexdigest()
|
||||
# Reserve a durable snapshot before admission. Retries survive restarts,
|
||||
# edits/deletions of the favorite, and lost HTTP responses.
|
||||
with self.store.connection() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute(
|
||||
"SELECT payload FROM automation_records WHERE kind='favorite_run' AND id=?",
|
||||
(value.request_id,),
|
||||
).fetchone()
|
||||
record = json.loads(row[0]) if row else None
|
||||
if record and record["signature"] != signature:
|
||||
raise ValueError("request_id 已用于其他内容")
|
||||
if record and record.get("result"):
|
||||
return record["result"]
|
||||
if record is None:
|
||||
row = conn.execute(
|
||||
"SELECT payload FROM automation_records WHERE kind='favorite' AND id=?", (key,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise KeyError("收藏不存在")
|
||||
record = {
|
||||
"signature": signature,
|
||||
"favorite": self.updated(json.loads(row[0]), value),
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO automation_records VALUES (?,?,?,?)",
|
||||
(
|
||||
"favorite_run",
|
||||
value.request_id,
|
||||
json.dumps(record, ensure_ascii=False),
|
||||
time.time(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
favorite = record["favorite"]
|
||||
snapshot = favorite["snapshot"]
|
||||
run_id = str(uuid5(NAMESPACE_URL, "artemis-favorite:" + value.request_id))
|
||||
if favorite["kind"] == "batch":
|
||||
from apps.admin_console.services.automation_service import create_batch, batch_action
|
||||
|
||||
batch = create_batch(
|
||||
{**snapshot, "name": favorite["name"], "request_id": "favorite-" + run_id}
|
||||
)
|
||||
if batch["status"] == "draft":
|
||||
batch = batch_action(batch["id"], "resume")
|
||||
result = {"kind": "batch", "batch_id": batch["id"], "status": batch["status"]}
|
||||
elif favorite["kind"] == "exploration":
|
||||
from apps.admin_console.services.exploration_service import exploration_service
|
||||
from artemis.exploration.models import CreateExploration
|
||||
|
||||
session = exploration_service.create(CreateExploration(request_id=run_id, **snapshot))
|
||||
admission = await exploration_service.start(session["id"], run_id)
|
||||
run = admission.get("run", admission)
|
||||
rejection = admission.get("admission", {})
|
||||
outcome = run.get("outcome") or {}
|
||||
if (
|
||||
rejection.get("status") in {"error", "rejected"}
|
||||
or outcome.get("status") == "rejected"
|
||||
):
|
||||
raise ValueError(
|
||||
rejection.get("error")
|
||||
or rejection.get("message")
|
||||
or outcome.get("error")
|
||||
or "探索未入队"
|
||||
)
|
||||
result = {
|
||||
"kind": "exploration",
|
||||
"session_id": run["id"],
|
||||
"exploration_id": session["id"],
|
||||
"admission": admission,
|
||||
}
|
||||
else:
|
||||
from apps.admin_console.routers.tasks import run_task
|
||||
from apps.admin_console.schemas.task_schema import RunRequest
|
||||
|
||||
admission = await run_task(
|
||||
RunRequest(
|
||||
goal=snapshot["goal"],
|
||||
profile=snapshot["runtime_overrides"].get("profile", "flash"),
|
||||
runtime_overrides=TaskOptions.model_validate(snapshot["runtime_overrides"]),
|
||||
session_id=run_id,
|
||||
ingress="favorite",
|
||||
expected_output=snapshot.get("expected_output"),
|
||||
enable_outputter=snapshot.get("enable_outputter"),
|
||||
app_path=snapshot.get("app_path"),
|
||||
)
|
||||
)
|
||||
if not admission.get("tasks"):
|
||||
raise ValueError(admission.get("error") or admission.get("message") or "任务未入队")
|
||||
result = {"kind": "task", "session_id": run_id, "admission": admission}
|
||||
record["result"] = result
|
||||
self.store.put("favorite_run", value.request_id, record)
|
||||
return result
|
||||
|
||||
|
||||
favorites = FavoriteService()
|
||||
@@ -5,8 +5,14 @@ from typing import Callable, cast
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import threading
|
||||
import zipfile
|
||||
from apps.admin_console.services.export_cache import (
|
||||
compression,
|
||||
export_lock,
|
||||
fingerprint,
|
||||
remember,
|
||||
reusable,
|
||||
)
|
||||
from apps.admin_console.services.request_diagnostics import diagnostics_json
|
||||
|
||||
from docx import Document
|
||||
@@ -28,7 +34,7 @@ from artemis.runtime.case_result import RESULT_LABELS, resolve_case_result
|
||||
from apps.admin_console.services.case_review import reviewed_result
|
||||
|
||||
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
|
||||
EXPORT_LOCK = threading.RLock()
|
||||
BATCH_ARCHIVE_SIGNATURE = "artemis-batch-layout-2"
|
||||
STATUS = {
|
||||
"completed": "执行完成",
|
||||
"success": "执行完成",
|
||||
@@ -741,9 +747,12 @@ def excel_report(report: dict, path: Path):
|
||||
wb.save(path)
|
||||
|
||||
|
||||
def export_session(session_id: str, formats: list[str] | None = None) -> list[Path]:
|
||||
with EXPORT_LOCK:
|
||||
report = session_report(session_id)
|
||||
def export_session(
|
||||
session_id: str, formats: list[str] | None = None, *, report: dict | None = None
|
||||
) -> list[Path]:
|
||||
with export_lock("session:" + identifier(session_id)):
|
||||
report = report if report is not None else session_report(session_id)
|
||||
signature = fingerprint(report, session_screenshots(report))
|
||||
directory = EXPORT_ROOT / "sessions" / identifier(session_id)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
outputs = []
|
||||
@@ -751,9 +760,13 @@ def export_session(session_id: str, formats: list[str] | None = None) -> list[Pa
|
||||
if fmt not in {"docx", "xlsx"}:
|
||||
raise ValueError("不支持的报告格式")
|
||||
path = directory / f"任务报告.{fmt}"
|
||||
if reusable(path, signature, report["status"]):
|
||||
outputs.append(path)
|
||||
continue
|
||||
temporary = directory / f"生成中.{fmt}"
|
||||
(word_report if fmt == "docx" else excel_report)(report, temporary)
|
||||
temporary.replace(path)
|
||||
remember(path, signature)
|
||||
outputs.append(path)
|
||||
return outputs
|
||||
|
||||
@@ -845,13 +858,19 @@ def session_artifacts(report: dict) -> list[tuple[Path, str]]:
|
||||
]
|
||||
|
||||
|
||||
def export_session_media(session_id: str, kind: str) -> Path:
|
||||
def session_download(session_id: str, kind: str) -> tuple[Path, str]:
|
||||
with export_lock("session:" + identifier(session_id)):
|
||||
report = session_report(session_id)
|
||||
return export_session_media(session_id, kind, report=report), archive_case_name(report)
|
||||
|
||||
|
||||
def export_session_media(session_id: str, kind: str, *, report: dict | None = None) -> Path:
|
||||
from apps.admin_console.services.evidence_service import export_index, plugin_files
|
||||
|
||||
if kind not in {"screenshots", "video", "all"}:
|
||||
raise ValueError("不支持的下载类型")
|
||||
with EXPORT_LOCK:
|
||||
report = session_report(session_id)
|
||||
with export_lock("session:" + identifier(session_id)):
|
||||
report = report if report is not None else 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 []
|
||||
@@ -870,42 +889,61 @@ def export_session_media(session_id: str, kind: str) -> Path:
|
||||
raise ValueError("此任务没有可下载的步骤截图")
|
||||
target = target_dir / f"{kind}.zip"
|
||||
temporary = target_dir / f".{kind}.tmp"
|
||||
files = images + videos + artifacts + extras
|
||||
diagnostics = diagnostics_json(session_id) if kind == "all" else None
|
||||
diagnostic_state = json.loads(diagnostics) if diagnostics else None
|
||||
if isinstance(diagnostic_state, dict):
|
||||
diagnostic_state.pop("collected_at", None)
|
||||
signature = fingerprint(report, files, diagnostic_state)
|
||||
if reusable(target, signature, report["status"]):
|
||||
return target
|
||||
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]
|
||||
"videos": ["任务录屏/安卓/" + name for _, name in videos]
|
||||
+ [name for path, name in extras if path.suffix.lower() in {".mp4", ".mkv", ".webm"}],
|
||||
"artifacts": [name for _, name in artifacts],
|
||||
"note": "录屏按时间顺序分段;暂停期间未占用设备,也未录制其他任务。",
|
||||
"snapshot": report["status"]
|
||||
not in {"completed", "success", "failed", "cancelled", "interrupted", "skipped"},
|
||||
}
|
||||
prefix = archive_case_name(report) + "/" if kind == "all" else ""
|
||||
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, prefix + path.name)
|
||||
for path in export_session(session_id, ["docx", "xlsx"], report=report):
|
||||
archive.write(path, prefix + path.name, compress_type=compression(path))
|
||||
for source, name in images:
|
||||
archive.write(source, f"{prefix}步骤截图/{name}")
|
||||
archive.write(source, f"{prefix}步骤截图/{name}", compress_type=compression(source))
|
||||
for source, name in videos:
|
||||
archive.write(source, f"{prefix}任务录屏/{name}", compress_type=zipfile.ZIP_STORED)
|
||||
archive.write(
|
||||
source, f"{prefix}任务录屏/安卓/{name}", compress_type=zipfile.ZIP_STORED
|
||||
)
|
||||
for source, name in artifacts:
|
||||
archive.write(source, f"{prefix}任务文件/安卓/{name}")
|
||||
archive.write(
|
||||
source, f"{prefix}任务文件/安卓/{name}", compress_type=compression(source)
|
||||
)
|
||||
for source, name in extras:
|
||||
archive.write(
|
||||
source,
|
||||
prefix + name,
|
||||
compress_type=zipfile.ZIP_STORED
|
||||
if source.suffix.lower() in {".mp4", ".mkv", ".webm"}
|
||||
else zipfile.ZIP_DEFLATED,
|
||||
compress_type=compression(source),
|
||||
)
|
||||
if kind == "all":
|
||||
archive.writestr(prefix + "模型请求诊断.json", diagnostics_json(session_id))
|
||||
archive.writestr(prefix + "模型请求诊断.json", diagnostics or "{}")
|
||||
archive.writestr(
|
||||
prefix + "证据索引.json",
|
||||
json.dumps(
|
||||
export_index(session_id, report, videos),
|
||||
export_index(
|
||||
session_id,
|
||||
report,
|
||||
videos,
|
||||
images=images,
|
||||
artifacts=artifacts,
|
||||
extras=extras,
|
||||
),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
default=str,
|
||||
@@ -915,13 +953,18 @@ def export_session_media(session_id: str, kind: str) -> Path:
|
||||
prefix + "文件清单.json", json.dumps(manifest, ensure_ascii=False, indent=2)
|
||||
)
|
||||
temporary.replace(target)
|
||||
if fingerprint(report, files, diagnostic_state) == signature:
|
||||
remember(target, signature)
|
||||
return target
|
||||
|
||||
|
||||
def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None = None) -> Path:
|
||||
from apps.admin_console.services.evidence_service import export_index, plugin_files
|
||||
|
||||
with EXPORT_LOCK:
|
||||
with export_lock(
|
||||
"batch:" + identifier(batch_id),
|
||||
*("session:" + job["session_id"] for job in store.jobs(batch_id)),
|
||||
):
|
||||
batch = store.get("batch", identifier(batch_id))
|
||||
if not batch:
|
||||
raise ValueError("批次不存在")
|
||||
@@ -959,34 +1002,47 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
|
||||
temporary = directory / "批次报告.tmp"
|
||||
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for path in outputs:
|
||||
archive.write(path, path.name)
|
||||
archive.write(path, path.name, compress_type=compression(path))
|
||||
for number, report in enumerate(records, 1):
|
||||
if progress:
|
||||
progress(number - 1, len(records), f"正在打包任务 {number}/{len(records)}")
|
||||
prefix = f"任务报告/{number:04d}_{archive_case_name(report)}"
|
||||
for path in export_session(report["id"], formats):
|
||||
archive.write(path, f"{prefix}/{path.name}")
|
||||
for source, name in session_screenshots(report):
|
||||
archive.write(source, f"{prefix}/步骤截图/{name}")
|
||||
for source, name in session_videos(report):
|
||||
images = session_screenshots(report)
|
||||
videos = session_videos(report)
|
||||
artifacts = session_artifacts(report)
|
||||
extras = plugin_files(report["id"])
|
||||
for path in export_session(report["id"], formats, report=report):
|
||||
archive.write(path, f"{prefix}/{path.name}", compress_type=compression(path))
|
||||
for source, name in images:
|
||||
archive.write(
|
||||
source, f"{prefix}/任务录屏/{name}", compress_type=zipfile.ZIP_STORED
|
||||
source, f"{prefix}/步骤截图/{name}", compress_type=compression(source)
|
||||
)
|
||||
for source, name in session_artifacts(report):
|
||||
archive.write(source, f"{prefix}/任务文件/安卓/{name}")
|
||||
for source, name in plugin_files(report["id"]):
|
||||
for source, name in videos:
|
||||
archive.write(
|
||||
source, f"{prefix}/任务录屏/安卓/{name}", compress_type=zipfile.ZIP_STORED
|
||||
)
|
||||
for source, name in artifacts:
|
||||
archive.write(
|
||||
source, f"{prefix}/任务文件/安卓/{name}", compress_type=compression(source)
|
||||
)
|
||||
for source, name in extras:
|
||||
archive.write(
|
||||
source,
|
||||
f"{prefix}/{name}",
|
||||
compress_type=zipfile.ZIP_STORED
|
||||
if source.suffix.lower() in {".mp4", ".mkv", ".webm"}
|
||||
else zipfile.ZIP_DEFLATED,
|
||||
compress_type=compression(source),
|
||||
)
|
||||
archive.writestr(f"{prefix}/模型请求诊断.json", diagnostics_json(report["id"]))
|
||||
archive.writestr(
|
||||
f"{prefix}/证据索引.json",
|
||||
json.dumps(
|
||||
export_index(report["id"], report, session_videos(report)),
|
||||
export_index(
|
||||
report["id"],
|
||||
report,
|
||||
videos,
|
||||
images=images,
|
||||
artifacts=artifacts,
|
||||
extras=extras,
|
||||
),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
default=str,
|
||||
@@ -997,4 +1053,5 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
|
||||
if progress:
|
||||
progress(len(records), len(records), "正在完成压缩包")
|
||||
temporary.replace(target)
|
||||
remember(target, BATCH_ARCHIVE_SIGNATURE)
|
||||
return target
|
||||
@@ -1033,6 +1033,9 @@ class TaskQueueService:
|
||||
if not isinstance(goal, str) or not goal.strip():
|
||||
raise ValueError("Queued task must contain a non-empty string goal.")
|
||||
cls._begin_task_run(task_item, run_key, sess_id, goal, profile)
|
||||
from apps.admin_console.services.execution_config import persist_execution_config
|
||||
|
||||
await asyncio.to_thread(persist_execution_config, task_item)
|
||||
|
||||
target = cls._task_target(task_item)
|
||||
cmd, env = cls._build_worker_invocation(
|
||||
@@ -1342,6 +1345,21 @@ class TaskQueueService:
|
||||
device_serial = await device_pool.select_device_async()
|
||||
except Exception:
|
||||
device_serial = None
|
||||
from apps.admin_console.services.execution_config import execution_config
|
||||
|
||||
config_snapshot = execution_config(
|
||||
{
|
||||
"profile": profile,
|
||||
"device_serial": device_serial,
|
||||
"expected_output": expected_output,
|
||||
"enable_outputter": enable_outputter,
|
||||
"verification_level": verification_level,
|
||||
"explorer_mode": explorer_mode,
|
||||
"locked_app_package": locked_app_package,
|
||||
"app_path": app_path,
|
||||
"runtime_overrides": runtime_overrides,
|
||||
}
|
||||
)
|
||||
for i, goal in enumerate(goals):
|
||||
task_item = cls._create_queue_item(
|
||||
goal,
|
||||
@@ -1362,6 +1380,7 @@ class TaskQueueService:
|
||||
)
|
||||
if runtime_overrides:
|
||||
task_item["runtime_overrides"] = runtime_overrides
|
||||
task_item["execution_config"] = config_snapshot
|
||||
if pinned:
|
||||
try:
|
||||
task_item["plugin_resources"] = PluginStore().reserve(
|
||||
|
||||
Reference in new issue
Block a user