Files
ai-video-fullstack/backend/test_schemas.py
2026-08-10 13:49:24 +08:00

387 lines
13 KiB
Python
Raw Permalink 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.
"""Contracts for persisted text test cases and batch execution results."""
from __future__ import annotations
import json
import re
from datetime import datetime
from typing import Annotated, Any, Literal, Union
from pydantic import Field, field_validator, model_validator
from schemas import CamelModel
TestCaseInputMode = Literal[
"fixed_script_text",
"fixed_script_turn_voice",
"fixed_script_continuous_voice",
"user_sim_text",
"user_sim_voice",
]
ContextRole = Literal["agent", "user", "tool_call", "tool_result"]
EvaluationKind = Literal["reply", "tool_call", "overall"]
EvaluationStatus = Literal["pass", "fail"]
CaseStatus = Literal["waiting", "running", "pass", "fail", "error", "skipped"]
RunStatus = Literal["queued", "running", "completed", "cancelled"]
ErrorStage = Literal["pipeline", "model", "tool", "evaluation"]
StopReason = Literal["manual", "assertion_failure", "execution_error"]
class ContextTurn(CamelModel):
role: ContextRole
content: str = Field(max_length=20_000)
tool_name: str | None = Field(default=None, max_length=128)
tool_call_id: str | None = Field(default=None, max_length=128)
is_error: bool | None = None
@model_validator(mode="after")
def validate_content(self):
self.content = self.content.strip()
self.tool_call_id = (self.tool_call_id or "").strip() or None
if not self.content:
raise ValueError("上下文内容不能为空")
if self.role in {"tool_call", "tool_result"}:
self.tool_name = (self.tool_name or "").strip()
if not self.tool_name:
raise ValueError("工具上下文必须填写工具名称")
try:
payload = json.loads(self.content)
except json.JSONDecodeError as exc:
raise ValueError("工具上下文必须是有效 JSON") from exc
if self.role == "tool_call" and not isinstance(payload, dict):
raise ValueError("Tool Call 参数必须是 JSON 对象")
return self
class ToolParamAssertion(CamelModel):
name: str = Field(min_length=1, max_length=128)
match_mode: Literal["exact", "regex", "llm"]
value: str = Field(min_length=1, max_length=4_000)
@field_validator("name", "value")
@classmethod
def strip_required_text(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("参数名称和值不能为空")
return value
@model_validator(mode="after")
def validate_regex(self):
if self.match_mode == "regex":
try:
re.compile(self.value)
except re.error as exc:
raise ValueError(f"正则表达式无效: {exc}") from exc
return self
class ToolMockResponse(CamelModel):
outcome: Literal["success", "error"] = "success"
body: str = Field(default='{"status":"ok"}', max_length=100_000)
delay_ms: int = Field(default=0, ge=0, le=60_000)
@model_validator(mode="after")
def validate_json_body(self):
if not self.body.strip():
raise ValueError("Mock 工具返回值不能为空")
try:
json.loads(self.body)
except json.JSONDecodeError as exc:
raise ValueError("Mock 工具返回值必须是有效 JSON") from exc
return self
class ReplyExpectedBehavior(CamelModel):
id: str = Field(min_length=1, max_length=128)
type: Literal["reply"] = "reply"
assertion_type: Literal["keyword", "llm"]
keywords: list[str] = Field(default_factory=list, max_length=50)
keyword_match_mode: Literal["any", "all"] = "any"
negate_keywords: bool = False
llm_criteria: str = Field(default="", max_length=4_000)
@model_validator(mode="after")
def validate_assertion(self):
self.keywords = [item.strip() for item in self.keywords if item.strip()]
self.llm_criteria = self.llm_criteria.strip()
if self.assertion_type == "keyword" and not self.keywords:
raise ValueError("关键词断言至少需要一个关键词")
if self.assertion_type == "llm" and not self.llm_criteria:
raise ValueError("LLM 判断要求不能为空")
return self
class ToolCallExpectedBehavior(CamelModel):
id: str = Field(min_length=1, max_length=128)
type: Literal["tool_call"] = "tool_call"
tool_id: str = Field(min_length=1, max_length=40)
function_name: str = Field(min_length=1, max_length=128)
expectation: Literal["called", "not_called"]
min_calls: int = Field(default=1, ge=0, le=100)
max_calls: int | None = Field(default=None, ge=0, le=100)
param_assertions: list[ToolParamAssertion] = Field(default_factory=list, max_length=50)
mock_response: ToolMockResponse = Field(default_factory=ToolMockResponse)
@model_validator(mode="after")
def validate_call_expectation(self):
self.tool_id = self.tool_id.strip()
self.function_name = self.function_name.strip()
if not self.tool_id or not self.function_name:
raise ValueError("必须选择有效的工具")
if self.expectation == "called" and self.min_calls < 1:
raise ValueError("应调用工具时最少调用次数必须大于 0")
if self.max_calls is not None and self.max_calls < self.min_calls:
raise ValueError("最多调用次数不能小于最少调用次数")
return self
ExpectedBehavior = Annotated[
Union[ReplyExpectedBehavior, ToolCallExpectedBehavior],
Field(discriminator="type"),
]
class FixedInputTurn(CamelModel):
id: str = Field(min_length=1, max_length=128)
user_input: str = Field(min_length=1, max_length=20_000)
behaviors: list[ExpectedBehavior] = Field(default_factory=list, max_length=100)
@field_validator("user_input")
@classmethod
def strip_user_input(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("用户输入不能为空")
return value
class OverallCriterion(CamelModel):
id: str = Field(min_length=1, max_length=128)
type: Literal["llm"] = "llm"
name: str = Field(min_length=1, max_length=80)
criteria: str = Field(min_length=1, max_length=2_000)
@field_validator("name", "criteria")
@classmethod
def strip_criterion(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("整体评估标准不能为空")
return value
class TestCaseDefinition(CamelModel):
context_turns: list[ContextTurn] = Field(default_factory=list, max_length=100)
turns: list[FixedInputTurn] = Field(min_length=1, max_length=100)
overall_criteria: list[OverallCriterion] = Field(default_factory=list, max_length=50)
@model_validator(mode="after")
def validate_has_expectation(self):
if not any(turn.behaviors for turn in self.turns) and not self.overall_criteria:
raise ValueError("至少配置一项有效的预期行为或整体评估标准")
return self
@model_validator(mode="after")
def validate_tool_context_pairs(self):
pending: list[tuple[str | None, str]] = []
seen_ids: set[str] = set()
for index, turn in enumerate(self.context_turns):
if turn.role == "tool_call":
call_id = (turn.tool_call_id or "").strip() or None
if call_id and call_id in seen_ids:
raise ValueError(f"上下文第 {index + 1} 条 Tool Call ID 重复")
if call_id:
seen_ids.add(call_id)
pending.append((call_id, turn.tool_name or ""))
continue
if turn.role != "tool_result":
continue
result_id = (turn.tool_call_id or "").strip() or None
matched_index = -1
for pending_index in range(len(pending) - 1, -1, -1):
call_id, tool_name = pending[pending_index]
if result_id:
matches = call_id == result_id
else:
matches = tool_name == turn.tool_name
if matches:
matched_index = pending_index
break
if matched_index < 0:
raise ValueError(
f"上下文第 {index + 1} 条 Tool Result 没有匹配的 Tool Call"
)
_call_id, tool_name = pending.pop(matched_index)
if tool_name != turn.tool_name:
raise ValueError(
f"上下文第 {index + 1} 条 Tool Result 的工具名称不匹配"
)
if pending:
names = "".join(tool_name for _call_id, tool_name in pending)
raise ValueError(f"上下文 Tool Call 缺少对应的 Tool Result{names}")
return self
class TestSuiteCreate(CamelModel):
name: str = Field(min_length=1, max_length=128)
description: str = Field(default="", max_length=2_048)
@field_validator("name")
@classmethod
def strip_name(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("测试集名称不能为空")
return value
class TestSuiteUpdate(TestSuiteCreate):
pass
class TestSuiteOut(CamelModel):
id: str
name: str
description: str
case_count: int = 0
passed_count: int = 0
run_count: int = 0
updated_at: datetime
class TestCaseWrite(CamelModel):
name: str = Field(min_length=1, max_length=128)
description: str = Field(default="", max_length=2_048)
input_mode: TestCaseInputMode = "fixed_script_text"
context_turns: list[ContextTurn] = Field(default_factory=list, max_length=100)
turns: list[FixedInputTurn] = Field(min_length=1, max_length=100)
overall_criteria: list[OverallCriterion] = Field(default_factory=list, max_length=50)
@field_validator("name")
@classmethod
def strip_name(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("测试用例名称不能为空")
return value
@model_validator(mode="after")
def validate_mvp_definition(self):
if self.input_mode != "fixed_script_text":
raise ValueError("第一版只支持固定脚本 · 文字")
TestCaseDefinition(
context_turns=self.context_turns,
turns=self.turns,
overall_criteria=self.overall_criteria,
)
return self
def definition(self) -> TestCaseDefinition:
return TestCaseDefinition(
context_turns=self.context_turns,
turns=self.turns,
overall_criteria=self.overall_criteria,
)
class TestCaseOut(TestCaseWrite):
id: str
suite_id: str
last_result: Literal["pass", "fail", "not_run"]
sort_order: int
updated_at: datetime
class TestCaseOrderIn(CamelModel):
case_ids: list[str] = Field(min_length=1, max_length=1_000)
class TestCaseBulkDeleteIn(CamelModel):
case_ids: list[str] = Field(min_length=1, max_length=1_000)
class BatchRunConfig(CamelModel):
concurrency: int = Field(default=3, ge=1, le=20)
timeout_secs: int = Field(default=60, ge=1, le=600)
failure_strategy: Literal["continue", "stop_on_fail"] = "continue"
error_retry_count: int = Field(default=0, ge=0, le=3)
error_strategy: Literal["continue", "stop_on_error"] = "continue"
class BatchRunCreate(CamelModel):
assistant_id: str = Field(min_length=1, max_length=40)
evaluator_model_resource_id: str = Field(min_length=1, max_length=40)
case_ids: list[str] = Field(min_length=1, max_length=1_000)
config: BatchRunConfig = Field(default_factory=BatchRunConfig)
title: str | None = Field(default=None, max_length=256)
@field_validator("case_ids")
@classmethod
def unique_case_ids(cls, values: list[str]) -> list[str]:
normalized = list(dict.fromkeys(value.strip() for value in values if value.strip()))
if not normalized:
raise ValueError("至少选择一个测试用例")
return normalized
class BatchExecutionError(CamelModel):
code: str
message: str
stage: ErrorStage
retryable: bool
class BatchEvaluationResult(CamelModel):
id: str
label: str
kind: EvaluationKind
status: EvaluationStatus
expected: str
actual: str
reason: str = ""
class BatchToolCallRecord(CamelModel):
id: str
function_name: str
arguments_json: str
result_json: str
outcome: Literal["success", "error"]
duration_ms: int = 0
class BatchTurnResult(CamelModel):
id: str
index: int
user_input: str
assistant_reply: str
tool_calls: list[BatchToolCallRecord] = Field(default_factory=list)
evaluations: list[BatchEvaluationResult] = Field(default_factory=list)
class BatchRunCaseOut(CamelModel):
id: str
name: str
status: CaseStatus
turns: list[BatchTurnResult] = Field(default_factory=list)
overall_criteria: list[BatchEvaluationResult] = Field(default_factory=list)
attempt_count: int
max_attempts: int
execution_error: BatchExecutionError | None = None
class BatchRunSnapshotOut(CamelModel):
id: str
status: RunStatus
title: str
assistant_name: str
config: dict[str, Any]
cases: list[BatchRunCaseOut]
started_at: datetime
finished_at: datetime | None
stop_reason: StopReason | None