feat: release 1.5.1 with human review and batch filters
This commit is contained in:
1 parent
53c5680260
commit
bdd1aa2c14
36 files changed
+672
-57
No files matched your search
@@ -13,7 +13,7 @@ from apps.admin_console.services import automation_service as service
|
||||
from apps.admin_console.services import batch_archive as archive_service
|
||||
from apps.admin_console.services import report_export, task_import
|
||||
from artemis.config.settings import settings
|
||||
from artemis.runtime.case_result import read_case_result
|
||||
from apps.admin_console.services.case_review import read_reviewed_result
|
||||
|
||||
router = APIRouter(prefix="/api/automation", tags=["automation"])
|
||||
|
||||
@@ -24,6 +24,12 @@ class ReportPreferences(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class CaseReviewRequest(BaseModel):
|
||||
model_config = {"extra": "forbid"}
|
||||
status: Literal["passed", "failed"]
|
||||
reason: str = Field(default="", max_length=2000)
|
||||
|
||||
|
||||
class BatchRequest(ReportPreferences):
|
||||
request_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{1,100}$")
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
@@ -125,8 +131,8 @@ def batch_by_request(request_id: str):
|
||||
def batch_detail(batch_id: str):
|
||||
jobs = store.jobs(batch_id)
|
||||
for job in jobs:
|
||||
job["test_result"] = read_case_result(
|
||||
job["session_id"], job["status"], job.get("error") or ""
|
||||
job["test_result"] = read_reviewed_result(
|
||||
job["session_id"], job["status"], job.get("error") or "", storage=store
|
||||
)
|
||||
return {**require("batch", batch_id), "jobs": jobs}
|
||||
|
||||
@@ -207,6 +213,42 @@ async def download_report(
|
||||
raise HTTPException(404, str(exc)) from exc
|
||||
|
||||
|
||||
@router.put("/reports/{session_id}/review")
|
||||
def review_case(session_id: str, value: CaseReviewRequest):
|
||||
report(session_id)
|
||||
job = store.job(session_id) or {}
|
||||
batch_id = job.get("batch_id")
|
||||
if (
|
||||
batch_id
|
||||
and archive_service.status(batch_id, require("batch", batch_id))["status"] == "preparing"
|
||||
):
|
||||
raise HTTPException(409, "批次报告正在打包,请完成后再保存人工复验")
|
||||
with report_export.EXPORT_LOCK:
|
||||
current = report(session_id)
|
||||
if current["status"] not in {
|
||||
"completed",
|
||||
"success",
|
||||
"failed",
|
||||
"cancelled",
|
||||
"interrupted",
|
||||
"skipped",
|
||||
}:
|
||||
raise HTTPException(409, "请在任务结束后进行人工复验")
|
||||
try:
|
||||
store.save_case_review(session_id, value.status, value.reason)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
if batch_id:
|
||||
archive_service.forget(batch_id)
|
||||
# A finishing worker can publish report_ready just after export;
|
||||
# removing only the generated ZIP also prevents that stale cache hit.
|
||||
try:
|
||||
archive_service.archive_path(batch_id).unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass # An open download may hold the file; report_ready is already invalidated.
|
||||
return report(session_id)
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
def history(
|
||||
q: str = Query(default="", max_length=200),
|
||||
@@ -215,6 +257,7 @@ def history(
|
||||
end: float | None = None,
|
||||
page: int = Query(default=1, ge=1),
|
||||
size: int = Query(default=30, ge=1, le=100),
|
||||
batch_id: str = Query(default="", max_length=100),
|
||||
):
|
||||
# SQL filtering/pagination searches the entire persisted history, not a UI cache.
|
||||
query = """WITH history AS (
|
||||
@@ -226,7 +269,7 @@ def history(
|
||||
COALESCE(json_extract(j.payload,'$.case.category'),'') category,
|
||||
COALESCE(json_extract(j.payload,'$.case.subcategory'),'') subcategory,
|
||||
COALESCE(json_extract(j.payload,'$.case.preconditions'),'') preconditions,
|
||||
COALESCE(json_extract(j.payload,'$.error'),'') error
|
||||
COALESCE(json_extract(j.payload,'$.error'),'') error, j.batch_id
|
||||
FROM sessions s LEFT JOIN automation_jobs j ON s.session_id=j.session_id
|
||||
UNION ALL
|
||||
SELECT j.session_id,json_extract(j.payload,'$.case.goal'),
|
||||
@@ -237,11 +280,22 @@ def history(
|
||||
COALESCE(json_extract(j.payload,'$.case.category'),''),
|
||||
COALESCE(json_extract(j.payload,'$.case.subcategory'),''),
|
||||
COALESCE(json_extract(j.payload,'$.case.preconditions'),''),
|
||||
COALESCE(json_extract(j.payload,'$.error'),'')
|
||||
COALESCE(json_extract(j.payload,'$.error'),''), j.batch_id
|
||||
FROM automation_jobs j WHERE NOT EXISTS (SELECT 1 FROM sessions s WHERE s.session_id=j.session_id)
|
||||
) """
|
||||
where = " WHERE status IN ('completed','success','failed','cancelled','interrupted','skipped')"
|
||||
args = []
|
||||
if batch_id == "__batch__":
|
||||
where += " AND batch_id IS NOT NULL"
|
||||
elif batch_id == "__single__":
|
||||
where += " AND batch_id IS NULL"
|
||||
elif batch_id:
|
||||
try:
|
||||
identifier(batch_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
where += " AND batch_id = ?"
|
||||
args.append(batch_id)
|
||||
if q:
|
||||
where += " AND (name LIKE ? ESCAPE '\\' OR initial_goal LIKE ? ESCAPE '\\' OR session_id LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\' OR case_id LIKE ? ESCAPE '\\' OR category LIKE ? ESCAPE '\\' OR subcategory LIKE ? ESCAPE '\\' OR preconditions LIKE ? ESCAPE '\\')"
|
||||
escaped = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
||||
@@ -266,8 +320,8 @@ def history(
|
||||
items = [dict(row) for row in rows]
|
||||
jobs = store.jobs_for_sessions([item["session_id"] for item in items])
|
||||
for item in items:
|
||||
item["test_result"] = read_case_result(
|
||||
item["session_id"], item["status"], item.get("error") or ""
|
||||
item["test_result"] = read_reviewed_result(
|
||||
item["session_id"], item["status"], item.get("error") or "", storage=store
|
||||
)
|
||||
if job := jobs.get(item["session_id"]):
|
||||
item["name"] = task_import.execution_title(job)
|
||||
|
||||
@@ -20,7 +20,7 @@ from fastapi import APIRouter, HTTPException
|
||||
|
||||
from artemis.config import DB_PATH, TRACES_PATH
|
||||
from artemis.runtime import trace_store
|
||||
from artemis.runtime.case_result import read_case_result
|
||||
from apps.admin_console.services.case_review import read_reviewed_result
|
||||
|
||||
try:
|
||||
from admin_console.core.state import state
|
||||
@@ -83,6 +83,7 @@ def _list_sessions_sync():
|
||||
job = jobs_by_id.get(s_id)
|
||||
if job:
|
||||
case = job.get("case") or {}
|
||||
row_dict["batch_id"] = job.get("batch_id")
|
||||
row_dict["case_id"] = case.get("case_id") or ""
|
||||
row_dict["display_name"] = execution_title(job) or row_dict.get("initial_goal")
|
||||
row_dict["iteration"] = job.get("iteration", 1)
|
||||
@@ -131,8 +132,12 @@ def _list_sessions_sync():
|
||||
else:
|
||||
row_dict["video_url"] = None
|
||||
row_dict["recording_status"] = recording_status
|
||||
row_dict["test_result"] = read_case_result(
|
||||
s_id, row_dict.get("status") or "", row_dict.get("error") or "", root=TRACES_PATH
|
||||
row_dict["test_result"] = read_reviewed_result(
|
||||
s_id,
|
||||
row_dict.get("status") or "",
|
||||
row_dict.get("error") or "",
|
||||
root=TRACES_PATH,
|
||||
storage=store,
|
||||
)
|
||||
|
||||
agent_names = agent_names_by_session.get(s_id, [])
|
||||
@@ -194,8 +199,10 @@ async def get_session_details(session_id: str):
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
result = dict(row)
|
||||
result["test_result"] = read_case_result(
|
||||
session_id, result.get("status") or "", root=TRACES_PATH
|
||||
job = store.job(session_id) or {}
|
||||
result["batch_id"] = job.get("batch_id")
|
||||
result["test_result"] = read_reviewed_result(
|
||||
session_id, result.get("status") or "", root=TRACES_PATH, storage=store
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from artemis.core.diagnostics import readiness_engine
|
||||
from apps.admin_console.services.automation_store import store
|
||||
from apps.admin_console.services.task_import import execution_title
|
||||
from artemis.runtime import DeviceExecutionLock, device_pool
|
||||
from artemis.runtime.task_control import (
|
||||
add_instruction,
|
||||
@@ -423,6 +425,15 @@ async def get_status():
|
||||
seen_ids.add(sid)
|
||||
queue_data.append(g_item)
|
||||
|
||||
batch_jobs = store.jobs_for_sessions(
|
||||
[str(item["session_id"]) for item in [*queue_data, *active_tasks] if item.get("session_id")]
|
||||
)
|
||||
queue_data = [{**item} for item in queue_data]
|
||||
for item in [*queue_data, *active_tasks]:
|
||||
if job := batch_jobs.get(str(item.get("session_id"))):
|
||||
item["batch_id"] = job.get("batch_id")
|
||||
item["display_name"] = execution_title(job)
|
||||
|
||||
if is_running:
|
||||
is_paused = state.is_paused
|
||||
return {
|
||||
|
||||
@@ -149,6 +149,52 @@ class AutomationStore:
|
||||
def preferences(self) -> dict:
|
||||
return self.get("settings", "reports") or {"formats": ["docx", "xlsx"]}
|
||||
|
||||
def save_case_review(self, session_id: str, status: str, reason: str) -> dict:
|
||||
"""Append reviews atomically; invalidate the batch's generated report."""
|
||||
identifier(session_id)
|
||||
if status not in {"passed", "failed"}:
|
||||
raise ValueError("人工复验结果只能为通过或不通过")
|
||||
review = {
|
||||
"status": status,
|
||||
"reason": reason.strip()
|
||||
or ("人工复验确认通过。" if status == "passed" else "人工复验确认不通过。"),
|
||||
"reviewed_at": time.time(),
|
||||
}
|
||||
with self.connection() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute(
|
||||
"SELECT payload FROM automation_records WHERE kind='case_review' AND id=?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
record = json.loads(row[0]) if row else {"reviews": []}
|
||||
record["reviews"].append(review)
|
||||
conn.execute(
|
||||
"INSERT INTO automation_records VALUES (?,?,?,?) ON CONFLICT(kind,id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at",
|
||||
(
|
||||
"case_review",
|
||||
session_id,
|
||||
json.dumps(record, ensure_ascii=False),
|
||||
review["reviewed_at"],
|
||||
),
|
||||
)
|
||||
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), review["reviewed_at"], job[0]),
|
||||
)
|
||||
conn.commit()
|
||||
return review
|
||||
|
||||
def create_batch(self, batch: dict, jobs: list[dict], payload: dict) -> dict:
|
||||
"""Commit a batch and all its jobs atomically; repeated request IDs never duplicate work."""
|
||||
request_id = payload.get("request_id")
|
||||
@@ -247,6 +293,11 @@ class AutomationStore:
|
||||
batch = json.loads(row[0])
|
||||
if batch.get("status") != "deleting":
|
||||
raise ValueError("批次尚未准备删除")
|
||||
conn.execute(
|
||||
"DELETE FROM automation_records WHERE kind='case_review' AND id IN "
|
||||
"(SELECT session_id FROM automation_jobs WHERE batch_id=?)",
|
||||
(batch_id,),
|
||||
)
|
||||
conn.execute("DELETE FROM automation_jobs WHERE batch_id=?", (batch_id,))
|
||||
conn.execute(
|
||||
"DELETE FROM automation_records WHERE kind='batch' AND id=?",
|
||||
@@ -260,9 +311,14 @@ class AutomationStore:
|
||||
with self.connection() as conn:
|
||||
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=?",
|
||||
(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.commit()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Human review overlays the AI verdict without changing execution evidence."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from apps.admin_console.services.automation_store import AutomationStore
|
||||
from artemis.runtime.case_result import read_case_result
|
||||
|
||||
|
||||
def reviewed_result(
|
||||
session_id: str, ai_result: dict | None, storage: AutomationStore
|
||||
) -> dict | None:
|
||||
original = {**ai_result, "source": "ai"} if ai_result else None
|
||||
record = storage.get("case_review", session_id)
|
||||
reviews = (record or {}).get("reviews") or []
|
||||
if not reviews:
|
||||
return original
|
||||
latest = reviews[-1]
|
||||
return {
|
||||
"status": latest["status"],
|
||||
"reason": latest["reason"],
|
||||
"evidence": [],
|
||||
"source": "human",
|
||||
"reviewed_at": latest["reviewed_at"],
|
||||
"ai_result": original,
|
||||
}
|
||||
|
||||
|
||||
def read_reviewed_result(
|
||||
session_id: str,
|
||||
execution_status: str,
|
||||
error: str = "",
|
||||
*,
|
||||
storage: AutomationStore,
|
||||
root: Path | None = None,
|
||||
) -> dict | None:
|
||||
return reviewed_result(
|
||||
session_id,
|
||||
read_case_result(session_id, execution_status, error, root=root),
|
||||
storage,
|
||||
)
|
||||
@@ -24,6 +24,7 @@ from apps.admin_console.database.repositories.step_repository import StepReposit
|
||||
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"
|
||||
EXPORT_LOCK = threading.RLock()
|
||||
@@ -65,7 +66,10 @@ def text(value) -> str:
|
||||
def verdict(outcome: dict | None) -> str:
|
||||
result = (outcome or {}).get("test_result")
|
||||
if isinstance(result, dict) and result.get("status") in RESULT_LABELS:
|
||||
return RESULT_LABELS[result["status"]]
|
||||
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 "验证失败"
|
||||
@@ -183,9 +187,10 @@ def session_report(session_id: str, db_path=None) -> dict:
|
||||
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"))
|
||||
test_result = resolve_case_result(outcome, status, 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.model_dump()}
|
||||
outcome = {**outcome, "test_result": test_result}
|
||||
verification = verdict(outcome)
|
||||
if status in {"failed", "interrupted"} and verification.startswith("未验证"):
|
||||
verification = "未验证(任务异常终止)"
|
||||
@@ -204,7 +209,7 @@ def session_report(session_id: str, db_path=None) -> dict:
|
||||
"expected": case.get("expected", ""),
|
||||
"status": status,
|
||||
"verdict": verification,
|
||||
"test_result": test_result.model_dump() if test_result else None,
|
||||
"test_result": test_result,
|
||||
"outcome": outcome,
|
||||
"content": content,
|
||||
"steps": steps,
|
||||
@@ -241,6 +246,13 @@ def metadata(report: dict) -> list[tuple]:
|
||||
("预期结果", report["expected"] or "未指定"),
|
||||
("执行状态", STATUS.get(report["status"], report["status"])),
|
||||
("验证结论", report["verdict"]),
|
||||
("人工复验时间", time_text((report.get("test_result") or {}).get("reviewed_at"))),
|
||||
(
|
||||
"原 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 "未记录"),
|
||||
(
|
||||
"验收证据",
|
||||
|
||||
Reference in new issue
Block a user