diff --git a/apps/admin_console/routers/automation.py b/apps/admin_console/routers/automation.py index 7077e58..e03d9d8 100644 --- a/apps/admin_console/routers/automation.py +++ b/apps/admin_console/routers/automation.py @@ -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) diff --git a/apps/admin_console/routers/sessions.py b/apps/admin_console/routers/sessions.py index 13c511c..67f7dac 100644 --- a/apps/admin_console/routers/sessions.py +++ b/apps/admin_console/routers/sessions.py @@ -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 diff --git a/apps/admin_console/routers/tasks.py b/apps/admin_console/routers/tasks.py index d1f928d..ee2af24 100644 --- a/apps/admin_console/routers/tasks.py +++ b/apps/admin_console/routers/tasks.py @@ -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 { diff --git a/apps/admin_console/services/automation_store.py b/apps/admin_console/services/automation_store.py index 73182b1..5a6f0a1 100644 --- a/apps/admin_console/services/automation_store.py +++ b/apps/admin_console/services/automation_store.py @@ -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() diff --git a/apps/admin_console/services/case_review.py b/apps/admin_console/services/case_review.py new file mode 100644 index 0000000..49e5a4a --- /dev/null +++ b/apps/admin_console/services/case_review.py @@ -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, + ) diff --git a/apps/admin_console/services/report_export.py b/apps/admin_console/services/report_export.py index 54fa1c8..3dd56ed 100644 --- a/apps/admin_console/services/report_export.py +++ b/apps/admin_console/services/report_export.py @@ -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 "未记录"), ( "验收证据", diff --git a/apps/showcase_ui/package-lock.json b/apps/showcase_ui/package-lock.json index 0eeba3d..3c29c38 100644 --- a/apps/showcase_ui/package-lock.json +++ b/apps/showcase_ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "artemis-showcase-ui", - "version": "1.5.0", + "version": "1.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "artemis-showcase-ui", - "version": "1.5.0", + "version": "1.5.1", "dependencies": { "@angular/cdk": "^22.1.5", "@angular/common": "^22.1.4", diff --git a/apps/showcase_ui/package.json b/apps/showcase_ui/package.json index a21ddd1..4a681a4 100644 --- a/apps/showcase_ui/package.json +++ b/apps/showcase_ui/package.json @@ -1,6 +1,6 @@ { "name": "artemis-showcase-ui", - "version": "1.5.0", + "version": "1.5.1", "scripts": { "ng": "ng", "start": "ng serve --proxy-config proxy.conf.json", diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html index 535beb5..c36c4ba 100644 --- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html +++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html @@ -44,7 +44,7 @@ limitations under the License. -
报告与自动化
+
报告与自动化
list_alt 任务队列 +