feat(intelligent-eval): logical delete for terminal evals
All checks were successful
CI / test (push) Successful in 3m24s

Add `deleted` terminal status: completed/cancelled/failed → deleted via
DELETE /api/intelligent-evals/{id} (idempotent, 409 for non-terminal).
Deleted evals are hidden from list, detail, stats, and all sub-resource
endpoints (sessions/report/decision-logs/config-snapshots); child tables
are untouched (audit-safe). Frontend shows a Popconfirm-guarded delete
button for terminal evals only.
This commit is contained in:
sinohqb 2026-08-21 14:34:04 +08:00
parent d77a363f58
commit 2d2c5a2904
9 changed files with 208 additions and 36 deletions

View File

@ -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)

View File

@ -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。

View File

@ -20,6 +20,7 @@ class IntelligentEvalStatus(str, Enum):
COMPLETED = "completed"
CANCELLED = "cancelled"
FAILED = "failed"
DELETED = "deleted"
class IntelligentEvalSessionStatus(str, Enum):

View File

@ -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()

View File

@ -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)

View File

@ -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<IntelligentEval>(`/intelligent-evals/${id}/reject`, { feedback }),
cancel: (id: string) => api.post<IntelligentEval>(`/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) =>

View File

@ -8,8 +8,14 @@ export const EVAL_STATUS: Record<IntelligentEvalStatus, { label: string; color:
completed: { label: '已完成', color: 'success' },
cancelled: { label: '已取消', color: 'default' },
failed: { label: '失败', color: 'error' },
deleted: { label: '已删除', color: 'default' },
}
// 终态集合(与后端 lifecycle 状态机一致):仅终态评估可被逻辑删除
export const TERMINAL_STATUSES: ReadonlySet<IntelligentEvalStatus> = new Set<IntelligentEvalStatus>([
'completed', 'cancelled', 'failed',
])
export const SESSION_STATUS: Record<IntelligentEvalSessionStatus, { label: string; color: string }> = {
running: { label: '进行中', color: 'processing' },
completed: { label: '已完成', color: 'success' },

View File

@ -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<IntelligentEval> = [
{
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) => (
<Space size={4}>
<Button size="small" onClick={() => openDetail(ev.id)}></Button>
{TERMINAL_STATUSES.has(ev.status) && (
<Popconfirm
title="确认删除"
description="删除后不可恢复,确定要删除该评估吗?"
onConfirm={() => handleDelete(ev.id)}
okText="删除"
cancelText="取消"
>
<Button size="small" danger icon={<DeleteOutlined />} onClick={(e) => e.stopPropagation()} />
</Popconfirm>
)}
</Space>
),
},
]

View File

@ -598,3 +598,103 @@ class TestExecutionProgress:
async def test_unknown_eval_returns_404(self, client, seeded_db):
resp = await client.get("/api/intelligent-evals/missing/execution-progress")
assert resp.status_code == 404
class TestDeleteEval:
async def _create_completed(self, client) -> str:
eval_id = await _create_executing_eval(client)
resp = await client.put(f"/api/intelligent-evals/{eval_id}/report", json={"report": _report_payload()})
assert resp.status_code == 200, resp.text
return eval_id
async def _create_cancelled(self, client) -> str:
eval_id = await _create_executing_eval(client)
resp = await client.post(f"/api/intelligent-evals/{eval_id}/cancel")
assert resp.status_code == 200, resp.text
return eval_id
async def test_delete_completed_eval_hides_from_list_and_stats(self, client, seeded_db):
eval_id = await self._create_completed(client)
resp = await client.delete(f"/api/intelligent-evals/{eval_id}")
assert resp.status_code == 200, resp.text
assert resp.json() == {"ok": True}
list_resp = await client.get("/api/intelligent-evals", params={"page": 1})
data = list_resp.json()
assert all(ev["id"] != eval_id for ev in data["intelligent_evals"])
assert data["total"] == 0
assert data["stats"]["completed"] == 0
async def test_delete_cancelled_eval(self, client, seeded_db):
eval_id = await self._create_cancelled(client)
resp = await client.delete(f"/api/intelligent-evals/{eval_id}")
assert resp.status_code == 200, resp.text
async def test_delete_planning_eval_returns_409(self, client, seeded_db):
ev = await _create_eval(client) # planning state
resp = await client.delete(f"/api/intelligent-evals/{ev['id']}")
assert resp.status_code == 409
async def test_delete_executing_eval_returns_409(self, client, seeded_db):
eval_id = await _create_executing_eval(client)
resp = await client.delete(f"/api/intelligent-evals/{eval_id}")
assert resp.status_code == 409
async def test_delete_unknown_eval_returns_404(self, client, seeded_db):
resp = await client.delete("/api/intelligent-evals/missing")
assert resp.status_code == 404
async def test_delete_twice_is_idempotent(self, client, seeded_db):
eval_id = await self._create_completed(client)
await client.delete(f"/api/intelligent-evals/{eval_id}")
resp = await client.delete(f"/api/intelligent-evals/{eval_id}")
assert resp.status_code == 200, resp.text
async def test_deleted_eval_detail_returns_404(self, client, seeded_db):
eval_id = await self._create_completed(client)
await client.delete(f"/api/intelligent-evals/{eval_id}")
resp = await client.get(f"/api/intelligent-evals/{eval_id}")
assert resp.status_code == 404
async def test_deleted_eval_subresources_return_404(self, client, seeded_db):
eval_id = await self._create_completed(client)
await client.delete(f"/api/intelligent-evals/{eval_id}")
sessions_resp = await client.get(f"/api/intelligent-evals/{eval_id}/sessions")
assert sessions_resp.status_code == 404
report_resp = await client.get(f"/api/intelligent-evals/{eval_id}/report")
assert report_resp.status_code == 404
markdown_resp = await client.get(f"/api/intelligent-evals/{eval_id}/report/markdown")
assert markdown_resp.status_code == 404
logs_resp = await client.get(f"/api/intelligent-evals/{eval_id}/decision-logs")
assert logs_resp.status_code == 404
snapshots_resp = await client.get(f"/api/intelligent-evals/{eval_id}/config-snapshots")
assert snapshots_resp.status_code == 404
progress_resp = await client.get(f"/api/intelligent-evals/{eval_id}/execution-progress")
assert progress_resp.status_code == 404
async def test_deleted_eval_rejects_new_decision_logs(self, client, seeded_db):
eval_id = await self._create_completed(client)
await client.delete(f"/api/intelligent-evals/{eval_id}")
resp = await client.post(
f"/api/intelligent-evals/{eval_id}/decision-logs",
json={"decision_type": "wait", "reason": "late agent report", "cron_id": "w-1"},
)
assert resp.status_code == 404
async def test_list_with_status_deleted_returns_empty(self, client, seeded_db):
eval_id = await self._create_completed(client)
await client.delete(f"/api/intelligent-evals/{eval_id}")
resp = await client.get("/api/intelligent-evals", params={"page": 1, "status": "deleted"})
assert resp.status_code == 200
data = resp.json()
assert data["intelligent_evals"] == []
assert data["total"] == 0