feat: release 2.3.0 with workspace plugin extensions
This commit is contained in:
1 parent
8380057548
commit
bb873ec0e0
44 files changed
+1497
-48
No files matched your search
@@ -294,7 +294,7 @@ class AutomationStore:
|
||||
if batch.get("status") != "deleting":
|
||||
raise ValueError("批次尚未准备删除")
|
||||
conn.execute(
|
||||
"DELETE FROM automation_records WHERE kind='case_review' AND id IN "
|
||||
"DELETE FROM automation_records WHERE kind IN ('case_review','task_extensions') AND id IN "
|
||||
"(SELECT session_id FROM automation_jobs WHERE batch_id=?)",
|
||||
(batch_id,),
|
||||
)
|
||||
@@ -312,13 +312,15 @@ class AutomationStore:
|
||||
if session_id:
|
||||
conn.execute("DELETE FROM automation_jobs WHERE session_id=?", (session_id,))
|
||||
conn.execute(
|
||||
"DELETE FROM automation_records WHERE kind='case_review' AND id=?",
|
||||
"DELETE FROM automation_records WHERE kind IN ('case_review','task_extensions') AND id=?",
|
||||
(session_id,),
|
||||
)
|
||||
else:
|
||||
conn.execute("DELETE FROM automation_jobs")
|
||||
conn.execute("DELETE FROM automation_records WHERE kind='batch'")
|
||||
conn.execute("DELETE FROM automation_records WHERE kind='case_review'")
|
||||
conn.execute(
|
||||
"DELETE FROM automation_records WHERE kind IN ('case_review','task_extensions')"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ def archive_path(batch_id: str):
|
||||
|
||||
def cached(batch_id: str, batch: dict) -> bool:
|
||||
return bool(batch.get("report_ready")) and report_export.reusable(
|
||||
archive_path(batch_id), report_export.BATCH_ARCHIVE_SIGNATURE, "completed"
|
||||
archive_path(batch_id), report_export.batch_archive_signature(batch_id), "completed"
|
||||
)
|
||||
|
||||
|
||||
@@ -41,8 +41,14 @@ def _snapshot(state: dict) -> dict:
|
||||
def status(batch_id: str, batch: dict) -> dict:
|
||||
with _lock:
|
||||
state = _states.get(batch_id)
|
||||
if state:
|
||||
if state and state["status"] != "ready":
|
||||
return _snapshot(state)
|
||||
if (
|
||||
state
|
||||
and state.get("signature") == report_export.batch_archive_signature(batch_id)
|
||||
and archive_path(batch_id).is_file()
|
||||
):
|
||||
return _snapshot(state)
|
||||
if cached(batch_id, batch):
|
||||
return {
|
||||
"status": "ready",
|
||||
@@ -65,11 +71,12 @@ def status(batch_id: str, batch: dict) -> dict:
|
||||
|
||||
|
||||
def prepare(batch_id: str, batch: dict) -> dict:
|
||||
cache_ready = cached(batch_id, batch)
|
||||
with _lock:
|
||||
current = _states.get(batch_id)
|
||||
if current and current["status"] == "preparing":
|
||||
return _snapshot(current)
|
||||
if cached(batch_id, batch):
|
||||
if cache_ready:
|
||||
_states.pop(batch_id, None)
|
||||
return status_from_cache()
|
||||
state = {
|
||||
@@ -88,11 +95,17 @@ def prepare(batch_id: str, batch: dict) -> dict:
|
||||
def run() -> None:
|
||||
try:
|
||||
with report_export.export_lock("batch:" + batch_id):
|
||||
signature = report_export.batch_archive_signature(batch_id)
|
||||
latest = report_export.store.get("batch", batch_id)
|
||||
if not (latest and cached(batch_id, latest)):
|
||||
report_export.export_batch(batch_id, progress=update)
|
||||
with _lock:
|
||||
state.update(status="ready", stage="报告包已就绪", finished_at=time.monotonic())
|
||||
state.update(
|
||||
status="ready",
|
||||
stage="报告包已就绪",
|
||||
finished_at=time.monotonic(),
|
||||
signature=signature,
|
||||
)
|
||||
except Exception as exc:
|
||||
with _lock:
|
||||
state.update(
|
||||
|
||||
@@ -15,6 +15,7 @@ from apps.admin_console.services.export_cache import (
|
||||
)
|
||||
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
|
||||
@@ -36,6 +37,31 @@ 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": "执行完成",
|
||||
@@ -239,6 +265,7 @@ def session_report(session_id: str, db_path=None) -> dict:
|
||||
"subcategory": case.get("subcategory", ""),
|
||||
"preconditions": case.get("preconditions", ""),
|
||||
"task_config": case.get("task_config") or {},
|
||||
"task_extensions": task_extensions.report_sections(session_id, store),
|
||||
}
|
||||
|
||||
|
||||
@@ -659,6 +686,11 @@ def word_report(report: dict, path: Path):
|
||||
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()
|
||||
@@ -792,6 +824,10 @@ def excel_report(report: dict, path: Path):
|
||||
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":
|
||||
@@ -1064,7 +1100,9 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
|
||||
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)]
|
||||
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 = [("任务编号", "任务名称", "执行状态", "验证结论", "异常原因")]
|
||||
@@ -1078,6 +1116,17 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
|
||||
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)
|
||||
@@ -1088,6 +1137,15 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
|
||||
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)
|
||||
@@ -1146,5 +1204,5 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
|
||||
if progress:
|
||||
progress(len(records), len(records), "正在完成压缩包")
|
||||
temporary.replace(target)
|
||||
remember(target, BATCH_ARCHIVE_SIGNATURE)
|
||||
remember(target, signature)
|
||||
return target
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Human-authored task metadata owned by the host, scoped to installed plugins."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from functools import lru_cache
|
||||
|
||||
from apps.admin_console.services.automation_store import AutomationStore, identifier
|
||||
from apps.admin_console.database.repositories.session_repository import SessionRepository
|
||||
from artemis.plugins.models import PluginManifest
|
||||
from artemis.plugins.store import PluginStore
|
||||
|
||||
TERMINAL = {"completed", "success", "failed", "cancelled", "interrupted", "skipped"}
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def plugin_store() -> PluginStore:
|
||||
return PluginStore()
|
||||
|
||||
|
||||
def active_manifests() -> list[PluginManifest]:
|
||||
return [
|
||||
PluginManifest.model_validate(plugin["manifest"])
|
||||
for plugin in plugin_store().list()
|
||||
if plugin["enabled"] and plugin["manifest"].get("workspace")
|
||||
]
|
||||
|
||||
|
||||
def installation_signature() -> str:
|
||||
manifests = [manifest.model_dump(mode="json") for manifest in active_manifests()]
|
||||
return (
|
||||
hashlib.sha256(json.dumps(manifests, sort_keys=True).encode()).hexdigest()
|
||||
if manifests
|
||||
else ""
|
||||
)
|
||||
|
||||
|
||||
def task_context(session_id: str, store: AutomationStore) -> dict:
|
||||
identifier(session_id)
|
||||
session = SessionRepository(store.path).get_session_by_id(session_id) or {}
|
||||
job = store.job(session_id) or {}
|
||||
if not session and not job:
|
||||
raise ValueError("任务记录不存在")
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"initial_prompt": session.get("initial_goal") or (job.get("case") or {}).get("goal", ""),
|
||||
"editable": (job.get("status") or session.get("status")) in TERMINAL,
|
||||
}
|
||||
|
||||
|
||||
def contributions(session_id: str, store: AutomationStore) -> list[dict]:
|
||||
record = store.get("task_extensions", session_id) or {}
|
||||
result = []
|
||||
for manifest in active_manifests():
|
||||
assert manifest.workspace is not None
|
||||
saved = record.get(manifest.id) or {}
|
||||
values = saved.get("values", {})
|
||||
result.append(
|
||||
{
|
||||
"plugin_id": manifest.id,
|
||||
"name": manifest.name,
|
||||
"version": manifest.version,
|
||||
"workspace": manifest.workspace.model_dump(),
|
||||
"revision": saved.get("revision", 0),
|
||||
"values": {
|
||||
field.id: values.get(field.id, "") for field in manifest.workspace.fields
|
||||
},
|
||||
"history": saved.get("history", []),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def report_sections(session_id: str, store: AutomationStore) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"plugin_id": item["plugin_id"],
|
||||
"name": item["name"],
|
||||
"version": item["version"],
|
||||
"revision": item["revision"],
|
||||
"fields": [
|
||||
{"id": field["id"], "label": field["label"], "value": item["values"][field["id"]]}
|
||||
for field in item["workspace"]["fields"]
|
||||
if field["report"]
|
||||
],
|
||||
}
|
||||
for item in contributions(session_id, store)
|
||||
if any(field["report"] for field in item["workspace"]["fields"])
|
||||
]
|
||||
|
||||
|
||||
def save_values(
|
||||
store: AutomationStore,
|
||||
session_id: str,
|
||||
manifest: PluginManifest,
|
||||
values: dict[str, str],
|
||||
expected_revision: int,
|
||||
request_id: str,
|
||||
) -> None:
|
||||
"""CAS + request receipts prevent lost edits and repeated history entries."""
|
||||
identifier(session_id)
|
||||
assert manifest.workspace is not None
|
||||
fields = {field.id: field for field in manifest.workspace.fields}
|
||||
if values.keys() - fields.keys():
|
||||
raise ValueError("包含未声明的扩展字段")
|
||||
for key, value in values.items():
|
||||
field = fields[key]
|
||||
if len(value) > field.max_length or (
|
||||
field.kind == "choice" and value and value not in field.options
|
||||
):
|
||||
raise ValueError(f"字段 {field.label} 的内容不符合插件声明")
|
||||
now = time.time()
|
||||
with store.connection() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute(
|
||||
"SELECT payload FROM automation_records WHERE kind='task_extensions' AND id=?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
record = json.loads(row[0]) if row else {}
|
||||
saved = record.get(manifest.id, {"revision": 0, "values": {}, "history": []})
|
||||
for entry in saved["history"]:
|
||||
if entry["request_id"] == request_id:
|
||||
if entry["patch"] != values:
|
||||
raise ValueError("同一保存请求不能提交不同内容")
|
||||
return
|
||||
if saved["revision"] != expected_revision:
|
||||
raise ValueError("评审已在其他页面修改,请重新加载后保存")
|
||||
updated = {**saved["values"], **values}
|
||||
revision = saved["revision"] + 1
|
||||
saved["history"].append(
|
||||
{
|
||||
"revision": revision,
|
||||
"saved_at": now,
|
||||
"request_id": request_id,
|
||||
"patch": values,
|
||||
"values": updated,
|
||||
"plugin_version": manifest.version,
|
||||
"fields": [field.model_dump() for field in manifest.workspace.fields],
|
||||
}
|
||||
)
|
||||
record[manifest.id] = {**saved, "revision": revision, "values": updated}
|
||||
conn.execute(
|
||||
"INSERT INTO automation_records VALUES ('task_extensions',?,?,?) "
|
||||
"ON CONFLICT(kind,id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at",
|
||||
(session_id, json.dumps(record, ensure_ascii=False), now),
|
||||
)
|
||||
job = conn.execute(
|
||||
"SELECT batch_id FROM automation_jobs WHERE session_id=?", (session_id,)
|
||||
).fetchone()
|
||||
if job and job[0]:
|
||||
batch = conn.execute(
|
||||
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?", (job[0],)
|
||||
).fetchone()
|
||||
if not batch or json.loads(batch[0]).get("status") == "deleting":
|
||||
raise ValueError("批次正在删除,不能保存评审")
|
||||
payload = json.loads(batch[0])
|
||||
payload["report_ready"] = False
|
||||
conn.execute(
|
||||
"UPDATE automation_records SET payload=?,updated_at=? WHERE kind='batch' AND id=?",
|
||||
(json.dumps(payload, ensure_ascii=False), now, job[0]),
|
||||
)
|
||||
conn.commit()
|
||||
Reference in new issue
Block a user