diff --git a/backend/agenteval/intelligent_eval/decision_logs.py b/backend/agenteval/intelligent_eval/decision_logs.py index 824b05b..b6e0c5f 100644 --- a/backend/agenteval/intelligent_eval/decision_logs.py +++ b/backend/agenteval/intelligent_eval/decision_logs.py @@ -28,7 +28,8 @@ def _log_to_dict(log: IntelligentEvalDecisionLogDB) -> dict: def _require_eval(eval_id: str, session: Session) -> None: - if session.get(IntelligentEvalDB, eval_id) is None: + eval_db = session.get(IntelligentEvalDB, eval_id) + if eval_db is None or eval_db.status == IntelligentEvalStatus.DELETED.value: raise LookupError(f"intelligent eval {eval_id} not found") @@ -118,9 +119,7 @@ def count_decisions(eval_id: str, decision_type: str, session: Session) -> int: def list_decision_logs(eval_id: str, session: Session) -> list[dict]: """List decision logs for an eval. Raises ``LookupError`` if eval not found.""" - eval_db = session.get(IntelligentEvalDB, eval_id) - if eval_db is None: - raise LookupError(f"intelligent eval {eval_id} not found") + _require_eval(eval_id, session) logs = session.exec( select(IntelligentEvalDecisionLogDB) diff --git a/backend/agenteval/intelligent_eval/lifecycle.py b/backend/agenteval/intelligent_eval/lifecycle.py index 5830d62..e0b12cd 100644 --- a/backend/agenteval/intelligent_eval/lifecycle.py +++ b/backend/agenteval/intelligent_eval/lifecycle.py @@ -6,6 +6,7 @@ → failed pending_approval 可打回 → planning(附反馈) executing 可取消 → cancelled + completed / cancelled / failed 可删除 → deleted(逻辑删除) 非法转换抛 IntelligentEvalTransitionError,路由层映射为 409。 """ @@ -50,9 +51,10 @@ _TRANSITIONS: dict[IntelligentEvalStatus, set[IntelligentEvalStatus]] = { IntelligentEvalStatus.CANCELLED, IntelligentEvalStatus.FAILED, }, - IntelligentEvalStatus.COMPLETED: set(), - IntelligentEvalStatus.CANCELLED: set(), - IntelligentEvalStatus.FAILED: set(), + IntelligentEvalStatus.COMPLETED: {IntelligentEvalStatus.DELETED}, + IntelligentEvalStatus.CANCELLED: {IntelligentEvalStatus.DELETED}, + IntelligentEvalStatus.FAILED: {IntelligentEvalStatus.DELETED}, + IntelligentEvalStatus.DELETED: set(), } @@ -197,6 +199,17 @@ def cancel(session: Session, eval_id: str) -> IntelligentEval: return _transition(repo, ev, IntelligentEvalStatus.CANCELLED) +def delete_eval(session: Session, eval_id: str) -> IntelligentEval: + """逻辑删除:completed / cancelled / failed → deleted。幂等:已删除直接返回。""" + repo = IntelligentEvalRepository(session) + ev = repo._get_raw(eval_id) + if ev is None: + raise IntelligentEvalNotFoundError(f"intelligent eval {eval_id} not found") + if ev.status is IntelligentEvalStatus.DELETED: + return ev + return _transition(repo, ev, IntelligentEvalStatus.DELETED) + + def submit_report(session: Session, eval_id: str, report: dict[str, Any]) -> IntelligentEval: """OpenClaw 提交结构化报告:executing → completed。 diff --git a/backend/agenteval/intelligent_eval/models.py b/backend/agenteval/intelligent_eval/models.py index 0c8a322..95a48cd 100644 --- a/backend/agenteval/intelligent_eval/models.py +++ b/backend/agenteval/intelligent_eval/models.py @@ -20,6 +20,7 @@ class IntelligentEvalStatus(str, Enum): COMPLETED = "completed" CANCELLED = "cancelled" FAILED = "failed" + DELETED = "deleted" class IntelligentEvalSessionStatus(str, Enum): diff --git a/backend/agenteval/intelligent_eval/repository.py b/backend/agenteval/intelligent_eval/repository.py index 7b28e71..bb3201c 100644 --- a/backend/agenteval/intelligent_eval/repository.py +++ b/backend/agenteval/intelligent_eval/repository.py @@ -98,14 +98,25 @@ class IntelligentEvalRepository: ) def list_all(self) -> list[IntelligentEval]: - statement = select(IntelligentEvalDB).order_by(IntelligentEvalDB.created_at.desc()) + statement = ( + select(IntelligentEvalDB) + .where(IntelligentEvalDB.status != IntelligentEvalStatus.DELETED.value) + .order_by(IntelligentEvalDB.created_at.desc()) + ) return [self._from_db(r) for r in self.session.exec(statement).all()] def list_page( self, offset: int, limit: int, status: Optional[str] = None ) -> list[IntelligentEval]: - """Return one page of evals (created_at desc), optionally filtered by status.""" - statement = select(IntelligentEvalDB).order_by(IntelligentEvalDB.created_at.desc()) + """Return one page of evals (created_at desc), optionally filtered by status. + + Deleted evals are always excluded — the list API has no recycle bin. + """ + statement = ( + select(IntelligentEvalDB) + .where(IntelligentEvalDB.status != IntelligentEvalStatus.DELETED.value) + .order_by(IntelligentEvalDB.created_at.desc()) + ) if status: statement = statement.where(IntelligentEvalDB.status == status) statement = statement.offset(offset).limit(limit) @@ -113,21 +124,32 @@ class IntelligentEvalRepository: def count(self) -> int: """Total number of evaluations (for pagination metadata).""" - return self.session.exec(select(func.count()).select_from(IntelligentEvalDB)).one() + return self.session.exec( + select(func.count()) + .select_from(IntelligentEvalDB) + .where(IntelligentEvalDB.status != IntelligentEvalStatus.DELETED.value) + ).one() def count_by_status(self) -> dict[str, int]: """Count evaluations per status (for the list page stat bar).""" rows = self.session.exec( - select(IntelligentEvalDB.status, func.count(IntelligentEvalDB.id)).group_by( - IntelligentEvalDB.status - ) + select(IntelligentEvalDB.status, func.count(IntelligentEvalDB.id)) + .where(IntelligentEvalDB.status != IntelligentEvalStatus.DELETED.value) + .group_by(IntelligentEvalDB.status) ).all() - stats = {status.value: 0 for status in IntelligentEvalStatus} + stats = {status.value: 0 for status in IntelligentEvalStatus if status != IntelligentEvalStatus.DELETED} for status, cnt in rows: stats[status] = cnt return stats def get(self, eval_id: str) -> Optional[IntelligentEval]: + db = self.session.get(IntelligentEvalDB, eval_id) + if db is None or db.status == IntelligentEvalStatus.DELETED.value: + return None + return self._from_db(db) + + def _get_raw(self, eval_id: str) -> Optional[IntelligentEval]: + """包含已删除评估的原样读取(仅供 delete 幂等检查使用)。""" db = self.session.get(IntelligentEvalDB, eval_id) return self._from_db(db) if db else None @@ -144,7 +166,10 @@ class IntelligentEvalRepository: IntelligentEvalSessionDB.eval_id == IntelligentEvalDB.id, isouter=True, ) - .where(IntelligentEvalDB.id == eval_id) + .where( + IntelligentEvalDB.id == eval_id, + IntelligentEvalDB.status != IntelligentEvalStatus.DELETED.value, + ) .order_by(IntelligentEvalSessionDB.created_at.asc()) ) rows = self.session.exec(statement).all() diff --git a/backend/agenteval/web/routers/intelligent_evals.py b/backend/agenteval/web/routers/intelligent_evals.py index b7c5277..aa89626 100644 --- a/backend/agenteval/web/routers/intelligent_evals.py +++ b/backend/agenteval/web/routers/intelligent_evals.py @@ -70,6 +70,16 @@ def _translate(exc: Exception) -> HTTPException: return HTTPException(status_code=409, detail=exc.reason) +def _require_eval_exists(session: Session, eval_id: str) -> None: + """404 when the eval is missing or logically deleted.""" + from agenteval.intelligent_eval.models import IntelligentEvalStatus + from agenteval.storage.db import IntelligentEvalDB + + eval_db = session.get(IntelligentEvalDB, eval_id) + if eval_db is None or eval_db.status == IntelligentEvalStatus.DELETED.value: + raise HTTPException(status_code=404, detail=f"intelligent eval {eval_id} not found") + + def _eval_response(ev, session: Session) -> dict: """Serialize one stable intelligent-evaluation read projection.""" @@ -195,6 +205,16 @@ async def cancel(eval_id: str, session: Session = Depends(get_db)) -> dict: return _eval_response(ev, session) +@router.delete("/{eval_id}") +async def delete_eval(eval_id: str, session: Session = Depends(get_db)) -> dict: + """逻辑删除已终态的评测。非终态返回 409,不存在返回 404,幂等。""" + try: + lifecycle.delete_eval(session, eval_id) + except (IntelligentEvalNotFoundError, IntelligentEvalTransitionError) as exc: + raise _translate(exc) from exc + return {"ok": True} + + @router.put("/{eval_id}/report") async def submit_report(eval_id: str, request: SubmitReportRequest, session: Session = Depends(get_db)) -> dict: try: @@ -372,12 +392,8 @@ async def list_decision_logs(eval_id: str, session: Session = Depends(get_db)) - async def list_config_snapshots(eval_id: str, session: Session = Depends(get_db)) -> dict: """List all config snapshots for an evaluation.""" from agenteval.intelligent_eval import config_snapshot - from agenteval.storage.db import IntelligentEvalDB - # Verify eval exists - eval_db = session.get(IntelligentEvalDB, eval_id) - if eval_db is None: - raise HTTPException(status_code=404, detail=f"intelligent eval {eval_id} not found") + _require_eval_exists(session, eval_id) snapshots = config_snapshot.list_snapshots(eval_id, session) return { @@ -404,12 +420,8 @@ async def list_config_snapshots(eval_id: str, session: Session = Depends(get_db) async def get_config_snapshot(eval_id: str, snapshot_id: str, session: Session = Depends(get_db)) -> dict: """Get a single config snapshot.""" from agenteval.intelligent_eval import config_snapshot - from agenteval.storage.db import IntelligentEvalDB - # Verify eval exists - eval_db = session.get(IntelligentEvalDB, eval_id) - if eval_db is None: - raise HTTPException(status_code=404, detail=f"intelligent eval {eval_id} not found") + _require_eval_exists(session, eval_id) snapshot = config_snapshot.get_snapshot(snapshot_id, session) if snapshot is None or snapshot.eval_id != eval_id: @@ -443,12 +455,8 @@ async def compare_config_snapshots( ) -> dict: """Compare two config snapshots and return differences.""" from agenteval.intelligent_eval import config_snapshot - from agenteval.storage.db import IntelligentEvalDB - # Verify eval exists - eval_db = session.get(IntelligentEvalDB, eval_id) - if eval_db is None: - raise HTTPException(status_code=404, detail=f"intelligent eval {eval_id} not found") + _require_eval_exists(session, eval_id) # Get both snapshots snapshot1 = config_snapshot.get_snapshot(request.snapshot_id_1, session) diff --git a/frontend/web/src/api.ts b/frontend/web/src/api.ts index 082ec45..7040515 100644 --- a/frontend/web/src/api.ts +++ b/frontend/web/src/api.ts @@ -607,7 +607,7 @@ export const campaignsApi = { export type IntelligentEvalStatus = | 'draft' | 'planning' | 'pending_approval' - | 'executing' | 'completed' | 'cancelled' | 'failed' + | 'executing' | 'completed' | 'cancelled' | 'failed' | 'deleted' export type IntelligentEvalSessionStatus = 'running' | 'completed' | 'failed' | 'expired' @@ -826,6 +826,7 @@ export const intelligentEvalsApi = { reject: (id: string, feedback: string) => api.post(`/intelligent-evals/${id}/reject`, { feedback }), cancel: (id: string) => api.post(`/intelligent-evals/${id}/cancel`), + remove: (id: string) => api.delete<{ ok: boolean }>(`/intelligent-evals/${id}`), listSessions: (id: string) => api.get<{ sessions: IntelligentEvalSession[] }>(`/intelligent-evals/${id}/sessions`), listMessages: (id: string, sessionId: string) => diff --git a/frontend/web/src/components/intelligent_eval/status.ts b/frontend/web/src/components/intelligent_eval/status.ts index 20bd366..a904fe3 100644 --- a/frontend/web/src/components/intelligent_eval/status.ts +++ b/frontend/web/src/components/intelligent_eval/status.ts @@ -8,8 +8,14 @@ export const EVAL_STATUS: Record = new Set([ + 'completed', 'cancelled', 'failed', +]) + export const SESSION_STATUS: Record = { running: { label: '进行中', color: 'processing' }, completed: { label: '已完成', color: 'success' }, diff --git a/frontend/web/src/pages/IntelligentEvals.tsx b/frontend/web/src/pages/IntelligentEvals.tsx index c8e4700..810dda8 100644 --- a/frontend/web/src/pages/IntelligentEvals.tsx +++ b/frontend/web/src/pages/IntelligentEvals.tsx @@ -6,7 +6,7 @@ import { import type { TabsProps } from 'antd' import type { ColumnsType } from 'antd/es/table' import { - PlusOutlined, ReloadOutlined, StopOutlined, UnorderedListOutlined, + DeleteOutlined, PlusOutlined, ReloadOutlined, StopOutlined, UnorderedListOutlined, } from '@ant-design/icons' import FormDrawer from '../components/FormDrawer' import PageWrapper from '../components/PageWrapper' @@ -15,7 +15,7 @@ import ExecutionProcess from '../components/intelligent_eval/ExecutionProcess' import DecisionProcess from '../components/intelligent_eval/DecisionProcess' import ConfigSnapshots from '../components/intelligent_eval/ConfigSnapshots' import EvalReport from '../components/intelligent_eval/EvalReport' -import { EVAL_STATUS } from '../components/intelligent_eval/status' +import { EVAL_STATUS, TERMINAL_STATUSES } from '../components/intelligent_eval/status' import { useResource } from '../hooks/useResource' import { intelligentEvalsApi, targetsApi, @@ -143,6 +143,12 @@ export default function IntelligentEvalsPage() { } } + const handleDelete = async (id: string) => { + await intelligentEvalsApi.remove(id) + message.success('已删除') + void reloadList() + } + const columns: ColumnsType = [ { title: '名称', dataIndex: 'name', key: 'name', width: 300, ellipsis: true, @@ -184,9 +190,22 @@ export default function IntelligentEvalsPage() { render: (v: string | null) => (v ? formatDateTime(v) : '—'), }, { - title: '操作', key: 'action', width: 88, fixed: 'right' as const, + title: '操作', key: 'action', width: 130, fixed: 'right' as const, render: (_, ev) => ( - + + + {TERMINAL_STATUSES.has(ev.status) && ( + handleDelete(ev.id)} + okText="删除" + cancelText="取消" + > +