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
+2
View File
@@ -48,6 +48,7 @@ from routes import (
realtime_api_keys, realtime_api_keys,
site_settings, site_settings,
test_cases, test_cases,
test_assets,
test_runs, test_runs,
tools, tools,
voice_webrtc, voice_webrtc,
@@ -104,6 +105,7 @@ app.include_router(openai_realtime.router)
app.include_router(realtime_api_keys.router) app.include_router(realtime_api_keys.router)
app.include_router(site_settings.router) app.include_router(site_settings.router)
app.include_router(test_cases.router) app.include_router(test_cases.router)
app.include_router(test_assets.router)
app.include_router(test_runs.router) app.include_router(test_runs.router)
app.include_router(tools.router) app.include_router(tools.router)
app.include_router(voice_webrtc.router) app.include_router(voice_webrtc.router)
+17
View File
@@ -475,6 +475,23 @@ class TestSuite(Base):
) )
class TestAsset(Base):
"""Immutable image fixture referenced by one or more test definitions."""
__tablename__ = "test_assets"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
original_name: Mapped[str] = mapped_column(String(255), default="image.jpg")
storage_key: Mapped[str] = mapped_column(String(1024), unique=True)
mime_type: Mapped[str] = mapped_column(String(128), default="image/jpeg")
width: Mapped[int] = mapped_column(Integer)
height: Mapped[int] = mapped_column(Integer)
size_bytes: Mapped[int] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
class TestCase(Base): class TestCase(Base):
"""One editable test definition; nested turns remain one JSON document.""" """One editable test definition; nested turns remain one JSON document."""
@@ -0,0 +1,53 @@
"""add persistent image fixtures for test cases
Revision ID: 20260818_0023
Revises: 20260813_0022
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "20260818_0023"
down_revision: str | Sequence[str] | None = "20260813_0022"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"test_assets",
sa.Column("id", sa.String(length=40), nullable=False),
sa.Column(
"original_name",
sa.String(length=255),
server_default="image.jpg",
nullable=False,
),
sa.Column("storage_key", sa.String(length=1024), nullable=False),
sa.Column(
"mime_type",
sa.String(length=128),
server_default="image/jpeg",
nullable=False,
),
sa.Column("width", sa.Integer(), nullable=False),
sa.Column("height", sa.Integer(), nullable=False),
sa.Column("size_bytes", sa.Integer(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("storage_key"),
)
def downgrade() -> None:
op.drop_table("test_assets")
+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 db.session import get_session
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from services.auth import require_admin from services.auth import require_admin
from services.test_case_assets import canonicalize_test_assets
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from test_schemas import ( 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: async def _renumber_cases(session: AsyncSession, suite_id: str) -> None:
rows = ( rows = (
await session.execute( await session.execute(
@@ -232,13 +226,18 @@ async def create_test_case(
max_order = await session.scalar( max_order = await session.scalar(
select(func.max(TestCase.sort_order)).where(TestCase.suite_id == suite_id) 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( row = TestCase(
id=_new_id("tc"), id=_new_id("tc"),
suite_id=suite_id, suite_id=suite_id,
name=body.name.strip(), name=body.name.strip(),
description=body.description.strip(), description=body.description.strip(),
input_mode=body.input_mode, 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, sort_order=int(max_order if max_order is not None else -1) + 1,
last_result="not_run", last_result="not_run",
) )
@@ -315,7 +314,15 @@ async def update_test_case(
row = await session.get(TestCase, case_id) row = await session.get(TestCase, case_id)
if row is None: if row is None:
raise HTTPException(404, "测试用例不存在") 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 _touch_suite(session, row.suite_id)
await session.commit() await session.commit()
await session.refresh(row) 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.auth import require_admin
from services.config_resolver import resolve_runtime_config from services.config_resolver import resolve_runtime_config
from services.runtime_variables import DynamicVariableError, prepare_dynamic_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 services.test_runs.orchestrator import test_run_orchestrator
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -140,13 +142,18 @@ async def create_test_run(
raise HTTPException(422, "测试用例不存在: " + "、".join(missing)) raise HTTPException(422, "测试用例不存在: " + "、".join(missing))
ordered_cases = [by_id[case_id] for case_id in body.case_ids] 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} 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: for row in ordered_cases:
if row.input_mode != "fixed_script_text": if row.input_mode != "fixed_script_text":
raise HTTPException(422, f"用例“{row.name}”不是可运行的固定文字模式") raise HTTPException(422, f"用例“{row.name}”不是可运行的固定多模态模式")
try: try:
definition = TestCaseDefinition.model_validate(row.definition or {}) definition = TestCaseDefinition.model_validate(row.definition or {})
await canonicalize_test_assets(session, definition)
except ValueError as exc: except ValueError as exc:
raise HTTPException(422, f"用例“{row.name}”校验失败: {exc}") from 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 turn in definition.turns:
for behavior in turn.behaviors: for behavior in turn.behaviors:
if behavior.type != "tool_call": 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)) suite_ids = list(dict.fromkeys(row.suite_id for row in ordered_cases))
suites = ( suites = (
await session.execute(select(TestSuite).where(TestSuite.id.in_(suite_ids))) await session.execute(select(TestSuite).where(TestSuite.id.in_(suite_ids)))
@@ -192,7 +207,7 @@ async def create_test_run(
) )
session.add(run) session.add(run)
for position, row in enumerate(ordered_cases): for position, row in enumerate(ordered_cases):
definition = TestCaseDefinition.model_validate(row.definition or {}) definition = definitions_by_id[row.id]
snapshot = { snapshot = {
"id": row.id, "id": row.id,
"suiteId": row.suite_id, "suiteId": row.suite_id,
+41
View File
@@ -0,0 +1,41 @@
"""Shared validation and normalization for user-supplied images."""
from __future__ import annotations
from io import BytesIO
from PIL import Image, ImageOps, UnidentifiedImageError
import settings
def normalize_image(data: bytes) -> tuple[bytes, int, int]:
"""Return a bounded, orientation-corrected JPEG safe for model input."""
try:
source = Image.open(BytesIO(data))
width, height = source.size
if width <= 0 or height <= 0:
raise ValueError("图片尺寸无效")
if width * height > settings.INPUT_IMAGE_MAX_PIXELS:
raise ValueError("图片像素尺寸过大")
source.load()
except (UnidentifiedImageError, OSError) as exc:
raise ValueError("文件不是可识别的图片") from exc
image = ImageOps.exif_transpose(source)
image.thumbnail(
(settings.INPUT_IMAGE_MAX_EDGE, settings.INPUT_IMAGE_MAX_EDGE),
Image.Resampling.LANCZOS,
)
if image.mode in {"RGBA", "LA"} or "transparency" in image.info:
rgba = image.convert("RGBA")
background = Image.new("RGB", rgba.size, "white")
background.paste(rgba, mask=rgba.getchannel("A"))
image = background
else:
image = image.convert("RGB")
buffer = BytesIO()
image.save(buffer, format="JPEG", quality=85, optimize=True)
return buffer.getvalue(), image.width, image.height
+2 -34
View File
@@ -8,12 +8,10 @@ import hmac
import json import json
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from io import BytesIO
from uuid import uuid4 from uuid import uuid4
from PIL import Image, ImageOps, UnidentifiedImageError
import settings import settings
from services.image_processing import normalize_image
from services.object_storage import delete_object, get_object, put_object from services.object_storage import delete_object, get_object, put_object
@@ -46,38 +44,8 @@ def _signature(payload: str) -> str:
return _b64encode(digest) return _b64encode(digest)
def _normalize_image(data: bytes) -> tuple[bytes, int, int]:
try:
source = Image.open(BytesIO(data))
width, height = source.size
if width <= 0 or height <= 0:
raise ValueError("图片尺寸无效")
if width * height > settings.INPUT_IMAGE_MAX_PIXELS:
raise ValueError("图片像素尺寸过大")
source.load()
except (UnidentifiedImageError, OSError) as exc:
raise ValueError("文件不是可识别的图片") from exc
image = ImageOps.exif_transpose(source)
image.thumbnail(
(settings.INPUT_IMAGE_MAX_EDGE, settings.INPUT_IMAGE_MAX_EDGE),
Image.Resampling.LANCZOS,
)
if image.mode in {"RGBA", "LA"} or "transparency" in image.info:
rgba = image.convert("RGBA")
background = Image.new("RGB", rgba.size, "white")
background.paste(rgba, mask=rgba.getchannel("A"))
image = background
else:
image = image.convert("RGB")
buffer = BytesIO()
image.save(buffer, format="JPEG", quality=85, optimize=True)
return buffer.getvalue(), image.width, image.height
def store_input_image(data: bytes) -> StoredInputImage: def store_input_image(data: bytes) -> StoredInputImage:
normalized, width, height = _normalize_image(data) normalized, width, height = normalize_image(data)
key = f"{INPUT_ASSET_PREFIX}{uuid4().hex}.jpg" key = f"{INPUT_ASSET_PREFIX}{uuid4().hex}.jpg"
put_object(key, normalized, "image/jpeg") put_object(key, normalized, "image/jpeg")
expires_at = int(time.time()) + settings.INPUT_IMAGE_TOKEN_TTL_SECONDS expires_at = int(time.time()) + settings.INPUT_IMAGE_TOKEN_TTL_SECONDS
+6 -52
View File
@@ -34,12 +34,11 @@ from services.tool_policy import policy_for_tool
from services.vision import ( from services.vision import (
VISION_TOOL_NAME, VISION_TOOL_NAME,
analyze_image_with_vision_model, analyze_image_with_vision_model,
config_with_main_llm_as_vision,
config_with_vision_resource,
image_data_uri, image_data_uri,
image_frame_from_jpeg, image_frame_from_jpeg,
image_jpeg_bytes, image_jpeg_bytes,
resolve_vision_question, resolve_vision_question,
resolve_vision_input,
vision_system_hint, vision_system_hint,
vision_tool_schema_parts, vision_tool_schema_parts,
) )
@@ -151,30 +150,6 @@ def _require(value: str, label: str) -> str:
raise ValueError(f"缺少模型资源配置: {label}") raise ValueError(f"缺少模型资源配置: {label}")
def _vision_uses_main_llm(cfg: AssistantConfig) -> bool:
"""模型自己支持图片时,沿用 Pipecat 的同上下文视觉工具路径。"""
return not cfg.vision_model_resource_id and cfg.llm_support_image_input
def _workflow_vision_uses_main_llm(
cfg: AssistantConfig,
scope: dict[str, Any],
) -> bool:
"""Resolve native versus auxiliary vision for the active Workflow Agent."""
if not scope.get("enabled"):
raise ValueError("当前 Workflow Agent 节点未启用视觉能力")
if scope.get("vision_model_resource_id"):
return False
llm_resource_id = str(scope.get("llm_resource_id") or "")
resource = cfg.workflow_model_resources.get(llm_resource_id)
if not resource:
raise ValueError(f"当前 Workflow Agent 的 LLM 资源未加载:{llm_resource_id}")
if not resource.support_image_input:
raise ValueError("当前 Workflow Agent 的 LLM 不支持图片输入")
return True
def _multimodal_user_input_frame( def _multimodal_user_input_frame(
image_frame: UserImageRawFrame, image_frame: UserImageRawFrame,
prompt_text: str, prompt_text: str,
@@ -485,7 +460,7 @@ async def run_pipeline(
vision_native_mode = ( vision_native_mode = (
vision_enabled vision_enabled
and cfg.type != "workflow" and cfg.type != "workflow"
and _vision_uses_main_llm(cfg) and resolve_vision_input(cfg).native
) )
vision_state: dict[str, str | None] = {"client_id": None} vision_state: dict[str, str | None] = {"client_id": None}
workflow_vision_scope: dict[str, Any] = { workflow_vision_scope: dict[str, Any] = {
@@ -497,9 +472,9 @@ async def run_pipeline(
} }
def active_vision_uses_main_llm() -> bool: def active_vision_uses_main_llm() -> bool:
if cfg.type == "workflow": if cfg.type != "workflow":
return _workflow_vision_uses_main_llm(cfg, workflow_vision_scope)
return vision_native_mode return vision_native_mode
return resolve_vision_input(cfg, workflow_vision_scope).native
vision_description, vision_properties, vision_required = vision_tool_schema_parts( vision_description, vision_properties, vision_required = vision_tool_schema_parts(
cfg.vision_prompt_mode, cfg.vision_prompt_mode,
cfg.vision_prompt, cfg.vision_prompt,
@@ -609,29 +584,8 @@ async def run_pipeline(
workflow_vision_function = None workflow_vision_function = None
def active_vision_config() -> AssistantConfig: def active_vision_config() -> AssistantConfig:
if cfg.type != "workflow": scope = workflow_vision_scope if cfg.type == "workflow" else None
return cfg return resolve_vision_input(cfg, scope).config
if not workflow_vision_scope.get("enabled"):
raise ValueError("当前 Workflow Agent 节点未启用视觉能力")
vision_resource_id = str(
workflow_vision_scope.get("vision_model_resource_id") or ""
)
llm_resource_id = str(workflow_vision_scope.get("llm_resource_id") or "")
resource_id = vision_resource_id or llm_resource_id
resource = cfg.workflow_model_resources.get(resource_id)
if resource:
result = config_with_vision_resource(cfg, resource)
elif not vision_resource_id:
result = config_with_main_llm_as_vision(cfg)
else:
raise ValueError(f"视觉模型资源未加载:{vision_resource_id}")
result.vision_prompt_mode = str(
workflow_vision_scope.get("vision_prompt_mode") or "runtime"
)
result.vision_prompt = str(
workflow_vision_scope.get("vision_prompt") or ""
).strip()
return result
if cfg.type == "workflow" and vision_enabled: if cfg.type == "workflow" and vision_enabled:
async def native_flow_fetch_user_image(params: FunctionCallParams) -> None: async def native_flow_fetch_user_image(params: FunctionCallParams) -> None:
+44
View File
@@ -0,0 +1,44 @@
"""Persistent image fixtures used by repeatable assistant test cases."""
from __future__ import annotations
from dataclasses import dataclass
from services.image_processing import normalize_image
from services.object_storage import delete_object, get_object, put_object
TEST_ASSET_PREFIX = "test-assets/"
@dataclass(frozen=True)
class StoredTestImage:
storage_key: str
width: int
height: int
size_bytes: int
mime_type: str = "image/jpeg"
def store_test_image(asset_id: str, data: bytes) -> StoredTestImage:
normalized, width, height = normalize_image(data)
key = f"{TEST_ASSET_PREFIX}{asset_id}.jpg"
put_object(key, normalized, "image/jpeg")
return StoredTestImage(
storage_key=key,
width=width,
height=height,
size_bytes=len(normalized),
)
def read_test_image(storage_key: str) -> bytes:
if not storage_key.startswith(TEST_ASSET_PREFIX):
raise ValueError("测试图片存储位置无效")
return get_object(storage_key)
def delete_test_image(storage_key: str) -> None:
if not storage_key.startswith(TEST_ASSET_PREFIX):
raise ValueError("测试图片存储位置无效")
delete_object(storage_key)
+72
View File
@@ -0,0 +1,72 @@
"""Validation and loading helpers for image fixtures in test definitions."""
from __future__ import annotations
import asyncio
from db.models import TestAsset
from services.test_assets import read_test_image
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from test_schemas import TestCaseDefinition
def image_asset_ids(definition: TestCaseDefinition) -> set[str]:
return {
turn.image.asset_id
for turn in definition.turns
if turn.image is not None
}
async def canonicalize_test_assets(
session: AsyncSession,
definition: TestCaseDefinition,
) -> None:
"""Validate references and replace client metadata with authoritative values."""
asset_ids = image_asset_ids(definition)
if not asset_ids:
return
rows = (
await session.execute(select(TestAsset).where(TestAsset.id.in_(asset_ids)))
).scalars().all()
by_id = {row.id: row for row in rows}
missing = sorted(asset_ids - by_id.keys())
if missing:
raise ValueError("测试图片不存在: " + "、".join(missing))
for turn in definition.turns:
if turn.image is None:
continue
row = by_id[turn.image.asset_id]
turn.image.filename = row.original_name
turn.image.width = row.width
turn.image.height = row.height
turn.image.size_bytes = row.size_bytes
async def load_test_asset_bytes(
session: AsyncSession,
definition: TestCaseDefinition,
) -> dict[str, bytes]:
"""Load immutable image fixtures once before a transportless test attempt."""
asset_ids = image_asset_ids(definition)
if not asset_ids:
return {}
rows = (
await session.execute(select(TestAsset).where(TestAsset.id.in_(asset_ids)))
).scalars().all()
by_id = {row.id: row for row in rows}
missing = sorted(asset_ids - by_id.keys())
if missing:
raise ValueError("测试图片不存在: " + "、".join(missing))
pairs = await asyncio.gather(
*(
asyncio.to_thread(read_test_image, row.storage_key)
for row in rows
)
)
return {row.id: data for row, data in zip(rows, pairs, strict=True)}
+2
View File
@@ -161,6 +161,7 @@ class EvaluationEngine:
id=expected_turn.id, id=expected_turn.id,
index=index, index=index,
user_input=expected_turn.user_input, user_input=expected_turn.user_input,
image=expected_turn.image,
assistant_reply="", assistant_reply="",
tool_calls=[], tool_calls=[],
runtime_records=[], runtime_records=[],
@@ -174,6 +175,7 @@ class EvaluationEngine:
id=raw.id, id=raw.id,
index=index, index=index,
user_input=raw.user_input, user_input=raw.user_input,
image=raw.image,
assistant_reply=raw.assistant_reply, assistant_reply=raw.assistant_reply,
tool_calls=[item.as_result() for item in raw.tool_calls], tool_calls=[item.as_result() for item in raw.tool_calls],
runtime_records=list(raw.runtime_records), runtime_records=list(raw.runtime_records),
@@ -14,6 +14,7 @@ from db.session import SessionLocal
from models import RuntimeModelResource from models import RuntimeModelResource
from services.config_resolver import resolve_runtime_config from services.config_resolver import resolve_runtime_config
from services.runtime_variables import prepare_dynamic_config from services.runtime_variables import prepare_dynamic_config
from services.test_case_assets import load_test_asset_bytes
from services.test_runs.errors import TestExecutionError from services.test_runs.errors import TestExecutionError
from services.test_runs.evaluator import EvaluationEngine from services.test_runs.evaluator import EvaluationEngine
from services.test_runs.text_runner import ( from services.test_runs.text_runner import (
@@ -178,6 +179,19 @@ class TestRunOrchestrator:
snapshot = dict(row.case_snapshot or {}) snapshot = dict(row.case_snapshot or {})
definition = TestCaseDefinition.model_validate(snapshot) definition = TestCaseDefinition.model_validate(snapshot)
max_attempts = row.max_attempts max_attempts = row.max_attempts
try:
image_assets = await load_test_asset_bytes(session, definition)
except Exception as exc:
await self._mark_error(
run_case_id,
TestExecutionError(
code="TEST_IMAGE_READ_FAILED",
message=str(exc) or "测试图片无法读取",
stage="pipeline",
retryable=True,
),
)
return
for attempt in range(1, max_attempts + 1): for attempt in range(1, max_attempts + 1):
if control.stop_event.is_set(): if control.stop_event.is_set():
@@ -212,6 +226,7 @@ class TestRunOrchestrator:
attempt_cfg, attempt_cfg,
definition, definition,
timeout_seconds=timeout_seconds, timeout_seconds=timeout_seconds,
image_assets=image_assets,
on_turn_complete=save_turn_progress, on_turn_complete=save_turn_progress,
on_startup_complete=save_startup_progress, on_startup_complete=save_startup_progress,
) )
@@ -346,6 +361,11 @@ class TestRunOrchestrator:
"id": turn.id, "id": turn.id,
"index": turn.index, "index": turn.index,
"userInput": turn.user_input, "userInput": turn.user_input,
"image": (
turn.image.model_dump(mode="json", by_alias=True)
if turn.image is not None
else None
),
"assistantReply": turn.assistant_reply, "assistantReply": turn.assistant_reply,
"toolCalls": [item.as_result() for item in turn.tool_calls], "toolCalls": [item.as_result() for item in turn.tool_calls],
"runtimeRecords": list(turn.runtime_records), "runtimeRecords": list(turn.runtime_records),
+80 -12
View File
@@ -55,7 +55,8 @@ from services.pipecat.service_factory import config_with_resource
from services.pipecat.workflow_services import build_workflow_llm_switcher from services.pipecat.workflow_services import build_workflow_llm_switcher
from services.test_runs.errors import TestExecutionError from services.test_runs.errors import TestExecutionError
from services.test_runs.mock_tools import MockToolExecutor, TextClientToolPort from services.test_runs.mock_tools import MockToolExecutor, TextClientToolPort
from test_schemas import ContextTurn, FixedInputTurn, TestCaseDefinition from services.vision import build_image_user_message
from test_schemas import ContextTurn, FixedInputTurn, TestCaseDefinition, TestImageRef
IDLE_SETTLE_SECONDS = 0.2 IDLE_SETTLE_SECONDS = 0.2
@@ -104,6 +105,7 @@ class RawTurnResult:
user_input: str user_input: str
assistant_reply: str assistant_reply: str
tool_calls: list[RawToolCall] tool_calls: list[RawToolCall]
image: TestImageRef | None = None
runtime_records: list[dict[str, Any]] = field(default_factory=list) runtime_records: list[dict[str, Any]] = field(default_factory=list)
@@ -359,6 +361,31 @@ def _context_messages(context_turns: list[ContextTurn]) -> list[dict[str, Any]]:
return messages return messages
def _transcript_content(message: dict[str, Any]) -> str:
"""Keep evaluator transcripts readable without persisting image data URIs."""
content = message.get("content")
if isinstance(content, str):
return content.strip()
if not isinstance(content, list):
return ""
text_parts = [
str(part.get("text") or "").strip()
for part in content
if isinstance(part, dict)
and part.get("type") in {"text", "input_text"}
and str(part.get("text") or "").strip()
]
has_image = any(
isinstance(part, dict)
and part.get("type") in {"image_url", "input_image"}
for part in content
)
if has_image:
text_parts.append("[用户提交了一张测试图片]")
return "\n".join(text_parts).strip()
async def _wait_for_quiescence( async def _wait_for_quiescence(
capture: _TextCaptureProcessor, capture: _TextCaptureProcessor,
runner_task: asyncio.Task[None], runner_task: asyncio.Task[None],
@@ -423,6 +450,7 @@ class TextPipelineRunner:
definition: TestCaseDefinition, definition: TestCaseDefinition,
*, *,
timeout_seconds: int, timeout_seconds: int,
image_assets: dict[str, bytes] | None = None,
on_turn_complete: TurnCompleteCallback | None = None, on_turn_complete: TurnCompleteCallback | None = None,
on_startup_complete: StartupCompleteCallback | None = None, on_startup_complete: StartupCompleteCallback | None = None,
) -> TextCaseResult: ) -> TextCaseResult:
@@ -431,6 +459,7 @@ class TextPipelineRunner:
return await self._run( return await self._run(
cfg, cfg,
definition, definition,
image_assets=image_assets or {},
on_turn_complete=on_turn_complete, on_turn_complete=on_turn_complete,
on_startup_complete=on_startup_complete, on_startup_complete=on_startup_complete,
) )
@@ -447,6 +476,7 @@ class TextPipelineRunner:
cfg: AssistantConfig, cfg: AssistantConfig,
definition: TestCaseDefinition, definition: TestCaseDefinition,
*, *,
image_assets: dict[str, bytes],
on_turn_complete: TurnCompleteCallback | None, on_turn_complete: TurnCompleteCallback | None,
on_startup_complete: StartupCompleteCallback | None, on_startup_complete: StartupCompleteCallback | None,
) -> TextCaseResult: ) -> TextCaseResult:
@@ -545,6 +575,7 @@ class TextPipelineRunner:
executor_holder: dict[str, MockToolExecutor] = {} executor_holder: dict[str, MockToolExecutor] = {}
text_client_tools = TextClientToolPort() text_client_tools = TextClientToolPort()
workflow_vision_scope: dict[str, Any] = {"enabled": False}
def create_test_executor(store) -> MockToolExecutor: def create_test_executor(store) -> MockToolExecutor:
executor = MockToolExecutor(store) executor = MockToolExecutor(store)
@@ -699,7 +730,7 @@ class TextPipelineRunner:
transport=None, transport=None,
switch_services=switch_services, switch_services=switch_services,
set_knowledge_scope=knowledge.set_scope, set_knowledge_scope=knowledge.set_scope,
set_vision_scope=lambda _scope: None, set_vision_scope=lambda scope: workflow_vision_scope.update(scope),
vision_function=None, vision_function=None,
set_input_enabled=lambda _enabled: None, set_input_enabled=lambda _enabled: None,
apply_turn_config=lambda _enabled, _config: asyncio.sleep(0), apply_turn_config=lambda _enabled, _config: asyncio.sleep(0),
@@ -809,26 +840,32 @@ class TextPipelineRunner:
retryable=False, retryable=False,
) )
result = await self._run_turn( result = await self._run_turn(
cfg,
worker, worker,
runner_task, runner_task,
capture, capture,
executor, executor,
turn, turn,
index, index,
image_assets=image_assets,
workflow_vision_scope=workflow_vision_scope,
) )
results.append(result) results.append(result)
if on_turn_complete is not None: if on_turn_complete is not None:
await on_turn_complete(result) await on_turn_complete(result)
transcript = [ transcript = []
for message in context.get_messages():
if message.get("role") not in {"user", "assistant"}:
continue
content = _transcript_content(message)
if content:
transcript.append(
{ {
"role": str(message.get("role") or ""), "role": str(message.get("role") or ""),
"content": str(message.get("content") or ""), "content": content,
} }
for message in context.get_messages() )
if message.get("role") in {"user", "assistant"}
and str(message.get("content") or "").strip()
]
return TextCaseResult( return TextCaseResult(
turns=results, turns=results,
transcript=transcript, transcript=transcript,
@@ -853,12 +890,16 @@ class TextPipelineRunner:
async def _run_turn( async def _run_turn(
self, self,
cfg: AssistantConfig,
worker: PipelineWorker, worker: PipelineWorker,
runner_task: asyncio.Task[None], runner_task: asyncio.Task[None],
capture: _TextCaptureProcessor, capture: _TextCaptureProcessor,
executor: MockToolExecutor, executor: MockToolExecutor,
turn: FixedInputTurn, turn: FixedInputTurn,
index: int, index: int,
*,
image_assets: dict[str, bytes],
workflow_vision_scope: dict[str, Any],
) -> RawTurnResult: ) -> RawTurnResult:
tool_behaviors = [ tool_behaviors = [
behavior for behavior in turn.behaviors if behavior.type == "tool_call" behavior for behavior in turn.behaviors if behavior.type == "tool_call"
@@ -868,11 +909,37 @@ class TextPipelineRunner:
output_marker = len(capture.outputs) output_marker = len(capture.outputs)
tool_marker = len(capture.tool_calls) tool_marker = len(capture.tool_calls)
runtime_record_marker = len(capture.runtime_records) runtime_record_marker = len(capture.runtime_records)
await worker.queue_frame( user_message: dict[str, Any] = {
LLMMessagesAppendFrame( "role": "user",
messages=[{"role": "user", "content": turn.user_input}], "content": turn.user_input,
run_llm=True, }
if turn.image is not None:
image_bytes = image_assets.get(turn.image.asset_id)
if image_bytes is None:
raise TestExecutionError(
code="TEST_IMAGE_MISSING",
message=f"测试图片不存在: {turn.image.asset_id}",
stage="pipeline",
retryable=False,
) )
try:
user_message = await build_image_user_message(
cfg,
image_bytes,
turn.user_input,
workflow_scope=(
workflow_vision_scope if cfg.type == "workflow" else None
),
)
except ValueError as exc:
raise TestExecutionError(
code="TEST_IMAGE_UNSUPPORTED",
message=str(exc),
stage="model",
retryable=False,
) from exc
await worker.queue_frame(
LLMMessagesAppendFrame(messages=[user_message], run_llm=True)
) )
await _wait_for_quiescence(capture, runner_task, activity_marker) await _wait_for_quiescence(capture, runner_task, activity_marker)
if executor.unmocked_calls: if executor.unmocked_calls:
@@ -886,6 +953,7 @@ class TextPipelineRunner:
id=turn.id, id=turn.id,
index=index, index=index,
user_input=turn.user_input, user_input=turn.user_input,
image=turn.image,
assistant_reply="\n\n".join(capture.outputs[output_marker:]).strip(), assistant_reply="\n\n".join(capture.outputs[output_marker:]).strip(),
tool_calls=list(capture.tool_calls[tool_marker:]), tool_calls=list(capture.tool_calls[tool_marker:]),
runtime_records=list( runtime_records=list(
+89
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
import base64 import base64
from dataclasses import dataclass
from io import BytesIO from io import BytesIO
from typing import Any from typing import Any
@@ -30,6 +31,14 @@ VISION_ANALYSIS_SYSTEM_PROMPT = (
) )
@dataclass(frozen=True)
class VisionInputResolution:
"""The active model path for one explicit user image turn."""
native: bool
config: AssistantConfig
def _require(value: str, label: str) -> str: def _require(value: str, label: str) -> str:
if value: if value:
return value return value
@@ -246,3 +255,83 @@ def config_with_main_llm_as_vision(cfg: AssistantConfig) -> AssistantConfig:
result.vision_llm_api_key = cfg.llm_api_key result.vision_llm_api_key = cfg.llm_api_key
result.vision_llm_base_url = cfg.llm_base_url result.vision_llm_base_url = cfg.llm_base_url
return result return result
def resolve_vision_input(
cfg: AssistantConfig,
workflow_scope: dict[str, Any] | None = None,
) -> VisionInputResolution:
"""Resolve native versus auxiliary vision using the active runtime scope."""
if cfg.type != "workflow":
if not cfg.vision_enabled:
raise ValueError("当前助手未开启视觉能力")
if cfg.vision_model_resource_id:
if not cfg.vision_llm_support_image_input:
raise ValueError("当前视觉模型不支持图片输入")
return VisionInputResolution(native=False, config=cfg)
if not cfg.llm_support_image_input:
raise ValueError("当前大语言模型不支持图片输入")
return VisionInputResolution(native=True, config=cfg)
scope = workflow_scope or {}
if not scope.get("enabled"):
raise ValueError("当前 Workflow Agent 节点未启用视觉能力")
vision_resource_id = str(scope.get("vision_model_resource_id") or "")
llm_resource_id = str(scope.get("llm_resource_id") or "")
resource_id = vision_resource_id or llm_resource_id
resource = cfg.workflow_model_resources.get(resource_id)
if resource is not None:
if not resource.support_image_input:
raise ValueError(f"当前视觉模型不支持图片输入: {resource.id}")
result = config_with_vision_resource(cfg, resource)
elif not vision_resource_id:
result = config_with_main_llm_as_vision(cfg)
else:
raise ValueError(f"视觉模型资源未加载: {vision_resource_id}")
result.vision_prompt_mode = normalize_vision_prompt_mode(
scope.get("vision_prompt_mode")
)
result.vision_prompt = str(scope.get("vision_prompt") or "").strip()
return VisionInputResolution(
native=not bool(vision_resource_id),
config=result,
)
async def build_image_user_message(
cfg: AssistantConfig,
image_bytes: bytes,
prompt_text: str,
*,
workflow_scope: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Build the same model-facing user message used by an explicit photo turn."""
question = prompt_text.strip() or "请根据用户刚提交的图片进行回复。"
frame = await asyncio.to_thread(image_frame_from_jpeg, image_bytes)
resolution = resolve_vision_input(cfg, workflow_scope)
if resolution.native:
data_uri = await asyncio.to_thread(image_data_uri, frame)
return {
"role": "user",
"content": [
{"type": "text", "text": question},
{"type": "image_url", "image_url": {"url": data_uri}},
],
}
observation = await analyze_image_with_vision_model(
resolution.config,
frame,
question,
)
return {
"role": "user",
"content": (
f"{question}\n\n"
"[视觉模型对用户刚提交图片的观察]\n"
f"{observation or '视觉模型没有返回有效观察结果。'}"
),
}
+25 -8
View File
@@ -143,20 +143,36 @@ ExpectedBehavior = Annotated[
] ]
class FixedInputTurn(CamelModel): class TestImageRef(CamelModel):
id: str = Field(min_length=1, max_length=128) asset_id: str = Field(min_length=1, max_length=40)
user_input: str = Field(min_length=1, max_length=20_000) filename: str = Field(default="image.jpg", max_length=255)
behaviors: list[ExpectedBehavior] = Field(default_factory=list, max_length=100) width: int = Field(ge=1)
height: int = Field(ge=1)
size_bytes: int = Field(default=0, ge=0)
@field_validator("user_input") @field_validator("asset_id", "filename")
@classmethod @classmethod
def strip_user_input(cls, value: str) -> str: def strip_image_text(cls, value: str) -> str:
value = value.strip() value = value.strip()
if not value: if not value:
raise ValueError("用户输入不能为空") raise ValueError("图片资源信息不完整")
return value 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
class OverallCriterion(CamelModel): class OverallCriterion(CamelModel):
id: str = Field(min_length=1, max_length=128) id: str = Field(min_length=1, max_length=128)
type: Literal["llm"] = "llm" type: Literal["llm"] = "llm"
@@ -272,7 +288,7 @@ class TestCaseWrite(CamelModel):
@model_validator(mode="after") @model_validator(mode="after")
def validate_mvp_definition(self): def validate_mvp_definition(self):
if self.input_mode != "fixed_script_text": if self.input_mode != "fixed_script_text":
raise ValueError("第一版只支持固定脚本 · 文字") raise ValueError("第一版只支持固定脚本 · 多模态")
TestCaseDefinition( TestCaseDefinition(
context_turns=self.context_turns, context_turns=self.context_turns,
turns=self.turns, turns=self.turns,
@@ -362,6 +378,7 @@ class BatchTurnResult(CamelModel):
id: str id: str
index: int index: int
user_input: str user_input: str
image: TestImageRef | None = None
assistant_reply: str assistant_reply: str
tool_calls: list[BatchToolCallRecord] = Field(default_factory=list) tool_calls: list[BatchToolCallRecord] = Field(default_factory=list)
runtime_records: list[dict[str, Any]] = Field(default_factory=list) runtime_records: list[dict[str, Any]] = Field(default_factory=list)
+14 -7
View File
@@ -9,8 +9,8 @@ from services.pipecat.pipeline import (
KnowledgeRetrievalProcessor, KnowledgeRetrievalProcessor,
UserTurnRoutingProcessor, UserTurnRoutingProcessor,
_knowledge_tool_description, _knowledge_tool_description,
_workflow_vision_uses_main_llm,
) )
from services.vision import resolve_vision_input
class KnowledgeToolDescriptionTest(unittest.TestCase): class KnowledgeToolDescriptionTest(unittest.TestCase):
@@ -159,29 +159,36 @@ class WorkflowVisionModeTest(unittest.TestCase):
capability="LLM", capability="LLM",
interface_type="openai-llm", interface_type="openai-llm",
support_image_input=True, support_image_input=True,
) ),
"auxiliary_vision": RuntimeModelResource(
id="auxiliary_vision",
name="独立视觉模型",
capability="LLM",
interface_type="openai-llm",
support_image_input=True,
),
}, },
) )
self.assertTrue( self.assertTrue(
_workflow_vision_uses_main_llm( resolve_vision_input(
cfg, cfg,
{ {
"enabled": True, "enabled": True,
"llm_resource_id": "agent_llm", "llm_resource_id": "agent_llm",
"vision_model_resource_id": None, "vision_model_resource_id": None,
}, },
) ).native
) )
self.assertFalse( self.assertFalse(
_workflow_vision_uses_main_llm( resolve_vision_input(
cfg, cfg,
{ {
"enabled": True, "enabled": True,
"llm_resource_id": "agent_llm", "llm_resource_id": "agent_llm",
"vision_model_resource_id": "auxiliary_vision", "vision_model_resource_id": "auxiliary_vision",
}, },
) ).native
) )
def test_rejects_a_non_visual_active_agent_llm(self): def test_rejects_a_non_visual_active_agent_llm(self):
@@ -199,7 +206,7 @@ class WorkflowVisionModeTest(unittest.TestCase):
) )
with self.assertRaisesRegex(ValueError, "不支持图片输入"): with self.assertRaisesRegex(ValueError, "不支持图片输入"):
_workflow_vision_uses_main_llm( resolve_vision_input(
cfg, cfg,
{ {
"enabled": True, "enabled": True,
+118
View File
@@ -0,0 +1,118 @@
from io import BytesIO
import unittest
from unittest.mock import AsyncMock, patch
from PIL import Image
from pydantic import ValidationError
from models import AssistantConfig
from services.test_assets import read_test_image, store_test_image
from services.test_runs.text_runner import _transcript_content
from services.vision import build_image_user_message
from test_schemas import FixedInputTurn, TestImageRef
def _jpeg_bytes() -> bytes:
buffer = BytesIO()
Image.new("RGB", (12, 8), (220, 40, 40)).save(buffer, format="JPEG")
return buffer.getvalue()
def _image_ref() -> TestImageRef:
return TestImageRef(
asset_id="ta_fixture",
filename="fixture.jpg",
width=12,
height=8,
size_bytes=128,
)
class TestImageSchemaTests(unittest.TestCase):
def test_allows_image_only_turn(self):
turn = FixedInputTurn(id="turn_1", image=_image_ref())
self.assertEqual(turn.user_input, "")
self.assertEqual(turn.image.asset_id, "ta_fixture")
def test_rejects_turn_without_text_or_image(self):
with self.assertRaisesRegex(ValidationError, "至少填写一项"):
FixedInputTurn(id="turn_1")
class PersistentTestAssetTests(unittest.TestCase):
def test_normalizes_and_stores_under_test_prefix(self):
with patch("services.test_assets.put_object") as put_object:
stored = store_test_image("ta_fixture", _jpeg_bytes())
self.assertEqual(stored.storage_key, "test-assets/ta_fixture.jpg")
self.assertEqual((stored.width, stored.height), (12, 8))
self.assertEqual(put_object.call_args.args[0], stored.storage_key)
self.assertEqual(put_object.call_args.args[2], "image/jpeg")
def test_reads_only_test_asset_keys(self):
with patch("services.test_assets.get_object", return_value=b"jpeg"):
self.assertEqual(read_test_image("test-assets/ta_fixture.jpg"), b"jpeg")
with self.assertRaisesRegex(ValueError, "存储位置无效"):
read_test_image("conversation-inputs/unsafe.jpg")
class ImageUserMessageTests(unittest.IsolatedAsyncioTestCase):
async def test_native_model_receives_multimodal_message(self):
message = await build_image_user_message(
AssistantConfig(
type="prompt",
vision_enabled=True,
llm_support_image_input=True,
),
_jpeg_bytes(),
"这是什么颜色?",
)
self.assertEqual(message["role"], "user")
self.assertEqual(message["content"][0]["text"], "这是什么颜色?")
self.assertTrue(
message["content"][1]["image_url"]["url"].startswith(
"data:image/jpeg;base64,"
)
)
async def test_auxiliary_model_observation_becomes_text_context(self):
with patch(
"services.vision.analyze_image_with_vision_model",
new=AsyncMock(return_value="画面中是一个红色方块。"),
):
message = await build_image_user_message(
AssistantConfig(
type="prompt",
vision_enabled=True,
vision_model_resource_id="vision_model",
vision_llm_support_image_input=True,
),
_jpeg_bytes(),
"画面里有什么?",
)
self.assertIsInstance(message["content"], str)
self.assertIn("红色方块", message["content"])
def test_transcript_does_not_persist_image_data(self):
content = _transcript_content(
{
"role": "user",
"content": [
{"type": "text", "text": "请看图"},
{
"type": "image_url",
"image_url": {"url": "data:image/jpeg;base64,secret"},
},
],
}
)
self.assertEqual(content, "请看图\n[用户提交了一张测试图片]")
self.assertNotIn("base64", content)
if __name__ == "__main__":
unittest.main()
@@ -51,6 +51,7 @@ import {
import { import {
batchRunsApi, batchRunsApi,
modelResourcesApi, modelResourcesApi,
testAssetsApi,
testCasesApi, testCasesApi,
testSuitesApi, testSuitesApi,
type ModelResource, type ModelResource,
@@ -101,7 +102,7 @@ function isRunFinished(run: BatchRunSnapshot): boolean {
function runnableMessage(item: TestCase): string { function runnableMessage(item: TestCase): string {
if (item.inputMode !== "fixed_script_text") { if (item.inputMode !== "fixed_script_text") {
return "当前自动测试仅支持固定脚本 · 文字"; return "当前自动测试仅支持固定脚本 · 多模态";
} }
return getTestCaseValidationMessage(item) ?? ""; return getTestCaseValidationMessage(item) ?? "";
} }
@@ -653,6 +654,16 @@ function AutoTestTimeline({
role: "user", role: "user",
label: `固定输入 · ${turn.index + 1} / ${maxTurns}`, label: `固定输入 · ${turn.index + 1} / ${maxTurns}`,
content: turn.userInput, content: turn.userInput,
attachments: turn.image
? [
{
id: turn.image.assetId,
type: "image",
url: testAssetsApi.contentUrl(turn.image.assetId),
alt: turn.image.filename,
},
]
: undefined,
}, },
}); });
items.push({ items.push({
@@ -28,6 +28,7 @@ import {
type BatchToolCallRecord, type BatchToolCallRecord,
type BatchTurnResult, type BatchTurnResult,
} from "@/data/batch-run"; } from "@/data/batch-run";
import { testAssetsApi } from "@/lib/api";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type BatchCaseDetailDrawerProps = { type BatchCaseDetailDrawerProps = {
@@ -98,7 +99,7 @@ export function BatchCaseDetailDrawer({
<section className="rounded-2xl border border-hairline bg-canvas-soft px-4 py-3.5"> <section className="rounded-2xl border border-hairline bg-canvas-soft px-4 py-3.5">
<p className="text-xs font-medium text-foreground">执行契约</p> <p className="text-xs font-medium text-foreground">执行契约</p>
<p className="mt-1 text-xs leading-5 text-muted-soft"> <p className="mt-1 text-xs leading-5 text-muted-soft">
正在执行固定文字脚本;完成后将展示每一轮回复、工具调用和全部评估标准。 正在执行固定多模态脚本;完成后将展示每轮回复、工具调用和评估标准。
</p> </p>
</section> </section>
<ExecutionTimeline status={item.status} /> <ExecutionTimeline status={item.status} />
@@ -235,9 +236,19 @@ function TurnResultCard({ turn }: { turn: BatchTurnResult }) {
</p> </p>
<div className="mt-2 grid gap-2 text-xs leading-5 sm:grid-cols-[72px_minmax(0,1fr)]"> <div className="mt-2 grid gap-2 text-xs leading-5 sm:grid-cols-[72px_minmax(0,1fr)]">
<span className="text-muted-soft">用户输入</span> <span className="text-muted-soft">用户输入</span>
<p className="whitespace-pre-wrap text-muted-foreground"> <div className="space-y-2 text-muted-foreground">
{turn.userInput} {turn.image ? (
</p> // eslint-disable-next-line @next/next/no-img-element
<img
src={testAssetsApi.contentUrl(turn.image.assetId)}
alt={turn.image.filename}
className="max-h-48 w-full max-w-xs rounded-xl object-cover"
/>
) : null}
{turn.userInput ? (
<p className="whitespace-pre-wrap">{turn.userInput}</p>
) : null}
</div>
<span className="text-muted-soft">Agent 回复</span> <span className="text-muted-soft">Agent 回复</span>
<p className="whitespace-pre-wrap text-muted-foreground"> <p className="whitespace-pre-wrap text-muted-foreground">
{turn.assistantReply} {turn.assistantReply}
@@ -112,6 +112,8 @@ function SuiteListView() {
} }
useEffect(() => { useEffect(() => {
// Initial API hydration is intentionally owned by this mount effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
void reloadSuites(); void reloadSuites();
}, []); }, []);
@@ -507,6 +509,8 @@ function SuiteDetailView({
} }
useEffect(() => { useEffect(() => {
// Suite changes remount this view; hydrate the selected case once.
// eslint-disable-next-line react-hooks/set-state-in-effect
void reload(initialCaseId ?? null); void reload(initialCaseId ?? null);
// suiteId changes by remount; the initial request only runs once per view. // suiteId changes by remount; the initial request only runs once per view.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -931,7 +935,7 @@ function SuiteDetailView({
{TEST_CASE_INPUT_MODE_LABEL[draft.inputMode]} · 开发中 {TEST_CASE_INPUT_MODE_LABEL[draft.inputMode]} · 开发中
</div> </div>
<p className="max-w-sm text-xs leading-5 text-muted-foreground"> <p className="max-w-sm text-xs leading-5 text-muted-foreground">
当前仅支持「固定脚本 · 文字」编辑器,其它输入模式稍后接入。 当前仅支持「固定脚本 · 多模态」编辑器,其它输入模式稍后接入。
</p> </p>
</div> </div>
)} )}
@@ -37,8 +37,8 @@ const FIXED_SCRIPT_OPTIONS: {
}[] = [ }[] = [
{ {
value: "fixed_script_text", value: "fixed_script_text",
title: "文字", title: "文字与图片",
description: "使用固定文字逐轮测试", description: "使用固定文字或图片逐轮测试",
icon: Type, icon: Type,
available: true, available: true,
}, },
@@ -2,7 +2,7 @@
/** /**
* 测试用例编辑器主体: * 测试用例编辑器主体:
* 对话上下文 → 固定文字脚本轮次 → 整体评估标准。 * 对话上下文 → 固定多模态脚本轮次 → 整体评估标准。
*/ */
import { import {
@@ -26,6 +26,8 @@ import {
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
GripVertical, GripVertical,
ImagePlus,
Loader2,
MessageSquareText, MessageSquareText,
MessagesSquare, MessagesSquare,
Plus, Plus,
@@ -71,12 +73,13 @@ import {
type KeywordMatchMode, type KeywordMatchMode,
type OverallCriterion, type OverallCriterion,
type ReplyExpectedBehavior, type ReplyExpectedBehavior,
type TestImageRef,
type TestCaseInputMode, type TestCaseInputMode,
type ToolCallExpectedBehavior, type ToolCallExpectedBehavior,
type ToolParamAssertion, type ToolParamAssertion,
type ToolParamMatchMode, type ToolParamMatchMode,
} from "@/data/test-suites"; } from "@/data/test-suites";
import { toolsApi, type Tool } from "@/lib/api"; import { testAssetsApi, toolsApi, type Tool } from "@/lib/api";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export type CaseEditorDraft = { export type CaseEditorDraft = {
@@ -637,6 +640,9 @@ export function NextReplyEditorBody({
onChangeUserInput={(userInput) => onChangeUserInput={(userInput) =>
updateTurn(turn.id, { userInput }) updateTurn(turn.id, { userInput })
} }
onChangeImage={(image) =>
updateTurn(turn.id, { image })
}
onAddReply={() => onAddReply={() =>
addBehavior(turn.id, createReplyBehavior("llm")) addBehavior(turn.id, createReplyBehavior("llm"))
} }
@@ -812,6 +818,7 @@ function SortableFixedInputTurn({
expandedBehaviorIds, expandedBehaviorIds,
onToggleBehavior, onToggleBehavior,
onChangeUserInput, onChangeUserInput,
onChangeImage,
onAddReply, onAddReply,
onAddToolCall, onAddToolCall,
onUpdateBehavior, onUpdateBehavior,
@@ -825,6 +832,7 @@ function SortableFixedInputTurn({
expandedBehaviorIds: Set<string>; expandedBehaviorIds: Set<string>;
onToggleBehavior: (behaviorId: string) => void; onToggleBehavior: (behaviorId: string) => void;
onChangeUserInput: (value: string) => void; onChangeUserInput: (value: string) => void;
onChangeImage: (value: TestImageRef | null) => void;
onAddReply: () => void; onAddReply: () => void;
onAddToolCall: () => void; onAddToolCall: () => void;
onUpdateBehavior: (behavior: ExpectedBehavior) => void; onUpdateBehavior: (behavior: ExpectedBehavior) => void;
@@ -840,6 +848,34 @@ function SortableFixedInputTurn({
isDragging, isDragging,
} = useSortable({ id: turn.id }); } = useSortable({ id: turn.id });
const [addOpen, setAddOpen] = useState(false); const [addOpen, setAddOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploadingImage, setUploadingImage] = useState(false);
const [imageError, setImageError] = useState("");
async function uploadImage(file: File | undefined) {
if (!file || uploadingImage) return;
if (!file.type.startsWith("image/")) {
setImageError("请选择图片文件");
return;
}
setUploadingImage(true);
setImageError("");
try {
const asset = await testAssetsApi.uploadImage(file);
onChangeImage({
assetId: asset.id,
filename: asset.filename,
width: asset.width,
height: asset.height,
sizeBytes: asset.sizeBytes,
});
} catch (error) {
setImageError(error instanceof Error ? error.message : "图片上传失败");
} finally {
setUploadingImage(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
const style = { const style = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
@@ -888,17 +924,108 @@ function SortableFixedInputTurn({
</div> </div>
<div className="space-y-3 pl-1"> <div className="space-y-3 pl-1">
<div className="space-y-1.5"> <div
className="space-y-1.5"
onDragOver={(event) => {
if (event.dataTransfer.types.includes("Files")) event.preventDefault();
}}
onDrop={(event) => {
const file = Array.from(event.dataTransfer.files).find((item) =>
item.type.startsWith("image/"),
);
if (!file) return;
event.preventDefault();
void uploadImage(file);
}}
>
<div className="text-xs font-medium text-muted-foreground"> <div className="text-xs font-medium text-muted-foreground">
用户输入 用户输入
</div> </div>
<Textarea <Textarea
value={turn.userInput} value={turn.userInput}
onChange={(event) => onChangeUserInput(event.target.value)} onChange={(event) => onChangeUserInput(event.target.value)}
placeholder="输入本轮用户原话…" onPaste={(event) => {
const file = Array.from(event.clipboardData.files).find((item) =>
item.type.startsWith("image/"),
);
if (!file) return;
event.preventDefault();
void uploadImage(file);
}}
placeholder={
turn.image
? "可选:补充用户与图片一起提交的问题…"
: "输入本轮用户原话,或添加一张图片…"
}
rows={2} rows={2}
className="field-sizing-fixed min-h-[56px] resize-y border-hairline-strong bg-background text-sm" className="field-sizing-fixed min-h-[56px] resize-y border-hairline-strong bg-background text-sm"
/> />
{turn.image ? (
<div className="flex items-center gap-3 rounded-xl border border-hairline bg-canvas-soft p-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={testAssetsApi.contentUrl(turn.image.assetId)}
alt={turn.image.filename}
className="h-16 w-20 shrink-0 rounded-lg object-cover"
/>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium text-foreground">
{turn.image.filename}
</div>
<div className="mt-1 text-[11px] text-muted-soft">
{turn.image.width} × {turn.image.height}
{turn.image.sizeBytes > 0
? ` · ${Math.max(1, Math.round(turn.image.sizeBytes / 1024))} KB`
: ""}
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0 text-muted-soft hover:text-destructive"
onClick={() => {
setImageError("");
onChangeImage(null);
}}
aria-label="移除本轮图片"
>
<Trash2 size={14} />
</Button>
</div>
) : null}
<div className="flex flex-wrap items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="sr-only"
onChange={(event) => void uploadImage(event.target.files?.[0])}
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1.5 rounded-full border-hairline-strong text-xs text-muted-foreground"
disabled={uploadingImage}
onClick={() => fileInputRef.current?.click()}
>
{uploadingImage ? (
<Loader2 size={13} className="animate-spin" />
) : (
<ImagePlus size={13} />
)}
{turn.image ? "替换图片" : "添加图片"}
</Button>
<span className="text-[11px] text-muted-soft">
支持选择、粘贴或拖入;每轮最多一张
</span>
</div>
{imageError ? (
<p className="text-[11px] text-destructive" role="alert">
{imageError}
</p>
) : null}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
+7
View File
@@ -54,6 +54,13 @@ export type BatchTurnResult = {
id: string; id: string;
index: number; index: number;
userInput: string; userInput: string;
image: {
assetId: string;
filename: string;
width: number;
height: number;
sizeBytes: number;
} | null;
assistantReply: string; assistantReply: string;
toolCalls: BatchToolCallRecord[]; toolCalls: BatchToolCallRecord[];
runtimeRecords?: Array<Record<string, unknown>>; runtimeRecords?: Array<Record<string, unknown>>;
+17 -6
View File
@@ -1,7 +1,7 @@
/** /**
* 测试集 / 测试用例的前后端共享契约与编辑器辅助函数。 * 测试集 / 测试用例的前后端共享契约与编辑器辅助函数。
* 两层:Test Suite → Test Case。 * 两层:Test Suite → Test Case。
* MVP 仅运行固定文字脚本;其它输入模式保留为产品路线提示。 * MVP 运行固定文字/图片脚本;其它输入模式保留为产品路线提示。
*/ */
/** 顶部模式选择器的唯一来源;不再单独维护「测试类型」字段 */ /** 顶部模式选择器的唯一来源;不再单独维护「测试类型」字段 */
@@ -80,6 +80,14 @@ export type ToolCallExpectedBehavior = {
export type ExpectedBehavior = ReplyExpectedBehavior | ToolCallExpectedBehavior; export type ExpectedBehavior = ReplyExpectedBehavior | ToolCallExpectedBehavior;
export type TestImageRef = {
assetId: string;
filename: string;
width: number;
height: number;
sizeBytes: number;
};
/** /**
* 固定输入的一轮:只编辑 User 输入;Agent 回复 / Tool Call 由运行时生成。 * 固定输入的一轮:只编辑 User 输入;Agent 回复 / Tool Call 由运行时生成。
* behaviors 为 0~N 个预期行为(AND);空数组表示仅推动对话、不做断言。 * behaviors 为 0~N 个预期行为(AND);空数组表示仅推动对话、不做断言。
@@ -87,6 +95,7 @@ export type ExpectedBehavior = ReplyExpectedBehavior | ToolCallExpectedBehavior;
export type FixedInputTurn = { export type FixedInputTurn = {
id: string; id: string;
userInput: string; userInput: string;
image: TestImageRef | null;
behaviors: ExpectedBehavior[]; behaviors: ExpectedBehavior[];
}; };
@@ -134,7 +143,7 @@ export const DEFAULT_INPUT_MODE: TestCaseInputMode = "fixed_script_text";
/** /**
* MVP 可运行性规则。 * MVP 可运行性规则。
* 目前执行器只支持固定文字;每一轮都需要用户输入,并至少配置一项断言。 * 目前执行器支持固定文字和单张图片;每轮至少有一种输入,并配置断言。
*/ */
export function getTestCaseValidationMessage( export function getTestCaseValidationMessage(
item: Pick< item: Pick<
@@ -219,10 +228,10 @@ export function getTestCaseValidationMessage(
.join("、")}`; .join("、")}`;
} }
const emptyTurnIndex = item.turns.findIndex( const emptyTurnIndex = item.turns.findIndex(
(turn) => !turn.userInput.trim(), (turn) => !turn.userInput.trim() && !turn.image,
); );
if (emptyTurnIndex >= 0) { if (emptyTurnIndex >= 0) {
return `第 ${emptyTurnIndex + 1} 轮用户输入不能为空`; return `第 ${emptyTurnIndex + 1} 轮需要填写用户文字或添加图片`;
} }
for (let turnIndex = 0; turnIndex < item.turns.length; turnIndex += 1) { for (let turnIndex = 0; turnIndex < item.turns.length; turnIndex += 1) {
const turn = item.turns[turnIndex]; const turn = item.turns[turnIndex];
@@ -267,7 +276,7 @@ export function isTestCaseRunnable(
} }
export const TEST_CASE_INPUT_MODE_LABEL: Record<TestCaseInputMode, string> = { export const TEST_CASE_INPUT_MODE_LABEL: Record<TestCaseInputMode, string> = {
fixed_script_text: "固定脚本 · 文字", fixed_script_text: "固定脚本 · 多模态",
fixed_script_turn_voice: "固定脚本 · 逐轮语音", fixed_script_turn_voice: "固定脚本 · 逐轮语音",
fixed_script_continuous_voice: "固定脚本 · 连续语音", fixed_script_continuous_voice: "固定脚本 · 连续语音",
user_sim_text: "用户模拟 · 文字", user_sim_text: "用户模拟 · 文字",
@@ -279,7 +288,7 @@ export const TEST_CASE_INPUT_MODE_SHORT_LABEL: Record<
TestCaseInputMode, TestCaseInputMode,
string string
> = { > = {
fixed_script_text: "固定文字", fixed_script_text: "固定输入",
fixed_script_turn_voice: "逐轮语音", fixed_script_turn_voice: "逐轮语音",
fixed_script_continuous_voice: "连续语音", fixed_script_continuous_voice: "连续语音",
user_sim_text: "模拟文字", user_sim_text: "模拟文字",
@@ -412,6 +421,7 @@ export function createEmptyFixedInputTurn(
return { return {
id: newEditorId("turn"), id: newEditorId("turn"),
userInput, userInput,
image: null,
behaviors: [], behaviors: [],
}; };
} }
@@ -484,6 +494,7 @@ export function cloneTurns(turns: FixedInputTurn[]): FixedInputTurn[] {
return turns.map((turn) => ({ return turns.map((turn) => ({
id: turn.id, id: turn.id,
userInput: turn.userInput, userInput: turn.userInput,
image: turn.image ? { ...turn.image } : null,
behaviors: turn.behaviors.map(cloneBehavior), behaviors: turn.behaviors.map(cloneBehavior),
})); }));
} }
+27
View File
@@ -854,6 +854,33 @@ export type TestCaseWrite = {
overallCriteria: OverallCriterion[]; overallCriteria: OverallCriterion[];
}; };
export type TestImageAsset = {
id: string;
filename: string;
width: number;
height: number;
sizeBytes: number;
contentUrl: string;
};
export const testAssetsApi = {
uploadImage: (file: File) => {
const body = new FormData();
body.append("file", file);
return request<TestImageAsset>("/api/test-assets/images", {
method: "POST",
body,
});
},
contentUrl: (assetId: string) =>
`${API_BASE}/api/test-assets/${encodeURIComponent(assetId)}/content`,
remove: (assetId: string) =>
request<{ ok: boolean }>(
`/api/test-assets/${encodeURIComponent(assetId)}`,
{ method: "DELETE" },
),
};
export const testSuitesApi = { export const testSuitesApi = {
list: () => request<TestSuite[]>("/api/test-suites"), list: () => request<TestSuite[]>("/api/test-suites"),
get: (id: string) => request<TestSuite>(`/api/test-suites/${id}`), get: (id: string) => request<TestSuite>(`/api/test-suites/${id}`),