"""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 zipfile from apps.admin_console.services.export_cache import ( compression, export_lock, fingerprint, remember, reusable, ) from apps.admin_console.services.request_diagnostics import diagnostics_json from apps.admin_console.services.task_lifecycle import guidance_details from apps.admin_console.services import task_extensions 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" BATCH_ARCHIVE_SIGNATURE = "artemis-batch-layout-3" def batch_archive_signature(batch_id: str | None = None) -> str: signature = task_extensions.installation_signature() if batch_id: with store.connection() as conn: changes = conn.execute( "SELECT r.id,r.updated_at FROM automation_records r " "JOIN automation_jobs j ON j.session_id=r.id " "WHERE r.kind='task_extensions' AND j.batch_id=? ORDER BY r.id", (batch_id,), ).fetchall() if changes: signature += ":" + fingerprint({"reviews": [tuple(row) for row in changes]}) return BATCH_ARCHIVE_SIGNATURE + (":" + signature if signature else "") def extension_rows(report: dict) -> list[tuple[str, str, str]]: return [ (section["name"], field["label"], field["value"]) for section in report.get("task_extensions", []) for field in section["fields"] ] 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: if result["status"] == "inconclusive" and result.get("verification_skipped"): return "未做 AI 判定" 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: from apps.admin_console.services.exploration_evidence import exploration_metadata from artemis.data_engine.evidence_sources import recorded_sources 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: lines = ["本任务未保存独立输出正文。", f"当前有效判定:{verification}"] if test_result: if test_result.get("reason"): lines.append(f"判定依据:{test_result['reason']}") lines.extend(f"- {item}" for item in test_result.get("evidence") or []) elif error: lines.append(f"异常原因:{error}") content = "\n\n".join(lines) return { "id": session_id, **guidance_details(session_id, db_path=repo.db_path), "exploration": exploration_metadata(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, "evidence_sources": recorded_sources(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", ""), "original_case": case.get("original_case"), "task_config": case.get("task_config") or {}, "task_extensions": task_extensions.report_sections(session_id, store), } def time_text(value) -> str: return ( datetime.fromtimestamp(float(value)).astimezone().strftime("%Y-%m-%d %H:%M:%S") if value else "—" ) def lifecycle_text(value: dict) -> str: labels = { "total": "总历时", "queued": "后台排队", "resource_wait": "等待设备/资源", "paused": "暂停与示范", "execution": "执行与整理", "final_review": "最终验收", "report_export": "报告导出", } durations = value.get("durations") or {} return ( "\n".join( f"{label}:{round(durations[key], 1)} 秒" if durations.get(key) is not None else f"{label}:未记录" for key, label in labels.items() ) + "\n执行含初始化与报告整理,不代表模型速度。" ) def guidance_text(items: list[dict]) -> str: labels = { "pending": "已接收,等待读取", "reading": "执行器正在读取", "consumed": "执行器已读取", "unapplied": "任务结束,未记录消费", "unconfirmed": "任务结束,读取未确认", } return ( "\n".join( f"{item['id']} · {labels.get(item['status'], '状态未知')} · 接收 {time_text(item.get('accepted_at') or item.get('timestamp'))}" + (f" · 读取 {time_text(item['consumed_at'])}" if item.get("consumed_at") else "") for item in items ) or "无指导记录" ) def source_text(value: dict) -> str: labels = { "android": "安卓界面", "computer": "电脑界面", "api": "接口数据", "terminal": "终端", "file": "文件", "other": "其他", } lines = [] for item in value.get("sources", []): references = ", ".join( " / ".join( f"{key}={ref[key]}" for key in ("step_id", "call_id", "source_id", "trace_id") if ref.get(key) ) or "编号未记录" for ref in item.get("references", []) ) variation = "(来源标签随步骤变化,详见原始步骤)" if item.get("label_varies") else "" lines.append( f"{labels.get(item['kind'], '其他')} · {item['label'] or item['target_id']}" f"{variation} · {references}" ) if value.get("sources_omitted"): lines.append(f"另 {value['sources_omitted']} 个来源见原始步骤记录。") lines.append( f"未关联到单一来源的步骤:{value.get('unattributed_step_count', '未知')}。" "窗口枚举等操作可能没有单一目标;此计数不表示截图丢失。" ) lines.append(value.get("note") or "来源清单不代表断言已验证。") return "\n".join(lines) def metadata(report: dict) -> list[tuple]: return [ ("任务编号", report["id"]), *( ( [ ("探索会话", report["exploration"]["id"]), ( "探索关联", "用例 v" + str(report["exploration"]["case_version"]) + " 复跑" if report["exploration"]["kind"] == "validation" else "探索执行(不等于独立复跑通过)", ), ] ) if report.get("exploration") else [] ), ("用例编号", report.get("case_id") or "—"), ("检测项目", report.get("category") or "—"), ("测试子项", report.get("subcategory") or "—"), ("前置约束提取", report.get("preconditions") or "—"), *( [ ("原始用例 · 预置条件", report["original_case"].get("preconditions") or "未填写"), ("原始用例 · 操作步骤", report["original_case"].get("steps") or "未填写"), ("原始用例 · 预期结果", report["original_case"].get("expected") or "未填写"), ] if report.get("original_case") is not None else [] ), ("任务配置", config_text(report.get("task_config"))), ("任务目标", report["goal"]), ("预期结果", report["expected"] or "未指定"), ("执行状态", STATUS.get(report["status"], report["status"])), ("耗时分项", lifecycle_text(report.get("lifecycle") or {})), ("指导消费", guidance_text(report.get("instructions") or [])), ("证据来源与边界", source_text(report.get("evidence_sources") or {})), ("验证结论", report["verdict"]), ("人工复验时间", time_text((report.get("test_result") or {}).get("reviewed_at"))), ( "原始结果" if ((report.get("test_result") or {}).get("ai_result") or {}).get( "verification_skipped" ) else "原 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" ) source = result.get("source") or payload.get("source") or {} evidence = [] if result.get("call_id"): evidence.append("调用:" + str(result["call_id"])) if source: evidence.append( f"来源:{source.get('kind', '')} · {source.get('label') or source.get('target_id', '')}" ) if result.get("execution"): evidence.append( f"执行:{result['execution']};副作用:{result.get('side_effect', 'unknown')};错误码:{result.get('error_code') or '无'}" ) for attachment in result.get("attachments") or []: evidence.append( f"文件:{attachment.get('name')};SHA-256:{attachment.get('sha256')}" ) if evidence: content = "\n".join([content or result.get("message") or "", *evidence]) 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"]) if review_rows := extension_rows(report): doc.add_heading("人工评审(插件)", 1) word_table( doc, [(f"{name} · {label}", value) for name, label, value in review_rows], (5.5, 11) ) 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)) if review_rows := extension_rows(report): sheet_rows( wb.create_sheet("人工评审"), [("插件", "项目", "内容"), *review_rows], (24, 30, 82) ) 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, *, report: dict | None = None ) -> list[Path]: with export_lock("session:" + identifier(session_id)): report = report if report is not None else session_report(session_id) signature = fingerprint(report, session_screenshots(report)) 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}" if reusable(path, signature, report["status"]): outputs.append(path) continue temporary = directory / f"生成中.{fmt}" (word_report if fmt == "docx" else excel_report)(report, temporary) temporary.replace(path) remember(path, signature) 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() # The recording row already ties the finalized trace folder to this session. if not source.is_relative_to(trace_root): return [] # A resumed recording lives under resume_NNNN; the initial recording is # in its parent, with any later segments in sibling resume folders. directory = source.parent if directory.name.startswith("resume_"): directory = directory.parent if not directory.is_dir() or not directory.is_relative_to(trace_root): return [] paths = [ p for p in directory.rglob("*.mp4") if p.is_file() and p.resolve().is_relative_to(directory.resolve()) and (p.parent == directory or p.parent.name.startswith("resume_")) ] paths.sort(key=lambda p: (0 if p.parent == directory else 1, p.parent.name, p.name)) return [(path, f"{i:04d}_{path.parent.name}_{path.name}") for i, path in enumerate(paths, 1)] def session_artifacts(report: dict) -> list[tuple[Path, str]]: """Only completed, task-owned Android files; never follow external links.""" traces = TRACES_PATH.resolve() root = traces / identifier(report["id"]) / "artifacts" / "android" if not root.is_dir() or root.resolve() != root: return [] return [ (path, path.name) for path in sorted(root.iterdir()) if path.is_file() and not path.is_symlink() and not path.name.startswith(".artemis-") and path.resolve().parent == root.resolve() ] def session_download(session_id: str, kind: str) -> tuple[Path, str]: with export_lock("session:" + identifier(session_id)): report = session_report(session_id) return export_session_media(session_id, kind, report=report), archive_case_name(report) def export_session_media(session_id: str, kind: str, *, report: dict | None = None) -> Path: from apps.admin_console.services.evidence_service import export_index, plugin_files if kind not in {"screenshots", "video", "all"}: raise ValueError("不支持的下载类型") with export_lock("session:" + identifier(session_id)): report = report if report is not None else session_report(session_id) target_dir = EXPORT_ROOT / "sessions" / identifier(session_id) target_dir.mkdir(parents=True, exist_ok=True) images = session_screenshots(report) if kind in {"screenshots", "all"} else [] videos = session_videos(report) if kind in {"video", "all"} else [] artifacts = session_artifacts(report) if kind == "all" else [] extras = plugin_files(session_id) if kind in {"video", "all"} else [] if kind == "video": extras = [ (path, name) for path, name in extras if path.suffix.lower() in {".mp4", ".mkv", ".webm"} ] if kind == "video" and not videos and not extras: raise ValueError("此任务没有已保存的录屏") if kind == "screenshots" and not images: raise ValueError("此任务没有可下载的步骤截图") target = target_dir / f"{kind}.zip" temporary = target_dir / f".{kind}.tmp" files = images + videos + artifacts + extras diagnostics = diagnostics_json(session_id) if kind == "all" else None diagnostic_state = json.loads(diagnostics) if diagnostics else None if isinstance(diagnostic_state, dict): diagnostic_state.pop("collected_at", None) signature = fingerprint(report, files, diagnostic_state) if reusable(target, signature, report["status"]): return target manifest = { "session_id": session_id, "name": report["name"], "status": report["status"], "verdict": report["verdict"], "screenshots": [name for _, name in images], "videos": ["任务录屏/安卓/" + name for _, name in videos] + [name for path, name in extras if path.suffix.lower() in {".mp4", ".mkv", ".webm"}], "artifacts": [name for _, name in artifacts], "note": "录屏按时间顺序分段;暂停期间未占用设备,也未录制其他任务。", "snapshot": report["status"] not in {"completed", "success", "failed", "cancelled", "interrupted", "skipped"}, } prefix = archive_case_name(report) + "/" if kind == "all" else "" with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive: if kind == "all": for path in export_session(session_id, ["docx", "xlsx"], report=report): archive.write(path, prefix + path.name, compress_type=compression(path)) for source, name in images: archive.write(source, f"{prefix}步骤截图/{name}", compress_type=compression(source)) for source, name in videos: archive.write( source, f"{prefix}任务录屏/安卓/{name}", compress_type=zipfile.ZIP_STORED ) for source, name in artifacts: archive.write( source, f"{prefix}任务文件/安卓/{name}", compress_type=compression(source) ) for source, name in extras: archive.write( source, prefix + name, compress_type=compression(source), ) if kind == "all": archive.writestr(prefix + "模型请求诊断.json", diagnostics or "{}") archive.writestr( prefix + "证据索引.json", json.dumps( export_index( session_id, report, videos, images=images, artifacts=artifacts, extras=extras, ), ensure_ascii=False, indent=2, default=str, ), ) archive.writestr( prefix + "文件清单.json", json.dumps(manifest, ensure_ascii=False, indent=2) ) temporary.replace(target) if fingerprint(report, files, diagnostic_state) == signature: remember(target, signature) return target def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None = None) -> Path: from apps.admin_console.services.evidence_service import export_index, plugin_files with export_lock( "batch:" + identifier(batch_id), *("session:" + job["session_id"] for job in store.jobs(batch_id)), ): batch = store.get("batch", identifier(batch_id)) if not batch: raise ValueError("批次不存在") directory = EXPORT_ROOT / "batches" / batch_id directory.mkdir(parents=True, exist_ok=True) formats = batch["formats"] with task_extensions.plugin_store().mutation_lock: signature = batch_archive_signature(batch_id) records = [session_report(job["session_id"]) for job in store.jobs(batch_id)] if progress: progress(0, len(records), "正在生成批次汇总") rows = [("任务编号", "任务名称", "执行状态", "验证结论", "异常原因")] rows += [ (r["id"], r["name"], STATUS.get(r["status"], r["status"]), r["verdict"], r["error"]) for r in records ] outputs = [] if "xlsx" in formats: wb = Workbook() assert wb.active is not None wb.active.title = "批次结果" sheet_rows(wb.active, rows, (40, 48, 16, 28, 70)) review_rows = [ (record["id"], record["name"], *row) for record in records for row in extension_rows(record) ] if review_rows: sheet_rows( wb.create_sheet("人工评审"), [("任务编号", "任务名称", "插件", "项目", "内容"), *review_rows], (40, 40, 24, 30, 70), ) path = directory / "批次结果.xlsx" wb.save(path) outputs.append(path) if "docx" in formats: doc = word_styles(Document(), landscape=True) doc.add_paragraph(batch["name"], style="Title") doc.add_paragraph( f"批次编号:{batch_id};共 {len(records)} 个任务。验证通过与执行完成分别统计。" ) word_table(doc, rows, (3.5, 5.4, 2.5, 3.5, 7.3), header=True) for record in records: if review_rows := extension_rows(record): doc.add_heading("人工评审 · " + record["name"], 1) doc.add_paragraph("任务编号:" + record["id"]) word_table( doc, [(f"{name} · {label}", value) for name, label, value in review_rows], (7, 15.2), ) path = directory / "批次汇总.docx" doc.save(str(path)) outputs.append(path) target = directory / "批次报告.zip" temporary = directory / "批次报告.tmp" with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive: for path in outputs: archive.write(path, path.name, compress_type=compression(path)) for number, report in enumerate(records, 1): if progress: progress(number - 1, len(records), f"正在打包任务 {number}/{len(records)}") prefix = f"任务报告/{number:04d}_{archive_case_name(report)}" images = session_screenshots(report) videos = session_videos(report) artifacts = session_artifacts(report) extras = plugin_files(report["id"]) for path in export_session(report["id"], formats, report=report): archive.write(path, f"{prefix}/{path.name}", compress_type=compression(path)) for source, name in images: archive.write( source, f"{prefix}/步骤截图/{name}", compress_type=compression(source) ) for source, name in videos: archive.write( source, f"{prefix}/任务录屏/安卓/{name}", compress_type=zipfile.ZIP_STORED ) for source, name in artifacts: archive.write( source, f"{prefix}/任务文件/安卓/{name}", compress_type=compression(source) ) for source, name in extras: archive.write( source, f"{prefix}/{name}", compress_type=compression(source), ) archive.writestr(f"{prefix}/模型请求诊断.json", diagnostics_json(report["id"])) archive.writestr( f"{prefix}/证据索引.json", json.dumps( export_index( report["id"], report, videos, images=images, artifacts=artifacts, extras=extras, ), ensure_ascii=False, indent=2, default=str, ), ) if progress: progress(number, len(records), f"已打包任务 {number}/{len(records)}") if progress: progress(len(records), len(records), "正在完成压缩包") temporary.replace(target) remember(target, signature) return target