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