"""Evidence-backed Word/Excel artifacts, independent of the language model runtime.""" from datetime import datetime from typing import cast from docx.styles.style import ParagraphStyle import json from pathlib import Path import re import threading import zipfile from docx import Document from docx.shared import Inches, Pt from docx.oxml.ns import qn from openpyxl import Workbook from openpyxl.styles import Font, PatternFill, Alignment 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 EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports" EXPORT_LOCK = threading.RLock() STATUS = { "completed": "执行完成", "success": "执行完成", "failed": "执行失败", "cancelled": "已取消", "interrupted": "执行中断", "skipped": "已跳过", "pending": "待执行", "queued": "已排队", "running": "执行中", "task_paused": "等待指导", "resuming": "等待设备恢复", } 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: 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 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 if not content: content = "本任务没有保存最终结论。以下内容仅来自已记录的执行状态和证据。" case = meta.get("case") or {} return { "id": session_id, "name": case.get("name") or session.get("initial_goal", session_id)[:120], "goal": case.get("goal") or session.get("initial_goal", ""), "expected": case.get("expected", ""), "status": meta.get("terminal_status") or meta.get("status") or session.get("status", "unknown"), "verdict": verdict(outcome), "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": meta.get("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().isoformat(timespec="seconds") 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 "—"), ("任务配置", text(report.get("task_config") or {})), ("任务目标", report["goal"]), ("预期结果", report["expected"] or "未指定"), ("执行状态", STATUS.get(report["status"], report["status"])), ("验证结论", report["verdict"]), ("开始时间", time_text(report["start"])), ("结束时间", time_text(report["end"])), ("设备", report["device"]), ("异常原因", report["error"] or "无独立异常记录"), ] 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 while index < len(lines): line = lines[index] if ( "|" 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_report(report: dict, path: Path): doc = Document() style = cast(ParagraphStyle, doc.styles["Normal"]) style.font.name = "Microsoft YaHei" style.font.size = Pt(10) style.element.rPr.rFonts.set(qn("w:eastAsia"), "微软雅黑") doc.add_heading(report["name"], 0) table = doc.add_table(rows=0, cols=2) table.columns[0].width = Inches(1.3) table.columns[1].width = Inches(5.2) table.style = "Light Shading Accent 1" for label, value in metadata(report): cells = table.add_row().cells cells[0].text, cells[1].text = label, text(value) cells[0].width, cells[1].width = Inches(1.3), Inches(5.2) doc.add_heading("结论与任务专属结果", 1) for kind, value in markdown_blocks(text(report["content"])): if kind == "table": table = doc.add_table(rows=0, cols=max(map(len, value))) table.style = "Light Shading Accent 1" for row in value: cells = table.add_row().cells for cell, item in zip(cells, row): cell.text = item elif isinstance(value, str) and value.startswith("#"): doc.add_heading(value.lstrip("# "), min(len(value) - len(value.lstrip("#")), 3)) else: doc.add_paragraph(str(value)) doc.add_heading("执行步骤与证据", 1) for step in report["steps"]: doc.add_paragraph( f"步骤 {step['step_number']} · {time_text(step.get('timestamp'))} · {step.get('summary') or '已记录操作'}" ) evidence = report["steps"][:1] + report["steps"][-3:] seen = set() 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) doc.add_paragraph(f"证据:步骤 {step['step_number']} / {name}") doc.add_picture(str(photo), width=Inches(2.6)) doc.add_paragraph(f"追溯编号:{report['id']}。未观察、未检查的项目不计为验证通过。") doc.save(str(path)) def sheet_rows(ws, rows): for row in rows: ws.append([text(value)[:32700] if value is not None else "" for value in row]) for cell in ws[ws.max_row]: # Always store text, including leading '=': never export executable formulas. cell.data_type = "s" cell.alignment = Alignment(wrap_text=True, vertical="top") ws.freeze_panes = "A2" if ws.max_row: ws.auto_filter.ref = ws.dimensions for cell in ws[1]: cell.font = Font(bold=True, color="FFFFFF") cell.fill = PatternFill("solid", fgColor="245C78") for column in ws.columns: ws.column_dimensions[column[0].column_letter].width = 32 def excel_report(report: dict, path: Path): wb = Workbook() assert wb.active is not None wb.active.title = "任务结果" sheet_rows(wb.active, [("项目", "内容"), *metadata(report)]) sheet_rows( wb.create_sheet("结论"), [("最终结论",), *[(line,) for line in report["content"].splitlines()]], ) for number, (kind, rows) in enumerate(markdown_blocks(report["content"])): if kind == "table": sheet_rows(wb.create_sheet(f"结果表{number + 1}"), rows) sheet_rows( wb.create_sheet("执行证据"), [ ("步骤", "时间", "说明", "结果", "截图引用"), *[ ( s["step_number"], time_text(s.get("timestamp")), s.get("summary"), text(s.get("last_execution_result")), s.get("post_image_name") or s.get("pre_image_name"), ) for s in report["steps"] ], ], ) 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()}", ) ) 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() session_root = (TRACES_PATH / identifier(report["id"])).resolve() if not source.is_relative_to(session_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(session_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 export_session_media(session_id: str, kind: str) -> Path: if kind not in {"screenshots", "video", "all"}: raise ValueError("不支持的下载类型") with EXPORT_LOCK: report = 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 [] if kind == "video" and not videos: raise ValueError("此任务没有已保存的录屏") if kind == "screenshots" and not images: raise ValueError("此任务没有可下载的步骤截图") target = target_dir / f"{kind}.zip" temporary = target_dir / f".{kind}.tmp" 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], "note": "录屏按时间顺序分段;暂停期间未占用设备,也未录制其他任务。", } with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive: if kind == "all": for path in export_session(session_id, ["docx", "xlsx"]): archive.write(path, path.name) for source, name in images: archive.write(source, f"步骤截图/{name}") for source, name in videos: archive.write(source, f"任务录屏/{name}") archive.writestr("文件清单.json", json.dumps(manifest, ensure_ascii=False, indent=2)) temporary.replace(target) return target def export_batch(batch_id: str) -> Path: with EXPORT_LOCK: 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"] records = [session_report(job["session_id"]) for job in store.jobs(batch_id)] 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) path = directory / "批次结果.xlsx" wb.save(path) outputs.append(path) if "docx" in formats: doc = Document() doc.add_heading(batch["name"], 0) doc.add_paragraph( f"批次编号:{batch_id};共 {len(records)} 个任务。验证通过与执行完成分别统计。" ) table = doc.add_table(rows=0, cols=5) table.style = "Light Shading Accent 1" for row in rows: for cell, value in zip(table.add_row().cells, row): cell.text = str(value) 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) for number, report in enumerate(records, 1): for path in export_session(report["id"], formats): archive.write(path, f"任务报告/{number:04d}_{report['id']}/{path.name}") temporary.replace(target) return target