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