feat(intelligent-eval): add backend for OpenClaw-driven intelligent evaluation (tickets 01-04)
Introduce 智能评估 as an evaluation paradigm parallel to static evaluation, driven by OpenClaw. The platform supplies storage, lifecycle, and reporting; OpenClaw plans and executes. - Data model: IntelligentEval + Session + Message tables (new, not reusing exploration) - Lifecycle state machine: draft → planning → pending_approval → executing → completed/cancelled/failed - Session API: create/message (channel-forwarded)/close with turn accounting - Report API: pydantic-validated structured report, executing → completed, Markdown export (pure renderer) - Alembic migration for the three tables; domain glossary added to CONTEXT.md
This commit is contained in:
parent
f93d2f224f
commit
1317552701
30
CONTEXT.md
30
CONTEXT.md
@ -108,6 +108,36 @@ _Avoid_: 评测结果、断言、日志
|
|||||||
常驻代理的周期性自主行动:经 OpenClaw heartbeat/cron 唤醒,调平台巡检 API 查看进行中活动的新结果与预算余量,自主决定"继续观察 / 派探索会话"。全局一个常驻巡检作业,无状态地巡检所有活动;OpenClaw 停摆只暂停探索,固定计划照常。
|
常驻代理的周期性自主行动:经 OpenClaw heartbeat/cron 唤醒,调平台巡检 API 查看进行中活动的新结果与预算余量,自主决定"继续观察 / 派探索会话"。全局一个常驻巡检作业,无状态地巡检所有活动;OpenClaw 停摆只暂停探索,固定计划照常。
|
||||||
_Avoid_: 轮询、心跳(heartbeat 是 OpenClaw 机制名,巡检是平台侧行为)
|
_Avoid_: 轮询、心跳(heartbeat 是 OpenClaw 机制名,巡检是平台侧行为)
|
||||||
|
|
||||||
|
## 智能评估
|
||||||
|
|
||||||
|
**静态评估(Static Evaluation)**:
|
||||||
|
v0.8 已完整的评测体系:考纲驱动、平台执行、规则判定。预设场景(用例+规则)、平台调度器派生 Run、统计通过率。回答"考纲过了多少"。
|
||||||
|
_Avoid_: 固定评估、传统评估
|
||||||
|
|
||||||
|
**智能评估(Intelligent Evaluation)**:
|
||||||
|
与静态评估并列的独立评测体系:目标驱动、OpenClaw 规划执行、AI 判定。用户只给方向(目标+种子+意图+角色),OpenClaw 全权规划与执行,产出可驱动被评对象改善的结构化报告。独立实体(IntelligentEval),与 Campaign 平级,不共享数据表与状态机。
|
||||||
|
_Avoid_: 动态评估、自动评估
|
||||||
|
|
||||||
|
**粗计划(Coarse Plan)**:
|
||||||
|
OpenClaw 在智能评估中产出的评估规划:评估维度、虚拟用户列表、时间分布编排、预算、完成标准。入库可见,用户审批后才执行。"粗"在于只定方向不定细节——具体对话策略、追问节奏、何时放弃由执行时动态决定。
|
||||||
|
_Avoid_: 计划、方案(太泛)
|
||||||
|
|
||||||
|
**智能评估会话(IntelligentEvalSession)**:
|
||||||
|
OpenClaw 以虚拟用户身份与被评对象的一段完整对话,全新实体(不复用 exploration_sessions)。归属智能评估、含人设/目标/维度、会话级评估(verdict)。
|
||||||
|
_Avoid_: 探索会话(那是静态评估增强层的概念)、运行
|
||||||
|
|
||||||
|
**结构化报告(Structured Report)**:
|
||||||
|
智能评估的最终产出:发现清单(问题+证据+严重程度+改善建议)+ 亮点 + 优先级建议。消费者有两个:人(看问题)和 AI(拿报告去改被评对象的提示词/SOP,驱动其进化)。
|
||||||
|
_Avoid_: 评估报告(与静态评估的报告混淆)、分析(与 Analysis 岗位混淆)
|
||||||
|
|
||||||
|
**评估角色(Evaluation Role)**:
|
||||||
|
OpenClaw 在智能评估中按职责拆分的三个角色:规划师(planner,产出粗计划)、评估者(evaluator,执行对话)、分析师(analyst,汇总报告)。每个角色可配置不同大模型,对应三个独立技能文件。
|
||||||
|
_Avoid_: 岗位(与模型用途的"岗位"概念冲突)
|
||||||
|
|
||||||
|
**时间窗口(Time Window)**:
|
||||||
|
智能评估的模拟约束:模拟一个完整服务周期(如 24h)内的用户交互分布。OpenClaw 规划时考虑交互时机(早高峰、午间冷清、晚间投诉多),通过 cron 自唤醒在对应时间点执行。是模拟约束而非硬截止。
|
||||||
|
_Avoid_: 窗口(与静态评估的"服务周期窗口"混淆时需加前缀)
|
||||||
|
|
||||||
## 模型配置
|
## 模型配置
|
||||||
|
|
||||||
**模型能力(Capability)**:
|
**模型能力(Capability)**:
|
||||||
|
|||||||
1
backend/agenteval/intelligent_eval/__init__.py
Normal file
1
backend/agenteval/intelligent_eval/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Intelligent evaluation (智能评估) — OpenClaw-driven independent evaluation."""
|
||||||
276
backend/agenteval/intelligent_eval/lifecycle.py
Normal file
276
backend/agenteval/intelligent_eval/lifecycle.py
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
"""Intelligent eval lifecycle (状态机 + 领域操作).
|
||||||
|
|
||||||
|
状态机:
|
||||||
|
draft → planning → pending_approval → executing → completed
|
||||||
|
→ cancelled
|
||||||
|
→ failed
|
||||||
|
pending_approval 可打回 → planning(附反馈)
|
||||||
|
executing 可取消 → cancelled
|
||||||
|
|
||||||
|
非法转换抛 IntelligentEvalTransitionError,路由层映射为 409。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from agenteval.channels.factory import ChannelFactory
|
||||||
|
from agenteval.config import get_settings
|
||||||
|
from agenteval.intelligent_eval.models import (
|
||||||
|
IntelligentEval,
|
||||||
|
IntelligentEvalMessage,
|
||||||
|
IntelligentEvalSession,
|
||||||
|
IntelligentEvalSessionStatus,
|
||||||
|
IntelligentEvalStatus,
|
||||||
|
)
|
||||||
|
from agenteval.intelligent_eval.repository import (
|
||||||
|
IntelligentEvalMessageRepository,
|
||||||
|
IntelligentEvalRepository,
|
||||||
|
IntelligentEvalSessionRepository,
|
||||||
|
)
|
||||||
|
from agenteval.storage.db import utc_now
|
||||||
|
from agenteval.storage.repository import TargetRepository
|
||||||
|
|
||||||
|
# 合法转换表:当前状态 → 允许的目标状态集合
|
||||||
|
_TRANSITIONS: dict[IntelligentEvalStatus, set[IntelligentEvalStatus]] = {
|
||||||
|
IntelligentEvalStatus.DRAFT: {IntelligentEvalStatus.PLANNING},
|
||||||
|
IntelligentEvalStatus.PLANNING: {IntelligentEvalStatus.PENDING_APPROVAL},
|
||||||
|
IntelligentEvalStatus.PENDING_APPROVAL: {IntelligentEvalStatus.EXECUTING, IntelligentEvalStatus.PLANNING, IntelligentEvalStatus.CANCELLED},
|
||||||
|
IntelligentEvalStatus.EXECUTING: {IntelligentEvalStatus.COMPLETED, IntelligentEvalStatus.CANCELLED, IntelligentEvalStatus.FAILED},
|
||||||
|
IntelligentEvalStatus.COMPLETED: set(),
|
||||||
|
IntelligentEvalStatus.CANCELLED: set(),
|
||||||
|
IntelligentEvalStatus.FAILED: set(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class IntelligentEvalNotFoundError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class IntelligentEvalTransitionError(Exception):
|
||||||
|
def __init__(self, reason: str):
|
||||||
|
self.reason = reason
|
||||||
|
super().__init__(reason)
|
||||||
|
|
||||||
|
|
||||||
|
class IntelligentEvalChannelError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_reply_text(content: Any) -> str:
|
||||||
|
"""Flatten a reply payload to text (tutu returns msgBody as a parsed object)."""
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
if isinstance(content, dict):
|
||||||
|
for key in ("content", "text", "message"):
|
||||||
|
value = content.get(key)
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
return value
|
||||||
|
if content is None:
|
||||||
|
return ""
|
||||||
|
return json.dumps(content, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_raise(repo: IntelligentEvalRepository, eval_id: str) -> IntelligentEval:
|
||||||
|
ev = repo.get(eval_id)
|
||||||
|
if ev is None:
|
||||||
|
raise IntelligentEvalNotFoundError(f"intelligent eval {eval_id} not found")
|
||||||
|
return ev
|
||||||
|
|
||||||
|
|
||||||
|
def _transition(repo: IntelligentEvalRepository, ev: IntelligentEval, target: IntelligentEvalStatus) -> IntelligentEval:
|
||||||
|
allowed = _TRANSITIONS.get(ev.status, set())
|
||||||
|
if target not in allowed:
|
||||||
|
raise IntelligentEvalTransitionError(
|
||||||
|
f"cannot transition from {ev.status.value} to {target.value}"
|
||||||
|
)
|
||||||
|
return repo.transition_status(ev.id, target)
|
||||||
|
|
||||||
|
|
||||||
|
def create_eval(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
target_id: str,
|
||||||
|
goal: str,
|
||||||
|
seeds: dict[str, Any],
|
||||||
|
intent: str,
|
||||||
|
role_description: str,
|
||||||
|
time_window_hours: int = 24,
|
||||||
|
) -> IntelligentEval:
|
||||||
|
"""创建智能评估并直接进入 planning 状态(draft → planning 一步完成)。"""
|
||||||
|
repo = IntelligentEvalRepository(session)
|
||||||
|
ev = repo.create(IntelligentEval(
|
||||||
|
name=name,
|
||||||
|
target_id=target_id,
|
||||||
|
status=IntelligentEvalStatus.DRAFT,
|
||||||
|
goal=goal,
|
||||||
|
seeds=seeds,
|
||||||
|
intent=intent,
|
||||||
|
role_description=role_description,
|
||||||
|
time_window_hours=time_window_hours,
|
||||||
|
))
|
||||||
|
return _transition(repo, ev, IntelligentEvalStatus.PLANNING)
|
||||||
|
|
||||||
|
|
||||||
|
def submit_plan(session: Session, eval_id: str, plan: dict[str, Any]) -> IntelligentEval:
|
||||||
|
"""OpenClaw 提交粗计划:planning → pending_approval。"""
|
||||||
|
repo = IntelligentEvalRepository(session)
|
||||||
|
ev = _get_or_raise(repo, eval_id)
|
||||||
|
ev.plan = plan
|
||||||
|
ev.plan_feedback = None
|
||||||
|
ev = repo.update(ev)
|
||||||
|
return _transition(repo, ev, IntelligentEvalStatus.PENDING_APPROVAL)
|
||||||
|
|
||||||
|
|
||||||
|
def approve(session: Session, eval_id: str) -> IntelligentEval:
|
||||||
|
"""用户批准:pending_approval → executing。"""
|
||||||
|
repo = IntelligentEvalRepository(session)
|
||||||
|
ev = _get_or_raise(repo, eval_id)
|
||||||
|
return _transition(repo, ev, IntelligentEvalStatus.EXECUTING)
|
||||||
|
|
||||||
|
|
||||||
|
def reject(session: Session, eval_id: str, feedback: str) -> IntelligentEval:
|
||||||
|
"""用户打回:pending_approval → planning(附反馈)。"""
|
||||||
|
repo = IntelligentEvalRepository(session)
|
||||||
|
ev = _get_or_raise(repo, eval_id)
|
||||||
|
ev.plan_feedback = feedback
|
||||||
|
ev = repo.update(ev)
|
||||||
|
return _transition(repo, ev, IntelligentEvalStatus.PLANNING)
|
||||||
|
|
||||||
|
|
||||||
|
def cancel(session: Session, eval_id: str) -> IntelligentEval:
|
||||||
|
"""用户取消:pending_approval / executing → cancelled。"""
|
||||||
|
repo = IntelligentEvalRepository(session)
|
||||||
|
ev = _get_or_raise(repo, eval_id)
|
||||||
|
return _transition(repo, ev, IntelligentEvalStatus.CANCELLED)
|
||||||
|
|
||||||
|
|
||||||
|
def submit_report(session: Session, eval_id: str, report: dict[str, Any]) -> IntelligentEval:
|
||||||
|
"""OpenClaw 提交结构化报告:executing → completed。
|
||||||
|
|
||||||
|
报告结构校验在路由层(pydantic),此处只负责落库与状态迁移。
|
||||||
|
"""
|
||||||
|
repo = IntelligentEvalRepository(session)
|
||||||
|
ev = _get_or_raise(repo, eval_id)
|
||||||
|
ev.report = report
|
||||||
|
ev = repo.update(ev)
|
||||||
|
return _transition(repo, ev, IntelligentEvalStatus.COMPLETED)
|
||||||
|
|
||||||
|
|
||||||
|
def get_eval(session: Session, eval_id: str) -> IntelligentEval:
|
||||||
|
repo = IntelligentEvalRepository(session)
|
||||||
|
return _get_or_raise(repo, eval_id)
|
||||||
|
|
||||||
|
|
||||||
|
def list_evals(session: Session) -> list[IntelligentEval]:
|
||||||
|
return IntelligentEvalRepository(session).list_all()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_session_or_raise(repo: IntelligentEvalSessionRepository, session_id: str) -> IntelligentEvalSession:
|
||||||
|
obj = repo.get(session_id)
|
||||||
|
if obj is None:
|
||||||
|
raise IntelligentEvalNotFoundError(f"intelligent eval session {session_id} not found")
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def open_session(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
eval_id: str,
|
||||||
|
persona: dict[str, Any],
|
||||||
|
goal: str,
|
||||||
|
dimension: str | None = None,
|
||||||
|
) -> IntelligentEvalSession:
|
||||||
|
"""创建虚拟用户会话:仅 executing 状态的评估可创建。"""
|
||||||
|
eval_repo = IntelligentEvalRepository(session)
|
||||||
|
ev = _get_or_raise(eval_repo, eval_id)
|
||||||
|
if ev.status != IntelligentEvalStatus.EXECUTING:
|
||||||
|
raise IntelligentEvalTransitionError(
|
||||||
|
f"评估不在执行中(当前 {ev.status.value}),无法创建会话"
|
||||||
|
)
|
||||||
|
repo = IntelligentEvalSessionRepository(session)
|
||||||
|
return repo.create(IntelligentEvalSession(
|
||||||
|
eval_id=ev.id,
|
||||||
|
target_id=ev.target_id,
|
||||||
|
persona=persona,
|
||||||
|
goal=goal,
|
||||||
|
dimension=dimension,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
async def conduct_turn(session: Session, *, session_id: str, content: str) -> dict[str, Any]:
|
||||||
|
"""一轮完整问答:状态检查 → 通道往返 → 双条消息落库 → 轮次自增。"""
|
||||||
|
repo = IntelligentEvalSessionRepository(session)
|
||||||
|
obj = _get_session_or_raise(repo, session_id)
|
||||||
|
if obj.status != IntelligentEvalSessionStatus.RUNNING:
|
||||||
|
raise IntelligentEvalTransitionError("会话不在进行中,拒收消息")
|
||||||
|
|
||||||
|
target = TargetRepository(session).get(obj.target_id)
|
||||||
|
if not target:
|
||||||
|
raise IntelligentEvalNotFoundError("session target not found")
|
||||||
|
|
||||||
|
channel = ChannelFactory.create(target)
|
||||||
|
sent_at = utc_now()
|
||||||
|
try:
|
||||||
|
send_result = await channel.send(content)
|
||||||
|
except Exception as exc: # channel adapters raise transport-specific errors
|
||||||
|
raise IntelligentEvalChannelError(f"评测对象通道发送失败: {exc}") from exc
|
||||||
|
if not send_result.ok:
|
||||||
|
raise IntelligentEvalChannelError(f"评测对象通道发送失败: {send_result.error}")
|
||||||
|
|
||||||
|
message_repo = IntelligentEvalMessageRepository(session)
|
||||||
|
message_repo.create(IntelligentEvalMessage(
|
||||||
|
session_id=obj.id, role="user", content=content, created_at=sent_at
|
||||||
|
))
|
||||||
|
repo.increment_turns(obj.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
reply = await channel.poll_reply(
|
||||||
|
send_result.question_msg_id,
|
||||||
|
timeout=get_settings().poll_reply_timeout,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise IntelligentEvalChannelError(f"等待评测对象回复失败: {exc}") from exc
|
||||||
|
if reply is None:
|
||||||
|
raise IntelligentEvalChannelError("等待评测对象回复超时")
|
||||||
|
|
||||||
|
received_at = utc_now()
|
||||||
|
latency_ms = int((received_at - sent_at).total_seconds() * 1000)
|
||||||
|
reply_text = coerce_reply_text(reply.content)
|
||||||
|
message_repo.create(IntelligentEvalMessage(
|
||||||
|
session_id=obj.id,
|
||||||
|
role="assistant",
|
||||||
|
content=reply_text,
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
created_at=received_at,
|
||||||
|
))
|
||||||
|
return {"reply": reply_text, "latency_ms": latency_ms, "turn_count": obj.turn_count + 1}
|
||||||
|
|
||||||
|
|
||||||
|
def close_session(session: Session, *, session_id: str, verdict: dict[str, Any]) -> IntelligentEvalSession:
|
||||||
|
"""关闭会话并记录结论(verdict);仅 running 会话可关闭。"""
|
||||||
|
repo = IntelligentEvalSessionRepository(session)
|
||||||
|
obj = _get_session_or_raise(repo, session_id)
|
||||||
|
if obj.status != IntelligentEvalSessionStatus.RUNNING:
|
||||||
|
raise IntelligentEvalTransitionError("会话不在进行中,无法关闭")
|
||||||
|
closed = repo.close(obj.id, verdict)
|
||||||
|
if closed is None:
|
||||||
|
raise IntelligentEvalNotFoundError(f"intelligent eval session {session_id} not found")
|
||||||
|
return closed
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_by_id(session: Session, session_id: str) -> IntelligentEvalSession:
|
||||||
|
return _get_session_or_raise(IntelligentEvalSessionRepository(session), session_id)
|
||||||
|
|
||||||
|
|
||||||
|
def list_sessions(session: Session, eval_id: str) -> list[IntelligentEvalSession]:
|
||||||
|
eval_repo = IntelligentEvalRepository(session)
|
||||||
|
_get_or_raise(eval_repo, eval_id)
|
||||||
|
return IntelligentEvalSessionRepository(session).list_by_eval(eval_id)
|
||||||
|
|
||||||
|
|
||||||
|
def list_messages(session: Session, session_id: str) -> list[IntelligentEvalMessage]:
|
||||||
|
_get_session_or_raise(IntelligentEvalSessionRepository(session), session_id)
|
||||||
|
return IntelligentEvalMessageRepository(session).list_by_session(session_id)
|
||||||
87
backend/agenteval/intelligent_eval/models.py
Normal file
87
backend/agenteval/intelligent_eval/models.py
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
"""Domain models for intelligent evaluation (智能评估).
|
||||||
|
|
||||||
|
Intelligent evaluation is an independent evaluation paradigm parallel to
|
||||||
|
static evaluation (v0.8). OpenClaw drives planning and execution; the platform
|
||||||
|
provides data storage, lifecycle management, and reporting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class IntelligentEvalStatus(str, Enum):
|
||||||
|
DRAFT = "draft"
|
||||||
|
PLANNING = "planning"
|
||||||
|
PENDING_APPROVAL = "pending_approval"
|
||||||
|
EXECUTING = "executing"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class IntelligentEvalSessionStatus(str, Enum):
|
||||||
|
RUNNING = "running"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
|
||||||
|
|
||||||
|
class IntelligentEvalMessage(BaseModel):
|
||||||
|
"""One chat message inside an intelligent eval session."""
|
||||||
|
|
||||||
|
id: Optional[str] = None
|
||||||
|
session_id: str
|
||||||
|
role: str = "user"
|
||||||
|
content: str = ""
|
||||||
|
latency_ms: Optional[int] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class IntelligentEvalSession(BaseModel):
|
||||||
|
"""A virtual-user session belonging to one intelligent evaluation."""
|
||||||
|
|
||||||
|
id: Optional[str] = None
|
||||||
|
eval_id: str
|
||||||
|
target_id: str
|
||||||
|
persona: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
goal: str = ""
|
||||||
|
dimension: Optional[str] = None
|
||||||
|
status: IntelligentEvalSessionStatus = IntelligentEvalSessionStatus.RUNNING
|
||||||
|
verdict: Optional[dict[str, Any]] = None
|
||||||
|
turn_count: int = 0
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
closed_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class IntelligentEval(BaseModel):
|
||||||
|
"""An intelligent evaluation instance — independent entity, peer to Campaign."""
|
||||||
|
|
||||||
|
id: Optional[str] = None
|
||||||
|
name: str
|
||||||
|
target_id: str
|
||||||
|
status: IntelligentEvalStatus = IntelligentEvalStatus.DRAFT
|
||||||
|
|
||||||
|
# User input (四件套)
|
||||||
|
goal: str = ""
|
||||||
|
seeds: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
intent: str = ""
|
||||||
|
role_description: str = ""
|
||||||
|
|
||||||
|
# Coarse plan (OpenClaw produces)
|
||||||
|
plan: Optional[dict[str, Any]] = None
|
||||||
|
plan_feedback: Optional[str] = None
|
||||||
|
|
||||||
|
# Time window
|
||||||
|
time_window_hours: int = 24
|
||||||
|
|
||||||
|
# Report
|
||||||
|
report: Optional[dict[str, Any]] = None
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
started_at: Optional[datetime] = None
|
||||||
|
completed_at: Optional[datetime] = None
|
||||||
130
backend/agenteval/intelligent_eval/report.py
Normal file
130
backend/agenteval/intelligent_eval/report.py
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
"""Structured report for intelligent evaluation (票据 04).
|
||||||
|
|
||||||
|
Two concerns live here so they stay co-located:
|
||||||
|
|
||||||
|
* **Validation** — pydantic models describing the report contract OpenClaw
|
||||||
|
submits. A malformed body yields a 422 before any state change happens.
|
||||||
|
* **Rendering** — ``render_report_markdown`` is a pure function (dict in,
|
||||||
|
string out) with no DB or I/O, so it unit-tests trivially.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class ReportEvidence(BaseModel):
|
||||||
|
session_id: str = ""
|
||||||
|
turn_index: Optional[int] = None
|
||||||
|
user_said: str = ""
|
||||||
|
assistant_replied: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class ReportFinding(BaseModel):
|
||||||
|
issue: str = Field(min_length=1)
|
||||||
|
severity: str = Field(min_length=1)
|
||||||
|
dimension: str = Field(min_length=1)
|
||||||
|
evidence: list[ReportEvidence] = Field(default_factory=list)
|
||||||
|
suggestion: Optional[str] = None
|
||||||
|
related_sop: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReportHighlight(BaseModel):
|
||||||
|
description: str = ""
|
||||||
|
dimension: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReportModel(BaseModel):
|
||||||
|
summary: str = Field(min_length=1)
|
||||||
|
scores: Optional[dict[str, Any]] = None
|
||||||
|
findings: list[ReportFinding] = Field(default_factory=list)
|
||||||
|
highlights: list[ReportHighlight] = Field(default_factory=list)
|
||||||
|
priority_recommendations: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _require_findings(self) -> "ReportModel":
|
||||||
|
if not self.findings:
|
||||||
|
raise ValueError("findings 不能为空")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
_SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
|
||||||
|
|
||||||
|
|
||||||
|
def _severity_rank(severity: str) -> int:
|
||||||
|
return _SEVERITY_ORDER.get((severity or "").lower(), 99)
|
||||||
|
|
||||||
|
|
||||||
|
def render_report_markdown(report: dict[str, Any], *, name: str = "", eval_id: str = "") -> str:
|
||||||
|
"""Render a validated report dict to Markdown. Pure function, no I/O."""
|
||||||
|
validated = ReportModel.model_validate(report)
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
title = name or "智能评估报告"
|
||||||
|
lines.append(f"# {title}")
|
||||||
|
lines.append("")
|
||||||
|
if eval_id:
|
||||||
|
lines.append(f"> 评估 ID:`{eval_id}`")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("## 总体概述")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(validated.summary.strip())
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if validated.scores:
|
||||||
|
lines.append("## 维度评分")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("| 维度 | 分数 |")
|
||||||
|
lines.append("| --- | --- |")
|
||||||
|
for dimension, score in validated.scores.items():
|
||||||
|
lines.append(f"| {dimension} | {score} |")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("## 问题发现")
|
||||||
|
lines.append("")
|
||||||
|
findings = sorted(validated.findings, key=lambda f: _severity_rank(f.severity))
|
||||||
|
for index, finding in enumerate(findings, start=1):
|
||||||
|
lines.append(f"### {index}. {finding.issue}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"- **严重程度**:{finding.severity}")
|
||||||
|
lines.append(f"- **维度**:{finding.dimension}")
|
||||||
|
if finding.suggestion:
|
||||||
|
lines.append(f"- **建议**:{finding.suggestion}")
|
||||||
|
if finding.related_sop:
|
||||||
|
lines.append(f"- **关联 SOP**:{finding.related_sop}")
|
||||||
|
if finding.evidence:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("**证据**:")
|
||||||
|
lines.append("")
|
||||||
|
for ev in finding.evidence:
|
||||||
|
header_bits = []
|
||||||
|
if ev.session_id:
|
||||||
|
header_bits.append(f"会话 `{ev.session_id}`")
|
||||||
|
if ev.turn_index is not None:
|
||||||
|
header_bits.append(f"第 {ev.turn_index} 轮")
|
||||||
|
header = "(" + ",".join(header_bits) + ")" if header_bits else ""
|
||||||
|
lines.append(f"> {header}")
|
||||||
|
if ev.user_said:
|
||||||
|
lines.append(f"> **用户**:{ev.user_said}")
|
||||||
|
if ev.assistant_replied:
|
||||||
|
lines.append(f"> **对象**:{ev.assistant_replied}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if validated.highlights:
|
||||||
|
lines.append("## 亮点")
|
||||||
|
lines.append("")
|
||||||
|
for highlight in validated.highlights:
|
||||||
|
suffix = f"({highlight.dimension})" if highlight.dimension else ""
|
||||||
|
lines.append(f"- {highlight.description}{suffix}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if validated.priority_recommendations:
|
||||||
|
lines.append("## 优先改进建议")
|
||||||
|
lines.append("")
|
||||||
|
for recommendation in validated.priority_recommendations:
|
||||||
|
lines.append(f"- {recommendation}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines).rstrip() + "\n"
|
||||||
231
backend/agenteval/intelligent_eval/repository.py
Normal file
231
backend/agenteval/intelligent_eval/repository.py
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
"""Repository for intelligent evaluation entities."""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from agenteval.intelligent_eval.models import (
|
||||||
|
IntelligentEval,
|
||||||
|
IntelligentEvalMessage,
|
||||||
|
IntelligentEvalSession,
|
||||||
|
IntelligentEvalSessionStatus,
|
||||||
|
IntelligentEvalStatus,
|
||||||
|
)
|
||||||
|
from agenteval.storage.db import (
|
||||||
|
IntelligentEvalDB,
|
||||||
|
IntelligentEvalMessageDB,
|
||||||
|
IntelligentEvalSessionDB,
|
||||||
|
get_session,
|
||||||
|
utc_now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IntelligentEvalRepository:
|
||||||
|
"""CRUD + lifecycle operations for intelligent evaluations."""
|
||||||
|
|
||||||
|
def __init__(self, session: Optional[Session] = None):
|
||||||
|
self.session = session or get_session()
|
||||||
|
|
||||||
|
def _to_db(self, obj: IntelligentEval) -> IntelligentEvalDB:
|
||||||
|
db = IntelligentEvalDB(
|
||||||
|
id=obj.id,
|
||||||
|
name=obj.name,
|
||||||
|
target_id=obj.target_id,
|
||||||
|
status=obj.status.value if isinstance(obj.status, IntelligentEvalStatus) else obj.status,
|
||||||
|
goal=obj.goal,
|
||||||
|
intent=obj.intent,
|
||||||
|
role_description=obj.role_description,
|
||||||
|
time_window_hours=obj.time_window_hours,
|
||||||
|
plan_feedback=obj.plan_feedback,
|
||||||
|
created_at=obj.created_at,
|
||||||
|
updated_at=obj.updated_at,
|
||||||
|
started_at=obj.started_at,
|
||||||
|
completed_at=obj.completed_at,
|
||||||
|
)
|
||||||
|
db.set_seeds(obj.seeds)
|
||||||
|
if obj.plan is not None:
|
||||||
|
db.set_plan(obj.plan)
|
||||||
|
if obj.report is not None:
|
||||||
|
db.set_report(obj.report)
|
||||||
|
return db
|
||||||
|
|
||||||
|
def _from_db(self, db: IntelligentEvalDB) -> IntelligentEval:
|
||||||
|
return IntelligentEval(
|
||||||
|
id=db.id,
|
||||||
|
name=db.name,
|
||||||
|
target_id=db.target_id,
|
||||||
|
status=IntelligentEvalStatus(db.status),
|
||||||
|
goal=db.goal,
|
||||||
|
seeds=db.get_seeds(),
|
||||||
|
intent=db.intent,
|
||||||
|
role_description=db.role_description,
|
||||||
|
plan=db.get_plan(),
|
||||||
|
plan_feedback=db.plan_feedback,
|
||||||
|
time_window_hours=db.time_window_hours,
|
||||||
|
report=db.get_report(),
|
||||||
|
created_at=db.created_at,
|
||||||
|
updated_at=db.updated_at,
|
||||||
|
started_at=db.started_at,
|
||||||
|
completed_at=db.completed_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_all(self) -> list[IntelligentEval]:
|
||||||
|
statement = select(IntelligentEvalDB).order_by(IntelligentEvalDB.created_at.desc())
|
||||||
|
return [self._from_db(r) for r in self.session.exec(statement).all()]
|
||||||
|
|
||||||
|
def get(self, eval_id: str) -> Optional[IntelligentEval]:
|
||||||
|
db = self.session.get(IntelligentEvalDB, eval_id)
|
||||||
|
return self._from_db(db) if db else None
|
||||||
|
|
||||||
|
def create(self, obj: IntelligentEval) -> IntelligentEval:
|
||||||
|
db = self._to_db(obj)
|
||||||
|
self.session.add(db)
|
||||||
|
self.session.commit()
|
||||||
|
self.session.refresh(db)
|
||||||
|
return self._from_db(db)
|
||||||
|
|
||||||
|
def update(self, obj: IntelligentEval) -> IntelligentEval:
|
||||||
|
db = self.session.get(IntelligentEvalDB, obj.id)
|
||||||
|
if not db:
|
||||||
|
raise ValueError(f"IntelligentEval {obj.id} not found")
|
||||||
|
updated = self._to_db(obj)
|
||||||
|
updated.id = db.id
|
||||||
|
# Preserve fields that _to_db doesn't set from None
|
||||||
|
self.session.delete(db)
|
||||||
|
self.session.add(updated)
|
||||||
|
self.session.commit()
|
||||||
|
self.session.refresh(updated)
|
||||||
|
return self._from_db(updated)
|
||||||
|
|
||||||
|
def transition_status(self, eval_id: str, new_status: IntelligentEvalStatus) -> Optional[IntelligentEval]:
|
||||||
|
db = self.session.get(IntelligentEvalDB, eval_id)
|
||||||
|
if not db:
|
||||||
|
return None
|
||||||
|
db.status = new_status.value
|
||||||
|
db.updated_at = utc_now()
|
||||||
|
if new_status == IntelligentEvalStatus.EXECUTING and db.started_at is None:
|
||||||
|
db.started_at = utc_now()
|
||||||
|
if new_status in (IntelligentEvalStatus.COMPLETED, IntelligentEvalStatus.CANCELLED, IntelligentEvalStatus.FAILED):
|
||||||
|
db.completed_at = utc_now()
|
||||||
|
self.session.add(db)
|
||||||
|
self.session.commit()
|
||||||
|
self.session.refresh(db)
|
||||||
|
return self._from_db(db)
|
||||||
|
|
||||||
|
def delete(self, eval_id: str) -> bool:
|
||||||
|
db = self.session.get(IntelligentEvalDB, eval_id)
|
||||||
|
if not db:
|
||||||
|
return False
|
||||||
|
self.session.delete(db)
|
||||||
|
self.session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class IntelligentEvalSessionRepository:
|
||||||
|
"""CRUD for intelligent eval sessions."""
|
||||||
|
|
||||||
|
def __init__(self, session: Optional[Session] = None):
|
||||||
|
self.session = session or get_session()
|
||||||
|
|
||||||
|
def _from_db(self, db: IntelligentEvalSessionDB) -> IntelligentEvalSession:
|
||||||
|
return IntelligentEvalSession(
|
||||||
|
id=db.id,
|
||||||
|
eval_id=db.eval_id,
|
||||||
|
target_id=db.target_id,
|
||||||
|
persona=db.get_persona(),
|
||||||
|
goal=db.goal,
|
||||||
|
dimension=db.dimension,
|
||||||
|
status=IntelligentEvalSessionStatus(db.status),
|
||||||
|
verdict=db.get_verdict(),
|
||||||
|
turn_count=db.turn_count,
|
||||||
|
created_at=db.created_at,
|
||||||
|
closed_at=db.closed_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_by_eval(self, eval_id: str) -> list[IntelligentEvalSession]:
|
||||||
|
statement = (
|
||||||
|
select(IntelligentEvalSessionDB)
|
||||||
|
.where(IntelligentEvalSessionDB.eval_id == eval_id)
|
||||||
|
.order_by(IntelligentEvalSessionDB.created_at.asc())
|
||||||
|
)
|
||||||
|
return [self._from_db(r) for r in self.session.exec(statement).all()]
|
||||||
|
|
||||||
|
def get(self, session_id: str) -> Optional[IntelligentEvalSession]:
|
||||||
|
db = self.session.get(IntelligentEvalSessionDB, session_id)
|
||||||
|
return self._from_db(db) if db else None
|
||||||
|
|
||||||
|
def create(self, obj: IntelligentEvalSession) -> IntelligentEvalSession:
|
||||||
|
db = IntelligentEvalSessionDB(
|
||||||
|
id=obj.id,
|
||||||
|
eval_id=obj.eval_id,
|
||||||
|
target_id=obj.target_id,
|
||||||
|
goal=obj.goal,
|
||||||
|
dimension=obj.dimension,
|
||||||
|
status=obj.status.value if isinstance(obj.status, IntelligentEvalSessionStatus) else obj.status,
|
||||||
|
turn_count=obj.turn_count,
|
||||||
|
)
|
||||||
|
db.set_persona(obj.persona)
|
||||||
|
if obj.verdict is not None:
|
||||||
|
db.set_verdict(obj.verdict)
|
||||||
|
self.session.add(db)
|
||||||
|
self.session.commit()
|
||||||
|
self.session.refresh(db)
|
||||||
|
return self._from_db(db)
|
||||||
|
|
||||||
|
def close(self, session_id: str, verdict: dict, status: IntelligentEvalSessionStatus = IntelligentEvalSessionStatus.COMPLETED) -> Optional[IntelligentEvalSession]:
|
||||||
|
db = self.session.get(IntelligentEvalSessionDB, session_id)
|
||||||
|
if not db:
|
||||||
|
return None
|
||||||
|
db.status = status.value
|
||||||
|
db.set_verdict(verdict)
|
||||||
|
db.closed_at = utc_now()
|
||||||
|
self.session.add(db)
|
||||||
|
self.session.commit()
|
||||||
|
self.session.refresh(db)
|
||||||
|
return self._from_db(db)
|
||||||
|
|
||||||
|
def increment_turns(self, session_id: str) -> None:
|
||||||
|
db = self.session.get(IntelligentEvalSessionDB, session_id)
|
||||||
|
if db:
|
||||||
|
db.turn_count += 1
|
||||||
|
self.session.add(db)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class IntelligentEvalMessageRepository:
|
||||||
|
"""CRUD for intelligent eval session messages."""
|
||||||
|
|
||||||
|
def __init__(self, session: Optional[Session] = None):
|
||||||
|
self.session = session or get_session()
|
||||||
|
|
||||||
|
def _from_db(self, db: IntelligentEvalMessageDB) -> IntelligentEvalMessage:
|
||||||
|
return IntelligentEvalMessage(
|
||||||
|
id=db.id,
|
||||||
|
session_id=db.session_id,
|
||||||
|
role=db.role,
|
||||||
|
content=db.content,
|
||||||
|
latency_ms=db.latency_ms,
|
||||||
|
created_at=db.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_by_session(self, session_id: str) -> list[IntelligentEvalMessage]:
|
||||||
|
statement = (
|
||||||
|
select(IntelligentEvalMessageDB)
|
||||||
|
.where(IntelligentEvalMessageDB.session_id == session_id)
|
||||||
|
.order_by(IntelligentEvalMessageDB.created_at.asc())
|
||||||
|
)
|
||||||
|
return [self._from_db(r) for r in self.session.exec(statement).all()]
|
||||||
|
|
||||||
|
def create(self, obj: IntelligentEvalMessage) -> IntelligentEvalMessage:
|
||||||
|
db = IntelligentEvalMessageDB(
|
||||||
|
id=obj.id,
|
||||||
|
session_id=obj.session_id,
|
||||||
|
role=obj.role,
|
||||||
|
content=obj.content,
|
||||||
|
latency_ms=obj.latency_ms,
|
||||||
|
created_at=obj.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(db)
|
||||||
|
self.session.commit()
|
||||||
|
self.session.refresh(db)
|
||||||
|
return self._from_db(db)
|
||||||
@ -474,6 +474,100 @@ class FileRecordDB(SQLModel, table=True):
|
|||||||
category: Optional[FileCategoryDB] = Relationship(back_populates="files")
|
category: Optional[FileCategoryDB] = Relationship(back_populates="files")
|
||||||
|
|
||||||
|
|
||||||
|
class IntelligentEvalDB(SQLModel, table=True):
|
||||||
|
"""Intelligent evaluation (智能评估) — independent entity, peer to Campaign."""
|
||||||
|
|
||||||
|
__tablename__ = "intelligent_evals"
|
||||||
|
|
||||||
|
id: Optional[str] = Field(default_factory=new_uuid, primary_key=True)
|
||||||
|
name: str
|
||||||
|
target_id: str = Field(foreign_key="eval_targets.id")
|
||||||
|
status: str = "draft"
|
||||||
|
|
||||||
|
# User input (四件套)
|
||||||
|
goal: str = ""
|
||||||
|
seeds: str = "{}"
|
||||||
|
intent: str = ""
|
||||||
|
role_description: str = ""
|
||||||
|
|
||||||
|
# Coarse plan (OpenClaw produces)
|
||||||
|
plan: Optional[str] = None
|
||||||
|
plan_feedback: Optional[str] = None
|
||||||
|
|
||||||
|
# Time window
|
||||||
|
time_window_hours: int = 24
|
||||||
|
|
||||||
|
# Report
|
||||||
|
report: Optional[str] = None
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at: Optional[datetime] = Field(default_factory=utc_now)
|
||||||
|
updated_at: Optional[datetime] = Field(default_factory=utc_now)
|
||||||
|
started_at: Optional[datetime] = None
|
||||||
|
completed_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
def get_seeds(self) -> dict[str, Any]:
|
||||||
|
return _json_loads(self.seeds)
|
||||||
|
|
||||||
|
def set_seeds(self, seeds: dict[str, Any]) -> None:
|
||||||
|
self.seeds = _json_dumps(seeds)
|
||||||
|
|
||||||
|
def get_plan(self) -> Optional[dict[str, Any]]:
|
||||||
|
return _json_loads(self.plan) if self.plan else None
|
||||||
|
|
||||||
|
def set_plan(self, plan: dict[str, Any]) -> None:
|
||||||
|
self.plan = _json_dumps(plan)
|
||||||
|
|
||||||
|
def get_report(self) -> Optional[dict[str, Any]]:
|
||||||
|
return _json_loads(self.report) if self.report else None
|
||||||
|
|
||||||
|
def set_report(self, report: dict[str, Any]) -> None:
|
||||||
|
self.report = _json_dumps(report)
|
||||||
|
|
||||||
|
|
||||||
|
class IntelligentEvalSessionDB(SQLModel, table=True):
|
||||||
|
"""A virtual-user session within an intelligent evaluation."""
|
||||||
|
|
||||||
|
__tablename__ = "intelligent_eval_sessions"
|
||||||
|
|
||||||
|
id: Optional[str] = Field(default_factory=new_uuid, primary_key=True)
|
||||||
|
eval_id: str = Field(index=True, foreign_key="intelligent_evals.id")
|
||||||
|
target_id: str = Field(foreign_key="eval_targets.id")
|
||||||
|
persona: str = "{}"
|
||||||
|
goal: str = ""
|
||||||
|
dimension: Optional[str] = None
|
||||||
|
status: str = "running"
|
||||||
|
verdict: Optional[str] = None
|
||||||
|
turn_count: int = 0
|
||||||
|
created_at: Optional[datetime] = Field(default_factory=utc_now)
|
||||||
|
closed_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
def get_persona(self) -> dict[str, Any]:
|
||||||
|
return _json_loads(self.persona)
|
||||||
|
|
||||||
|
def set_persona(self, persona: dict[str, Any]) -> None:
|
||||||
|
self.persona = _json_dumps(persona)
|
||||||
|
|
||||||
|
def get_verdict(self) -> Optional[dict[str, Any]]:
|
||||||
|
return _json_loads(self.verdict) if self.verdict else None
|
||||||
|
|
||||||
|
def set_verdict(self, verdict: dict[str, Any]) -> None:
|
||||||
|
self.verdict = _json_dumps(verdict)
|
||||||
|
|
||||||
|
|
||||||
|
class IntelligentEvalMessageDB(SQLModel, table=True):
|
||||||
|
"""One chat message inside an intelligent eval session."""
|
||||||
|
|
||||||
|
__tablename__ = "intelligent_eval_messages"
|
||||||
|
|
||||||
|
id: Optional[str] = Field(default_factory=new_uuid, primary_key=True)
|
||||||
|
session_id: str = Field(index=True, foreign_key="intelligent_eval_sessions.id")
|
||||||
|
role: str = "user"
|
||||||
|
content: str = ""
|
||||||
|
latency_ms: Optional[int] = None
|
||||||
|
created_at: Optional[datetime] = Field(default_factory=utc_now)
|
||||||
|
|
||||||
|
|
||||||
def init_db() -> None:
|
def init_db() -> None:
|
||||||
SQLModel.metadata.create_all(engine)
|
SQLModel.metadata.create_all(engine)
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,7 @@ from agenteval.web.routers import (
|
|||||||
campaigns,
|
campaigns,
|
||||||
exploration,
|
exploration,
|
||||||
files,
|
files,
|
||||||
|
intelligent_evals,
|
||||||
model_configs,
|
model_configs,
|
||||||
proxy,
|
proxy,
|
||||||
reports,
|
reports,
|
||||||
@ -102,6 +103,7 @@ app.include_router(scenarios.router, prefix="/api/scenarios", tags=["scenarios"]
|
|||||||
app.include_router(runs.router, prefix="/api/runs", tags=["runs"], dependencies=_api_deps)
|
app.include_router(runs.router, prefix="/api/runs", tags=["runs"], dependencies=_api_deps)
|
||||||
app.include_router(campaigns.router, prefix="/api/campaigns", tags=["campaigns"], dependencies=_api_deps)
|
app.include_router(campaigns.router, prefix="/api/campaigns", tags=["campaigns"], dependencies=_api_deps)
|
||||||
app.include_router(exploration.router, prefix="/api/exploration", tags=["exploration"], dependencies=_api_deps)
|
app.include_router(exploration.router, prefix="/api/exploration", tags=["exploration"], dependencies=_api_deps)
|
||||||
|
app.include_router(intelligent_evals.router, prefix="/api/intelligent-evals", tags=["intelligent-evals"], dependencies=_api_deps)
|
||||||
app.include_router(reports.router, prefix="/api/reports", tags=["reports"], dependencies=_api_deps)
|
app.include_router(reports.router, prefix="/api/reports", tags=["reports"], dependencies=_api_deps)
|
||||||
app.include_router(stats.router, prefix="/api/stats", tags=["stats"], dependencies=_api_deps)
|
app.include_router(stats.router, prefix="/api/stats", tags=["stats"], dependencies=_api_deps)
|
||||||
app.include_router(files.router, prefix="/api/files", tags=["files"], dependencies=_api_deps)
|
app.include_router(files.router, prefix="/api/files", tags=["files"], dependencies=_api_deps)
|
||||||
|
|||||||
244
backend/agenteval/web/routers/intelligent_evals.py
Normal file
244
backend/agenteval/web/routers/intelligent_evals.py
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
"""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.report import ReportModel, render_report_markdown
|
||||||
|
from agenteval.intelligent_eval.repository import IntelligentEvalSessionRepository
|
||||||
|
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:
|
||||||
|
data = ev.model_dump(mode="json")
|
||||||
|
sessions = IntelligentEvalSessionRepository(session).list_by_eval(ev.id)
|
||||||
|
data["session_count"] = len(sessions)
|
||||||
|
data["completed_sessions"] = sum(1 for s in sessions if s.status.value == "completed")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def create_eval(request: CreateEvalRequest, session: Session = Depends(get_db)) -> dict:
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
return _eval_response(ev, session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_evals(session: Session = Depends(get_db)) -> dict:
|
||||||
|
evals = lifecycle.list_evals(session)
|
||||||
|
return {"intelligent_evals": [_eval_response(ev, session) for ev in evals]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{eval_id}")
|
||||||
|
async def get_eval(eval_id: str, session: Session = Depends(get_db)) -> dict:
|
||||||
|
try:
|
||||||
|
ev = lifecycle.get_eval(session, eval_id)
|
||||||
|
except IntelligentEvalNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
return _eval_response(ev, session)
|
||||||
|
|
||||||
|
|
||||||
|
@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:
|
||||||
|
try:
|
||||||
|
ev = lifecycle.get_eval(session, eval_id)
|
||||||
|
except IntelligentEvalNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
if ev.report is None:
|
||||||
|
raise HTTPException(status_code=404, detail="report not submitted yet")
|
||||||
|
return ev.report
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{eval_id}/report/markdown", response_class=PlainTextResponse)
|
||||||
|
async def get_report_markdown(eval_id: str, session: Session = Depends(get_db)) -> PlainTextResponse:
|
||||||
|
try:
|
||||||
|
ev = lifecycle.get_eval(session, eval_id)
|
||||||
|
except IntelligentEvalNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
if ev.report is None:
|
||||||
|
raise HTTPException(status_code=404, detail="report not submitted yet")
|
||||||
|
markdown = render_report_markdown(ev.report, name=ev.name, eval_id=ev.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:
|
||||||
|
try:
|
||||||
|
sessions = lifecycle.list_sessions(session, eval_id)
|
||||||
|
except IntelligentEvalNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
return {"sessions": [s.model_dump(mode="json") for s in sessions]}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_owned_session(eval_id: str, session_id: str, session: Session):
|
||||||
|
obj = lifecycle.get_session_by_id(session, session_id)
|
||||||
|
if obj.eval_id != eval_id:
|
||||||
|
raise IntelligentEvalNotFoundError(f"intelligent eval session {session_id} not found")
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
@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:
|
||||||
|
_get_owned_session(eval_id, session_id, session)
|
||||||
|
return await lifecycle.conduct_turn(session, 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:
|
||||||
|
_get_owned_session(eval_id, session_id, session)
|
||||||
|
obj = lifecycle.close_session(session, 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:
|
||||||
|
try:
|
||||||
|
_get_owned_session(eval_id, session_id, session)
|
||||||
|
messages = lifecycle.list_messages(session, session_id)
|
||||||
|
except IntelligentEvalNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
return {"messages": [m.model_dump(mode="json") for m in messages]}
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
"""add intelligent eval tables
|
||||||
|
|
||||||
|
Revision ID: 99dbae2a20da
|
||||||
|
Revises: c5d8f0a2e4b7
|
||||||
|
Create Date: 2026-08-05 02:52:58.120905
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '99dbae2a20da'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'c5d8f0a2e4b7'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('intelligent_evals',
|
||||||
|
sa.Column('id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('target_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('goal', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('seeds', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('intent', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('role_description', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('plan', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||||
|
sa.Column('plan_feedback', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||||
|
sa.Column('time_window_hours', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('report', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('started_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('completed_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['target_id'], ['eval_targets.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('intelligent_eval_sessions',
|
||||||
|
sa.Column('id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('eval_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('target_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('persona', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('goal', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('dimension', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||||
|
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('verdict', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||||
|
sa.Column('turn_count', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('closed_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['eval_id'], ['intelligent_evals.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['target_id'], ['eval_targets.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('intelligent_eval_sessions', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_intelligent_eval_sessions_eval_id'), ['eval_id'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('intelligent_eval_messages',
|
||||||
|
sa.Column('id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('session_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('role', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('content', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('latency_ms', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['session_id'], ['intelligent_eval_sessions.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('intelligent_eval_messages', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_intelligent_eval_messages_session_id'), ['session_id'], unique=False)
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('intelligent_eval_messages', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_intelligent_eval_messages_session_id'))
|
||||||
|
|
||||||
|
op.drop_table('intelligent_eval_messages')
|
||||||
|
with op.batch_alter_table('intelligent_eval_sessions', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_intelligent_eval_sessions_eval_id'))
|
||||||
|
|
||||||
|
op.drop_table('intelligent_eval_sessions')
|
||||||
|
op.drop_table('intelligent_evals')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@ -37,6 +37,9 @@ def db_session(tmp_db_path: Path) -> Session:
|
|||||||
EvalTargetDB,
|
EvalTargetDB,
|
||||||
ExplorationMessageDB,
|
ExplorationMessageDB,
|
||||||
ExplorationSessionDB,
|
ExplorationSessionDB,
|
||||||
|
IntelligentEvalDB,
|
||||||
|
IntelligentEvalMessageDB,
|
||||||
|
IntelligentEvalSessionDB,
|
||||||
ModelConfigDB,
|
ModelConfigDB,
|
||||||
ScenarioDB,
|
ScenarioDB,
|
||||||
ScenarioModelBindingDB,
|
ScenarioModelBindingDB,
|
||||||
|
|||||||
@ -481,6 +481,9 @@ def test_exploration_config_migration_on_existing_db(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
SQLModel.metadata.create_all(create_engine(database_url))
|
SQLModel.metadata.create_all(create_engine(database_url))
|
||||||
with create_engine(database_url).begin() as connection:
|
with create_engine(database_url).begin() as connection:
|
||||||
|
connection.execute(text("DROP TABLE IF EXISTS intelligent_eval_messages"))
|
||||||
|
connection.execute(text("DROP TABLE IF EXISTS intelligent_eval_sessions"))
|
||||||
|
connection.execute(text("DROP TABLE IF EXISTS intelligent_evals"))
|
||||||
connection.execute(text("DROP TABLE IF EXISTS exploration_sessions"))
|
connection.execute(text("DROP TABLE IF EXISTS exploration_sessions"))
|
||||||
connection.execute(text("DROP TABLE IF EXISTS exploration_messages"))
|
connection.execute(text("DROP TABLE IF EXISTS exploration_messages"))
|
||||||
connection.execute(text("ALTER TABLE campaigns DROP COLUMN exploration_seeds"))
|
connection.execute(text("ALTER TABLE campaigns DROP COLUMN exploration_seeds"))
|
||||||
|
|||||||
@ -470,6 +470,9 @@ def test_exploration_migration_on_existing_db(tmp_path, monkeypatch):
|
|||||||
with create_engine(database_url).begin() as connection:
|
with create_engine(database_url).begin() as connection:
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
connection.execute(text("DROP TABLE IF EXISTS intelligent_eval_messages"))
|
||||||
|
connection.execute(text("DROP TABLE IF EXISTS intelligent_eval_sessions"))
|
||||||
|
connection.execute(text("DROP TABLE IF EXISTS intelligent_evals"))
|
||||||
connection.execute(text("DROP TABLE IF EXISTS exploration_sessions"))
|
connection.execute(text("DROP TABLE IF EXISTS exploration_sessions"))
|
||||||
connection.execute(text("DROP TABLE IF EXISTS exploration_messages"))
|
connection.execute(text("DROP TABLE IF EXISTS exploration_messages"))
|
||||||
connection.execute(text("ALTER TABLE campaigns DROP COLUMN exploration_seeds"))
|
connection.execute(text("ALTER TABLE campaigns DROP COLUMN exploration_seeds"))
|
||||||
|
|||||||
@ -189,6 +189,9 @@ async def test_patrol_migration_column_on_existing_db(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
SQLModel.metadata.create_all(create_engine(database_url))
|
SQLModel.metadata.create_all(create_engine(database_url))
|
||||||
with create_engine(database_url).begin() as connection:
|
with create_engine(database_url).begin() as connection:
|
||||||
|
connection.execute(text("DROP TABLE IF EXISTS intelligent_eval_messages"))
|
||||||
|
connection.execute(text("DROP TABLE IF EXISTS intelligent_eval_sessions"))
|
||||||
|
connection.execute(text("DROP TABLE IF EXISTS intelligent_evals"))
|
||||||
connection.execute(text("DROP TABLE IF EXISTS exploration_sessions"))
|
connection.execute(text("DROP TABLE IF EXISTS exploration_sessions"))
|
||||||
connection.execute(text("DROP TABLE IF EXISTS exploration_messages"))
|
connection.execute(text("DROP TABLE IF EXISTS exploration_messages"))
|
||||||
connection.execute(text("ALTER TABLE campaigns DROP COLUMN exploration_seeds"))
|
connection.execute(text("ALTER TABLE campaigns DROP COLUMN exploration_seeds"))
|
||||||
|
|||||||
511
tests/integration/test_intelligent_evals_api.py
Normal file
511
tests/integration/test_intelligent_evals_api.py
Normal file
@ -0,0 +1,511 @@
|
|||||||
|
"""Integration tests for intelligent eval lifecycle + session + report API (tickets 02, 03, 04).
|
||||||
|
|
||||||
|
Uses httpx.AsyncClient with app= to drive the FastAPI app in-process.
|
||||||
|
Covers the full lifecycle (create → plan → approve → execute → cancel),
|
||||||
|
reject/resubmit flow, illegal transition rejections (409), the session
|
||||||
|
lifecycle (create → message → close) with channel I/O stubbed via MockChannel,
|
||||||
|
and the report flow (submit → completed → read → Markdown export).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from agenteval.models import ChannelType, EvalTarget, PlatformType, TargetStatus
|
||||||
|
from agenteval.storage.repository import TargetRepository
|
||||||
|
from agenteval.web.app import app
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def seeded_db(db_session, monkeypatch):
|
||||||
|
"""Patch get_session/get_db to the test session and seed a target."""
|
||||||
|
from agenteval.storage import db as db_module
|
||||||
|
from agenteval.storage import repository as repo_module
|
||||||
|
from agenteval.web import app as app_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(app_module, "init_db", lambda: None)
|
||||||
|
|
||||||
|
def _test_get_session():
|
||||||
|
return db_session
|
||||||
|
|
||||||
|
monkeypatch.setattr(db_module, "get_session", _test_get_session)
|
||||||
|
monkeypatch.setattr(repo_module, "get_session", _test_get_session)
|
||||||
|
|
||||||
|
from agenteval.web.deps import get_db
|
||||||
|
|
||||||
|
def _test_get_db():
|
||||||
|
try:
|
||||||
|
yield db_session
|
||||||
|
finally:
|
||||||
|
pass
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = _test_get_db
|
||||||
|
|
||||||
|
target = EvalTarget(
|
||||||
|
id="t-1",
|
||||||
|
name="mock-target",
|
||||||
|
platform=PlatformType.AI_DIGITAL_EMPLOYEE,
|
||||||
|
channel_type=ChannelType.TUTU_API,
|
||||||
|
channel_config={"base_url": "http://mock", "token": "x"},
|
||||||
|
status=TargetStatus.ACTIVE,
|
||||||
|
)
|
||||||
|
TargetRepository(db_session).create(target)
|
||||||
|
|
||||||
|
yield db_session
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
async def client():
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_eval(client, **overrides) -> dict:
|
||||||
|
payload = {
|
||||||
|
"name": "客服助手智能评估",
|
||||||
|
"target_id": "t-1",
|
||||||
|
"goal": "评估退货流程处理能力",
|
||||||
|
"seeds": {"personas": ["急躁老客户"], "goals": ["完成退货"]},
|
||||||
|
"intent": "考察退货全流程",
|
||||||
|
"role_description": "模拟真实用户",
|
||||||
|
"time_window_hours": 24,
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
resp = await client.post("/api/intelligent-evals", json=payload)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def _submit_plan(client, eval_id: str, plan: dict | None = None) -> dict:
|
||||||
|
if plan is None:
|
||||||
|
plan = {
|
||||||
|
"dimensions": ["退货流程", "投诉处理"],
|
||||||
|
"virtual_users": [{"persona": {"background": "老客户"}, "goal": "完成退货"}],
|
||||||
|
"time_distribution": [{"time_slot": "0-2h", "sessions": 1, "scenario": "早间咨询"}],
|
||||||
|
"estimated_sessions": 3,
|
||||||
|
"budget": {"max_turns_per_session": 12, "total_max_turns": 36},
|
||||||
|
"completion_criteria": "每个维度至少一个会话",
|
||||||
|
}
|
||||||
|
resp = await client.put(f"/api/intelligent-evals/{eval_id}/plan", json={"plan": plan})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateEval:
|
||||||
|
async def test_create_transitions_to_planning(self, client, seeded_db):
|
||||||
|
data = await _create_eval(client)
|
||||||
|
assert data["status"] == "planning"
|
||||||
|
assert data["name"] == "客服助手智能评估"
|
||||||
|
assert data["goal"] == "评估退货流程处理能力"
|
||||||
|
assert data["time_window_hours"] == 24
|
||||||
|
assert "id" in data
|
||||||
|
|
||||||
|
async def test_create_requires_name(self, client, seeded_db):
|
||||||
|
resp = await client.post("/api/intelligent-evals", json={
|
||||||
|
"name": "", "target_id": "t-1", "goal": "test",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
async def test_create_requires_goal(self, client, seeded_db):
|
||||||
|
resp = await client.post("/api/intelligent-evals", json={
|
||||||
|
"name": "test", "target_id": "t-1", "goal": "",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlanApproval:
|
||||||
|
async def test_submit_plan_transitions_to_pending_approval(self, client, seeded_db):
|
||||||
|
ev = await _create_eval(client)
|
||||||
|
data = await _submit_plan(client, ev["id"])
|
||||||
|
assert data["status"] == "pending_approval"
|
||||||
|
assert data["plan"]["dimensions"] == ["退货流程", "投诉处理"]
|
||||||
|
|
||||||
|
async def test_approve_transitions_to_executing(self, client, seeded_db):
|
||||||
|
ev = await _create_eval(client)
|
||||||
|
await _submit_plan(client, ev["id"])
|
||||||
|
resp = await client.post(f"/api/intelligent-evals/{ev['id']}/approve")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["status"] == "executing"
|
||||||
|
assert data["started_at"] is not None
|
||||||
|
|
||||||
|
async def test_reject_transitions_back_to_planning(self, client, seeded_db):
|
||||||
|
ev = await _create_eval(client)
|
||||||
|
await _submit_plan(client, ev["id"])
|
||||||
|
resp = await client.post(f"/api/intelligent-evals/{ev['id']}/reject", json={"feedback": "缺少投诉维度"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["status"] == "planning"
|
||||||
|
assert data["plan_feedback"] == "缺少投诉维度"
|
||||||
|
|
||||||
|
async def test_reject_then_resubmit(self, client, seeded_db):
|
||||||
|
ev = await _create_eval(client)
|
||||||
|
await _submit_plan(client, ev["id"], plan={"dimensions": ["A"]})
|
||||||
|
await client.post(f"/api/intelligent-evals/{ev['id']}/reject", json={"feedback": "加B"})
|
||||||
|
data = await _submit_plan(client, ev["id"], plan={"dimensions": ["A", "B"]})
|
||||||
|
assert data["status"] == "pending_approval"
|
||||||
|
assert data["plan"]["dimensions"] == ["A", "B"]
|
||||||
|
assert data["plan_feedback"] is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestCancel:
|
||||||
|
async def _create_executing(self, client) -> str:
|
||||||
|
ev = await _create_eval(client)
|
||||||
|
await _submit_plan(client, ev["id"])
|
||||||
|
await client.post(f"/api/intelligent-evals/{ev['id']}/approve")
|
||||||
|
return ev["id"]
|
||||||
|
|
||||||
|
async def test_cancel_executing(self, client, seeded_db):
|
||||||
|
eval_id = await self._create_executing(client)
|
||||||
|
resp = await client.post(f"/api/intelligent-evals/{eval_id}/cancel")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["status"] == "cancelled"
|
||||||
|
assert data["completed_at"] is not None
|
||||||
|
|
||||||
|
async def test_cancel_pending_approval(self, client, seeded_db):
|
||||||
|
ev = await _create_eval(client)
|
||||||
|
await _submit_plan(client, ev["id"])
|
||||||
|
resp = await client.post(f"/api/intelligent-evals/{ev['id']}/cancel")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["status"] == "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class TestIllegalTransitions:
|
||||||
|
async def test_approve_from_planning_returns_409(self, client, seeded_db):
|
||||||
|
ev = await _create_eval(client)
|
||||||
|
resp = await client.post(f"/api/intelligent-evals/{ev['id']}/approve")
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
async def test_double_cancel_returns_409(self, client, seeded_db):
|
||||||
|
ev = await _create_eval(client)
|
||||||
|
await _submit_plan(client, ev["id"])
|
||||||
|
await client.post(f"/api/intelligent-evals/{ev['id']}/approve")
|
||||||
|
await client.post(f"/api/intelligent-evals/{ev['id']}/cancel")
|
||||||
|
resp = await client.post(f"/api/intelligent-evals/{ev['id']}/cancel")
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
async def test_submit_plan_from_executing_returns_409(self, client, seeded_db):
|
||||||
|
ev = await _create_eval(client)
|
||||||
|
await _submit_plan(client, ev["id"])
|
||||||
|
await client.post(f"/api/intelligent-evals/{ev['id']}/approve")
|
||||||
|
resp = await client.put(f"/api/intelligent-evals/{ev['id']}/plan", json={"plan": {"new": True}})
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
async def test_not_found_returns_404(self, client, seeded_db):
|
||||||
|
resp = await client.get("/api/intelligent-evals/nonexistent")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestListAndGet:
|
||||||
|
async def test_list_empty(self, client, seeded_db):
|
||||||
|
resp = await client.get("/api/intelligent-evals")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["intelligent_evals"] == []
|
||||||
|
|
||||||
|
async def test_list_returns_created(self, client, seeded_db):
|
||||||
|
await _create_eval(client, name="e1")
|
||||||
|
await _create_eval(client, name="e2")
|
||||||
|
resp = await client.get("/api/intelligent-evals")
|
||||||
|
assert len(resp.json()["intelligent_evals"]) == 2
|
||||||
|
|
||||||
|
async def test_get_by_id(self, client, seeded_db):
|
||||||
|
ev = await _create_eval(client, name="e1")
|
||||||
|
resp = await client.get(f"/api/intelligent-evals/{ev['id']}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["name"] == "e1"
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_executing_eval(client) -> str:
|
||||||
|
ev = await _create_eval(client)
|
||||||
|
await _submit_plan(client, ev["id"])
|
||||||
|
await client.post(f"/api/intelligent-evals/{ev['id']}/approve")
|
||||||
|
return ev["id"]
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_session(client, eval_id: str, **overrides) -> dict:
|
||||||
|
payload = {
|
||||||
|
"persona": {"background": "急躁老客户", "style": "直接"},
|
||||||
|
"goal": "完成退货",
|
||||||
|
"dimension": "退货流程",
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
resp = await client.post(f"/api/intelligent-evals/{eval_id}/sessions", json=payload)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateSession:
|
||||||
|
async def test_create_session_in_executing(self, client, seeded_db):
|
||||||
|
eval_id = await _create_executing_eval(client)
|
||||||
|
data = await _create_session(client, eval_id)
|
||||||
|
assert data["eval_id"] == eval_id
|
||||||
|
assert data["target_id"] == "t-1"
|
||||||
|
assert data["status"] == "running"
|
||||||
|
assert data["persona"]["background"] == "急躁老客户"
|
||||||
|
assert data["dimension"] == "退货流程"
|
||||||
|
assert data["turn_count"] == 0
|
||||||
|
|
||||||
|
async def test_create_session_in_planning_returns_409(self, client, seeded_db):
|
||||||
|
ev = await _create_eval(client)
|
||||||
|
resp = await client.post(f"/api/intelligent-evals/{ev['id']}/sessions", json={"goal": "g"})
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
async def test_create_session_requires_goal(self, client, seeded_db):
|
||||||
|
eval_id = await _create_executing_eval(client)
|
||||||
|
resp = await client.post(f"/api/intelligent-evals/{eval_id}/sessions", json={"goal": ""})
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
async def test_create_session_unknown_eval_returns_404(self, client, seeded_db):
|
||||||
|
resp = await client.post("/api/intelligent-evals/nope/sessions", json={"goal": "g"})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
async def test_eval_response_counts_sessions(self, client, seeded_db):
|
||||||
|
eval_id = await _create_executing_eval(client)
|
||||||
|
await _create_session(client, eval_id)
|
||||||
|
resp = await client.get(f"/api/intelligent-evals/{eval_id}")
|
||||||
|
data = resp.json()
|
||||||
|
assert data["session_count"] == 1
|
||||||
|
assert data["completed_sessions"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestConductTurn:
|
||||||
|
@pytest.fixture()
|
||||||
|
def mock_channel(self, monkeypatch):
|
||||||
|
from agenteval.intelligent_eval import lifecycle as lifecycle_module
|
||||||
|
|
||||||
|
from tests.unit.mock_channel import MockChannel
|
||||||
|
|
||||||
|
channel = MockChannel(reply_text="好的,已为您发起退货申请")
|
||||||
|
|
||||||
|
class _StubFactory:
|
||||||
|
@staticmethod
|
||||||
|
def create(target):
|
||||||
|
return channel
|
||||||
|
|
||||||
|
monkeypatch.setattr(lifecycle_module, "ChannelFactory", _StubFactory)
|
||||||
|
return channel
|
||||||
|
|
||||||
|
async def test_turn_round_trip_persists_messages(self, client, seeded_db, mock_channel):
|
||||||
|
eval_id = await _create_executing_eval(client)
|
||||||
|
session = await _create_session(client, eval_id)
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/intelligent-evals/{eval_id}/sessions/{session['id']}/messages",
|
||||||
|
json={"content": "我要退货"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
data = resp.json()
|
||||||
|
assert data["reply"] == "好的,已为您发起退货申请"
|
||||||
|
assert data["turn_count"] == 1
|
||||||
|
|
||||||
|
msgs = await client.get(f"/api/intelligent-evals/{eval_id}/sessions/{session['id']}/messages")
|
||||||
|
messages = msgs.json()["messages"]
|
||||||
|
assert len(messages) == 2
|
||||||
|
assert messages[0]["role"] == "user"
|
||||||
|
assert messages[0]["content"] == "我要退货"
|
||||||
|
assert messages[1]["role"] == "assistant"
|
||||||
|
assert messages[1]["content"] == "好的,已为您发起退货申请"
|
||||||
|
assert messages[1]["latency_ms"] is not None
|
||||||
|
|
||||||
|
detail = await client.get(f"/api/intelligent-evals/{eval_id}/sessions")
|
||||||
|
assert detail.json()["sessions"][0]["turn_count"] == 1
|
||||||
|
|
||||||
|
async def test_turn_increments_across_rounds(self, client, seeded_db, mock_channel):
|
||||||
|
eval_id = await _create_executing_eval(client)
|
||||||
|
session = await _create_session(client, eval_id)
|
||||||
|
for expected in (1, 2, 3):
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/intelligent-evals/{eval_id}/sessions/{session['id']}/messages",
|
||||||
|
json={"content": f"第{expected}轮"},
|
||||||
|
)
|
||||||
|
assert resp.json()["turn_count"] == expected
|
||||||
|
|
||||||
|
async def test_turn_unknown_session_returns_404(self, client, seeded_db, mock_channel):
|
||||||
|
resp = await client.post("/api/intelligent-evals/nope/sessions/nope/messages", json={"content": "hi"})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
async def test_channel_send_failure_returns_502(self, client, seeded_db, monkeypatch):
|
||||||
|
from agenteval.intelligent_eval import lifecycle as lifecycle_module
|
||||||
|
|
||||||
|
from tests.unit.mock_channel import MockChannel
|
||||||
|
|
||||||
|
channel = MockChannel(send_ok=False)
|
||||||
|
|
||||||
|
class _StubFactory:
|
||||||
|
@staticmethod
|
||||||
|
def create(target):
|
||||||
|
return channel
|
||||||
|
|
||||||
|
monkeypatch.setattr(lifecycle_module, "ChannelFactory", _StubFactory)
|
||||||
|
eval_id = await _create_executing_eval(client)
|
||||||
|
session = await _create_session(client, eval_id)
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/intelligent-evals/{eval_id}/sessions/{session['id']}/messages",
|
||||||
|
json={"content": "我要退货"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 502
|
||||||
|
|
||||||
|
|
||||||
|
class TestCloseSession:
|
||||||
|
async def test_close_records_verdict(self, client, seeded_db):
|
||||||
|
eval_id = await _create_executing_eval(client)
|
||||||
|
session = await _create_session(client, eval_id)
|
||||||
|
verdict = {"passed": True, "score": 0.85, "notes": "退货流程顺畅"}
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/intelligent-evals/{eval_id}/sessions/{session['id']}/close",
|
||||||
|
json={"verdict": verdict},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
data = resp.json()
|
||||||
|
assert data["status"] == "completed"
|
||||||
|
assert data["verdict"] == verdict
|
||||||
|
assert data["closed_at"] is not None
|
||||||
|
|
||||||
|
eval_resp = await client.get(f"/api/intelligent-evals/{eval_id}")
|
||||||
|
assert eval_resp.json()["completed_sessions"] == 1
|
||||||
|
|
||||||
|
async def test_double_close_returns_409(self, client, seeded_db):
|
||||||
|
eval_id = await _create_executing_eval(client)
|
||||||
|
session = await _create_session(client, eval_id)
|
||||||
|
await client.post(
|
||||||
|
f"/api/intelligent-evals/{eval_id}/sessions/{session['id']}/close",
|
||||||
|
json={"verdict": {"passed": True}},
|
||||||
|
)
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/intelligent-evals/{eval_id}/sessions/{session['id']}/close",
|
||||||
|
json={"verdict": {"passed": True}},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
async def test_message_after_close_returns_409(self, client, seeded_db, monkeypatch):
|
||||||
|
from agenteval.intelligent_eval import lifecycle as lifecycle_module
|
||||||
|
|
||||||
|
from tests.unit.mock_channel import MockChannel
|
||||||
|
|
||||||
|
class _StubFactory:
|
||||||
|
@staticmethod
|
||||||
|
def create(target):
|
||||||
|
return MockChannel()
|
||||||
|
|
||||||
|
monkeypatch.setattr(lifecycle_module, "ChannelFactory", _StubFactory)
|
||||||
|
eval_id = await _create_executing_eval(client)
|
||||||
|
session = await _create_session(client, eval_id)
|
||||||
|
await client.post(
|
||||||
|
f"/api/intelligent-evals/{eval_id}/sessions/{session['id']}/close",
|
||||||
|
json={"verdict": {"passed": True}},
|
||||||
|
)
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/intelligent-evals/{eval_id}/sessions/{session['id']}/messages",
|
||||||
|
json={"content": "还在吗"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
async def test_close_unknown_session_returns_404(self, client, seeded_db):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/intelligent-evals/nope/sessions/nope/close",
|
||||||
|
json={"verdict": {"passed": True}},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestListSessions:
|
||||||
|
async def test_list_sessions_unknown_eval_returns_404(self, client, seeded_db):
|
||||||
|
resp = await client.get("/api/intelligent-evals/nope/sessions")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
async def test_messages_unknown_session_returns_404(self, client, seeded_db):
|
||||||
|
resp = await client.get("/api/intelligent-evals/nope/sessions/nope/messages")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
async def test_session_accessed_via_wrong_eval_returns_404(self, client, seeded_db):
|
||||||
|
eval_id = await _create_executing_eval(client)
|
||||||
|
session = await _create_session(client, eval_id)
|
||||||
|
other = await _create_eval(client, name="other")
|
||||||
|
resp = await client.get(f"/api/intelligent-evals/{other['id']}/sessions/{session['id']}/messages")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def _report_payload() -> dict:
|
||||||
|
return {
|
||||||
|
"summary": "整体表现良好。",
|
||||||
|
"scores": {"退货流程": 0.7},
|
||||||
|
"findings": [
|
||||||
|
{
|
||||||
|
"issue": "未主动确认订单号",
|
||||||
|
"severity": "high",
|
||||||
|
"dimension": "退货流程",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"session_id": "s-1",
|
||||||
|
"turn_index": 3,
|
||||||
|
"user_said": "我要退货",
|
||||||
|
"assistant_replied": "好的",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"suggestion": "增加确认步骤",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"highlights": [{"description": "上下文连贯", "dimension": "多轮追问"}],
|
||||||
|
"priority_recommendations": ["先修退货确认"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestReport:
|
||||||
|
async def test_submit_report_completes_eval(self, client, seeded_db):
|
||||||
|
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
|
||||||
|
data = resp.json()
|
||||||
|
assert data["status"] == "completed"
|
||||||
|
assert data["completed_at"] is not None
|
||||||
|
assert data["report"]["summary"] == "整体表现良好。"
|
||||||
|
|
||||||
|
async def test_get_report_round_trip(self, client, seeded_db):
|
||||||
|
eval_id = await _create_executing_eval(client)
|
||||||
|
await client.put(f"/api/intelligent-evals/{eval_id}/report", json={"report": _report_payload()})
|
||||||
|
resp = await client.get(f"/api/intelligent-evals/{eval_id}/report")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
report = resp.json()
|
||||||
|
assert report["summary"] == "整体表现良好。"
|
||||||
|
assert report["findings"][0]["issue"] == "未主动确认订单号"
|
||||||
|
|
||||||
|
async def test_markdown_export(self, client, seeded_db):
|
||||||
|
eval_id = await _create_executing_eval(client)
|
||||||
|
await client.put(f"/api/intelligent-evals/{eval_id}/report", json={"report": _report_payload()})
|
||||||
|
resp = await client.get(f"/api/intelligent-evals/{eval_id}/report/markdown")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers["content-type"].startswith("text/markdown")
|
||||||
|
assert "# 客服助手智能评估" in resp.text
|
||||||
|
assert "未主动确认订单号" in resp.text
|
||||||
|
|
||||||
|
async def test_submit_report_requires_executing(self, client, seeded_db):
|
||||||
|
ev = await _create_eval(client) # planning state
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/intelligent-evals/{ev['id']}/report", json={"report": _report_payload()}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
async def test_submit_report_rejects_invalid_structure(self, client, seeded_db):
|
||||||
|
eval_id = await _create_executing_eval(client)
|
||||||
|
bad = {"summary": "缺 findings"}
|
||||||
|
resp = await client.put(f"/api/intelligent-evals/{eval_id}/report", json={"report": bad})
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
async def test_submit_report_rejects_finding_missing_fields(self, client, seeded_db):
|
||||||
|
eval_id = await _create_executing_eval(client)
|
||||||
|
payload = _report_payload()
|
||||||
|
payload["findings"] = [{"issue": "只有 issue"}]
|
||||||
|
resp = await client.put(f"/api/intelligent-evals/{eval_id}/report", json={"report": payload})
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
async def test_get_report_before_submission_returns_404(self, client, seeded_db):
|
||||||
|
eval_id = await _create_executing_eval(client)
|
||||||
|
resp = await client.get(f"/api/intelligent-evals/{eval_id}/report")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
async def test_markdown_before_submission_returns_404(self, client, seeded_db):
|
||||||
|
eval_id = await _create_executing_eval(client)
|
||||||
|
resp = await client.get(f"/api/intelligent-evals/{eval_id}/report/markdown")
|
||||||
|
assert resp.status_code == 404
|
||||||
160
tests/unit/test_intelligent_eval_model.py
Normal file
160
tests/unit/test_intelligent_eval_model.py
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
"""Smoke tests for intelligent eval data model (ticket 01)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from agenteval.intelligent_eval.models import (
|
||||||
|
IntelligentEval,
|
||||||
|
IntelligentEvalMessage,
|
||||||
|
IntelligentEvalSession,
|
||||||
|
IntelligentEvalSessionStatus,
|
||||||
|
IntelligentEvalStatus,
|
||||||
|
)
|
||||||
|
from agenteval.intelligent_eval.repository import (
|
||||||
|
IntelligentEvalMessageRepository,
|
||||||
|
IntelligentEvalRepository,
|
||||||
|
IntelligentEvalSessionRepository,
|
||||||
|
)
|
||||||
|
from agenteval.storage.db import EvalTargetDB
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
from sqlmodel import Session, SQLModel, create_engine
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_session():
|
||||||
|
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
session = Session(engine)
|
||||||
|
target = EvalTargetDB(id="t1", name="test-target")
|
||||||
|
session.add(target)
|
||||||
|
session.commit()
|
||||||
|
yield session
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestIntelligentEvalRepository:
|
||||||
|
def test_create_and_get(self, db_session):
|
||||||
|
repo = IntelligentEvalRepository(db_session)
|
||||||
|
ev = repo.create(IntelligentEval(
|
||||||
|
name="test-eval",
|
||||||
|
target_id="t1",
|
||||||
|
goal="evaluate customer service",
|
||||||
|
seeds={"personas": [], "goals": []},
|
||||||
|
intent="test intent",
|
||||||
|
role_description="impatient customer",
|
||||||
|
time_window_hours=24,
|
||||||
|
))
|
||||||
|
assert ev.id is not None
|
||||||
|
assert ev.status == IntelligentEvalStatus.DRAFT
|
||||||
|
assert ev.name == "test-eval"
|
||||||
|
assert ev.time_window_hours == 24
|
||||||
|
|
||||||
|
fetched = repo.get(ev.id)
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.goal == "evaluate customer service"
|
||||||
|
assert fetched.seeds == {"personas": [], "goals": []}
|
||||||
|
|
||||||
|
def test_list_all(self, db_session):
|
||||||
|
repo = IntelligentEvalRepository(db_session)
|
||||||
|
repo.create(IntelligentEval(name="eval-1", target_id="t1"))
|
||||||
|
repo.create(IntelligentEval(name="eval-2", target_id="t1"))
|
||||||
|
all_evals = repo.list_all()
|
||||||
|
assert len(all_evals) == 2
|
||||||
|
|
||||||
|
def test_status_transitions(self, db_session):
|
||||||
|
repo = IntelligentEvalRepository(db_session)
|
||||||
|
ev = repo.create(IntelligentEval(name="eval", target_id="t1"))
|
||||||
|
assert ev.status == IntelligentEvalStatus.DRAFT
|
||||||
|
|
||||||
|
ev = repo.transition_status(ev.id, IntelligentEvalStatus.PLANNING)
|
||||||
|
assert ev.status == IntelligentEvalStatus.PLANNING
|
||||||
|
|
||||||
|
ev = repo.transition_status(ev.id, IntelligentEvalStatus.PENDING_APPROVAL)
|
||||||
|
assert ev.status == IntelligentEvalStatus.PENDING_APPROVAL
|
||||||
|
|
||||||
|
ev = repo.transition_status(ev.id, IntelligentEvalStatus.EXECUTING)
|
||||||
|
assert ev.status == IntelligentEvalStatus.EXECUTING
|
||||||
|
assert ev.started_at is not None
|
||||||
|
|
||||||
|
ev = repo.transition_status(ev.id, IntelligentEvalStatus.COMPLETED)
|
||||||
|
assert ev.status == IntelligentEvalStatus.COMPLETED
|
||||||
|
assert ev.completed_at is not None
|
||||||
|
|
||||||
|
def test_plan_and_report_json(self, db_session):
|
||||||
|
repo = IntelligentEvalRepository(db_session)
|
||||||
|
ev = repo.create(IntelligentEval(name="eval", target_id="t1"))
|
||||||
|
|
||||||
|
plan = {"dimensions": ["退货"], "virtual_users": [], "estimated_sessions": 3}
|
||||||
|
ev.plan = plan
|
||||||
|
ev.status = IntelligentEvalStatus.PENDING_APPROVAL
|
||||||
|
ev = repo.update(ev)
|
||||||
|
assert ev.plan == plan
|
||||||
|
|
||||||
|
report = {"summary": "good", "findings": []}
|
||||||
|
ev.report = report
|
||||||
|
ev = repo.update(ev)
|
||||||
|
assert ev.report == report
|
||||||
|
|
||||||
|
|
||||||
|
class TestIntelligentEvalSessionRepository:
|
||||||
|
def test_create_and_list(self, db_session):
|
||||||
|
eval_repo = IntelligentEvalRepository(db_session)
|
||||||
|
ev = eval_repo.create(IntelligentEval(name="eval", target_id="t1"))
|
||||||
|
|
||||||
|
sess_repo = IntelligentEvalSessionRepository(db_session)
|
||||||
|
sess = sess_repo.create(IntelligentEvalSession(
|
||||||
|
eval_id=ev.id,
|
||||||
|
target_id="t1",
|
||||||
|
persona={"name": "user1", "patience": "low"},
|
||||||
|
goal="complete return",
|
||||||
|
dimension="退货流程",
|
||||||
|
))
|
||||||
|
assert sess.id is not None
|
||||||
|
assert sess.status == IntelligentEvalSessionStatus.RUNNING
|
||||||
|
assert sess.persona == {"name": "user1", "patience": "low"}
|
||||||
|
|
||||||
|
sessions = sess_repo.list_by_eval(ev.id)
|
||||||
|
assert len(sessions) == 1
|
||||||
|
|
||||||
|
def test_close_session(self, db_session):
|
||||||
|
eval_repo = IntelligentEvalRepository(db_session)
|
||||||
|
ev = eval_repo.create(IntelligentEval(name="eval", target_id="t1"))
|
||||||
|
|
||||||
|
sess_repo = IntelligentEvalSessionRepository(db_session)
|
||||||
|
sess = sess_repo.create(IntelligentEvalSession(eval_id=ev.id, target_id="t1"))
|
||||||
|
|
||||||
|
verdict = {"goal_achieved": True, "issues": []}
|
||||||
|
closed = sess_repo.close(sess.id, verdict)
|
||||||
|
assert closed.status == IntelligentEvalSessionStatus.COMPLETED
|
||||||
|
assert closed.verdict == verdict
|
||||||
|
assert closed.closed_at is not None
|
||||||
|
|
||||||
|
def test_increment_turns(self, db_session):
|
||||||
|
eval_repo = IntelligentEvalRepository(db_session)
|
||||||
|
ev = eval_repo.create(IntelligentEval(name="eval", target_id="t1"))
|
||||||
|
|
||||||
|
sess_repo = IntelligentEvalSessionRepository(db_session)
|
||||||
|
sess = sess_repo.create(IntelligentEvalSession(eval_id=ev.id, target_id="t1"))
|
||||||
|
assert sess.turn_count == 0
|
||||||
|
|
||||||
|
sess_repo.increment_turns(sess.id)
|
||||||
|
sess_repo.increment_turns(sess.id)
|
||||||
|
updated = sess_repo.get(sess.id)
|
||||||
|
assert updated.turn_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestIntelligentEvalMessageRepository:
|
||||||
|
def test_create_and_list(self, db_session):
|
||||||
|
eval_repo = IntelligentEvalRepository(db_session)
|
||||||
|
ev = eval_repo.create(IntelligentEval(name="eval", target_id="t1"))
|
||||||
|
|
||||||
|
sess_repo = IntelligentEvalSessionRepository(db_session)
|
||||||
|
sess = sess_repo.create(IntelligentEvalSession(eval_id=ev.id, target_id="t1"))
|
||||||
|
|
||||||
|
msg_repo = IntelligentEvalMessageRepository(db_session)
|
||||||
|
msg_repo.create(IntelligentEvalMessage(session_id=sess.id, role="user", content="hello"))
|
||||||
|
msg_repo.create(IntelligentEvalMessage(session_id=sess.id, role="assistant", content="hi", latency_ms=120))
|
||||||
|
|
||||||
|
messages = msg_repo.list_by_session(sess.id)
|
||||||
|
assert len(messages) == 2
|
||||||
|
assert messages[0].role == "user"
|
||||||
|
assert messages[1].role == "assistant"
|
||||||
|
assert messages[1].latency_ms == 120
|
||||||
92
tests/unit/test_intelligent_eval_report.py
Normal file
92
tests/unit/test_intelligent_eval_report.py
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
"""Unit tests for the pure Markdown report renderer (票据 04)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from agenteval.intelligent_eval.report import render_report_markdown
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
def _report() -> dict:
|
||||||
|
return {
|
||||||
|
"summary": "整体表现良好,退货流程存在确认缺失。",
|
||||||
|
"scores": {"退货流程": 0.7, "投诉处理": 0.85},
|
||||||
|
"findings": [
|
||||||
|
{
|
||||||
|
"issue": "未主动确认订单号",
|
||||||
|
"severity": "high",
|
||||||
|
"dimension": "退货流程",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"session_id": "s-1",
|
||||||
|
"turn_index": 3,
|
||||||
|
"user_said": "我要退货",
|
||||||
|
"assistant_replied": "好的,已为您发起",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"suggestion": "增加订单号确认步骤",
|
||||||
|
"related_sop": "退货处理流程 §3.2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"issue": "投诉共情不足",
|
||||||
|
"severity": "low",
|
||||||
|
"dimension": "投诉处理",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"highlights": [{"description": "多轮追问保持上下文连贯", "dimension": "多轮追问"}],
|
||||||
|
"priority_recommendations": ["先修退货确认", "再优化投诉共情"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_renders_title_and_summary():
|
||||||
|
md = render_report_markdown(_report(), name="客服评估", eval_id="e-1")
|
||||||
|
assert "# 客服评估" in md
|
||||||
|
assert "整体表现良好" in md
|
||||||
|
assert "`e-1`" in md
|
||||||
|
|
||||||
|
|
||||||
|
def test_renders_scores_table():
|
||||||
|
md = render_report_markdown(_report())
|
||||||
|
assert "| 维度 | 分数 |" in md
|
||||||
|
assert "| 退货流程 | 0.7 |" in md
|
||||||
|
|
||||||
|
|
||||||
|
def test_sorts_findings_by_severity():
|
||||||
|
md = render_report_markdown(_report())
|
||||||
|
high_index = md.index("未主动确认订单号")
|
||||||
|
low_index = md.index("投诉共情不足")
|
||||||
|
assert high_index < low_index
|
||||||
|
|
||||||
|
|
||||||
|
def test_renders_evidence_block():
|
||||||
|
md = render_report_markdown(_report())
|
||||||
|
assert "会话 `s-1`" in md
|
||||||
|
assert "第 3 轮" in md
|
||||||
|
assert "**用户**:我要退货" in md
|
||||||
|
assert "**对象**:好的,已为您发起" in md
|
||||||
|
|
||||||
|
|
||||||
|
def test_renders_highlights_and_recommendations():
|
||||||
|
md = render_report_markdown(_report())
|
||||||
|
assert "多轮追问保持上下文连贯(多轮追问)" in md
|
||||||
|
assert "- 先修退货确认" in md
|
||||||
|
|
||||||
|
|
||||||
|
def test_minimal_report():
|
||||||
|
md = render_report_markdown({"summary": "只有概述", "findings": [{"issue": "i", "severity": "s", "dimension": "d"}]})
|
||||||
|
assert "只有概述" in md
|
||||||
|
assert "## 维度评分" not in md
|
||||||
|
assert "## 亮点" not in md
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_missing_summary():
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
render_report_markdown({"findings": [{"issue": "i", "severity": "s", "dimension": "d"}]})
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_empty_findings():
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
render_report_markdown({"summary": "x", "findings": []})
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_finding_missing_required_fields():
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
render_report_markdown({"summary": "x", "findings": [{"issue": "i"}]})
|
||||||
Loading…
Reference in New Issue
Block a user