feat(plugins): add source-aware evidence and recording timelines
This commit is contained in:
1 parent
72ff342605
commit
ef7af9988e
61 files changed
+3847
-108
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
Reference in new issue
Block a user