feat: support image inputs in assistant tests
This commit is contained in:
@@ -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}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user