AgentEvalTool/backend/agenteval/web/routers/intelligent_evals.py
sinohqb 71543f042a refactor(intelligent-eval): 可见性接缝收敛(Phase 1)
将「已删即 404」语义收进 IntelligentEvalRepository 单一接缝,消除三处独立裁决;
任务监控开始隐藏已删评估的任务(本 Phase 唯一刻意行为变化)。

- repository.py 新增 visible() 谓词与 require_live_eval() 服务接缝;
  六处裸谓词统一走它,get()/get_including_deleted() 语义不变。
- decision_logs.py 删除本地 _require_eval,三处调用迁至 repository 接缝;
  count_decisions 由 len(.all()) 改为 func.count。
- task_queue.py list_tasks 与 stats 过滤已删评估的任务(行为变化)。
- web/routers/intelligent_evals.py: _require_eval_exists → _require_live_eval,
  把 LookupError 翻译为 404;expired 会话 Markdown 标注下沉至
  read_model.report_markdown_by_eval;配置快照 11 字段序列化收至
  config_snapshot.snapshot_to_dict 单一出口。
- AGENTS.md 登记可见性纪律(已知陷阱 #6)。
- 补 characterization 测试锁定四处契约;更新 task_queue 测试以使用
  真实 eval_id(可见性过滤后字面 eval_id 不再可见)。
2026-08-24 05:47:00 +08:00

441 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""API routes for intelligent evaluation (智能评估).
领域逻辑(状态机)在 intelligent_eval/lifecycle.py本层只做 HTTP 翻译:
NotFound→404、TransitionError→409。
"""
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import PlainTextResponse
from pydantic import BaseModel, Field
from sqlmodel import Session
from agenteval.intelligent_eval import lifecycle
from agenteval.intelligent_eval.lifecycle import (
IntelligentEvalChannelError,
IntelligentEvalNotFoundError,
IntelligentEvalTransitionError,
)
from agenteval.intelligent_eval.read_model import IntelligentEvalReadModel
from agenteval.intelligent_eval.report import ReportModel
from agenteval.intelligent_eval.repository import IntelligentEvalRepository
from agenteval.web.deps import get_db
router = APIRouter()
class CreateEvalRequest(BaseModel):
name: str = Field(min_length=1)
target_id: str = Field(min_length=1)
goal: str = Field(min_length=1)
seeds: dict[str, Any] = Field(default_factory=dict)
intent: str = ""
role_description: str = ""
time_window_hours: int = Field(default=24, ge=1)
class SubmitPlanRequest(BaseModel):
plan: dict[str, Any]
class RejectRequest(BaseModel):
feedback: str = Field(min_length=1)
class CreateSessionRequest(BaseModel):
persona: dict[str, Any] = Field(default_factory=dict)
goal: str = Field(min_length=1)
dimension: str | None = None
class SendMessageRequest(BaseModel):
content: str = Field(min_length=1)
class CloseSessionRequest(BaseModel):
verdict: dict[str, Any]
class SubmitReportRequest(BaseModel):
report: ReportModel
def _translate(exc: Exception) -> HTTPException:
if isinstance(exc, IntelligentEvalNotFoundError):
return HTTPException(status_code=404, detail=str(exc))
if isinstance(exc, IntelligentEvalChannelError):
return HTTPException(status_code=502, detail=str(exc))
return HTTPException(status_code=409, detail=exc.reason)
def _require_live_eval(session: Session, eval_id: str) -> None:
"""把可见性接缝翻译成 HTTP缺失或已删 → 404。"""
try:
IntelligentEvalRepository(session).require_live_eval(eval_id)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
def _eval_response(ev, session: Session) -> dict:
"""Serialize one stable intelligent-evaluation read projection."""
projection = IntelligentEvalReadModel(session).list_item(ev)
return projection.model_dump(mode="json")
@router.post("")
async def create_eval(request: CreateEvalRequest, session: Session = Depends(get_db)) -> dict:
try:
ev = lifecycle.create_eval(
session,
name=request.name,
target_id=request.target_id,
goal=request.goal,
seeds=request.seeds,
intent=request.intent,
role_description=request.role_description,
time_window_hours=request.time_window_hours,
)
except IntelligentEvalNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return _eval_response(ev, session)
@router.get("")
async def list_evals(
page: int | None = None,
page_size: int = 20,
status: str | None = None,
session: Session = Depends(get_db),
) -> dict:
"""List intelligent evaluations, optionally paginated and filtered by status.
不传 ``page`` 时返回全部(向后兼容);传 ``page``(从 1 起)时按
``created_at`` 倒序分页,返回 ``total`` + ``stats``(各状态计数)供前端
服务端分页与状态统计条。
"""
reader = IntelligentEvalReadModel(session)
if page is None:
evals = lifecycle.list_evals(session)
return {"intelligent_evals": [item.model_dump(mode="json") for item in reader.list_items(evals)]}
page_size = max(1, min(page_size, 100))
offset = (max(1, page) - 1) * page_size
evals, total, stats = lifecycle.list_evals_page(session, offset, page_size, status)
return {
"intelligent_evals": [item.model_dump(mode="json") for item in reader.list_items(evals)],
"total": total,
"stats": stats,
"page": page,
"page_size": page_size,
}
@router.get("/tasks")
async def list_tasks(
status: str | None = None,
limit: int = 100,
eval_id: str | None = None,
session: Session = Depends(get_db),
) -> dict:
"""List task-queue entries with eval names (monitor UI).
注意:此端点必须注册在 ``/{eval_id}`` 之前,否则 ``/tasks`` 会被
``{eval_id}`` 捕获为 eval_id="tasks"
"""
from agenteval.intelligent_eval.task_queue import list_tasks as _list
return _list(session, status=status, limit=limit, eval_id=eval_id)
@router.get("/{eval_id}")
async def get_eval(eval_id: str, session: Session = Depends(get_db)) -> dict:
projection = IntelligentEvalReadModel(session).detail_by_id(eval_id)
if projection is None:
raise HTTPException(status_code=404, detail=f"intelligent eval {eval_id} not found")
return projection.model_dump(mode="json")
@router.get("/{eval_id}/execution-progress")
async def get_execution_progress(eval_id: str, session: Session = Depends(get_db)) -> dict:
"""执行过程视图:生命周期阶段、阻塞点、下一步动作与时段计划 vs 实际。"""
progress = IntelligentEvalReadModel(session).execution_progress_by_id(eval_id)
if progress is None:
raise HTTPException(status_code=404, detail=f"intelligent eval {eval_id} not found")
return progress.model_dump(mode="json")
@router.put("/{eval_id}/plan")
async def submit_plan(eval_id: str, request: SubmitPlanRequest, session: Session = Depends(get_db)) -> dict:
try:
ev = lifecycle.submit_plan(session, eval_id, request.plan)
except (IntelligentEvalNotFoundError, IntelligentEvalTransitionError) as exc:
raise _translate(exc) from exc
return _eval_response(ev, session)
@router.post("/{eval_id}/approve")
async def approve(eval_id: str, session: Session = Depends(get_db)) -> dict:
try:
ev = lifecycle.approve(session, eval_id)
except (IntelligentEvalNotFoundError, IntelligentEvalTransitionError) as exc:
raise _translate(exc) from exc
return _eval_response(ev, session)
@router.post("/{eval_id}/reject")
async def reject(eval_id: str, request: RejectRequest, session: Session = Depends(get_db)) -> dict:
try:
ev = lifecycle.reject(session, eval_id, request.feedback)
except (IntelligentEvalNotFoundError, IntelligentEvalTransitionError) as exc:
raise _translate(exc) from exc
return _eval_response(ev, session)
@router.post("/{eval_id}/cancel")
async def cancel(eval_id: str, session: Session = Depends(get_db)) -> dict:
try:
ev = lifecycle.cancel(session, eval_id)
except (IntelligentEvalNotFoundError, IntelligentEvalTransitionError) as exc:
raise _translate(exc) from exc
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:
ev = lifecycle.submit_report(session, eval_id, request.report.model_dump())
except (IntelligentEvalNotFoundError, IntelligentEvalTransitionError) as exc:
raise _translate(exc) from exc
return _eval_response(ev, session)
@router.get("/{eval_id}/report")
async def get_report(eval_id: str, session: Session = Depends(get_db)) -> dict:
report = IntelligentEvalReadModel(session).report_by_eval(eval_id)
if report is None:
raise HTTPException(status_code=404, detail="report not submitted yet")
return report[1]
@router.get("/{eval_id}/report/markdown", response_class=PlainTextResponse)
async def get_report_markdown(eval_id: str, session: Session = Depends(get_db)) -> PlainTextResponse:
markdown = IntelligentEvalReadModel(session).report_markdown_by_eval(eval_id)
if markdown is None:
raise HTTPException(status_code=404, detail="report not submitted yet")
return PlainTextResponse(markdown, media_type="text/markdown; charset=utf-8")
@router.post("/{eval_id}/sessions")
async def create_session(eval_id: str, request: CreateSessionRequest, session: Session = Depends(get_db)) -> dict:
try:
obj = lifecycle.open_session(
session,
eval_id=eval_id,
persona=request.persona,
goal=request.goal,
dimension=request.dimension,
)
except (IntelligentEvalNotFoundError, IntelligentEvalTransitionError) as exc:
raise _translate(exc) from exc
return obj.model_dump(mode="json")
@router.get("/{eval_id}/sessions")
async def list_sessions(eval_id: str, session: Session = Depends(get_db)) -> dict:
sessions = IntelligentEvalReadModel(session).sessions_by_eval(eval_id)
if sessions is None:
raise HTTPException(status_code=404, detail=f"intelligent eval {eval_id} not found")
return {"sessions": [s.model_dump(mode="json") for s in sessions]}
@router.post("/{eval_id}/sessions/{session_id}/messages")
async def send_message(
eval_id: str, session_id: str, request: SendMessageRequest, session: Session = Depends(get_db)
) -> dict:
try:
return await lifecycle.conduct_turn(
session,
eval_id=eval_id,
session_id=session_id,
content=request.content,
)
except (
IntelligentEvalNotFoundError,
IntelligentEvalTransitionError,
IntelligentEvalChannelError,
) as exc:
raise _translate(exc) from exc
@router.post("/{eval_id}/sessions/{session_id}/close")
async def close_session(
eval_id: str, session_id: str, request: CloseSessionRequest, session: Session = Depends(get_db)
) -> dict:
try:
obj = lifecycle.close_session(
session,
eval_id=eval_id,
session_id=session_id,
verdict=request.verdict,
)
except (IntelligentEvalNotFoundError, IntelligentEvalTransitionError) as exc:
raise _translate(exc) from exc
return obj.model_dump(mode="json")
@router.get("/{eval_id}/sessions/{session_id}/messages")
async def list_messages(eval_id: str, session_id: str, session: Session = Depends(get_db)) -> dict:
messages = IntelligentEvalReadModel(session).messages_by_session(eval_id, session_id)
if messages is None:
raise HTTPException(status_code=404, detail=f"intelligent eval session {session_id} not found")
return {"messages": [m.model_dump(mode="json") for m in messages]}
@router.get("/tasks/next")
async def get_next_task(session: Session = Depends(get_db)) -> dict:
"""Get next pending task for OpenClaw workers."""
from agenteval.intelligent_eval.task_queue import get_next_task_with_eval
result = get_next_task_with_eval(session)
return result if result is not None else {"task": None}
@router.post("/tasks/{task_id}/assign")
async def assign_task(task_id: str, cron_id: str, session: Session = Depends(get_db)) -> dict:
"""Assign a task to a cron worker."""
from agenteval.intelligent_eval import task_queue
success = task_queue.assign_task(task_id, cron_id, session)
if not success:
raise HTTPException(status_code=404, detail="task not found or already assigned")
return {"success": True}
@router.post("/tasks/{task_id}/complete")
async def complete_task(
task_id: str,
success: bool,
error: str | None = None,
session: Session = Depends(get_db),
) -> dict:
"""Mark a task as completed or failed."""
from agenteval.intelligent_eval import task_queue
completed = task_queue.complete_task(task_id, success, error, session)
if not completed:
raise HTTPException(status_code=404, detail="task not found")
return {"success": True}
class DecisionLogRequest(BaseModel):
decision_type: str = Field(min_length=1) # execute_session / wait / start_analysis
reason: str = Field(min_length=1)
context: dict[str, Any] = Field(default_factory=dict)
cron_id: str = Field(min_length=1)
@router.post("/{eval_id}/decision-logs")
async def create_decision_log(
eval_id: str,
request: DecisionLogRequest,
session: Session = Depends(get_db),
) -> dict:
"""Create a decision log entry for an intelligent eval."""
from agenteval.intelligent_eval.decision_logs import create_decision_log as _create
try:
return _create(eval_id, request.decision_type, request.reason, request.cron_id, request.context, session)
except LookupError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
@router.get("/{eval_id}/decision-logs")
async def list_decision_logs(eval_id: str, session: Session = Depends(get_db)) -> dict:
"""List all decision logs for an evaluation."""
from agenteval.intelligent_eval.decision_logs import list_decision_logs as _list
try:
return {"logs": _list(eval_id, session)}
except LookupError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
@router.get("/{eval_id}/config-snapshots")
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
_require_live_eval(session, eval_id)
snapshots = config_snapshot.list_snapshots(eval_id, session)
return {"snapshots": [config_snapshot.snapshot_to_dict(s) for s in snapshots]}
@router.get("/{eval_id}/config-snapshots/{snapshot_id}")
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
_require_live_eval(session, eval_id)
snapshot = config_snapshot.get_snapshot(snapshot_id, session)
if snapshot is None or snapshot.eval_id != eval_id:
raise HTTPException(status_code=404, detail=f"snapshot {snapshot_id} not found")
return config_snapshot.snapshot_to_dict(snapshot)
class CompareSnapshotsRequest(BaseModel):
snapshot_id_1: str = Field(min_length=1)
snapshot_id_2: str = Field(min_length=1)
@router.post("/{eval_id}/config-snapshots/compare")
async def compare_config_snapshots(
eval_id: str,
request: CompareSnapshotsRequest,
session: Session = Depends(get_db),
) -> dict:
"""Compare two config snapshots and return differences."""
from agenteval.intelligent_eval import config_snapshot
_require_live_eval(session, eval_id)
# Get both snapshots
snapshot1 = config_snapshot.get_snapshot(request.snapshot_id_1, session)
snapshot2 = config_snapshot.get_snapshot(request.snapshot_id_2, session)
if snapshot1 is None or snapshot1.eval_id != eval_id:
raise HTTPException(status_code=404, detail=f"snapshot {request.snapshot_id_1} not found")
if snapshot2 is None or snapshot2.eval_id != eval_id:
raise HTTPException(status_code=404, detail=f"snapshot {request.snapshot_id_2} not found")
# Compare snapshots
diffs = config_snapshot.compare_snapshots(snapshot1, snapshot2)
return {
"snapshot_1": {
"id": snapshot1.id,
"snapshot_type": snapshot1.snapshot_type,
"created_at": snapshot1.created_at.isoformat() if snapshot1.created_at else None,
},
"snapshot_2": {
"id": snapshot2.id,
"snapshot_type": snapshot2.snapshot_type,
"created_at": snapshot2.created_at.isoformat() if snapshot2.created_at else None,
},
"differences": diffs,
}