AgentEvalTool/backend/agenteval/models.py

348 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""Shared Pydantic models for AgentEvalTool."""
from datetime import datetime
from enum import Enum
from typing import Any, Optional
from pydantic import BaseModel, Field, field_validator
class PlatformType(str, Enum):
AI_DIGITAL_EMPLOYEE = "ai_digital_employee"
AI_ASSISTANT = "ai_assistant"
class ChannelType(str, Enum):
TUTU_API = "tutu-api"
OPENCLAW = "openclaw"
HTTP = "http"
class TargetStatus(str, Enum):
ACTIVE = "active"
INACTIVE = "inactive"
ERROR = "error"
class CaseType(str, Enum):
SINGLE = "single"
MULTI_TURN = "multi_turn"
DYNAMIC = "dynamic"
class ModelCapability(str, Enum):
CHAT = "chat"
EMBEDDING = "embedding"
MODERATION = "moderation"
class ModelProtocol(str, Enum):
OPENAI_COMPATIBLE = "openai_compatible"
ANTHROPIC = "anthropic"
GOOGLE_GEMINI = "google_gemini"
DASHSCOPE = "dashscope"
class ModelModality(str, Enum):
TEXT = "text"
IMAGE = "image"
AUDIO = "audio"
VIDEO = "video"
class ModelPurpose(str, Enum):
GENERATOR = "generator"
JUDGE = "judge"
EMBEDDING = "embedding"
MODERATION = "moderation"
ANALYSIS = "analysis"
class EvalTarget(BaseModel):
"""Evaluation target (the agent being evaluated)."""
id: Optional[str] = None
name: str
description: str = ""
platform: PlatformType = PlatformType.AI_DIGITAL_EMPLOYEE
channel_type: ChannelType = ChannelType.TUTU_API
channel_config: dict[str, Any] = Field(default_factory=dict)
status: TargetStatus = TargetStatus.ACTIVE
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Expectation(BaseModel):
"""Expected behavior for a test case."""
intent: Optional[str] = None
keywords_include: list[str] = Field(default_factory=list)
keywords_exclude: list[str] = Field(default_factory=list)
response_time_max_ms: Optional[int] = None
coherence_min_score: Optional[float] = None
class EvalRuleConfig(BaseModel):
"""Configuration for an evaluation rule."""
type: str
params: dict[str, Any] = Field(default_factory=dict)
weight: float = 1.0 # used when rule_logic == "weighted"
class RuleLogic(str, Enum):
"""How to combine multiple rule results for a case."""
ALL = "all" # all rules must pass (default)
ANY = "any" # at least one rule must pass
WEIGHTED = "weighted" # weighted average score >= threshold
class Case(BaseModel):
"""A single evaluation case within a scenario."""
id: str
type: CaseType = CaseType.SINGLE
messages: list[str] = Field(default_factory=list)
prompt: Optional[str] = None
turns: int = 3
expectations: Expectation = Field(default_factory=Expectation)
eval_rules: list[EvalRuleConfig] = Field(default_factory=list)
rule_logic: RuleLogic = RuleLogic.ALL
rule_pass_threshold: float = 0.6 # used when rule_logic == "weighted"
@field_validator("messages")
@classmethod
def messages_not_empty(cls, v: list[str], info) -> list[str]:
data = info.data
case_type = data.get("type") if data else None
if case_type and case_type != CaseType.DYNAMIC and not v:
raise ValueError("messages must not be empty for non-dynamic cases")
return v
class Scenario(BaseModel):
"""A collection of evaluation cases."""
id: Optional[str] = None
name: str
description: str = ""
tags: list[str] = Field(default_factory=list)
cases: list[Case] = Field(default_factory=list)
model_bindings: dict[ModelPurpose, str] = Field(default_factory=dict)
llm_config: Optional[dict[str, Any]] = None
# 考纲版本由系统维护ADR-0001API 传入值会被忽略
version: int = 1
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
@field_validator("cases")
@classmethod
def cases_not_empty(cls, v: list[Case]) -> list[Case]:
if not v:
raise ValueError("scenario must contain at least one case")
return v
class RunStatus(str, Enum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
class RunTrigger(str, Enum):
MANUAL = "manual"
AI_ASSISTANT = "ai_assistant"
CLI = "cli"
CAMPAIGN = "campaign"
class RunError(BaseModel):
"""Unified run-level error: user cancellation vs genuine execution fault."""
code: str = "error"
message: str = ""
class CaseOutcomeSummary(BaseModel):
"""Per-case authoritative verdict snapshot stored in the run summary."""
passed: bool = False
connectivity: bool = False
class RunSummary(BaseModel):
"""Typed value of ``EvalRun.summary`` — the single interface for its keys.
All fields are defaulted and unknown keys are preserved so summary dicts
written by older versions keep parsing (and survive read-modify-write).
"""
model_config = {"extra": "allow"}
total_cases: int = 0
passed_cases: int = 0
failed_cases: int = 0
total_rules: int = 0
passed_rules: int = 0
# 用例级通过率含执行失败ADR-0002失败/取消的 run 无此值
pass_rate: Optional[float] = None
# 判定型通过率:连通用例从分子分母双双剔除;无判定型用例时为空
judged_pass_rate: Optional[float] = None
avg_latency_ms: Optional[float] = None
case_outcomes: dict[str, CaseOutcomeSummary] = Field(default_factory=dict)
case_errors: list[dict[str, str]] = Field(default_factory=list)
model_configs: dict[str, Any] = Field(default_factory=dict)
error: Optional[RunError] = None
@field_validator("error", mode="before")
@classmethod
def _coerce_legacy_error(cls, v: Any) -> Any:
if isinstance(v, str):
return {"code": "error", "message": v}
return v
@property
def is_cancelled(self) -> bool:
"""User-initiated cancellation — excluded from aggregation (ADR-0004)."""
return self.error is not None and self.error.code == "cancelled_by_user"
class EvalRun(BaseModel):
"""A single evaluation run."""
# summary 以属性赋值写入engine/routers赋值时即校验成 RunSummary
model_config = {"validate_assignment": True}
id: Optional[str] = None
target_id: str
scenario_id: str
# 创建时快照的场景考纲版本ADR-0001
scenario_version: int = 1
# 归属的评估活动Campaign手动/单次运行为空
campaign_id: Optional[str] = None
# Campaign 计划条目的持久化身份;历史 Run 保持为空,不做回填。
campaign_plan_index: Optional[int] = Field(default=None, ge=0)
campaign_occurrence_index: Optional[int] = Field(default=None, ge=0)
status: RunStatus = RunStatus.PENDING
triggered_by: RunTrigger = RunTrigger.MANUAL
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
summary: Optional[RunSummary] = None
class CampaignStatus(str, Enum):
"""Lifecycle of an evaluation campaign (评估活动)."""
PLANNED = "planned"
RUNNING = "running"
COMPLETED = "completed"
CANCELLED = "cancelled"
FAILED = "failed"
class CampaignPlanEntry(BaseModel):
"""One static plan item: run a scenario N times at a window offset."""
scenario_id: str
offset_seconds: int = Field(ge=0)
count: int = Field(default=1, ge=1)
class SchedulerState(BaseModel):
"""Durable scheduler progress for a campaign — restart-safe (ADR-0003).
``spawned_indices`` are the plan entries already派生 into child Runs;
``errors`` records entries whose spawn failed (marked spawned to avoid
infinite retry).
"""
spawned_indices: list[int] = Field(default_factory=list)
errors: list[dict[str, Any]] = Field(default_factory=list)
class CampaignSummary(BaseModel):
"""Typed value of ``Campaign.summary`` — mirrors RunSummary's treatment.
Unknown top-level keys are preserved (extra=allow) so summaries written by
older versions keep parsing and survive read-modify-write.
"""
model_config = {"extra": "allow"}
scheduler: SchedulerState = Field(default_factory=SchedulerState)
class ExplorationSeeds(BaseModel):
"""Seed set (种子集) for exploratory evaluation: seed personas × seed goals.
The comparability unit for exploratory evaluation — campaigns sharing a
seed set are comparable across periods. Empty/absent means the campaign
opts out of exploration.
"""
personas: list[str] = Field(default_factory=list)
goals: list[str] = Field(default_factory=list)
class ExplorationBudgetConfig(BaseModel):
"""Per-campaign exploration budget override; unset fields fall back to
platform defaults at enforcement time."""
max_sessions: Optional[int] = Field(default=None, gt=0)
max_turns: Optional[int] = Field(default=None, gt=0)
min_interval_seconds: Optional[int] = Field(default=None, gt=0)
class Campaign(BaseModel):
"""An evaluation campaign: a service-cycle window over a single target,
driving many child Runs from a static plan (ADR-0003)."""
# summary 以属性赋值写入scheduler loop赋值时即校验成 CampaignSummary
model_config = {"validate_assignment": True}
id: Optional[str] = None
name: str
target_id: str
window_seconds: int = Field(gt=0)
time_scale: float = Field(default=1.0, gt=0)
plan: list[CampaignPlanEntry] = Field(min_length=1)
status: CampaignStatus = CampaignStatus.PLANNED
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
created_at: Optional[datetime] = None
summary: Optional[CampaignSummary] = None
analysis_model_config_id: Optional[str] = None
exploration_seeds: Optional[ExplorationSeeds] = None
exploration_budget: Optional[ExplorationBudgetConfig] = None
last_patrolled_at: Optional[datetime] = None
class Turn(BaseModel):
"""A single turn in a conversation during evaluation."""
id: Optional[str] = None
run_id: str
case_id: str
round_index: int
sent_message: dict[str, Any] = Field(default_factory=dict)
sent_at: Optional[datetime] = None
question_msg_id: Optional[str] = None
reply: Optional[dict[str, Any]] = None
received_at: Optional[datetime] = None
latency_ms: Optional[int] = None
class EvalResult(BaseModel):
"""Result of applying one evaluation rule to one turn."""
id: Optional[str] = None
run_id: str
case_id: str
turn_id: str
rule_type: str
passed: bool
score: Optional[float] = None
reason: str = ""