feat: support image inputs in assistant tests

This commit is contained in:
Xin Wang
2026-08-23 23:46:18 +08:00
parent 0966f382b6
commit 9e4dfbe596
26 changed files with 1010 additions and 146 deletions
+172
View File
@@ -0,0 +1,172 @@
"""Persistent image fixtures for repeatable assistant tests."""
from __future__ import annotations
import asyncio
from pathlib import Path
from uuid import uuid4
from db.models import TestAsset, TestCase, TestRunCase
from db.session import get_session
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import Response
from loguru import logger
from pydantic import BaseModel
from services.auth import require_admin
from services.test_assets import delete_test_image, read_test_image, store_test_image
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
import settings
router = APIRouter(
prefix="/api/test-assets",
tags=["test-assets"],
dependencies=[Depends(require_admin)],
)
class TestImageAssetOut(BaseModel):
id: str
filename: str
width: int
height: int
sizeBytes: int
contentUrl: str
def _new_id() -> str:
return f"ta_{uuid4().hex}"
def _safe_filename(value: str | None) -> str:
name = Path(value or "image.jpg").name.strip()
return (name or "image.jpg")[:255]
def _out(row: TestAsset) -> TestImageAssetOut:
return TestImageAssetOut(
id=row.id,
filename=row.original_name,
width=row.width,
height=row.height,
sizeBytes=row.size_bytes,
contentUrl=f"/api/test-assets/{row.id}/content",
)
def _contains_asset_id(value: object, asset_id: str) -> bool:
if isinstance(value, dict):
if value.get("assetId") == asset_id or value.get("asset_id") == asset_id:
return True
return any(_contains_asset_id(item, asset_id) for item in value.values())
if isinstance(value, list):
return any(_contains_asset_id(item, asset_id) for item in value)
return False
async def _is_referenced(session: AsyncSession, asset_id: str) -> bool:
definitions = (
await session.execute(select(TestCase.definition))
).scalars().all()
if any(_contains_asset_id(value, asset_id) for value in definitions):
return True
snapshots = (
await session.execute(select(TestRunCase.case_snapshot))
).scalars().all()
return any(_contains_asset_id(value, asset_id) for value in snapshots)
@router.post("/images", response_model=TestImageAssetOut)
async def upload_test_image(
file: UploadFile = File(...),
session: AsyncSession = Depends(get_session),
):
data = await file.read(settings.INPUT_IMAGE_MAX_BYTES + 1)
if len(data) > settings.INPUT_IMAGE_MAX_BYTES:
limit_mb = settings.INPUT_IMAGE_MAX_BYTES // 1024 // 1024
raise HTTPException(413, f"图片不能超过 {limit_mb} MB")
if not data:
raise HTTPException(400, "图片文件不能为空")
asset_id = _new_id()
try:
stored = await asyncio.to_thread(store_test_image, asset_id, data)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
except Exception as exc:
raise HTTPException(503, "图片暂时无法上传,请确认对象存储可用") from exc
row = TestAsset(
id=asset_id,
original_name=_safe_filename(file.filename),
storage_key=stored.storage_key,
mime_type=stored.mime_type,
width=stored.width,
height=stored.height,
size_bytes=stored.size_bytes,
)
session.add(row)
try:
await session.commit()
except Exception:
await session.rollback()
try:
await asyncio.to_thread(delete_test_image, stored.storage_key)
except Exception:
logger.warning(f"回滚测试图片上传时清理对象失败: {stored.storage_key}")
raise
await session.refresh(row)
return _out(row)
@router.get("/{asset_id}", response_model=TestImageAssetOut)
async def get_test_asset(
asset_id: str,
session: AsyncSession = Depends(get_session),
):
row = await session.get(TestAsset, asset_id)
if row is None:
raise HTTPException(404, "测试图片不存在")
return _out(row)
@router.get("/{asset_id}/content")
async def get_test_asset_content(
asset_id: str,
session: AsyncSession = Depends(get_session),
):
row = await session.get(TestAsset, asset_id)
if row is None:
raise HTTPException(404, "测试图片不存在")
try:
data = await asyncio.to_thread(read_test_image, row.storage_key)
except Exception as exc:
raise HTTPException(503, "测试图片暂时无法读取") from exc
return Response(
content=data,
media_type=row.mime_type,
headers={"Cache-Control": "private, max-age=3600"},
)
@router.delete("/{asset_id}")
async def delete_test_asset(
asset_id: str,
session: AsyncSession = Depends(get_session),
):
row = await session.get(TestAsset, asset_id)
if row is None:
raise HTTPException(404, "测试图片不存在")
if await _is_referenced(session, asset_id):
raise HTTPException(409, "测试图片仍被用例或运行记录引用")
storage_key = row.storage_key
await session.delete(row)
await session.commit()
try:
await asyncio.to_thread(delete_test_image, storage_key)
except Exception:
logger.warning(f"测试图片数据已删除,但对象存储清理失败: {storage_key}")
return {"ok": True}
+16 -9
View File
@@ -9,6 +9,7 @@ from db.models import TestCase, TestRunCase, TestSuite
from db.session import get_session
from fastapi import APIRouter, Depends, HTTPException, Query
from services.auth import require_admin
from services.test_case_assets import canonicalize_test_assets
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from test_schemas import (
@@ -79,13 +80,6 @@ def _case_out(row: TestCase) -> TestCaseOut:
)
def _apply_case_write(row: TestCase, body: TestCaseWrite) -> None:
row.name = body.name.strip()
row.description = body.description.strip()
row.input_mode = body.input_mode
row.definition = body.definition().model_dump(mode="json", by_alias=True)
async def _renumber_cases(session: AsyncSession, suite_id: str) -> None:
rows = (
await session.execute(
@@ -232,13 +226,18 @@ async def create_test_case(
max_order = await session.scalar(
select(func.max(TestCase.sort_order)).where(TestCase.suite_id == suite_id)
)
definition = body.definition()
try:
await canonicalize_test_assets(session, definition)
except ValueError as exc:
raise HTTPException(422, str(exc)) from exc
row = TestCase(
id=_new_id("tc"),
suite_id=suite_id,
name=body.name.strip(),
description=body.description.strip(),
input_mode=body.input_mode,
definition=body.definition().model_dump(mode="json", by_alias=True),
definition=definition.model_dump(mode="json", by_alias=True),
sort_order=int(max_order if max_order is not None else -1) + 1,
last_result="not_run",
)
@@ -315,7 +314,15 @@ async def update_test_case(
row = await session.get(TestCase, case_id)
if row is None:
raise HTTPException(404, "测试用例不存在")
_apply_case_write(row, body)
definition = body.definition()
try:
await canonicalize_test_assets(session, definition)
except ValueError as exc:
raise HTTPException(422, str(exc)) from exc
row.name = body.name.strip()
row.description = body.description.strip()
row.input_mode = body.input_mode
row.definition = definition.model_dump(mode="json", by_alias=True)
await _touch_suite(session, row.suite_id)
await session.commit()
await session.refresh(row)
+17 -2
View File
@@ -18,6 +18,8 @@ from fastapi import APIRouter, Depends, HTTPException
from services.auth import require_admin
from services.config_resolver import resolve_runtime_config
from services.runtime_variables import DynamicVariableError, prepare_dynamic_config
from services.realtime.launcher import validate_visual_runtime
from services.test_case_assets import canonicalize_test_assets, image_asset_ids
from services.test_runs.orchestrator import test_run_orchestrator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -140,13 +142,18 @@ async def create_test_run(
raise HTTPException(422, "测试用例不存在: " + "、".join(missing))
ordered_cases = [by_id[case_id] for case_id in body.case_ids]
tools_by_id = {tool.id: tool for tool in runtime_config.tools}
definitions_by_id: dict[str, TestCaseDefinition] = {}
has_image_input = False
for row in ordered_cases:
if row.input_mode != "fixed_script_text":
raise HTTPException(422, f"用例“{row.name}”不是可运行的固定文字模式")
raise HTTPException(422, f"用例“{row.name}”不是可运行的固定多模态模式")
try:
definition = TestCaseDefinition.model_validate(row.definition or {})
await canonicalize_test_assets(session, definition)
except ValueError as exc:
raise HTTPException(422, f"用例“{row.name}”校验失败: {exc}") from exc
definitions_by_id[row.id] = definition
has_image_input = has_image_input or bool(image_asset_ids(definition))
for turn in definition.turns:
for behavior in turn.behaviors:
if behavior.type != "tool_call":
@@ -159,6 +166,14 @@ async def create_test_run(
"不属于当前被测助手",
)
if has_image_input:
try:
vision_enabled = validate_visual_runtime(runtime_config)
except ValueError as exc:
raise HTTPException(422, str(exc)) from exc
if not vision_enabled:
raise HTTPException(422, "测试用例包含图片,但当前助手未开启视觉能力")
suite_ids = list(dict.fromkeys(row.suite_id for row in ordered_cases))
suites = (
await session.execute(select(TestSuite).where(TestSuite.id.in_(suite_ids)))
@@ -192,7 +207,7 @@ async def create_test_run(
)
session.add(run)
for position, row in enumerate(ordered_cases):
definition = TestCaseDefinition.model_validate(row.definition or {})
definition = definitions_by_id[row.id]
snapshot = {
"id": row.id,
"suiteId": row.suite_id,