AgentEvalTool/backend/agenteval/web/routers/intelligent_evals.py
sinohqb ee639afb0d feat(intelligent-eval): add decision process UI (ticket 09)
- Add list_decision_logs API endpoint
- Add DecisionProcess component with timeline, list, filter, and export
- Add decision log API calls to api.ts
- Add "决策过程" button in EvalDetail to access decision history
- Implement decision log export to JSON
- Pass TypeScript type checking

All 853 tests passing.
2026-08-12 10:59:51 +08:00

491 lines
17 KiB
Python
Raw 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, render_report_markdown
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 _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(session: Session = Depends(get_db)) -> dict:
evals = lifecycle.list_evals(session)
reader = IntelligentEvalReadModel(session)
return {"intelligent_evals": [item.model_dump(mode="json") for item in reader.list_items(evals)]}
@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.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.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:
report = IntelligentEvalReadModel(session).report_by_eval(eval_id)
if report is None:
raise HTTPException(status_code=404, detail="report not submitted yet")
name, payload = report
markdown = render_report_markdown(payload, name=name, eval_id=eval_id)
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.
Returns the highest-priority pending task, or None if no tasks available.
"""
from agenteval.intelligent_eval import task_queue
task = task_queue.get_next_task(session)
if task is None:
return {"task": None}
# Load eval details
from agenteval.storage.db import IntelligentEvalDB
eval_db = session.get(IntelligentEvalDB, task.eval_id)
if eval_db is None:
return {"task": None}
return {
"task": {
"id": task.id,
"eval_id": task.eval_id,
"priority": task.priority,
"reason": task.reason,
"eval": {
"id": eval_db.id,
"name": eval_db.name,
"status": eval_db.status,
"plan": eval_db.get_plan(),
"started_at": eval_db.started_at.isoformat() if eval_db.started_at else 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.storage.db import IntelligentEvalDecisionLogDB
# Verify eval exists
from agenteval.storage.db import IntelligentEvalDB
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")
# Create decision log
log = IntelligentEvalDecisionLogDB(
eval_id=eval_id,
decision_type=request.decision_type,
reason=request.reason,
cron_id=request.cron_id,
)
log.set_context(request.context)
session.add(log)
session.commit()
session.refresh(log)
return {
"id": log.id,
"eval_id": log.eval_id,
"decision_type": log.decision_type,
"reason": log.reason,
"context": log.get_context(),
"cron_id": log.cron_id,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
@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 sqlmodel import select
from agenteval.storage.db import IntelligentEvalDB, IntelligentEvalDecisionLogDB
# 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")
# Get all decision logs
logs = session.exec(
select(IntelligentEvalDecisionLogDB)
.where(IntelligentEvalDecisionLogDB.eval_id == eval_id)
.order_by(IntelligentEvalDecisionLogDB.created_at.desc())
).all()
return {
"logs": [
{
"id": log.id,
"eval_id": log.eval_id,
"decision_type": log.decision_type,
"reason": log.reason,
"context": log.get_context(),
"cron_id": log.cron_id,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
for log in logs
]
}
@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
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")
snapshots = config_snapshot.list_snapshots(eval_id, session)
return {
"snapshots": [
{
"id": s.id,
"eval_id": s.eval_id,
"snapshot_type": s.snapshot_type,
"goal": s.goal,
"seeds": s.get_seeds(),
"intent": s.intent,
"role_description": s.role_description,
"time_window_hours": s.time_window_hours,
"plan": s.get_plan(),
"created_at": s.created_at.isoformat() if s.created_at else None,
"created_by": s.created_by,
}
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
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")
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 {
"id": snapshot.id,
"eval_id": snapshot.eval_id,
"snapshot_type": snapshot.snapshot_type,
"goal": snapshot.goal,
"seeds": snapshot.get_seeds(),
"intent": snapshot.intent,
"role_description": snapshot.role_description,
"time_window_hours": snapshot.time_window_hours,
"plan": snapshot.get_plan(),
"created_at": snapshot.created_at.isoformat() if snapshot.created_at else None,
"created_by": snapshot.created_by,
}
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
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")
# 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,
}