1209 lines
50 KiB
Python
1209 lines
50 KiB
Python
"""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-2"
|
||
|
||
|
||
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:
|
||
content = (
|
||
"本任务未生成最终结论。请查看异常原因和已记录的执行步骤。"
|
||
if status in {"failed", "interrupted"}
|
||
else "本任务没有保存最终结论。以下内容仅来自已记录的执行状态和证据。"
|
||
)
|
||
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", ""),
|
||
"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") if ref.get(key)
|
||
)
|
||
or "编号未记录"
|
||
for ref in item.get("references", [])
|
||
)
|
||
lines.append(
|
||
f"{labels.get(item['kind'], '其他')} · {item['label'] or item['target_id']} · {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 "—"),
|
||
("任务配置", 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()
|
||
# 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 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
|