AgentEvalTool/backend/agenteval/models.py
sinohqb aa40c8e0d8
Some checks failed
CI / test (push) Failing after 12s
refactor(campaign): type Campaign.summary as CampaignSummary VO
Campaign.summary was a bare Optional[dict] while RunSummary is a typed VO —
scheduler state (spawned_indices/errors) flowed untyped through
set_/get_summary. Introduce CampaignSummary + SchedulerState (extra=allow,
validate_assignment), mirroring RunSummary; campaign_runner reads/writes the
VO. Also converge the ~10 repeated JSON column get/set pairs onto
_json_dumps/_json_loads helpers, unifying ensure_ascii=False and fixing the
set_modalities ensure_ascii=True trap.
2026-07-31 15:09:10 +08:00

319 lines
9.2 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"
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
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 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
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 = ""