Files
ai-video-fullstack/backend/test_schemas.py
T

662 lines
23 KiB
Python
Raw Normal View History

2026-08-10 13:49:24 +08:00
"""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
2026-08-26 09:36:37 +08:00
class ToolSimulationResponse(CamelModel):
2026-08-10 13:49:24 +08:00
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
2026-08-26 09:36:37 +08:00
class AnyToolSimulationScope(CamelModel):
phase: Literal["any"] = "any"
class StartupToolSimulationScope(CamelModel):
phase: Literal["startup"] = "startup"
class TurnToolSimulationScope(CamelModel):
phase: Literal["turn"] = "turn"
turn_id: str = Field(min_length=1, max_length=128)
ToolSimulationScope = Annotated[
Union[
AnyToolSimulationScope,
StartupToolSimulationScope,
TurnToolSimulationScope,
],
Field(discriminator="phase"),
]
class ToolSimulationStep(ToolSimulationResponse):
id: str = Field(min_length=1, max_length=128)
scope: ToolSimulationScope = Field(default_factory=AnyToolSimulationScope)
class ToolSimulationBase(CamelModel):
tool_id: str = Field(min_length=1, max_length=40)
function_name: str = Field(min_length=1, max_length=128)
@field_validator("tool_id", "function_name")
@classmethod
def strip_tool_identity(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("必须选择有效的工具")
return value
class FixedToolSimulation(ToolSimulationBase):
mode: Literal["fixed"] = "fixed"
response: ToolSimulationResponse = Field(default_factory=ToolSimulationResponse)
class SequenceToolSimulation(ToolSimulationBase):
mode: Literal["sequence"] = "sequence"
steps: list[ToolSimulationStep] = Field(min_length=1, max_length=10_000)
ToolSimulation = Annotated[
Union[FixedToolSimulation, SequenceToolSimulation],
Field(discriminator="mode"),
]
2026-08-10 13:49:24 +08:00
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)
@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 TestImageRef(CamelModel):
asset_id: str = Field(min_length=1, max_length=40)
filename: str = Field(default="image.jpg", max_length=255)
width: int = Field(ge=1)
height: int = Field(ge=1)
size_bytes: int = Field(default=0, ge=0)
2026-08-10 13:49:24 +08:00
@field_validator("asset_id", "filename")
2026-08-10 13:49:24 +08:00
@classmethod
def strip_image_text(cls, value: str) -> str:
2026-08-10 13:49:24 +08:00
value = value.strip()
if not value:
raise ValueError("图片资源信息不完整")
2026-08-10 13:49:24 +08:00
return value
class FixedInputTurn(CamelModel):
id: str = Field(min_length=1, max_length=128)
user_input: str = Field(default="", max_length=20_000)
image: TestImageRef | None = None
behaviors: list[ExpectedBehavior] = Field(default_factory=list, max_length=100)
@model_validator(mode="after")
def validate_input(self):
self.user_input = self.user_input.strip()
if not self.user_input and self.image is None:
raise ValueError("用户输入和图片至少填写一项")
return self
2026-08-10 13:49:24 +08:00
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)
2026-08-26 09:36:37 +08:00
tool_simulations: list[ToolSimulation] = Field(default_factory=list, max_length=100)
2026-08-10 13:49:24 +08:00
overall_criteria: list[OverallCriterion] = Field(default_factory=list, max_length=50)
2026-08-26 09:36:37 +08:00
@model_validator(mode="before")
@classmethod
def migrate_legacy_tool_mocks(cls, raw):
"""Normalize the former per-assertion mockResponse contract on read."""
if not isinstance(raw, dict):
return raw
if "tool_simulations" in raw or "toolSimulations" in raw:
return raw
groups: dict[tuple[str, str], list[dict[str, Any]]] = {}
for turn_index, turn in enumerate(raw.get("turns") or []):
if not isinstance(turn, dict):
continue
turn_id = str(turn.get("id") or "").strip()
for behavior_index, behavior in enumerate(turn.get("behaviors") or []):
if not isinstance(behavior, dict):
continue
if behavior.get("type") != "tool_call":
continue
if behavior.get("expectation") != "called":
continue
mock = behavior.get("mock_response") or behavior.get("mockResponse")
if not isinstance(mock, dict):
continue
tool_id = str(
behavior.get("tool_id") or behavior.get("toolId") or ""
).strip()
function_name = str(
behavior.get("function_name")
or behavior.get("functionName")
or ""
).strip()
if not tool_id or not function_name:
continue
response = {
"outcome": str(mock.get("outcome") or "success"),
"body": str(mock.get("body") or '{"status":"ok"}'),
"delayMs": int(
mock.get("delay_ms")
if mock.get("delay_ms") is not None
else mock.get("delayMs") or 0
),
}
groups.setdefault((tool_id, function_name), []).append(
{
"response": response,
"turnId": turn_id,
"turnIndex": turn_index,
"behaviorIndex": behavior_index,
"minCalls": max(
1,
int(
behavior.get("min_calls")
if behavior.get("min_calls") is not None
else behavior.get("minCalls") or 1
),
),
}
)
migrated: list[dict[str, Any]] = []
for (tool_id, function_name), entries in groups.items():
response_signatures = {
json.dumps(entry["response"], ensure_ascii=False, sort_keys=True)
for entry in entries
}
if len(response_signatures) == 1:
migrated.append(
{
"toolId": tool_id,
"functionName": function_name,
"mode": "fixed",
"response": entries[0]["response"],
}
)
continue
steps: list[dict[str, Any]] = []
for entry in entries:
for repeat_index in range(entry["minCalls"]):
steps.append(
{
"id": (
f"legacy_step_{entry['turnIndex'] + 1}_"
f"{entry['behaviorIndex'] + 1}_{repeat_index + 1}"
),
"scope": {
"phase": "turn",
"turnId": entry["turnId"],
},
**entry["response"],
}
)
migrated.append(
{
"toolId": tool_id,
"functionName": function_name,
"mode": "sequence",
"steps": steps,
}
)
normalized = dict(raw)
normalized["toolSimulations"] = migrated
return normalized
2026-08-10 13:49:24 +08:00
@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
2026-08-26 09:36:37 +08:00
@model_validator(mode="after")
def validate_tool_simulations(self):
tool_ids: set[str] = set()
step_ids: set[str] = set()
turn_ids = {turn.id for turn in self.turns}
simulations_by_tool: dict[str, ToolSimulation] = {}
for simulation in self.tool_simulations:
if simulation.tool_id in tool_ids:
raise ValueError(
f"工具 {simulation.function_name} 只能配置一条工具模拟"
)
tool_ids.add(simulation.tool_id)
simulations_by_tool[simulation.tool_id] = simulation
if simulation.mode != "sequence":
continue
for step in simulation.steps:
if step.id in step_ids:
raise ValueError(f"工具模拟步骤 ID 重复: {step.id}")
step_ids.add(step.id)
if step.scope.phase == "turn" and step.scope.turn_id not in turn_ids:
raise ValueError(
f"工具模拟步骤引用了不存在的轮次: {step.scope.turn_id}"
)
for turn in self.turns:
for behavior in turn.behaviors:
if behavior.type != "tool_call" or behavior.expectation != "called":
continue
simulation = simulations_by_tool.get(behavior.tool_id)
if simulation is None:
raise ValueError(
f"工具 {behavior.function_name} 配置了应调用断言,但缺少工具模拟"
)
if simulation.function_name != behavior.function_name:
raise ValueError(
f"工具 {behavior.function_name} 的断言与模拟不匹配"
)
if simulation.mode == "fixed":
continue
covers_turn = any(
step.scope.phase == "any"
or (
step.scope.phase == "turn"
and step.scope.turn_id == turn.id
)
for step in simulation.steps
)
if not covers_turn:
raise ValueError(
f"工具 {behavior.function_name} 缺少覆盖本轮的模拟步骤"
)
return self
2026-08-10 13:49:24 +08:00
@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)
2026-08-26 09:36:37 +08:00
tool_simulations: list[ToolSimulation] = Field(default_factory=list, max_length=100)
2026-08-10 13:49:24 +08:00
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("第一版只支持固定脚本 · 多模态")
2026-08-10 13:49:24 +08:00
TestCaseDefinition(
context_turns=self.context_turns,
turns=self.turns,
2026-08-26 09:36:37 +08:00
tool_simulations=self.tool_simulations,
2026-08-10 13:49:24 +08:00
overall_criteria=self.overall_criteria,
)
return self
def definition(self) -> TestCaseDefinition:
return TestCaseDefinition(
context_turns=self.context_turns,
turns=self.turns,
2026-08-26 09:36:37 +08:00
tool_simulations=self.tool_simulations,
2026-08-10 13:49:24 +08:00
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)
dynamic_variables: dict[str, str | int | float | bool] = Field(
default_factory=dict,
max_length=50,
)
2026-08-10 13:49:24 +08:00
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
image: TestImageRef | None = None
2026-08-10 13:49:24 +08:00
assistant_reply: str
tool_calls: list[BatchToolCallRecord] = Field(default_factory=list)
2026-08-13 09:30:43 +08:00
runtime_records: list[dict[str, Any]] = Field(default_factory=list)
2026-08-10 13:49:24 +08:00
evaluations: list[BatchEvaluationResult] = Field(default_factory=list)
class BatchStartupResult(CamelModel):
completed: bool = False
greeting: str = ""
assistant_outputs: list[str] = Field(default_factory=list)
auto_confirmed_messages: list[dict[str, Any]] = Field(default_factory=list)
2026-08-13 09:30:43 +08:00
runtime_records: list[dict[str, Any]] = Field(default_factory=list)
2026-08-10 13:49:24 +08:00
class BatchRunCaseOut(CamelModel):
id: str
name: str
status: CaseStatus
turns: list[BatchTurnResult] = Field(default_factory=list)
startup: BatchStartupResult = Field(default_factory=BatchStartupResult)
2026-08-10 13:49:24 +08:00
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
2026-08-26 12:18:18 +08:00
class PromptImprovementChange(CamelModel):
before: str = Field(max_length=100_000)
after: str = Field(min_length=1, max_length=100_000)
reason: str = Field(min_length=1, max_length=4_000)
evidence: list[str] = Field(default_factory=list, max_length=8)
class PromptImprovementManualRecommendation(CamelModel):
category: Literal["tool", "test_case", "other"]
title: str = Field(min_length=1, max_length=200)
detail: str = Field(min_length=1, max_length=4_000)
class PromptImprovementOut(CamelModel):
diagnosis: str = Field(min_length=1, max_length=4_000)
prompt_change: PromptImprovementChange | None = None
manual_recommendations: list[PromptImprovementManualRecommendation] = Field(
default_factory=list,
max_length=20,
)
evaluator_name: str = Field(min_length=1, max_length=128)