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:
sinohqb 2026-08-05 03:18:52 +08:00
parent f93d2f224f
commit 1317552701
17 changed files with 1962 additions and 0 deletions

View File

@ -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**:

View File

@ -0,0 +1 @@
"""Intelligent evaluation (智能评估) — OpenClaw-driven independent evaluation."""

View 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)

View 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

View 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"

View 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)

View File

@ -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)

View File

@ -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)

View File

@ -0,0 +1,244 @@
"""API routes for intelligent evaluation (智能评估).
领域逻辑状态机 intelligent_eval/lifecycle.py本层只做 HTTP 翻译
NotFound404TransitionError409
"""
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]}

View File

@ -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 ###

View File

@ -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,

View File

@ -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"))

View File

@ -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"))

View File

@ -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"))

View 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

View 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

View 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"}]})