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

423 lines
17 KiB
Python

"""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