"""Evidence-backed Word/Excel artifacts, independent of the language model runtime.""" from datetime import datetime from typing import Callable, cast import json from pathlib import Path import re import threading import zipfile from docx import Document from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT from docx.oxml import OxmlElement from docx.oxml.ns import qn from docx.shared import Cm, Pt, RGBColor from docx.styles.style import ParagraphStyle from openpyxl import Workbook from openpyxl.styles import Alignment, Border, Font, PatternFill, Side from openpyxl.utils import get_column_letter from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT from apps.admin_console.database.repositories.session_repository import SessionRepository from apps.admin_console.database.repositories.step_repository import StepRepository from apps.admin_console.services.automation_store import store, identifier from apps.admin_console.services.task_import import execution_title from artemis.runtime.case_result import RESULT_LABELS, resolve_case_result from apps.admin_console.services.case_review import reviewed_result EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports" EXPORT_LOCK = threading.RLock() STATUS = { "completed": "执行完成", "success": "执行完成", "failed": "执行失败", "cancelled": "已取消", "interrupted": "执行中断", "skipped": "已跳过", "pending": "待执行", "queued": "已排队", "running": "执行中", "task_paused": "等待指导", "resuming": "等待设备恢复", } def archive_case_name(report: dict) -> str: """Keep case identifiers readable without allowing ZIP paths or Windows-invalid names.""" fallback = str(report.get("id") or "task")[:8] candidate = str(report.get("case_id") or fallback).strip() candidate = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "_", candidate).strip(" .") return candidate[:80] or fallback def read_json(path: Path, fallback=None): try: return json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError): return fallback def text(value) -> str: raw = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str) return re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", raw) def verdict(outcome: dict | None) -> str: result = (outcome or {}).get("test_result") if isinstance(result, dict) and result.get("status") in RESULT_LABELS: label = RESULT_LABELS[result["status"]] if result.get("source"): label += "(人工确认)" if result["source"] == "human" else "(AI确认)" return label tests = (outcome or {}).get("tests") or {} if tests.get("failed", 0): return "验证失败" if tests.get("unchecked", 0) or tests.get("inconclusive", 0): return "验证不完整" if tests.get("passed", 0): return "验证通过" return "未验证(执行完成不等于测试通过)" def failure_reason(directory: Path, status: str, recorded: str | None) -> str: def redact(value: str) -> str: safe = text(value).strip() safe = re.sub(r"sk-[A-Za-z0-9_-]{12,}", "[已隐藏密钥]", safe) safe = re.sub( r"(?i)(\b(?:authorization\s*:\s*bearer|bearer)\s+)[^\s,;]+", r"\1[已隐藏密钥]", safe, ) safe = re.sub( r"(?i)(\b(?:OPENAI_API_KEY|api[_-]?key)\s*[:=]\s*['\"]?)[^\s,'\";]+", r"\1[已隐藏密钥]", safe, ) return safe[:500] from apps.admin_console.services.automation_store import RECOVERY_ERRORS if status == "completed" and recorded in RECOVERY_ERRORS: return "" if recorded: return redact(recorded) if status not in {"failed", "interrupted"}: return "" state = read_json(directory / "status.json", {}) if isinstance(state, dict) and state.get("error"): return redact(state["error"]) result = read_json(directory / "result.json", {}) if isinstance(result, dict) and result.get("status") == "failed": reason = result.get("error") or result.get("explanation") if reason: return redact(reason) log_lines: list[str] = [] for name in ("stdout.log", "stderr.log"): path = directory / name try: with path.open("rb") as stream: stream.seek(0, 2) stream.seek(max(0, stream.tell() - 131072)) log_lines.extend(stream.read().decode("utf-8", errors="replace").splitlines()) except OSError: continue for line in reversed(log_lines): match = re.search(r"Error running automation:\s*(.+)", line) if match: return redact(match.group(1)) for line in reversed(log_lines): stripped = line.strip() if stripped.startswith(("⚠", "ℹ", "WARNING", "INFO", "DEBUG")): continue if any( name in line for name in ( "VisualStepSummarizer:", "StepMemoryService:", "Could not clean unowned screen wake locks", ) ): continue exception_line = re.fullmatch( r"(?:[A-Za-z_]\w*\.)*[A-Za-z_]\w*(?:Error|Exception|Timeout):\s*.*", stripped, ) if exception_line and not re.search(r"(?i)(?:timeout|timed out|超时)", stripped): return redact(stripped) if re.search( r"(?i)(?:\b(?:TimeoutError|APITimeoutError|ReadTimeout|ConnectTimeout)\s*:|" r"(?:模型请求|任务执行|model request|task execution).{0,100}" r"(?:超时|timed out|deadline exceeded))", stripped, ): return redact("模型请求或任务执行超时:" + stripped) return "任务未完成,运行日志中没有可提取的具体异常;请查看执行步骤。" def session_report(session_id: str, db_path=None) -> dict: identifier(session_id) meta = store.job(session_id) or {} repo = SessionRepository(db_path) if db_path else SessionRepository() session = repo.get_session_by_id(session_id) or {} if not session and not meta: raise ValueError("任务记录不存在") steps = StepRepository(db_path).get_session_steps(session_id) if session else [] steps = [s for s in steps if s.get("step_id") != "pre-planning"] directory = TRACES_PATH / session_id outcome = read_json(directory / "run_outcome.json", {}) content = "" for name in ("output.md", "output.txt", "output.json"): path = directory / "notes" / name if path.is_file(): content = path.read_text(encoding="utf-8") break result = read_json(directory / "result.json") if not content and result is not None: content = text(result) for step in reversed(steps): action = step.get("action_taken") or {} if isinstance(action, dict) and action.get("action") == "report_task_status": args = action.get("args") or {} if not content: content = args.get("explanation") or "" if not outcome: outcome = {"task_status": args.get("status"), "tests": {}} break case = meta.get("case") or {} status = meta.get("terminal_status") or meta.get("status") or session.get("status", "unknown") error = failure_reason(directory, status, meta.get("error")) ai_result = resolve_case_result(outcome, status, error) test_result = reviewed_result(session_id, ai_result.model_dump() if ai_result else None, store) if test_result: outcome = {**outcome, "test_result": test_result} verification = verdict(outcome) if status in {"failed", "interrupted"} and verification.startswith("未验证"): verification = "未验证(任务异常终止)" if not content: content = ( "本任务未生成最终结论。请查看异常原因和已记录的执行步骤。" if status in {"failed", "interrupted"} else "本任务没有保存最终结论。以下内容仅来自已记录的执行状态和证据。" ) return { "id": session_id, "name": execution_title(meta) or session.get("initial_goal", session_id)[:120], "iteration": meta.get("iteration", 1), "repeat_count": meta.get("repeat_count", 1), "goal": case.get("goal") or session.get("initial_goal", ""), "expected": case.get("expected", ""), "status": status, "verdict": verification, "test_result": test_result, "outcome": outcome, "content": content, "steps": steps, "start": session.get("start_time") or meta.get("started_at"), "end": session.get("end_time") or meta.get("ended_at"), "error": error, "device": meta.get("device_serial") or text(session.get("device_info") or ""), "tags": case.get("tags", ""), "case_id": case.get("case_id", ""), "category": case.get("category", ""), "subcategory": case.get("subcategory", ""), "preconditions": case.get("preconditions", ""), "task_config": case.get("task_config") or {}, } def time_text(value) -> str: return ( datetime.fromtimestamp(float(value)).astimezone().strftime("%Y-%m-%d %H:%M:%S") if value else "—" ) def metadata(report: dict) -> list[tuple]: return [ ("任务编号", report["id"]), ("用例编号", report.get("case_id") or "—"), ("检测项目", report.get("category") or "—"), ("测试子项", report.get("subcategory") or "—"), ("前置约束提取", report.get("preconditions") or "—"), ("任务配置", config_text(report.get("task_config"))), ("任务目标", report["goal"]), ("预期结果", report["expected"] or "未指定"), ("执行状态", STATUS.get(report["status"], report["status"])), ("验证结论", report["verdict"]), ("人工复验时间", time_text((report.get("test_result") or {}).get("reviewed_at"))), ( "原 AI 结论", verdict({"test_result": (report.get("test_result") or {}).get("ai_result")}) if (report.get("test_result") or {}).get("source") == "human" else "—", ), ("判定依据", (report.get("test_result") or {}).get("reason") or "未记录"), ( "验收证据", "\n".join((report.get("test_result") or {}).get("evidence") or []) or "未记录", ), ("开始时间", time_text(report["start"])), ("结束时间", time_text(report["end"])), ("设备", report["device"]), ("异常原因", report["error"] or "无独立异常记录"), ] def config_text(config) -> str: if not isinstance(config, dict): return text(config or {}) return json.dumps( {key: value for key, value in config.items() if value is not None}, ensure_ascii=False, indent=2, ) def image_path(name: str | None) -> Path | None: if not name: return None root = Path(IMAGES_DIR).resolve() path = (root / (name if Path(name).suffix else name + ".jpg")).resolve() return ( path if path.is_relative_to(root) and path.is_file() and path.suffix.lower() in {".jpg", ".png", ".jpeg"} else None ) def markdown_blocks(content: str): lines = content.splitlines() index = 0 in_code = False while index < len(lines): line = lines[index] if line.strip().startswith("```"): in_code = not in_code if ( not in_code and "|" in line and index + 1 < len(lines) and re.fullmatch(r"[\s|:\-]+", lines[index + 1]) and "-" in lines[index + 1] ): rows = [[c.strip() for c in line.strip().strip("|").split("|")]] index += 2 while index < len(lines) and "|" in lines[index] and lines[index].strip(): rows.append([c.strip() for c in lines[index].strip().strip("|").split("|")]) index += 1 yield "table", rows else: yield "text", line index += 1 def word_styles(doc, *, landscape: bool = False): section = doc.sections[0] section.page_width, section.page_height = ( (Cm(29.7), Cm(21)) if landscape else (Cm(21), Cm(29.7)) ) section.left_margin = section.right_margin = Cm(2) section.top_margin = section.bottom_margin = Cm(1.9) for name, size, color in ( ("Normal", 10.5, "263445"), ("Title", 20, "0F172A"), ("Heading 1", 14, "0F172A"), ("Heading 2", 11.5, "0F172A"), ("Heading 3", 10.5, "0F172A"), ("List Bullet", 10.5, "263445"), ("List Number", 10.5, "263445"), ("Quote", 10.5, "263445"), ): style = cast(ParagraphStyle, doc.styles[name]) style.font.name = "Microsoft YaHei" style.font.size = Pt(size) style.font.color.rgb = RGBColor.from_string(color) style.element.get_or_add_rPr().get_or_add_rFonts().set(qn("w:eastAsia"), "微软雅黑") style.paragraph_format.space_after = Pt(7 if name == "Normal" else 9) if name == "Normal": style.paragraph_format.line_spacing = 1.3 if name == "Title": ppr = style.element.get_or_add_pPr() border = ppr.find(qn("w:pBdr")) if border is not None: ppr.remove(border) return doc def shade_cell(cell, fill: str): shading = OxmlElement("w:shd") shading.set(qn("w:fill"), fill) cell._tc.get_or_add_tcPr().append(shading) def word_table(doc, rows, widths: tuple[float, ...], *, header: bool = False): table = doc.add_table(rows=0, cols=len(widths)) table.autofit = False table.style = "Table Grid" borders = OxmlElement("w:tblBorders") for edge in ("top", "left", "bottom", "right", "insideH", "insideV"): line = OxmlElement(f"w:{edge}") line.set(qn("w:val"), "single") line.set(qn("w:sz"), "4") line.set(qn("w:color"), "D9D9D9") borders.append(line) table._tbl.tblPr.append(borders) for column, width in zip(table.columns, widths): column.width = Cm(width) for index, row in enumerate(rows): cells = table.add_row().cells if header and index == 0: table.rows[-1]._tr.get_or_add_trPr().append(OxmlElement("w:tblHeader")) for position, cell in enumerate(cells): cell.width = Cm(widths[position]) cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER cell.text = text(row[position]) if position < len(row) else "" margins = OxmlElement("w:tcMar") for edge, size in (("top", "85"), ("bottom", "85"), ("left", "110"), ("right", "110")): margin = OxmlElement(f"w:{edge}") margin.set(qn("w:w"), size) margin.set(qn("w:type"), "dxa") margins.append(margin) cell._tc.get_or_add_tcPr().append(margins) if (header and index == 0) or (not header and position == 0): shade_cell(cell, "DDEBF5") for run in cell.paragraphs[0].runs: run.bold = True elif index % 2 == 0: shade_cell(cell, "F6F9FC") for paragraph in cell.paragraphs: paragraph.paragraph_format.space_after = Pt(3) for run in paragraph.runs: run.font.size = Pt(8.5 if len(widths) > 2 else 9.5) doc.add_paragraph().paragraph_format.space_after = Pt(2) return table def add_markdown_inline(paragraph, value: str): """Keep emphasis readable in Word without importing untrusted HTML.""" pieces = re.split(r"(\*\*[^*]+\*\*|`[^`]+`|\*[^*]+\*)", value) for piece in pieces: if not piece: continue run = paragraph.add_run( piece.strip("*") if piece.startswith("*") else piece.strip("`") if piece.startswith("`") else piece ) run.bold = piece.startswith("**") run.italic = piece.startswith("*") and not piece.startswith("**") if piece.startswith("`"): run.font.name = "Consolas" def add_markdown_content(doc, content: str): in_code = False for kind, value in markdown_blocks(text(content)): if kind == "table": columns = max(map(len, value)) if columns: word_table(doc, value, tuple(16 / columns for _ in range(columns)), header=True) continue line = str(value) if line.strip().startswith("```"): in_code = not in_code continue if in_code: paragraph = doc.add_paragraph() shade = OxmlElement("w:shd") shade.set(qn("w:fill"), "F1F5F9") paragraph._p.get_or_add_pPr().append(shade) paragraph.add_run(line).font.name = "Consolas" elif not line.strip(): continue elif match := re.match(r"^(#{1,3})\s+(.+)$", line): doc.add_heading(match.group(2), level=min(len(match.group(1)) + 1, 3)) elif match := re.match(r"^\s*([-*+] |\d+\. )(.+)$", line): paragraph = doc.add_paragraph( style="List Number" if match.group(1)[0].isdigit() else "List Bullet" ) add_markdown_inline(paragraph, match.group(2)) else: paragraph = doc.add_paragraph(style="Quote" if line.lstrip().startswith("> ") else None) add_markdown_inline( paragraph, line.lstrip("> ") if line.lstrip().startswith("> ") else line ) def plugin_operations(steps: list[dict]) -> list[dict]: operations = [] for step in steps: for trace in step.get("generic_tools", []): payload = trace.get("payload") or {} if isinstance(payload, str): try: payload = json.loads(payload) except ValueError: continue if not isinstance(payload, dict) or not payload.get("plugin_id"): continue result = payload.get("result") or {} if isinstance(result, str): try: result = json.loads(result) except ValueError: result = {} if not isinstance(result, dict): result = {} content = "\n".join( str(block.get("text", "")) for block in result.get("content", []) if isinstance(block, dict) and block.get("type") == "text" ) operations.append( { "step": step.get("step_number"), "plugin": payload["plugin_id"], "version": payload.get("plugin_version", ""), "tool": result.get("tool") or trace.get("name"), "resources": ", ".join(payload.get("resource_ids") or []), "status": result.get("status") or trace.get("status", "unknown"), "content": ( content or payload.get("error") or result.get("message") or "未返回文字证据" )[:6000], } ) return operations def word_report(report: dict, path: Path): doc = word_styles(Document()) doc.add_paragraph(report["name"], style="Title") doc.add_paragraph( f"执行状态:{STATUS.get(report['status'], report['status'])} 验证结论:{report['verdict']}" ) if report["error"]: doc.add_heading("异常原因", 1) doc.add_paragraph(text(report["error"])) if result := report.get("test_result"): doc.add_heading("测试结论与依据", 1) doc.add_paragraph(text(result.get("reason") or "未记录")) for evidence in result.get("evidence") or []: doc.add_paragraph(text(evidence), style="List Bullet") doc.add_heading("任务信息", 1) details = [ (label, value) for label, value in metadata(report) if label in {"任务编号", "用例编号", "检测项目", "测试子项", "开始时间", "结束时间", "设备"} ] word_table(doc, details, (3.2, 13.3)) doc.add_heading("任务目标与预期" if not report["expected"] else "任务目标", 2) doc.add_paragraph(report["goal"] or "未记录") if report["expected"]: doc.add_heading("预期结果", 2) doc.add_paragraph(report["expected"]) if report.get("preconditions"): doc.add_paragraph("前置约束:" + text(report["preconditions"])) if report.get("task_config"): doc.add_paragraph("任务配置:" + config_text(report["task_config"])) doc.add_heading("结论与发现", 1) add_markdown_content(doc, report["content"]) doc.add_heading("执行步骤与证据", 1) for step in report["steps"]: paragraph = doc.add_paragraph() paragraph.paragraph_format.keep_together = True paragraph.add_run( f"步骤 {step['step_number']} · {time_text(step.get('timestamp'))}\n" ).bold = True paragraph.add_run(step.get("summary") or "已记录操作") operations = plugin_operations(report["steps"]) if operations: doc.add_heading("扩展插件操作", 2) for operation in operations: doc.add_paragraph( f"步骤 {operation['step']} · {operation['plugin']} v{operation['version']} · {operation['tool']} · {operation['status']}" ) doc.add_paragraph("资源:" + operation["resources"]) doc.add_paragraph(operation["content"]) evidence = report["steps"][:1] + report["steps"][-1:] seen: set[str] = set() images: list[tuple[Path, int]] = [] for step in evidence: name = step.get("post_image_name") or step.get("pre_image_name") photo = image_path(name) if photo and name not in seen: seen.add(name) images.append((photo, step["step_number"])) if images: doc.add_page_break() doc.add_heading("关键截图", 2) table = doc.add_table(rows=1, cols=len(images)) table.autofit = False for cell, (photo, step_number) in zip(table.rows[0].cells, images): cell.width = Cm(8 if len(images) == 2 else 12) cell.paragraphs[0].add_run(f"步骤 {step_number}").bold = True cell.add_paragraph().add_run().add_picture( str(photo), width=Cm(7.5 if len(images) == 2 else 8.5) ) doc.add_paragraph("其余步骤截图可从任务资料包单独下载。") doc.add_paragraph(f"追溯编号:{report['id']}。未观察、未检查的项目不计为验证通过。") doc.save(str(path)) def sheet_rows(ws, rows, widths: tuple[int, ...] = ()): ws.sheet_view.showGridLines = False ws.freeze_panes = "A2" ws.sheet_properties.pageSetUpPr.fitToPage = True ws.page_setup.fitToWidth = 1 if len(widths) > 3: ws.page_setup.orientation = "landscape" ws.page_setup.paperSize = ws.PAPERSIZE_A3 ws.print_options.horizontalCentered = True for index, row in enumerate(rows, 1): ws.append([text(value)[:32700] if value is not None else "" for value in row]) longest = 1 for column, cell in enumerate(ws[index], 1): # Always store text, including leading '=': never export executable formulas. cell.data_type = "s" cell.alignment = Alignment(wrap_text=True, vertical="center" if index == 1 else "top") cell.font = Font( name="微软雅黑", size=10, bold=index == 1, color="FFFFFF" if index == 1 else "253244", ) cell.border = Border(bottom=Side(style="hair", color="E2E8F0")) if index == 1: cell.fill = PatternFill("solid", fgColor="245C78") elif index % 2 == 1 or column == 1: cell.fill = PatternFill("solid", fgColor="F3F7FA" if column == 1 else "F8FAFC") width = widths[column - 1] if column <= len(widths) else 32 ws.column_dimensions[get_column_letter(column)].width = width lines = str(cell.value or "").splitlines() or [""] height_lines = sum( max(1, (sum(2 if ord(char) > 255 else 1 for char in line) + width - 1) // width) for line in lines ) longest = max(longest, height_lines) ws.row_dimensions[index].height = 30 if index == 1 else min(300, max(27, longest * 18 + 8)) if ws.max_row > 1: ws.auto_filter.ref = ws.dimensions def conclusion_rows(content: str): rows = [("类型", "结论与发现")] table_number = 0 in_code = False for kind, value in markdown_blocks(content): if kind == "table": table_number += 1 rows.append(("表格", f"详见「结果表{table_number}」工作表")) elif line := str(value).strip(): if line.startswith("```"): in_code = not in_code continue if in_code: rows.append(("代码", line)) continue if match := re.match(r"^#{1,6}\s+(.*)$", line): label, body = "小节", match.group(1) elif match := re.match(r"^(?:[-*+] |\d+\. )(.*)$", line): label, body = "要点", match.group(1) else: label, body = "正文", line.lstrip("> ") rows.append( ( label, re.sub(r"\*\*([^*]+)\*\*|`([^`]+)`", lambda m: m.group(1) or m.group(2), body), ) ) return rows def execution_result_label(result) -> str: if not isinstance(result, dict): return text(result or "未记录")[:120] status = result.get("status") labels = { "dispatched": "动作已发出(需核对截图)", "success": "动作返回成功", "failed": "动作失败", } label = labels.get(status, text(status or "未记录")) if result.get("error"): label += "\n" + text(result["error"])[:180] return label def excel_report(report: dict, path: Path): wb = Workbook() assert wb.active is not None wb.active.title = "任务结果" sheet_rows(wb.active, [("项目", "内容"), *metadata(report)], (22, 82)) sheet_rows(wb.create_sheet("结论"), conclusion_rows(report["content"]), (15, 96)) table_number = 0 for kind, rows in markdown_blocks(report["content"]): if kind == "table": table_number += 1 sheet_rows(wb.create_sheet(f"结果表{table_number}"), rows) sheet_rows( wb.create_sheet("执行证据"), [ ("步骤", "时间", "说明", "结果", "截图引用"), *[ ( s["step_number"], time_text(s.get("timestamp")), s.get("summary"), execution_result_label(s.get("last_execution_result")), "见步骤截图资料包" if s.get("post_image_name") or s.get("pre_image_name") else "无截图", ) for s in report["steps"] ], ], (10, 22, 68, 26, 24), ) operations = plugin_operations(report["steps"]) if operations: sheet_rows( wb.create_sheet("插件操作"), [ ("步骤", "插件", "版本", "工具", "资源", "调用状态", "观察与结果"), *[ ( op["step"], op["plugin"], op["version"], op["tool"], op["resources"], op["status"], op["content"], ) for op in operations ], ], (10, 24, 14, 26, 24, 16, 80), ) wb.save(path) def export_session(session_id: str, formats: list[str] | None = None) -> list[Path]: with EXPORT_LOCK: report = session_report(session_id) directory = EXPORT_ROOT / "sessions" / identifier(session_id) directory.mkdir(parents=True, exist_ok=True) outputs = [] for fmt in formats if formats is not None else store.preferences()["formats"]: if fmt not in {"docx", "xlsx"}: raise ValueError("不支持的报告格式") path = directory / f"任务报告.{fmt}" temporary = directory / f"生成中.{fmt}" (word_report if fmt == "docx" else excel_report)(report, temporary) temporary.replace(path) outputs.append(path) return outputs def session_screenshots(report: dict) -> list[tuple[Path, str]]: """Ordered, path-checked pre/post observations for each recorded step.""" screenshots: list[tuple[Path, str]] = [] for index, step in enumerate(report["steps"], 1): for phase, key in (("操作前", "pre_image_name"), ("操作后", "post_image_name")): source = image_path(step.get(key)) if source: screenshots.append( ( source, f"{index:04d}_步骤{int(step.get('step_number') or index):04d}_{phase}{source.suffix.lower()}", ) ) # Plugin observations are attached to their own tool traces rather than # replacing a phone's step image in a mixed task. seen: set[tuple[str, str]] = set() for tool_index, trace in enumerate(step.get("generic_tools", []), 1): payload = trace.get("payload") or {} if isinstance(payload, str): try: payload = json.loads(payload) except ValueError: continue if not isinstance(payload, dict) or not payload.get("plugin_id"): continue plugin_id = re.sub(r"[^a-zA-Z0-9_-]", "_", str(payload["plugin_id"]))[:48] names = payload.get("image_names") or [payload.get("post_image_name")] for image_index, name in enumerate(names, 1): source = image_path(name) if source and (plugin_id, str(name)) not in seen: seen.add((plugin_id, str(name))) screenshots.append( ( source, f"{index:04d}_插件_{plugin_id}_{tool_index:03d}_{image_index:02d}{source.suffix.lower()}", ) ) return screenshots def session_videos(report: dict) -> list[tuple[Path, str]]: """Find only verified files attached to this session's recording directory.""" session = SessionRepository().get_session_by_id(report["id"]) or {} recording = SessionRepository().get_video_recording_for_session(report["id"]) or {} candidate = recording.get("local_video_path") or session.get("video_filepath") if not candidate: return [] source = Path(str(candidate)).resolve() trace_root = TRACES_PATH.resolve() # Workers save recordings in their finalized web_