Files
Artemis-Eapil/apps/admin_console/services/report_export.py
T

743 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Evidence-backed Word/Excel artifacts, independent of the language model runtime."""
from datetime import datetime
from typing import 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 case_title
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:
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]
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"])
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"))
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": case_title(case) or session.get("initial_goal", session_id)[:120],
"goal": case.get("goal") or session.get("initial_goal", ""),
"expected": case.get("expected", ""),
"status": status,
"verdict": verification,
"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["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 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"]))
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("任务目标", 2)
doc.add_paragraph(report["goal"] or "未记录")
doc.add_heading("预期结果", 2)
doc.add_paragraph(report["expected"] or "未指定")
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 "已记录操作")
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),
)
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()
trace_root = TRACES_PATH.resolve()
# Workers save recordings in their finalized web_<time>_<id>_PASS/FAIL
# folder. The recording row already ties that path 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 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": "录屏按时间顺序分段;暂停期间未占用设备,也未录制其他任务。",
}
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"]):
archive.write(path, prefix + path.name)
for source, name in images:
archive.write(source, f"{prefix}步骤截图/{name}")
for source, name in videos:
archive.write(source, f"{prefix}任务录屏/{name}")
archive.writestr(
prefix + "文件清单.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, (40, 48, 16, 28, 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)
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}_{archive_case_name(report)}/{path.name}"
)
temporary.replace(target)
return target