Files
Artemis-Eapil/apps/admin_console/services/evidence_service.py
T
chenmingxuan fac4ccb760 feat: prepare 2.2.0 with UI and execution fixes
Add dark theme, richer Markdown and readable plugin results.
Improve Android input evidence, response integrity and diagnostics.
Unify lifecycle, guidance receipts and evidence sources across clients.
Keep direct action dispatch without an added AI quota approval layer.
Align version metadata at 2.2.0 and bundle Helper 1.2.2.

Validation: 3063 backend tests before the version bump; 44 after.
Frontend: 317 tests and production build passed.
2026-10-26 00:44:13 +08:00

162 lines
6.5 KiB
Python

"""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]]:
from apps.admin_console.services.exploration_evidence import demonstration_files
root = task_root(session_id)
files = dict(demonstration_files(session_id))
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, *, 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) 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}
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) 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 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,
"exploration": exploration_index(session_id, paths),
"name": report["name"],
"status": report["status"],
"verdict": report["verdict"],
"steps": report.get("steps", []),
"evidence_sources": report.get("evidence_sources"),
"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": "工具执行完成不等于用例通过。录像保留真实时间与缺口;各来源截图不能相互替代。",
}