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