Some checks failed
CI / test (push) Failing after 32s
- Remove unused imports (json, datetime, timedelta, Any, Optional, IntelligentEval) - Remove unused variable (estimated_sessions) - Remove whitespace from blank line - Organize import blocks All ruff checks passing.
489 lines
17 KiB
Python
489 lines
17 KiB
Python
"""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."""
|
||
# Verify eval exists
|
||
from agenteval.storage.db import IntelligentEvalDB, IntelligentEvalDecisionLogDB
|
||
|
||
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,
|
||
}
|