feat(intelligent-eval): logical delete for terminal evals
All checks were successful
CI / test (push) Successful in 3m24s
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:
parent
d77a363f58
commit
2d2c5a2904
@ -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)
|
||||
|
||||
@ -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。
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ class IntelligentEvalStatus(str, Enum):
|
||||
COMPLETED = "completed"
|
||||
CANCELLED = "cancelled"
|
||||
FAILED = "failed"
|
||||
DELETED = "deleted"
|
||||
|
||||
|
||||
class IntelligentEvalSessionStatus(str, Enum):
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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) => (
|
||||
<Button size="small" onClick={() => openDetail(ev.id)}>查看</Button>
|
||||
<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>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user