feat(plugins): add source-aware evidence and recording timelines

This commit is contained in:
chenmingxuan committed 2026-10-16 10:20:11 +08:00
1 parent 72ff342605
commit ef7af9988e
61 files changed
+3847 -108

No files matched your search

+68
View File
@@ -115,6 +115,74 @@ async def get_session_video(session_id: str):
def _get_session_video_sync(session_id: str):
from apps.admin_console.services.evidence_service import plugin_sources
from apps.admin_console.services.report_export import session_videos
legacy = _get_legacy_session_video_sync(session_id)
sources = []
if legacy.get("video_segments") or legacy.get("video_url"):
segments = legacy.get("video_segments") or []
# Pauses create sibling resume directories. Only collect files already
# attached to this task; unrelated recordings never enter its timeline.
if segments and all("offset_ms" in segment for segment in segments):
combined = {}
for path, _ in session_videos({"id": session_id}):
for segment in media_service.resolve_video_segments(
media_service.path_to_video_url(path)
):
if "offset_ms" in segment:
combined[segment["url"]] = segment
if combined:
segments = sorted(combined.values(), key=lambda segment: segment["offset_ms"])
sources.append(
{
"id": "android",
"kind": "android",
"label": "安卓",
"target_ids": ["android"],
"status": "ready",
"message": "",
"segments": [
{**s, "start": float(s.get("offset_ms", s["start"] * 1000)) / 1000}
for s in segments
],
"url": legacy.get("video_url"),
}
)
sources += plugin_sources(session_id)
legacy["sources"] = sources
legacy["session_timeline"] = any(source["segments"] for source in sources)
row = dict(session_repo.get_session_by_id(session_id) or {})
legacy["session_duration"] = max(
max(
0.0,
float(row.get("end_time") or row.get("start_time") or 0)
- float(row.get("start_time") or 0),
),
max(
(s["start"] + s["duration"] for source in sources for s in source["segments"]),
default=0,
),
)
ready = next((s for s in sources if s.get("segments") or s.get("url")), None)
if ready:
legacy.update(
status="ready",
has_video=True,
video_url=ready.get("url") or ready["segments"][0]["url"],
video_segments=ready["segments"],
)
elif any(s.get("status") == "recording" for s in sources):
legacy.update(status="processing", retry_after_ms=1000)
elif sources:
legacy.update(
status="failed",
message=";".join(str(s.get("message") or "录像不可用") for s in sources),
)
return legacy
def _get_legacy_session_video_sync(session_id: str):
video_rec_map = session_repo.get_video_recordings_map()
video_idx = media_service.build_video_index()
row = session_repo.get_session_by_id(session_id)
@@ -0,0 +1,151 @@
"""Read task-owned plugin evidence without executing tools or converting media."""
import json
import math
from pathlib import Path
import re
from artemis.config import TRACES_PATH
def task_root(session_id: str) -> Path:
if not re.fullmatch(r"[A-Za-z0-9_-]{1,100}", session_id):
raise ValueError("无效任务编号")
root = TRACES_PATH.resolve() / session_id
if root.resolve() != root:
raise ValueError("任务证据目录不能是符号链接")
return root
def evidence_file(root: Path, relative: str) -> Path | None:
path = root / relative
try:
if (
path.is_file()
and path.resolve() == path.absolute()
and path.resolve().is_relative_to(root.resolve())
):
return path
except OSError:
return None
return None
def plugin_sources(session_id: str) -> list[dict]:
root = task_root(session_id)
path = evidence_file(root, "evidence/recordings/recordings.json")
if not path or path.stat().st_size > 4 * 1024 * 1024:
return []
try:
data = json.loads(path.read_text(encoding="utf-8"))
if data.get("version") != 1 or not isinstance(data.get("sources"), list):
return []
from apps.admin_console.services.media_service import MediaService
sources = []
for item in data["sources"]:
if not isinstance(item, dict) or not isinstance(item.get("id"), str):
continue
source = {**item, "segments": []}
for segment in item.get("segments", []):
if not isinstance(segment, dict):
continue
video = evidence_file(root, str(segment.get("file") or ""))
if video is None or video.suffix.lower() != ".mp4":
continue
duration = float(segment.get("duration", 0))
start = float(segment.get("offset_ms", 0)) / 1000
if (
not math.isfinite(duration)
or duration <= 0
or not math.isfinite(start)
or start < 0
):
continue
source["segments"].append(
{
**segment,
"url": MediaService.path_to_video_url(video),
"start": start,
"duration": duration,
}
)
sources.append(source)
return sources
except (OSError, ValueError, TypeError, KeyError):
return []
def plugin_files(session_id: str) -> list[tuple[Path, str]]:
root = task_root(session_id)
files = {}
for source in plugin_sources(session_id):
for segment in source["segments"]:
path = evidence_file(root, segment["file"])
if path:
files[path] = "任务录屏/电脑/" + path.name
for directory in (root / "artifacts" / "plugins", root / "evidence"):
if not directory.is_dir():
continue
for path in directory.rglob("*"):
if path.suffix.lower() == ".tmp" or (
directory.name == "evidence" and path.suffix.lower() in {".mp4", ".mkv"}
):
continue
relative = path.relative_to(root).as_posix()
checked = evidence_file(root, relative)
if checked:
files[checked] = (
"任务文件/插件/" + path.relative_to(root / "artifacts" / "plugins").as_posix()
if directory.name == "plugins"
else "证据索引/" + path.relative_to(root / "evidence").as_posix()
)
return list(files.items())
def export_index(session_id: str, report: dict, android_videos: list) -> dict:
from apps.admin_console.services.report_export import session_artifacts, session_screenshots
extras = plugin_files(session_id)
recordings = plugin_sources(session_id)
root = task_root(session_id)
paths = {path.resolve(): name for path, name in extras}
for source in recordings:
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)
files = [(path, "步骤截图/" + name) for path, name in images]
files += [(path, "任务录屏/" + name) for path, name in android_videos]
files += [(path, "任务文件/安卓/" + name) for path, name in artifacts]
files += extras
calls = []
ledger = evidence_file(root, "evidence/calls.jsonl")
if ledger:
with ledger.open(encoding="utf-8") as handle:
for line in handle:
try:
call = json.loads(line)
if not isinstance(call, dict):
continue
for attachment in call.get("attachments", []):
relative = str(attachment.get("path") or "")
attachment["file"] = paths.get((root / relative).resolve())
calls.append(call)
except (ValueError, TypeError, AttributeError):
continue # A partial final line after a crash is not a valid receipt.
return {
"version": 1,
"session_id": session_id,
"name": report["name"],
"status": report["status"],
"verdict": report["verdict"],
"steps": report.get("steps", []),
"calls": calls,
"recordings": recordings,
"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": "工具执行完成不等于用例通过。录像保留真实时间与缺口;各来源截图不能相互替代。",
}
@@ -175,6 +175,7 @@ class CapabilitySettings(BaseModel):
)
video_enabled: bool = False
recording_enabled: bool = False
recording: dict = Field(default_factory=dict)
outputter_enabled: bool = True
step_summarizer_enabled: bool = True
screenshot_limit_enabled: bool = True
@@ -204,6 +205,7 @@ def get_capability_settings() -> CapabilitySettings:
return CapabilitySettings(
video_enabled=settings.ARTEMIS_VIDEO_ENABLED,
recording_enabled=settings.ARTEMIS_RECORDING_ENABLED,
recording=load_agent_config().recording.model_dump(),
outputter_enabled=settings.ARTEMIS_OUTPUTTER_ENABLED,
step_summarizer_enabled=settings.ARTEMIS_STEP_SUMMARIZER_ENABLED,
screenshot_limit_enabled=screenshot.enabled,
@@ -212,6 +214,12 @@ def get_capability_settings() -> CapabilitySettings:
def save_capability_settings(update: CapabilitySettings) -> CapabilitySettings:
from artemis.config.agent import RecordingConfig
import json
recording = RecordingConfig.model_validate(
{**load_agent_config().recording.model_dump(), **update.recording}
).model_dump()
values = {
"ARTEMIS_ADB_AUTO_CONNECT": update.adb_auto_connect,
"ARTEMIS_ADB_DISCOVERY_INTERVAL_SECONDS": update.adb_discovery_interval_seconds,
@@ -229,6 +237,8 @@ def save_capability_settings(update: CapabilitySettings) -> CapabilitySettings:
"ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS": update.worker_cleanup_timeout_seconds,
}
persist_environment({key: str(value).lower() for key, value in values.items()})
persist_environment({"ARTEMIS_RECORDING_CONFIG": json.dumps(recording)})
settings.ARTEMIS_RECORDING_CONFIG = recording
for key, value in values.items():
setattr(settings, key, value)
return get_capability_settings()
+67 -4
View File
@@ -475,6 +475,24 @@ def plugin_operations(steps: list[dict]) -> list[dict]:
for block in result.get("content", [])
if isinstance(block, dict) and block.get("type") == "text"
)
source = result.get("source") or payload.get("source") or {}
evidence = []
if result.get("call_id"):
evidence.append("调用:" + str(result["call_id"]))
if source:
evidence.append(
f"来源:{source.get('kind', '')} · {source.get('label') or source.get('target_id', '')}"
)
if result.get("execution"):
evidence.append(
f"执行:{result['execution']};副作用:{result.get('side_effect', 'unknown')};错误码:{result.get('error_code') or '无'}"
)
for attachment in result.get("attachments") or []:
evidence.append(
f"文件:{attachment.get('name')};SHA-256:{attachment.get('sha256')}"
)
if evidence:
content = "\n".join([content or result.get("message") or "", *evidence])
operations.append(
{
"step": step.get("step_number"),
@@ -809,6 +827,8 @@ def session_artifacts(report: dict) -> list[tuple[Path, str]]:
def export_session_media(session_id: str, kind: str) -> 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:
@@ -818,7 +838,10 @@ def export_session_media(session_id: str, kind: str) -> Path:
images = session_screenshots(report) if kind in {"screenshots", "all"} else []
videos = session_videos(report) if kind in {"video", "all"} else []
artifacts = session_artifacts(report) if kind == "all" else []
if kind == "video" and not videos:
extras = plugin_files(session_id) if kind in {"video", "all"} else []
if kind == "video":
extras = [(path, name) for path, name in extras if path.suffix.lower() == ".mp4"]
if kind == "video" and not videos and not extras:
raise ValueError("此任务没有已保存的录屏")
if kind == "screenshots" and not images:
raise ValueError("此任务没有可下载的步骤截图")
@@ -830,7 +853,8 @@ def export_session_media(session_id: str, kind: str) -> Path:
"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() == ".mp4"],
"artifacts": [name for _, name in artifacts],
"note": "录屏按时间顺序分段;暂停期间未占用设备,也未录制其他任务。",
}
@@ -842,9 +866,27 @@ def export_session_media(session_id: str, kind: str) -> Path:
for source, name in images:
archive.write(source, f"{prefix}步骤截图/{name}")
for source, name in videos:
archive.write(source, f"{prefix}任务录屏/{name}")
archive.write(source, f"{prefix}任务录屏/{name}", compress_type=zipfile.ZIP_STORED)
for source, name in artifacts:
archive.write(source, f"{prefix}任务文件/安卓/{name}")
for source, name in extras:
archive.write(
source,
prefix + name,
compress_type=zipfile.ZIP_STORED
if source.suffix.lower() == ".mp4"
else zipfile.ZIP_DEFLATED,
)
if kind == "all":
archive.writestr(
prefix + "证据索引.json",
json.dumps(
export_index(session_id, report, videos),
ensure_ascii=False,
indent=2,
default=str,
),
)
archive.writestr(
prefix + "文件清单.json", json.dumps(manifest, ensure_ascii=False, indent=2)
)
@@ -853,6 +895,8 @@ def export_session_media(session_id: str, kind: str) -> Path:
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:
batch = store.get("batch", identifier(batch_id))
if not batch:
@@ -901,9 +945,28 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
for source, name in session_screenshots(report):
archive.write(source, f"{prefix}/步骤截图/{name}")
for source, name in session_videos(report):
archive.write(source, f"{prefix}/任务录屏/{name}")
archive.write(
source, f"{prefix}/任务录屏/{name}", compress_type=zipfile.ZIP_STORED
)
for source, name in session_artifacts(report):
archive.write(source, f"{prefix}/任务文件/安卓/{name}")
for source, name in plugin_files(report["id"]):
archive.write(
source,
f"{prefix}/{name}",
compress_type=zipfile.ZIP_STORED
if source.suffix.lower() == ".mp4"
else zipfile.ZIP_DEFLATED,
)
archive.writestr(
f"{prefix}/证据索引.json",
json.dumps(
export_index(report["id"], report, session_videos(report)),
ensure_ascii=False,
indent=2,
default=str,
),
)
if progress:
progress(number, len(records), f"已打包任务 {number}/{len(records)}")
if progress:
+8 -1
View File
@@ -8,8 +8,9 @@ import zipfile
from openpyxl import Workbook, load_workbook
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_serializer
from artemis.plugins.models import ExecutionTarget, PluginSelection
from artemis.config.agent import RecordingConfig
HEADERS = (
"用例编号",
@@ -38,6 +39,7 @@ class TaskOptions(BaseModel):
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
recording: RecordingConfig | 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)
@@ -51,6 +53,11 @@ class TaskOptions(BaseModel):
plugins: list[PluginSelection] | None = None
execution_target: ExecutionTarget | None = None
@field_serializer("recording", when_used="unless-none")
def serialize_recording(self, value: RecordingConfig) -> dict:
# A per-task partial override must not replace other saved recording defaults.
return value.model_dump(exclude_unset=True)
class TaskCase(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -586,6 +586,10 @@ class TaskQueueService:
env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend"))
env["ARTEMIS_TASK_WORKER"] = "1"
overrides = task_item.get("runtime_overrides") or {}
if overrides.get("recording") is not None:
env["ARTEMIS_TASK_RECORDING_CONFIG"] = json.dumps(overrides["recording"])
else:
env.pop("ARTEMIS_TASK_RECORDING_CONFIG", None)
for option, variable in {
"model": "OPENAI_MODEL",
"reasoning_effort": "OPENAI_REASONING_EFFORT",
@@ -869,6 +873,10 @@ class TaskQueueService:
registered at recording start; remuxing that file is all that is
needed to publish the full video.
"""
from apps.admin_console.services.evidence_service import task_root
from artemis.plugins.recording import recover_interrupted_recordings
await recover_interrupted_recordings(task_root(str(sess_id)))
rec_info = session_repo.get_video_recording_for_session(sess_id)
rec_status = (rec_info or {}).get("status")
if rec_status == "ready":
@@ -1890,6 +1898,14 @@ class TaskQueueService:
recordings published.
"""
recovered = 0
from artemis.config import TRACES_PATH
from artemis.plugins.recording import recover_interrupted_recordings
# This hook runs in the startup worker thread, outside the server event loop.
for manifest in TRACES_PATH.glob("*/evidence/recordings/recordings.json"):
root = manifest.parent.parent.parent
if asyncio.run(recover_interrupted_recordings(root)):
recovered += 1
try:
pending = session_repo.get_unfinalized_video_recordings()
except Exception as exc: