feat: enable full AI automation and safe startup updates
This commit is contained in:
1 parent
2ddec53954
commit
7b1a0755e2
62 files changed
+3123
-240
No files matched your search
@@ -0,0 +1,328 @@
|
||||
"""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": "执行中",
|
||||
}
|
||||
|
||||
|
||||
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", ""),
|
||||
}
|
||||
|
||||
|
||||
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["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 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
|
||||
Reference in new issue
Block a user