Files
Artemis-Eapil/apps/admin_console/services/evidence_service.py
T

152 lines
5.9 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]]:
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": "工具执行完成不等于用例通过。录像保留真实时间与缺口;各来源截图不能相互替代。",
}