feat: add persisted batch text testing

This commit is contained in:
Xin Wang
2026-08-10 13:49:24 +08:00
parent 5b8b9fd097
commit 19e8c8c108
27 changed files with 3855 additions and 1450 deletions

View File

@@ -27,6 +27,10 @@ from db.session import sync_default_tools, sync_interface_definitions
from services.knowledge import recover_interrupted_documents from services.knowledge import recover_interrupted_documents
from services.post_call.worker import recover_interrupted_analyses, run_analysis_worker from services.post_call.worker import recover_interrupted_analyses, run_analysis_worker
from services.webhooks.worker import recover_interrupted_webhooks, run_webhook_worker from services.webhooks.worker import recover_interrupted_webhooks, run_webhook_worker
from services.test_runs.orchestrator import (
recover_interrupted_test_runs,
test_run_orchestrator,
)
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@@ -41,6 +45,8 @@ from routes import (
model_registry, model_registry,
node_types, node_types,
site_settings, site_settings,
test_cases,
test_runs,
tools, tools,
voice_webrtc, voice_webrtc,
voice_ws, voice_ws,
@@ -54,6 +60,7 @@ async def lifespan(_app: FastAPI):
await recover_interrupted_documents() await recover_interrupted_documents()
await recover_interrupted_analyses() await recover_interrupted_analyses()
await recover_interrupted_webhooks() await recover_interrupted_webhooks()
await recover_interrupted_test_runs()
analysis_worker = asyncio.create_task( analysis_worker = asyncio.create_task(
run_analysis_worker(), name="post-call-analysis-worker" run_analysis_worker(), name="post-call-analysis-worker"
) )
@@ -68,6 +75,7 @@ async def lifespan(_app: FastAPI):
await asyncio.gather( await asyncio.gather(
analysis_worker, webhook_worker, return_exceptions=True analysis_worker, webhook_worker, return_exceptions=True
) )
await test_run_orchestrator.shutdown()
await voice_webrtc.shutdown_active_sessions() await voice_webrtc.shutdown_active_sessions()
@@ -91,6 +99,8 @@ app.include_router(mcp_servers.router)
app.include_router(model_registry.router) app.include_router(model_registry.router)
app.include_router(node_types.router) app.include_router(node_types.router)
app.include_router(site_settings.router) app.include_router(site_settings.router)
app.include_router(test_cases.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)
app.include_router(voice_ws.router) app.include_router(voice_ws.router)

View File

@@ -422,3 +422,115 @@ class WebhookDelivery(Base):
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now() DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
) )
class TestSuite(Base):
"""A persisted group of text-based assistant test cases."""
__tablename__ = "test_suites"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
name: Mapped[str] = mapped_column(String(128))
description: Mapped[str] = mapped_column(String(2048), default="")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class TestCase(Base):
"""One editable test definition; nested turns remain one JSON document."""
__tablename__ = "test_cases"
__table_args__ = (
UniqueConstraint("suite_id", "sort_order", name="uq_test_case_suite_order"),
)
id: Mapped[str] = mapped_column(String(40), primary_key=True)
suite_id: Mapped[str] = mapped_column(
String(40),
ForeignKey("test_suites.id", ondelete="CASCADE"),
index=True,
)
name: Mapped[str] = mapped_column(String(128))
description: Mapped[str] = mapped_column(String(2048), default="")
input_mode: Mapped[str] = mapped_column(
String(40), default="fixed_script_text", index=True
)
definition: Mapped[dict] = mapped_column(JSONB, default=dict)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
last_result: Mapped[str] = mapped_column(String(16), default="not_run")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class TestRun(Base):
"""One immutable batch-run request plus its aggregate lifecycle state."""
__tablename__ = "test_runs"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
assistant_id: Mapped[str | None] = mapped_column(
String(40),
ForeignKey("assistants.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
assistant_name: Mapped[str] = mapped_column(String(128), default="")
title: Mapped[str] = mapped_column(String(256), default="批量测试")
status: Mapped[str] = mapped_column(String(16), index=True, default="queued")
config: Mapped[dict] = mapped_column(JSONB, default=dict)
stop_reason: Mapped[str | None] = mapped_column(String(32), nullable=True)
cancel_requested: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
finished_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
class TestRunCase(Base):
"""Run-local test case snapshot and detailed execution result."""
__tablename__ = "test_run_cases"
__table_args__ = (
UniqueConstraint("run_id", "test_case_id", name="uq_test_run_case"),
)
id: Mapped[str] = mapped_column(String(40), primary_key=True)
run_id: Mapped[str] = mapped_column(
String(40),
ForeignKey("test_runs.id", ondelete="CASCADE"),
index=True,
)
test_case_id: Mapped[str | None] = mapped_column(
String(40),
ForeignKey("test_cases.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
test_case_name: Mapped[str] = mapped_column(String(128))
suite_id: Mapped[str | None] = mapped_column(String(40), nullable=True)
position: Mapped[int] = mapped_column(Integer, default=0)
case_snapshot: Mapped[dict] = mapped_column(JSONB, default=dict)
status: Mapped[str] = mapped_column(String(16), index=True, default="waiting")
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
max_attempts: Mapped[int] = mapped_column(Integer, default=1)
result: Mapped[dict] = mapped_column(JSONB, default=dict)
execution_error: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
finished_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)

View File

@@ -0,0 +1,112 @@
"""add persisted test suites, cases, and batch runs
Revision ID: 20260810_0016
Revises: 20260807_0015
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260810_0016"
down_revision: str | Sequence[str] | None = "20260807_0015"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"test_suites",
sa.Column("id", sa.String(length=40), nullable=False),
sa.Column("name", sa.String(length=128), nullable=False),
sa.Column("description", sa.String(length=2048), server_default="", nullable=False),
sa.Column("assistant_id", sa.String(length=40), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["assistant_id"], ["assistants.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_test_suites_assistant_id", "test_suites", ["assistant_id"])
op.create_table(
"test_cases",
sa.Column("id", sa.String(length=40), nullable=False),
sa.Column("suite_id", sa.String(length=40), nullable=False),
sa.Column("name", sa.String(length=128), nullable=False),
sa.Column("description", sa.String(length=2048), server_default="", nullable=False),
sa.Column("input_mode", sa.String(length=40), server_default="fixed_script_text", nullable=False),
sa.Column("definition", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column("sort_order", sa.Integer(), server_default="0", nullable=False),
sa.Column("last_result", sa.String(length=16), server_default="not_run", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["suite_id"], ["test_suites.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("suite_id", "sort_order", name="uq_test_case_suite_order"),
)
op.create_index("ix_test_cases_suite_id", "test_cases", ["suite_id"])
op.create_index("ix_test_cases_input_mode", "test_cases", ["input_mode"])
op.create_table(
"test_runs",
sa.Column("id", sa.String(length=40), nullable=False),
sa.Column("assistant_id", sa.String(length=40), nullable=True),
sa.Column("assistant_name", sa.String(length=128), server_default="", nullable=False),
sa.Column("title", sa.String(length=256), server_default="批量测试", nullable=False),
sa.Column("status", sa.String(length=16), server_default="queued", nullable=False),
sa.Column("config", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column("stop_reason", sa.String(length=32), nullable=True),
sa.Column("cancel_requested", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["assistant_id"], ["assistants.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_test_runs_assistant_id", "test_runs", ["assistant_id"])
op.create_index("ix_test_runs_status", "test_runs", ["status"])
op.create_table(
"test_run_cases",
sa.Column("id", sa.String(length=40), nullable=False),
sa.Column("run_id", sa.String(length=40), nullable=False),
sa.Column("test_case_id", sa.String(length=40), nullable=True),
sa.Column("test_case_name", sa.String(length=128), nullable=False),
sa.Column("suite_id", sa.String(length=40), nullable=True),
sa.Column("position", sa.Integer(), server_default="0", nullable=False),
sa.Column("case_snapshot", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column("status", sa.String(length=16), server_default="waiting", nullable=False),
sa.Column("attempt_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("max_attempts", sa.Integer(), server_default="1", nullable=False),
sa.Column("result", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column("execution_error", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["run_id"], ["test_runs.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["test_case_id"], ["test_cases.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("run_id", "test_case_id", name="uq_test_run_case"),
)
op.create_index("ix_test_run_cases_run_id", "test_run_cases", ["run_id"])
op.create_index("ix_test_run_cases_test_case_id", "test_run_cases", ["test_case_id"])
op.create_index("ix_test_run_cases_status", "test_run_cases", ["status"])
def downgrade() -> None:
op.drop_index("ix_test_run_cases_status", table_name="test_run_cases")
op.drop_index("ix_test_run_cases_test_case_id", table_name="test_run_cases")
op.drop_index("ix_test_run_cases_run_id", table_name="test_run_cases")
op.drop_table("test_run_cases")
op.drop_index("ix_test_runs_status", table_name="test_runs")
op.drop_index("ix_test_runs_assistant_id", table_name="test_runs")
op.drop_table("test_runs")
op.drop_index("ix_test_cases_input_mode", table_name="test_cases")
op.drop_index("ix_test_cases_suite_id", table_name="test_cases")
op.drop_table("test_cases")
op.drop_index("ix_test_suites_assistant_id", table_name="test_suites")
op.drop_table("test_suites")

View File

@@ -0,0 +1,43 @@
"""remove obsolete assistant binding from test suites
Revision ID: 20260810_0017
Revises: 20260810_0016
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "20260810_0017"
down_revision: str | Sequence[str] | None = "20260810_0016"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.drop_index("ix_test_suites_assistant_id", table_name="test_suites")
op.drop_column("test_suites", "assistant_id")
def downgrade() -> None:
op.add_column(
"test_suites",
sa.Column("assistant_id", sa.String(length=40), nullable=True),
)
op.create_foreign_key(
"fk_test_suites_assistant_id_assistants",
"test_suites",
"assistants",
["assistant_id"],
["id"],
ondelete="SET NULL",
)
op.create_index(
"ix_test_suites_assistant_id",
"test_suites",
["assistant_id"],
)

View File

@@ -0,0 +1,369 @@
"""Persistent test suite and fixed-text test case CRUD."""
from __future__ import annotations
from datetime import UTC, datetime
from uuid import uuid4
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 sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from test_schemas import (
TestCaseBulkDeleteIn,
TestCaseDefinition,
TestCaseOrderIn,
TestCaseOut,
TestCaseWrite,
TestSuiteCreate,
TestSuiteOut,
TestSuiteUpdate,
)
router = APIRouter(
tags=["tests"],
dependencies=[Depends(require_admin)],
)
def _new_id(prefix: str) -> str:
return f"{prefix}_{uuid4().hex}"
async def _touch_suite(session: AsyncSession, suite_id: str) -> None:
suite = await session.get(TestSuite, suite_id)
if suite is not None:
suite.updated_at = datetime.now(UTC)
async def _suite_out(session: AsyncSession, row: TestSuite) -> TestSuiteOut:
total = await session.scalar(
select(func.count()).select_from(TestCase).where(TestCase.suite_id == row.id)
)
run = await session.scalar(
select(func.count(func.distinct(TestRunCase.run_id))).where(
TestRunCase.suite_id == row.id
)
)
passed = await session.scalar(
select(func.count())
.select_from(TestCase)
.where(TestCase.suite_id == row.id, TestCase.last_result == "pass")
)
return TestSuiteOut(
id=row.id,
name=row.name,
description=row.description,
case_count=int(total or 0),
passed_count=int(passed or 0),
run_count=int(run or 0),
updated_at=row.updated_at,
)
def _case_out(row: TestCase) -> TestCaseOut:
definition = TestCaseDefinition.model_validate(row.definition or {})
return TestCaseOut(
id=row.id,
suite_id=row.suite_id,
name=row.name,
description=row.description,
input_mode=row.input_mode,
last_result=row.last_result,
sort_order=row.sort_order,
updated_at=row.updated_at,
**definition.model_dump(),
)
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(
select(TestCase)
.where(TestCase.suite_id == suite_id)
.order_by(TestCase.sort_order, TestCase.created_at, TestCase.id)
)
).scalars().all()
offset = len(rows) + 1
for index, row in enumerate(rows):
row.sort_order = offset + index
await session.flush()
for index, row in enumerate(rows):
row.sort_order = index
@router.get("/api/test-suites", response_model=list[TestSuiteOut])
async def list_test_suites(session: AsyncSession = Depends(get_session)):
rows = (
await session.execute(select(TestSuite).order_by(TestSuite.updated_at.desc()))
).scalars().all()
return [await _suite_out(session, row) for row in rows]
@router.post("/api/test-suites", response_model=TestSuiteOut)
async def create_test_suite(
body: TestSuiteCreate,
session: AsyncSession = Depends(get_session),
):
row = TestSuite(
id=_new_id("suite"),
name=body.name.strip(),
description=body.description.strip(),
)
session.add(row)
await session.commit()
await session.refresh(row)
return await _suite_out(session, row)
@router.get("/api/test-suites/{suite_id}", response_model=TestSuiteOut)
async def get_test_suite(
suite_id: str,
session: AsyncSession = Depends(get_session),
):
row = await session.get(TestSuite, suite_id)
if row is None:
raise HTTPException(404, "测试集不存在")
return await _suite_out(session, row)
@router.put("/api/test-suites/{suite_id}", response_model=TestSuiteOut)
async def update_test_suite(
suite_id: str,
body: TestSuiteUpdate,
session: AsyncSession = Depends(get_session),
):
row = await session.get(TestSuite, suite_id)
if row is None:
raise HTTPException(404, "测试集不存在")
row.name = body.name.strip()
row.description = body.description.strip()
await session.commit()
await session.refresh(row)
return await _suite_out(session, row)
@router.delete("/api/test-suites/{suite_id}")
async def delete_test_suite(
suite_id: str,
session: AsyncSession = Depends(get_session),
):
row = await session.get(TestSuite, suite_id)
if row is None:
raise HTTPException(404, "测试集不存在")
await session.delete(row)
await session.commit()
return {"ok": True}
@router.post("/api/test-suites/{suite_id}/duplicate", response_model=TestSuiteOut)
async def duplicate_test_suite(
suite_id: str,
session: AsyncSession = Depends(get_session),
):
source = await session.get(TestSuite, suite_id)
if source is None:
raise HTTPException(404, "测试集不存在")
copied = TestSuite(
id=_new_id("suite"),
name=f"{source.name}(副本)",
description=source.description,
)
session.add(copied)
source_cases = (
await session.execute(
select(TestCase)
.where(TestCase.suite_id == suite_id)
.order_by(TestCase.sort_order)
)
).scalars().all()
for index, source_case in enumerate(source_cases):
session.add(
TestCase(
id=_new_id("tc"),
suite_id=copied.id,
name=source_case.name,
description=source_case.description,
input_mode=source_case.input_mode,
definition=dict(source_case.definition or {}),
sort_order=index,
last_result="not_run",
)
)
await session.commit()
await session.refresh(copied)
return await _suite_out(session, copied)
@router.get("/api/test-cases", response_model=list[TestCaseOut])
async def list_test_cases(
suite_id: str | None = Query(default=None, alias="suiteId"),
session: AsyncSession = Depends(get_session),
):
statement = select(TestCase)
if suite_id:
statement = statement.where(TestCase.suite_id == suite_id)
rows = (
await session.execute(
statement.order_by(TestCase.suite_id, TestCase.sort_order, TestCase.name)
)
).scalars().all()
return [_case_out(row) for row in rows]
@router.post("/api/test-suites/{suite_id}/cases", response_model=TestCaseOut)
async def create_test_case(
suite_id: str,
body: TestCaseWrite,
session: AsyncSession = Depends(get_session),
):
if await session.get(TestSuite, suite_id) is None:
raise HTTPException(404, "测试集不存在")
max_order = await session.scalar(
select(func.max(TestCase.sort_order)).where(TestCase.suite_id == suite_id)
)
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),
sort_order=int(max_order if max_order is not None else -1) + 1,
last_result="not_run",
)
session.add(row)
await _touch_suite(session, suite_id)
await session.commit()
await session.refresh(row)
return _case_out(row)
@router.post("/api/test-cases/bulk-delete")
async def bulk_delete_test_cases(
body: TestCaseBulkDeleteIn,
session: AsyncSession = Depends(get_session),
):
rows = (
await session.execute(select(TestCase).where(TestCase.id.in_(body.case_ids)))
).scalars().all()
suite_ids = {row.suite_id for row in rows}
for row in rows:
await session.delete(row)
await session.flush()
for suite_id in suite_ids:
await _renumber_cases(session, suite_id)
await _touch_suite(session, suite_id)
await session.commit()
return {"ok": True, "deleted": len(rows)}
@router.put("/api/test-suites/{suite_id}/case-order")
async def reorder_test_cases(
suite_id: str,
body: TestCaseOrderIn,
session: AsyncSession = Depends(get_session),
):
rows = (
await session.execute(
select(TestCase)
.where(TestCase.suite_id == suite_id)
.order_by(TestCase.sort_order)
)
).scalars().all()
if {row.id for row in rows} != set(body.case_ids) or len(rows) != len(body.case_ids):
raise HTTPException(422, "排序列表必须包含测试集内全部用例且不能重复")
by_id = {row.id: row for row in rows}
offset = len(rows) + 1
for index, row in enumerate(rows):
row.sort_order = offset + index
await session.flush()
for index, case_id in enumerate(body.case_ids):
by_id[case_id].sort_order = index
await _touch_suite(session, suite_id)
await session.commit()
return {"ok": True}
@router.get("/api/test-cases/{case_id}", response_model=TestCaseOut)
async def get_test_case(
case_id: str,
session: AsyncSession = Depends(get_session),
):
row = await session.get(TestCase, case_id)
if row is None:
raise HTTPException(404, "测试用例不存在")
return _case_out(row)
@router.put("/api/test-cases/{case_id}", response_model=TestCaseOut)
async def update_test_case(
case_id: str,
body: TestCaseWrite,
session: AsyncSession = Depends(get_session),
):
row = await session.get(TestCase, case_id)
if row is None:
raise HTTPException(404, "测试用例不存在")
_apply_case_write(row, body)
await _touch_suite(session, row.suite_id)
await session.commit()
await session.refresh(row)
return _case_out(row)
@router.delete("/api/test-cases/{case_id}")
async def delete_test_case(
case_id: str,
session: AsyncSession = Depends(get_session),
):
row = await session.get(TestCase, case_id)
if row is None:
raise HTTPException(404, "测试用例不存在")
suite_id = row.suite_id
await session.delete(row)
await session.flush()
await _renumber_cases(session, suite_id)
await _touch_suite(session, suite_id)
await session.commit()
return {"ok": True}
@router.post("/api/test-cases/{case_id}/duplicate", response_model=TestCaseOut)
async def duplicate_test_case(
case_id: str,
session: AsyncSession = Depends(get_session),
):
source = await session.get(TestCase, case_id)
if source is None:
raise HTTPException(404, "测试用例不存在")
max_order = await session.scalar(
select(func.max(TestCase.sort_order)).where(
TestCase.suite_id == source.suite_id
)
)
copied = TestCase(
id=_new_id("tc"),
suite_id=source.suite_id,
name=f"{source.name}(副本)",
description=source.description,
input_mode=source.input_mode,
definition=dict(source.definition or {}),
sort_order=int(max_order if max_order is not None else -1) + 1,
last_result="not_run",
)
session.add(copied)
await _touch_suite(session, source.suite_id)
await session.commit()
await session.refresh(copied)
return _case_out(copied)

240
backend/routes/test_runs.py Normal file
View File

@@ -0,0 +1,240 @@
"""Start, inspect, and cancel persisted batch text test runs."""
from __future__ import annotations
from datetime import UTC, datetime
from uuid import uuid4
from db.models import (
Assistant,
ModelResource,
TestCase,
TestRun,
TestRunCase,
TestSuite,
)
from db.session import get_session
from fastapi import APIRouter, Depends, HTTPException
from services.auth import require_admin
from services.config_resolver import resolve_runtime_config
from services.test_runs.orchestrator import test_run_orchestrator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from test_schemas import (
BatchExecutionError,
BatchRunCaseOut,
BatchRunCreate,
BatchRunSnapshotOut,
BatchTurnResult,
BatchEvaluationResult,
TestCaseDefinition,
)
router = APIRouter(
prefix="/api/test-runs",
tags=["tests"],
dependencies=[Depends(require_admin)],
)
def _new_id(prefix: str) -> str:
return f"{prefix}_{uuid4().hex}"
async def _run_out(
session: AsyncSession,
run: TestRun,
) -> BatchRunSnapshotOut:
rows = (
await session.execute(
select(TestRunCase)
.where(TestRunCase.run_id == run.id)
.order_by(TestRunCase.position)
)
).scalars().all()
cases: list[BatchRunCaseOut] = []
for row in rows:
result = dict(row.result or {})
cases.append(
BatchRunCaseOut(
id=str(row.test_case_id or row.case_snapshot.get("id") or row.id),
name=row.test_case_name,
status=row.status,
turns=[
BatchTurnResult.model_validate(item)
for item in result.get("turns") or []
],
overall_criteria=[
BatchEvaluationResult.model_validate(item)
for item in result.get("overallCriteria")
or result.get("overall_criteria")
or []
],
attempt_count=row.attempt_count,
max_attempts=row.max_attempts,
execution_error=(
BatchExecutionError.model_validate(row.execution_error)
if row.execution_error
else None
),
)
)
return BatchRunSnapshotOut(
id=run.id,
status=run.status,
title=run.title,
assistant_name=run.assistant_name,
config=dict(run.config or {}),
cases=cases,
started_at=run.started_at or run.created_at,
finished_at=run.finished_at,
stop_reason=run.stop_reason,
)
@router.post("", response_model=BatchRunSnapshotOut)
async def create_test_run(
body: BatchRunCreate,
session: AsyncSession = Depends(get_session),
):
assistant = await session.get(Assistant, body.assistant_id)
if assistant is None:
raise HTTPException(404, "被测助手不存在")
if assistant.runtime_mode != "pipeline":
raise HTTPException(422, "第一版批量测试只支持 Pipeline 运行模式")
if assistant.type not in {"prompt", "workflow"}:
raise HTTPException(422, f"第一版批量测试暂不支持 {assistant.type} 类型助手")
evaluator_resource = await session.get(
ModelResource,
body.evaluator_model_resource_id,
)
if (
evaluator_resource is None
or evaluator_resource.capability != "LLM"
or not evaluator_resource.enabled
):
raise HTTPException(422, "评估模型不存在、已停用或不是 LLM 资源")
try:
runtime_config = await resolve_runtime_config(session, assistant.id)
except ValueError as exc:
raise HTTPException(422, str(exc)) from exc
rows = (
await session.execute(select(TestCase).where(TestCase.id.in_(body.case_ids)))
).scalars().all()
by_id = {row.id: row for row in rows}
missing = [case_id for case_id in body.case_ids if case_id not in by_id]
if missing:
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}
for row in ordered_cases:
if row.input_mode != "fixed_script_text":
raise HTTPException(422, f"用例“{row.name}”不是可运行的固定文字模式")
try:
definition = TestCaseDefinition.model_validate(row.definition or {})
except ValueError as exc:
raise HTTPException(422, f"用例“{row.name}”校验失败: {exc}") from exc
for turn in definition.turns:
for behavior in turn.behaviors:
if behavior.type != "tool_call":
continue
tool = tools_by_id.get(behavior.tool_id)
if tool is None or tool.function_name != behavior.function_name:
raise HTTPException(
422,
f"用例“{row.name}”引用的工具 {behavior.function_name} "
"不属于当前被测助手",
)
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)))
).scalars().all()
suite_by_id = {row.id: row for row in suites}
if body.title:
title = body.title.strip()
elif len(suite_ids) == 1 and suite_ids[0] in suite_by_id:
title = f"{suite_by_id[suite_ids[0]].name} · {len(ordered_cases)} 个用例"
else:
title = f"批量测试 · {len(ordered_cases)} 个用例"
now = datetime.now(UTC)
run = TestRun(
id=_new_id("run"),
assistant_id=assistant.id,
assistant_name=assistant.name,
title=title,
status="queued",
config={
"suiteCount": len(suite_ids),
"evaluatorModelResourceId": evaluator_resource.id,
"evaluatorModelResourceName": evaluator_resource.name,
"evaluatorModel": str(
(evaluator_resource.values or {}).get("modelId") or ""
),
**body.config.model_dump(mode="json", by_alias=True),
},
started_at=now,
)
session.add(run)
for position, row in enumerate(ordered_cases):
definition = TestCaseDefinition.model_validate(row.definition or {})
snapshot = {
"id": row.id,
"suiteId": row.suite_id,
"name": row.name,
"description": row.description,
"inputMode": row.input_mode,
**definition.model_dump(mode="json", by_alias=True),
}
session.add(
TestRunCase(
id=_new_id("rc"),
run_id=run.id,
test_case_id=row.id,
test_case_name=row.name,
suite_id=row.suite_id,
position=position,
case_snapshot=snapshot,
status="waiting",
attempt_count=0,
max_attempts=body.config.error_retry_count + 1,
result={"turns": [], "overallCriteria": []},
)
)
await session.commit()
await session.refresh(run)
test_run_orchestrator.start(run.id)
return await _run_out(session, run)
@router.get("/{run_id}", response_model=BatchRunSnapshotOut)
async def get_test_run(
run_id: str,
session: AsyncSession = Depends(get_session),
):
run = await session.get(TestRun, run_id)
if run is None:
raise HTTPException(404, "批量测试运行不存在")
return await _run_out(session, run)
@router.post("/{run_id}/cancel", response_model=BatchRunSnapshotOut)
async def cancel_test_run(
run_id: str,
session: AsyncSession = Depends(get_session),
):
run = await session.get(TestRun, run_id)
if run is None:
raise HTTPException(404, "批量测试运行不存在")
if run.status in {"completed", "cancelled"}:
return await _run_out(session, run)
run.cancel_requested = True
run.stop_reason = "manual"
await session.commit()
await test_run_orchestrator.cancel(run_id)
await session.refresh(run)
return await _run_out(session, run)

View File

@@ -99,6 +99,9 @@ class BrainRuntime:
Callable[[bool, dict[str, Any]], Awaitable[None]] | None Callable[[bool, dict[str, Any]], Awaitable[None]] | None
) = None ) = None
flow_global_functions: list[Any] = field(default_factory=list) flow_global_functions: list[Any] = field(default_factory=list)
# Tests replace external side effects without changing Pipecat or stored
# tool resources. Production leaves this unset and uses ToolExecutor.
tool_executor_factory: Callable[[Any], Any] | None = None
@dataclass(frozen=True) @dataclass(frozen=True)

View File

@@ -103,6 +103,8 @@ class PromptBrain(BaseBrain):
async def setup(self, cfg: AssistantConfig, runtime: BrainRuntime) -> None: async def setup(self, cfg: AssistantConfig, runtime: BrainRuntime) -> None:
self._runtime = runtime self._runtime = runtime
if runtime.tool_executor_factory is not None:
self._tools = runtime.tool_executor_factory(self._store)
self._tools.set_client_tools(runtime.client_tools) self._tools.set_client_tools(runtime.client_tools)
self._actions = ActionRunner( self._actions = ActionRunner(
self._tools, self._tools,
@@ -651,14 +653,27 @@ class PromptBrain(BaseBrain):
if not kind: if not kind:
raise ValueError(f"系统工具 {tool.id} 缺少有效 kind") raise ValueError(f"系统工具 {tool.id} 缺少有效 kind")
if kind == "end_conversation": if kind == "end_conversation":
return self._make_end_call_tool(tool, runtime) schema, handler = self._make_end_call_tool(tool, runtime)
if kind == "update_state": elif kind == "update_state":
return self._make_update_state_tool(tool, runtime) schema, handler = self._make_update_state_tool(tool, runtime)
if kind == "skip_turn": elif kind == "skip_turn":
return self._make_skip_turn_tool(tool) schema, handler = self._make_skip_turn_tool(tool)
if kind == "request_human_handoff": elif kind == "request_human_handoff":
return self._make_handoff_tool(tool, runtime) schema, handler = self._make_handoff_tool(tool, runtime)
raise ValueError(f"未知系统工具: {kind}") else:
raise ValueError(f"未知系统工具: {kind}")
if runtime.tool_executor_factory is None:
return schema, handler
async def mock_system_tool(params: FunctionCallParams) -> None:
result = await self._tools.execute(
tool,
dict(params.arguments or {}),
)
await params.result_callback(result)
return schema, mock_system_tool
def _make_update_state_tool(self, tool, runtime: BrainRuntime): def _make_update_state_tool(self, tool, runtime: BrainRuntime):
"""更新已声明的动态变量(会话状态),并让模型继续当前回答。""" """更新已声明的动态变量(会话状态),并让模型继续当前回答。"""

View File

@@ -214,7 +214,12 @@ class WorkflowBrain(BaseBrain):
self._cfg = cfg self._cfg = cfg
self._runtime = runtime self._runtime = runtime
self._store = DynamicVariableStore.from_config(cfg) self._store = DynamicVariableStore.from_config(cfg)
self._tools = ToolExecutor(self._store, client_tools=runtime.client_tools) self._tools = (
runtime.tool_executor_factory(self._store)
if runtime.tool_executor_factory is not None
else ToolExecutor(self._store, client_tools=runtime.client_tools)
)
self._tools.set_client_tools(runtime.client_tools)
self._actions = ActionRunner( self._actions = ActionRunner(
self._tools, self._tools,
is_session_ending=lambda: runtime.call_end.ending, is_session_ending=lambda: runtime.call_end.ending,
@@ -934,18 +939,28 @@ class WorkflowBrain(BaseBrain):
"""Build one platform-owned tool scoped to the active Agent node.""" """Build one platform-owned tool scoped to the active Agent node."""
kind = system_tool_kind(tool.definition or {}) kind = system_tool_kind(tool.definition or {})
if kind == "update_state": if kind == "update_state":
return self._workflow_update_state_tool( schema = self._workflow_update_state_tool(
tool, tool,
node_id, node_id,
state_variable_names=state_variable_names, state_variable_names=state_variable_names,
) )
if kind == "skip_turn": elif kind == "skip_turn":
return self._workflow_skip_turn_tool(tool) schema = self._workflow_skip_turn_tool(tool)
if kind == "request_human_handoff": elif kind == "request_human_handoff":
return self._workflow_handoff_tool(tool, node_id) schema = self._workflow_handoff_tool(tool, node_id)
if kind == "end_conversation": elif kind == "end_conversation":
return self._workflow_end_conversation_tool(tool, node_id) schema = self._workflow_end_conversation_tool(tool, node_id)
raise ValueError(f"系统工具 {tool.id} 缺少有效 kind") else:
raise ValueError(f"系统工具 {tool.id} 缺少有效 kind")
runtime = self._require_runtime()
if runtime.tool_executor_factory is None:
return schema
async def mock_system_tool(args, _flow_manager):
return await self._tools.execute(tool, dict(args or {}))
return replace(schema, handler=mock_system_tool)
def _workflow_update_state_tool( def _workflow_update_state_tool(
self, self,

View File

@@ -0,0 +1 @@
"""Fixed-text test execution domain."""

View File

@@ -0,0 +1,24 @@
"""Structured failures shared by the text runner and batch orchestrator."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class TestExecutionError(RuntimeError):
code: str
message: str
stage: str
retryable: bool = False
def __post_init__(self) -> None:
RuntimeError.__init__(self, self.message)
def as_dict(self) -> dict[str, object]:
return {
"code": self.code,
"message": self.message,
"stage": self.stage,
"retryable": self.retryable,
}

View File

@@ -0,0 +1,374 @@
"""Deterministic and LLM-backed assertions for text test results."""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from typing import Any
import httpx
from models import RuntimeModelResource
from services.test_runs.errors import TestExecutionError
from services.test_runs.text_runner import RawToolCall, RawTurnResult, TextCaseResult
from test_schemas import (
BatchEvaluationResult,
BatchTurnResult,
FixedInputTurn,
OverallCriterion,
ReplyExpectedBehavior,
TestCaseDefinition,
ToolCallExpectedBehavior,
ToolParamAssertion,
)
@dataclass(frozen=True)
class EvaluatedCase:
turns: list[BatchTurnResult]
overall_criteria: list[BatchEvaluationResult]
@property
def passed(self) -> bool:
evaluations = [
item
for turn in self.turns
for item in turn.evaluations
] + self.overall_criteria
return all(item.status == "pass" for item in evaluations)
class LLMJudge:
"""Small OpenAI-compatible JSON judge using a dedicated model resource."""
def __init__(
self,
resource: RuntimeModelResource,
*,
timeout_seconds: float = 60,
):
self._resource = resource
self._timeout_seconds = timeout_seconds
async def evaluate(
self,
*,
criteria: str,
subject: str,
context: str,
) -> tuple[bool, str, str]:
values = self._resource.values or {}
secrets = self._resource.secrets or {}
base_url = str(values.get("apiUrl") or "").rstrip("/")
api_key = str(secrets.get("apiKey") or "")
model = str(values.get("modelId") or "")
if not base_url or not api_key or not model:
raise TestExecutionError(
code="EVALUATOR_NOT_CONFIGURED",
message="LLM 评估所需的模型资源未完整配置",
stage="evaluation",
)
prompt = (
"你是严格的自动化测试评估器。只根据提供的内容判断标准是否满足。\n"
"必须只输出一个 JSON 对象,不要输出 Markdown\n"
'{"passed":true或false,"reason":"简短中文理由","actual":"实际表现摘要"}\n\n'
f"评估对象:{subject}\n"
f"评估标准:{criteria}\n\n"
f"待评估内容:\n{context}"
)
payload: dict[str, Any] = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0,
}
extra_body = values.get("extraBody")
if isinstance(extra_body, dict):
payload.update(
{
key: value
for key, value in extra_body.items()
if key not in {"model", "messages"}
}
)
try:
async with httpx.AsyncClient(timeout=self._timeout_seconds) as client:
response = await client.post(
f"{base_url}/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json=payload,
)
response.raise_for_status()
body = response.json()
content = body["choices"][0]["message"]["content"]
if isinstance(content, list):
content = "".join(
str(part.get("text") or "")
for part in content
if isinstance(part, dict)
)
parsed = self._parse_json(str(content or ""))
return (
bool(parsed.get("passed")),
str(parsed.get("reason") or "LLM 未提供判断理由"),
str(parsed.get("actual") or ""),
)
except TestExecutionError:
raise
except (httpx.HTTPError, KeyError, IndexError, TypeError, ValueError) as exc:
raise TestExecutionError(
code="EVALUATOR_REQUEST_FAILED",
message=f"LLM 评估失败: {exc}",
stage="evaluation",
retryable=True,
) from exc
@staticmethod
def _parse_json(value: str) -> dict[str, Any]:
text = value.strip()
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text)
start = text.find("{")
end = text.rfind("}")
if start < 0 or end < start:
raise ValueError("评估模型没有返回 JSON")
parsed = json.loads(text[start : end + 1])
if (
not isinstance(parsed, dict)
or not isinstance(parsed.get("passed"), bool)
):
raise ValueError("评估模型返回格式不正确")
return parsed
class EvaluationEngine:
def __init__(self, judge_resource: RuntimeModelResource):
self._judge = LLMJudge(judge_resource)
async def evaluate(
self,
definition: TestCaseDefinition,
execution: TextCaseResult,
) -> EvaluatedCase:
raw_by_id = {turn.id: turn for turn in execution.turns}
turns: list[BatchTurnResult] = []
for index, expected_turn in enumerate(definition.turns):
raw = raw_by_id.get(expected_turn.id) or RawTurnResult(
id=expected_turn.id,
index=index,
user_input=expected_turn.user_input,
assistant_reply="",
tool_calls=[],
)
evaluations = [
await self._evaluate_behavior(behavior, raw)
for behavior in expected_turn.behaviors
]
turns.append(
BatchTurnResult(
id=raw.id,
index=index,
user_input=raw.user_input,
assistant_reply=raw.assistant_reply,
tool_calls=[item.as_result() for item in raw.tool_calls],
evaluations=evaluations,
)
)
transcript = self._format_transcript(execution, definition)
overall = [
await self._evaluate_overall(criterion, transcript)
for criterion in definition.overall_criteria
]
return EvaluatedCase(turns=turns, overall_criteria=overall)
async def _evaluate_behavior(
self,
behavior: ReplyExpectedBehavior | ToolCallExpectedBehavior,
turn: RawTurnResult,
) -> BatchEvaluationResult:
if behavior.type == "reply":
return await self._evaluate_reply(behavior, turn.assistant_reply)
return await self._evaluate_tool(behavior, turn.tool_calls)
async def _evaluate_reply(
self,
behavior: ReplyExpectedBehavior,
reply: str,
) -> BatchEvaluationResult:
if behavior.assertion_type == "llm":
passed, reason, actual = await self._judge.evaluate(
criteria=behavior.llm_criteria,
subject="当前轮助手回复",
context=reply or "(助手没有生成文字回复)",
)
return BatchEvaluationResult(
id=behavior.id,
label="回复 · LLM 判断",
kind="reply",
status="pass" if passed else "fail",
expected=behavior.llm_criteria,
actual=actual or reply,
reason="" if passed else reason,
)
normalized_reply = reply.casefold()
matches = [keyword.casefold() in normalized_reply for keyword in behavior.keywords]
matched = all(matches) if behavior.keyword_match_mode == "all" else any(matches)
passed = not matched if behavior.negate_keywords else matched
relation = (
"不应包含"
if behavior.negate_keywords
else "应包含全部"
if behavior.keyword_match_mode == "all"
else "应至少包含其一"
)
return BatchEvaluationResult(
id=behavior.id,
label="回复 · 关键词",
kind="reply",
status="pass" if passed else "fail",
expected=f"{relation}{''.join(behavior.keywords)}",
actual=reply or "(无文字回复)",
reason="" if passed else "实际回复未满足关键词规则。",
)
async def _evaluate_tool(
self,
behavior: ToolCallExpectedBehavior,
calls: list[RawToolCall],
) -> BatchEvaluationResult:
matching_calls = [
call for call in calls if call.function_name == behavior.function_name
]
count = len(matching_calls)
if behavior.expectation == "not_called":
passed = count == 0
return BatchEvaluationResult(
id=behavior.id,
label=f"工具 · {behavior.function_name}",
kind="tool_call",
status="pass" if passed else "fail",
expected=f"不应调用 {behavior.function_name}",
actual=f"实际调用 {count}{behavior.function_name}",
reason="" if passed else "检测到本轮不应发生的工具调用。",
)
count_passed = count >= behavior.min_calls and (
behavior.max_calls is None or count <= behavior.max_calls
)
params_passed = True
parameter_reason = ""
if count_passed and behavior.param_assertions:
params_passed = False
reasons: list[str] = []
for call in matching_calls:
call_passed, call_reason = await self._call_matches_parameters(
call, behavior.param_assertions
)
if call_passed:
params_passed = True
break
reasons.append(call_reason)
parameter_reason = "".join(item for item in reasons if item)
passed = count_passed and params_passed
count_text = (
f"{behavior.min_calls} 次以上"
if behavior.max_calls is None
else f"{behavior.min_calls}"
if behavior.min_calls == behavior.max_calls
else f"{behavior.min_calls}{behavior.max_calls}"
)
reason = ""
if not count_passed:
reason = "工具调用次数不符合要求。"
elif not params_passed:
reason = parameter_reason or "没有一次工具调用满足全部参数断言。"
return BatchEvaluationResult(
id=behavior.id,
label=f"工具 · {behavior.function_name}",
kind="tool_call",
status="pass" if passed else "fail",
expected=f"应调用 {behavior.function_name} {count_text}",
actual=f"实际调用 {count}{behavior.function_name}",
reason=reason,
)
async def _call_matches_parameters(
self,
call: RawToolCall,
assertions: list[ToolParamAssertion],
) -> tuple[bool, str]:
arguments = call.arguments if isinstance(call.arguments, dict) else {}
for assertion in assertions:
if assertion.name not in arguments:
return False, f"缺少参数 {assertion.name}"
actual = arguments[assertion.name]
if assertion.match_mode == "exact":
try:
expected: Any = json.loads(assertion.value)
except json.JSONDecodeError:
expected = assertion.value
if actual != expected and str(actual) != assertion.value:
return False, f"参数 {assertion.name} 与期望值不一致"
elif assertion.match_mode == "regex":
actual_text = (
actual
if isinstance(actual, str)
else json.dumps(actual, ensure_ascii=False, default=str)
)
if re.search(assertion.value, actual_text) is None:
return False, f"参数 {assertion.name} 未匹配正则表达式"
else:
passed, reason, _actual = await self._judge.evaluate(
criteria=assertion.value,
subject=f"工具参数 {assertion.name}",
context=json.dumps(actual, ensure_ascii=False, default=str),
)
if not passed:
return False, reason
return True, ""
async def _evaluate_overall(
self,
criterion: OverallCriterion,
transcript: str,
) -> BatchEvaluationResult:
passed, reason, actual = await self._judge.evaluate(
criteria=criterion.criteria,
subject="完整测试对话",
context=transcript,
)
return BatchEvaluationResult(
id=criterion.id,
label=criterion.name,
kind="overall",
status="pass" if passed else "fail",
expected=criterion.criteria,
actual=actual or "已评估完整对话",
reason="" if passed else reason,
)
@staticmethod
def _format_transcript(
execution: TextCaseResult,
definition: TestCaseDefinition,
) -> str:
if execution.transcript:
labels = {"user": "User", "assistant": "Agent"}
return "\n".join(
f"{labels.get(item['role'], item['role'])}: {item['content']}"
for item in execution.transcript
)
raw_by_id = {turn.id: turn for turn in execution.turns}
lines: list[str] = []
for turn in definition.turns:
raw = raw_by_id.get(turn.id)
lines.append(f"User: {turn.user_input}")
lines.append(f"Agent: {raw.assistant_reply if raw else ''}")
return "\n".join(lines)

View File

@@ -0,0 +1,85 @@
"""Test-only tool executor that never reaches external side effects."""
from __future__ import annotations
import asyncio
import json
from typing import Any
from models import RuntimeTool
from services.runtime_variables import DynamicVariableStore
from services.tool_executor import ToolExecutionError, ToolExecutor
from test_schemas import ToolCallExpectedBehavior
class MockToolExecutor(ToolExecutor):
"""Resolve the active turn's configured mocks and reject everything else."""
def __init__(self, store: DynamicVariableStore):
super().__init__(store)
self._mocks_by_tool_id: dict[str, ToolCallExpectedBehavior] = {}
self._mocks_by_function: dict[str, ToolCallExpectedBehavior] = {}
self.unmocked_calls: list[str] = []
def set_turn_behaviors(
self,
behaviors: list[ToolCallExpectedBehavior],
) -> None:
self._mocks_by_tool_id = {item.tool_id: item for item in behaviors}
self._mocks_by_function = {item.function_name: item for item in behaviors}
self.unmocked_calls = []
async def execute(
self,
tool: RuntimeTool,
arguments: dict[str, Any] | None = None,
*,
result_assignments: dict[str, str] | None = None,
) -> dict[str, Any]:
self.register_secrets(tool)
behavior = self._mocks_by_tool_id.get(tool.id) or self._mocks_by_function.get(
tool.function_name
)
if behavior is None:
self.unmocked_calls.append(tool.function_name)
raise ToolExecutionError(
f"UNMOCKED_TOOL_CALL: 工具 {tool.function_name} 未配置 Mock 返回值"
)
delay_seconds = behavior.mock_response.delay_ms / 1000
if delay_seconds:
await asyncio.sleep(delay_seconds)
payload = json.loads(behavior.mock_response.body)
status = "ok" if behavior.mock_response.outcome == "success" else "error"
if isinstance(payload, dict):
result: dict[str, Any] = dict(payload)
result["status"] = status
else:
result = {"status": status, "data": payload}
if result.get("status") != "ok":
return {**result, "updated_variables": []}
return self._apply_result_assignments(
tool,
result,
result_assignments=result_assignments,
)
class TextClientToolPort:
"""Automatically acknowledge built-in message stages in text tests."""
async def call(
self,
function_name: str,
arguments: dict[str, Any],
**_kwargs: Any,
) -> dict[str, Any]:
if function_name == "show_message":
return {
"status": "ok",
"data": {"action": "confirmed", "arguments": arguments},
}
raise ToolExecutionError(
f"UNMOCKED_CLIENT_TOOL: 客户端工具 {function_name} 不能脱离 Mock 执行"
)

View File

@@ -0,0 +1,410 @@
"""In-process MVP scheduler for persisted batch test runs."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from datetime import UTC, datetime
from loguru import logger
from sqlalchemy import select
from db.models import ModelResource, TestCase, TestRun, TestRunCase
from db.session import SessionLocal
from models import RuntimeModelResource
from services.config_resolver import resolve_runtime_config
from services.test_runs.errors import TestExecutionError
from services.test_runs.evaluator import EvaluationEngine
from services.test_runs.text_runner import TextPipelineRunner
from test_schemas import TestCaseDefinition
@dataclass
class _RunControl:
stop_event: asyncio.Event = field(default_factory=asyncio.Event)
reason: str | None = None
case_tasks: set[asyncio.Task[None]] = field(default_factory=set)
class TestRunOrchestrator:
"""Run cases concurrently while keeping PostgreSQL as the source of truth."""
def __init__(self) -> None:
self._run_tasks: dict[str, asyncio.Task[None]] = {}
self._controls: dict[str, _RunControl] = {}
def start(self, run_id: str) -> None:
current = self._run_tasks.get(run_id)
if current is not None and not current.done():
return
control = _RunControl()
self._controls[run_id] = control
task = asyncio.create_task(self._execute_run(run_id, control), name=f"test-run-{run_id}")
self._run_tasks[run_id] = task
task.add_done_callback(lambda _task: self._forget(run_id))
async def cancel(self, run_id: str) -> None:
control = self._controls.get(run_id)
if control is None:
return
control.reason = "manual"
control.stop_event.set()
for task in tuple(control.case_tasks):
task.cancel()
async def shutdown(self) -> None:
tasks = [task for task in self._run_tasks.values() if not task.done()]
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
self._run_tasks.clear()
self._controls.clear()
def _forget(self, run_id: str) -> None:
self._run_tasks.pop(run_id, None)
self._controls.pop(run_id, None)
async def _execute_run(self, run_id: str, control: _RunControl) -> None:
try:
async with SessionLocal() as session:
run = await session.get(TestRun, run_id)
if run is None:
return
run.status = "running"
run.started_at = run.started_at or datetime.now(UTC)
await session.commit()
try:
cfg = await resolve_runtime_config(session, str(run.assistant_id or ""))
except Exception as exc:
await self._fail_run_setup(run_id, str(exc))
return
config = dict(run.config or {})
evaluator_resource_id = str(
config.get("evaluatorModelResourceId") or ""
)
evaluator_row = await session.get(
ModelResource,
evaluator_resource_id,
)
if (
evaluator_row is None
or evaluator_row.capability != "LLM"
or not evaluator_row.enabled
):
await self._fail_run_setup(
run_id,
"评估模型不存在、已停用或不是 LLM 资源",
)
return
evaluator_resource = RuntimeModelResource(
id=evaluator_row.id,
name=evaluator_row.name,
capability=evaluator_row.capability,
interface_type=evaluator_row.interface_type,
values=evaluator_row.values or {},
secrets=evaluator_row.secrets or {},
support_image_input=bool(evaluator_row.support_image_input),
)
rows = (
await session.execute(
select(TestRunCase)
.where(TestRunCase.run_id == run_id)
.order_by(TestRunCase.position)
)
).scalars().all()
semaphore = asyncio.Semaphore(int(config.get("concurrency") or 3))
tasks = {
asyncio.create_task(
self._execute_case(
run_id,
row.id,
cfg,
evaluator_resource,
config,
semaphore,
control,
),
name=f"test-run-case-{row.id}",
)
for row in rows
}
control.case_tasks = tasks
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
await self._finish_run(run_id, control)
except asyncio.CancelledError:
await self._interrupt_run(run_id)
raise
except Exception as exc: # noqa: BLE001 - background task must persist failure
logger.exception(f"批量测试运行失败: run_id={run_id}: {exc}")
await self._fail_run_setup(run_id, str(exc))
async def _execute_case(
self,
run_id: str,
run_case_id: str,
cfg,
evaluator_resource: RuntimeModelResource,
config: dict,
semaphore: asyncio.Semaphore,
control: _RunControl,
) -> None:
async with semaphore:
if control.stop_event.is_set():
await self._mark_skipped(run_case_id)
return
async with SessionLocal() as session:
row = await session.get(TestRunCase, run_case_id)
if row is None:
return
snapshot = dict(row.case_snapshot or {})
definition = TestCaseDefinition.model_validate(snapshot)
max_attempts = row.max_attempts
for attempt in range(1, max_attempts + 1):
if control.stop_event.is_set():
await self._mark_skipped(run_case_id)
return
await self._mark_running(run_case_id, attempt)
try:
timeout_seconds = int(
config.get("timeoutSecs")
or config.get("timeout_secs")
or 60
)
async with asyncio.timeout(timeout_seconds):
execution = await TextPipelineRunner().run(
cfg.model_copy(deep=True),
definition,
timeout_seconds=timeout_seconds,
)
evaluated = await EvaluationEngine(evaluator_resource).evaluate(
definition,
execution,
)
except asyncio.CancelledError:
await self._mark_skipped(run_case_id)
raise
except TimeoutError:
error = TestExecutionError(
code="CASE_TIMEOUT",
message=f"测试用例执行与评估超过 {timeout_seconds}",
stage="pipeline",
retryable=True,
)
if attempt < max_attempts and not control.stop_event.is_set():
continue
await self._mark_error(run_case_id, error)
if str(
config.get("errorStrategy")
or config.get("error_strategy")
) == "stop_on_error":
await self._request_stop(run_id, control, "execution_error")
return
except TestExecutionError as exc:
if exc.retryable and attempt < max_attempts and not control.stop_event.is_set():
continue
await self._mark_error(run_case_id, exc)
if str(config.get("errorStrategy") or config.get("error_strategy")) == "stop_on_error":
await self._request_stop(run_id, control, "execution_error")
return
except Exception as exc: # noqa: BLE001 - normalize unknown runtime errors
error = TestExecutionError(
code="UNEXPECTED_EXECUTION_ERROR",
message=str(exc) or type(exc).__name__,
stage="pipeline",
retryable=False,
)
await self._mark_error(run_case_id, error)
if str(config.get("errorStrategy") or config.get("error_strategy")) == "stop_on_error":
await self._request_stop(run_id, control, "execution_error")
return
status = "pass" if evaluated.passed else "fail"
await self._mark_evaluated(run_case_id, status, evaluated)
if status == "fail" and str(
config.get("failureStrategy") or config.get("failure_strategy")
) == "stop_on_fail":
await self._request_stop(run_id, control, "assertion_failure")
return
async def _request_stop(
self,
run_id: str,
control: _RunControl,
reason: str,
) -> None:
if control.reason is None:
control.reason = reason
control.stop_event.set()
async with SessionLocal() as session:
run = await session.get(TestRun, run_id)
if run is not None and run.stop_reason is None:
run.stop_reason = reason
await session.commit()
async def _mark_running(self, run_case_id: str, attempt: int) -> None:
async with SessionLocal() as session:
row = await session.get(TestRunCase, run_case_id)
if row is None:
return
row.status = "running"
row.attempt_count = attempt
row.started_at = row.started_at or datetime.now(UTC)
row.finished_at = None
row.execution_error = None
await session.commit()
async def _mark_evaluated(self, run_case_id: str, status: str, evaluated) -> None:
async with SessionLocal() as session:
row = await session.get(TestRunCase, run_case_id)
if row is None:
return
row.status = status
row.result = {
"turns": [
item.model_dump(mode="json", by_alias=True)
for item in evaluated.turns
],
"overallCriteria": [
item.model_dump(mode="json", by_alias=True)
for item in evaluated.overall_criteria
],
}
row.execution_error = None
row.finished_at = datetime.now(UTC)
if row.test_case_id:
test_case = await session.get(TestCase, row.test_case_id)
if test_case is not None:
test_case.last_result = status
await session.commit()
async def _mark_error(
self,
run_case_id: str,
error: TestExecutionError,
) -> None:
async with SessionLocal() as session:
row = await session.get(TestRunCase, run_case_id)
if row is None:
return
row.status = "error"
row.execution_error = error.as_dict()
row.finished_at = datetime.now(UTC)
if row.test_case_id:
test_case = await session.get(TestCase, row.test_case_id)
if test_case is not None:
test_case.last_result = "fail"
await session.commit()
async def _mark_skipped(self, run_case_id: str) -> None:
async with SessionLocal() as session:
row = await session.get(TestRunCase, run_case_id)
if row is None or row.status in {"pass", "fail", "error", "skipped"}:
return
row.status = "skipped"
row.finished_at = datetime.now(UTC)
await session.commit()
async def _finish_run(self, run_id: str, control: _RunControl) -> None:
async with SessionLocal() as session:
run = await session.get(TestRun, run_id)
if run is None:
return
waiting = (
await session.execute(
select(TestRunCase).where(
TestRunCase.run_id == run_id,
TestRunCase.status.in_(["waiting", "running"]),
)
)
).scalars().all()
for row in waiting:
row.status = "skipped"
row.finished_at = datetime.now(UTC)
manual = bool(run.cancel_requested or control.reason == "manual")
run.status = "cancelled" if manual else "completed"
run.stop_reason = "manual" if manual else control.reason or run.stop_reason
run.finished_at = datetime.now(UTC)
await session.commit()
async def _fail_run_setup(self, run_id: str, message: str) -> None:
error = TestExecutionError(
code="RUN_SETUP_FAILED",
message=message or "批量测试初始化失败",
stage="pipeline",
retryable=False,
)
async with SessionLocal() as session:
run = await session.get(TestRun, run_id)
if run is None:
return
rows = (
await session.execute(
select(TestRunCase).where(
TestRunCase.run_id == run_id,
TestRunCase.status.in_(["waiting", "running"]),
)
)
).scalars().all()
now = datetime.now(UTC)
for row in rows:
row.status = "error"
row.execution_error = error.as_dict()
row.finished_at = now
run.status = "completed"
run.stop_reason = "execution_error"
run.finished_at = now
await session.commit()
async def _interrupt_run(self, run_id: str) -> None:
await _mark_run_interrupted(run_id, "后端服务停止,运行已中断")
async def _mark_run_interrupted(run_id: str, message: str) -> None:
async with SessionLocal() as session:
run = await session.get(TestRun, run_id)
if run is None or run.status not in {"queued", "running"}:
return
rows = (
await session.execute(
select(TestRunCase).where(
TestRunCase.run_id == run_id,
TestRunCase.status.in_(["waiting", "running"]),
)
)
).scalars().all()
now = datetime.now(UTC)
for row in rows:
if row.status == "running":
row.status = "error"
row.execution_error = {
"code": "SERVER_RESTARTED",
"message": message,
"stage": "pipeline",
"retryable": True,
}
else:
row.status = "skipped"
row.finished_at = now
run.status = "completed"
run.stop_reason = "execution_error"
run.finished_at = now
await session.commit()
async def recover_interrupted_test_runs() -> None:
async with SessionLocal() as session:
run_ids = (
await session.execute(
select(TestRun.id).where(TestRun.status.in_(["queued", "running"]))
)
).scalars().all()
for run_id in run_ids:
await _mark_run_interrupted(str(run_id), "后端服务重启,运行已中断")
test_run_orchestrator = TestRunOrchestrator()

View File

@@ -0,0 +1,687 @@
"""Transportless Pipecat runner for one persisted fixed-text test case."""
from __future__ import annotations
import asyncio
import json
import time
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable
from uuid import uuid4
from loguru import logger
from models import AssistantConfig
from pipecat.adapters.schemas.function_schema import FunctionSchema
from pipecat.adapters.schemas.tools_schema import ToolsSchema
from pipecat.frames.frames import (
BotStoppedSpeakingFrame,
CancelFrame,
EndFrame,
ErrorFrame,
FunctionCallInProgressFrame,
FunctionCallResultFrame,
LLMContextFrame,
LLMFullResponseEndFrame,
LLMFullResponseStartFrame,
LLMMessagesAppendFrame,
ManuallySwitchServiceFrame,
TTSSpeakFrame,
)
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import (
LLMContextAggregatorPair,
)
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.services.llm_service import FunctionCallParams
from pipecat.workers.runner import WorkerRunner
from db.session import SessionLocal
from services.brains import BrainRuntime, build_brain
from services.knowledge import search as search_knowledge
from services.pipecat.call_lifecycle import (
CallEndCoordinator,
FixedSpeechPlaybackMarkerFrame,
)
from services.pipecat.processors import (
KnowledgeRetrievalProcessor,
UserTurnRoutingProcessor,
)
from services.pipecat.service_factory import config_with_resource
from services.pipecat.workflow_services import build_workflow_llm_switcher
from services.test_runs.errors import TestExecutionError
from services.test_runs.mock_tools import MockToolExecutor, TextClientToolPort
from test_schemas import ContextTurn, FixedInputTurn, TestCaseDefinition
IDLE_SETTLE_SECONDS = 0.2
WORKER_START_TIMEOUT_SECONDS = 5.0
INTERNAL_CANCEL_REASON = "text_test_case_complete"
AUTOMATIC_KNOWLEDGE_HINT = (
"你已连接内部知识库。系统会在每轮用户问题前自动提供相关资料;"
"回答资料事实时只根据检索内容,资料不足要明确说明。"
)
ON_DEMAND_KNOWLEDGE_HINT = (
"你已连接内部知识库。当用户问题涉及可能存在于业务知识库中的事实时,"
"先调用 search_knowledge_base 检索;回答资料事实时只根据检索内容,"
"资料不足要明确说明。"
)
@dataclass
class RawToolCall:
id: str
function_name: str
arguments: Any
result: Any = None
outcome: str = "error"
duration_ms: int = 0
_started_at: float = field(default_factory=time.monotonic, repr=False)
def as_result(self) -> dict[str, Any]:
return {
"id": self.id,
"functionName": self.function_name,
"argumentsJson": json.dumps(
self.arguments, ensure_ascii=False, indent=2, default=str
),
"resultJson": json.dumps(
self.result, ensure_ascii=False, indent=2, default=str
),
"outcome": self.outcome,
"durationMs": self.duration_ms,
}
@dataclass
class RawTurnResult:
id: str
index: int
user_input: str
assistant_reply: str
tool_calls: list[RawToolCall]
@dataclass
class TextCaseResult:
turns: list[RawTurnResult]
transcript: list[dict[str, str]]
@dataclass
class _ResponseWindow:
active_llm: int = 0
active_assistant: int = 0
pending_tools: set[str] = field(default_factory=set)
activity_count: int = 0
last_activity_at: float = field(default_factory=time.monotonic)
def touch(self) -> None:
self.activity_count += 1
self.last_activity_at = time.monotonic()
@property
def idle(self) -> bool:
return self.active_llm == 0 and self.active_assistant == 0 and not self.pending_tools
class _TextCaptureProcessor(FrameProcessor):
"""Capture assistant/tool output and expose a reliable response boundary."""
def __init__(self, context: LLMContext):
super().__init__()
self.context = context
self.window = _ResponseWindow()
self.outputs: list[str] = []
self.tool_calls: list[RawToolCall] = []
self._tool_by_id: dict[str, RawToolCall] = {}
self.error: TestExecutionError | None = None
self.on_fixed_speech_complete: Callable[[], Awaitable[None]] | None = None
async def process_frame(self, frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, TTSSpeakFrame):
text = frame.text.strip()
if text:
self.outputs.append(text)
if frame.append_to_context:
self.context.add_message({"role": "assistant", "content": text})
self.window.touch()
if self.on_fixed_speech_complete is not None:
await self.on_fixed_speech_complete()
return
if isinstance(frame, FixedSpeechPlaybackMarkerFrame):
await frame.completion.mark_played()
self.window.touch()
return
if isinstance(frame, LLMFullResponseStartFrame):
self.window.active_llm += 1
self.window.touch()
elif isinstance(frame, LLMFullResponseEndFrame):
self.window.active_llm = max(0, self.window.active_llm - 1)
self.window.touch()
await self.push_frame(frame, direction)
# There is no TTS/output transport. This releases provider/tool
# follow-up logic that normally waits for a speaking boundary.
await self.push_frame(BotStoppedSpeakingFrame(), FrameDirection.UPSTREAM)
return
elif isinstance(frame, FunctionCallInProgressFrame):
call = RawToolCall(
id=frame.tool_call_id,
function_name=frame.function_name,
arguments=frame.arguments or {},
)
self.tool_calls.append(call)
self._tool_by_id[call.id] = call
self.window.pending_tools.add(call.id)
self.window.touch()
elif isinstance(frame, FunctionCallResultFrame):
call = self._tool_by_id.get(frame.tool_call_id)
if call is None:
call = RawToolCall(
id=frame.tool_call_id,
function_name=frame.function_name,
arguments=frame.arguments or {},
)
self.tool_calls.append(call)
self._tool_by_id[call.id] = call
call.result = frame.result
call.duration_ms = max(0, round((time.monotonic() - call._started_at) * 1000))
status = frame.result.get("status") if isinstance(frame.result, dict) else None
call.outcome = "error" if status in {"error", "failed", "timeout"} else "success"
self.window.pending_tools.discard(call.id)
self.window.touch()
elif isinstance(frame, ErrorFrame):
self.error = TestExecutionError(
code="PIPELINE_ERROR",
message=frame.error or "Pipeline 执行失败",
stage="model" if frame.processor else "pipeline",
retryable=True,
)
self.window.touch()
elif isinstance(frame, (EndFrame, CancelFrame)):
self.window.touch()
await self.push_frame(frame, direction)
def _context_messages(context_turns: list[ContextTurn]) -> list[dict[str, Any]]:
messages: list[dict[str, Any]] = []
latest_call_by_name: dict[str, str] = {}
for index, turn in enumerate(context_turns):
if turn.role in {"agent", "user"}:
messages.append(
{
"role": "assistant" if turn.role == "agent" else "user",
"content": turn.content,
}
)
continue
call_id = turn.tool_call_id or latest_call_by_name.get(turn.tool_name or "")
call_id = call_id or f"context_tool_{index}_{uuid4().hex[:8]}"
if turn.role == "tool_call":
latest_call_by_name[turn.tool_name or ""] = call_id
messages.append(
{
"role": "assistant",
"tool_calls": [
{
"id": call_id,
"type": "function",
"function": {
"name": turn.tool_name,
"arguments": json.dumps(
json.loads(turn.content), ensure_ascii=False
),
},
}
],
}
)
else:
result_payload = json.loads(turn.content)
if turn.is_error:
result_payload = (
{**result_payload, "status": "error"}
if isinstance(result_payload, dict)
else {"status": "error", "data": result_payload}
)
messages.append(
{
"role": "tool",
"tool_call_id": call_id,
"content": json.dumps(result_payload, ensure_ascii=False),
}
)
return messages
async def _wait_for_quiescence(
capture: _TextCaptureProcessor,
runner_task: asyncio.Task[None],
activity_marker: int,
*,
require_activity: bool = True,
) -> None:
while True:
if capture.error is not None:
raise capture.error
if runner_task.done():
await runner_task
return
has_activity = capture.window.activity_count > activity_marker
settled = time.monotonic() - capture.window.last_activity_at >= IDLE_SETTLE_SECONDS
if capture.window.idle and settled and (has_activity or not require_activity):
return
await asyncio.sleep(0.05)
class TextPipelineRunner:
"""Execute all fixed input turns in one isolated, transportless pipeline."""
async def run(
self,
cfg: AssistantConfig,
definition: TestCaseDefinition,
*,
timeout_seconds: int,
) -> TextCaseResult:
try:
async with asyncio.timeout(timeout_seconds):
return await self._run(cfg, definition)
except TimeoutError as exc:
raise TestExecutionError(
code="PIPELINE_TIMEOUT",
message=f"测试用例执行超过 {timeout_seconds}",
stage="pipeline",
retryable=True,
) from exc
async def _run(
self,
cfg: AssistantConfig,
definition: TestCaseDefinition,
) -> TextCaseResult:
if cfg.runtimeMode != "pipeline":
raise TestExecutionError(
code="UNSUPPORTED_RUNTIME_MODE",
message="第一版文本测试只支持 Pipeline 运行模式",
stage="pipeline",
)
if cfg.type not in {"prompt", "workflow"}:
raise TestExecutionError(
code="UNSUPPORTED_ASSISTANT_TYPE",
message=f"第一版文本测试暂不支持 {cfg.type} 类型助手",
stage="pipeline",
)
brain = build_brain(cfg)
seed_messages = _context_messages(definition.context_turns)
knowledge_config = cfg.knowledge_retrieval_config or {}
knowledge_mode = str(knowledge_config.get("mode") or "automatic")
def with_knowledge_hint(text: str) -> str:
if cfg.type != "prompt" or not cfg.knowledge_base_id:
return text
hint = (
AUTOMATIC_KNOWLEDGE_HINT
if knowledge_mode == "automatic"
else ON_DEMAND_KNOWLEDGE_HINT
)
return "\n\n".join(part for part in (text, hint) if part)
system_prompt = with_knowledge_hint(brain.system_prompt(cfg))
context = LLMContext(
messages=(
seed_messages
if cfg.type == "workflow"
else [{"role": "system", "content": system_prompt}, *seed_messages]
)
)
graph_settings = cfg.graph.get("settings") or {}
default_resource = cfg.workflow_model_resources.get(
str(graph_settings.get("defaultLlmResourceId") or "")
)
llm_cfg = (
config_with_resource(cfg, default_resource)
if cfg.type == "workflow" and default_resource is not None
else cfg
)
llm = brain.build_llm(llm_cfg, context)
llm_services: dict[str, FrameProcessor] = {}
current_llm_service: FrameProcessor = llm
if cfg.type == "workflow":
llm, llm_services, current_llm_service = build_workflow_llm_switcher(cfg, llm)
aggregators = LLMContextAggregatorPair(context)
user_aggregator = aggregators.user()
assistant_aggregator = aggregators.assistant()
capture = _TextCaptureProcessor(context)
knowledge = KnowledgeRetrievalProcessor(
cfg.knowledge_base_id if knowledge_mode == "automatic" else None,
top_n=int(knowledge_config.get("top_n", knowledge_config.get("topN", 5))),
score_threshold=float(
knowledge_config.get(
"score_threshold", knowledge_config.get("scoreThreshold", 0.0)
)
),
)
pipeline = Pipeline(
[
user_aggregator,
UserTurnRoutingProcessor(brain),
knowledge,
llm,
capture,
assistant_aggregator,
]
)
worker = PipelineWorker(
pipeline,
params=PipelineParams(enable_metrics=False),
enable_rtvi=False,
enable_turn_tracking=False,
idle_timeout_secs=None,
)
runner = WorkerRunner(handle_sigint=False, check_dangling_tasks=False)
worker_started = asyncio.Event()
@worker.event_handler("on_pipeline_started")
async def on_pipeline_started(_worker, _frame):
worker_started.set()
executor_holder: dict[str, MockToolExecutor] = {}
def create_test_executor(store) -> MockToolExecutor:
executor = MockToolExecutor(store)
executor_holder["executor"] = executor
return executor
async def queue_call_end(reason: str) -> None:
logger.debug(f"文本测试收到会话结束请求: {reason}")
await worker.queue_frame(EndFrame(reason=reason))
call_end = CallEndCoordinator(queue_call_end)
async def finish_fixed_speech_if_ending() -> None:
if call_end.ending:
await call_end.finish()
capture.on_fixed_speech_complete = finish_fixed_speech_if_ending
current_service = current_llm_service
async def switch_services(
llm_resource_id: str | None,
_asr_resource_id: str | None,
_tts_resource_id: str | None,
) -> None:
nonlocal current_service
target = (
llm_services.get(llm_resource_id)
if llm_resource_id
else current_llm_service
)
if target is None:
raise ValueError(f"Workflow LLM 资源未加载:{llm_resource_id}")
if target is current_service:
return
await worker.queue_frame(ManuallySwitchServiceFrame(service=target))
current_service = target
def set_system_prompt(text: str) -> None:
messages = context.get_messages()
text = with_knowledge_hint(text)
if messages and messages[0].get("role") == "system":
messages[0] = {"role": "system", "content": text}
else:
messages.insert(0, {"role": "system", "content": text})
knowledge_schema: FunctionSchema | None = None
if (
cfg.type == "prompt"
and cfg.knowledge_base_id
and knowledge_mode == "on_demand"
):
knowledge_schema = FunctionSchema(
name="search_knowledge_base",
description=(
"在当前助手绑定的知识库中检索资料。"
f"知识库:{cfg.knowledge_base_name}"
f"{cfg.knowledge_base_description}"
),
properties={
"query": {
"type": "string",
"description": "完整问题或检索关键词",
}
},
required=["query"],
)
async def search_bound_knowledge(params: FunctionCallParams) -> None:
query = str(params.arguments.get("query") or "").strip()
if not query:
await params.result_callback(
{"status": "error", "message": "检索问题为空"}
)
return
try:
async with SessionLocal() as session:
results = await search_knowledge(
session,
cfg.knowledge_base_id or "",
query,
top_k=int(
knowledge_config.get(
"top_n",
knowledge_config.get("topN", 5),
)
),
score_threshold=float(
knowledge_config.get(
"score_threshold",
knowledge_config.get("scoreThreshold", 0.0),
)
),
)
await params.result_callback(
{"status": "ok", "results": results}
)
except Exception as exc: # noqa: BLE001 - return tool errors to LLM
logger.warning(f"文本测试知识库检索失败: {exc}")
await params.result_callback(
{"status": "error", "message": "知识库检索暂时不可用"}
)
llm.register_function("search_knowledge_base", search_bound_knowledge)
def set_tools(schemas=None) -> None:
visible = list(schemas or [])
if knowledge_schema is not None:
visible.append(knowledge_schema)
if visible:
context.set_tools(ToolsSchema(standard_tools=visible))
else:
context.set_tools()
await brain.setup(
cfg,
BrainRuntime(
context=context,
llm=llm,
queue_frame=worker.queue_frame,
set_system_prompt=set_system_prompt,
set_tools=set_tools,
call_end=call_end,
session_id=f"test_{uuid4().hex}",
client_tools=TextClientToolPort(),
worker=worker,
context_aggregator=aggregators,
transport=None,
switch_services=switch_services,
set_knowledge_scope=knowledge.set_scope,
set_vision_scope=lambda _scope: None,
vision_function=None,
set_input_enabled=lambda _enabled: None,
apply_turn_config=lambda _enabled, _config: asyncio.sleep(0),
flow_global_functions=[],
tool_executor_factory=create_test_executor,
),
)
executor = executor_holder["executor"]
@assistant_aggregator.event_handler("on_assistant_turn_started")
async def on_assistant_turn_started(_aggregator):
capture.window.active_assistant += 1
capture.window.touch()
await brain.on_assistant_text_start(uuid4().hex)
@assistant_aggregator.event_handler("on_assistant_turn_stopped")
async def on_assistant_turn_stopped(_aggregator, message):
content = str(message.content or "").strip()
capture.window.active_assistant = max(
0, capture.window.active_assistant - 1
)
if content:
capture.outputs.append(content)
capture.window.touch()
await brain.on_assistant_text_end(
uuid4().hex,
content,
bool(getattr(message, "interrupted", False)),
)
if call_end.ending:
# Text tests have no audio transport. A fully aggregated
# response is therefore the equivalent of playback ending.
await call_end.finish()
all_tool_behaviors = [
behavior
for turn in definition.turns
for behavior in turn.behaviors
if behavior.type == "tool_call"
]
executor.set_turn_behaviors(all_tool_behaviors)
runner_task: asyncio.Task[None] | None = None
try:
await brain.run_preflight()
if executor.unmocked_calls:
raise TestExecutionError(
code="UNMOCKED_TOOL_CALL",
message="启动阶段调用了未配置 Mock 的工具: "
+ "".join(executor.unmocked_calls),
stage="tool",
)
await runner.add_workers(worker)
runner_task = asyncio.create_task(
runner.run(), name=f"text-test-{uuid4().hex[:12]}"
)
await asyncio.wait_for(
worker_started.wait(), timeout=WORKER_START_TIMEOUT_SECONDS
)
marker = capture.window.activity_count
await brain.on_connected(greeting_pending=False)
await brain.on_client_ready()
await _wait_for_quiescence(
capture,
runner_task,
marker,
require_activity=False,
)
# Startup messages initialize the session but are not a scripted
# user turn result in the fixed-input editor contract.
capture.outputs.clear()
capture.tool_calls.clear()
results: list[RawTurnResult] = []
for index, turn in enumerate(definition.turns):
if worker.has_finished():
raise TestExecutionError(
code="PIPELINE_ENDED_EARLY",
message=f"Pipeline 在第 {index + 1} 轮输入前已经结束",
stage="pipeline",
retryable=False,
)
result = await self._run_turn(
worker,
runner_task,
capture,
executor,
turn,
index,
)
results.append(result)
transcript = [
{
"role": str(message.get("role") or ""),
"content": str(message.get("content") or ""),
}
for message in context.get_messages()
if message.get("role") in {"user", "assistant"}
and str(message.get("content") or "").strip()
]
return TextCaseResult(turns=results, transcript=transcript)
except TestExecutionError:
raise
except asyncio.CancelledError:
raise
except Exception as exc:
raise TestExecutionError(
code="PIPELINE_EXECUTION_FAILED",
message=str(exc) or type(exc).__name__,
stage="model",
retryable=True,
) from exc
finally:
if not worker.has_finished():
await worker.cancel(reason=INTERNAL_CANCEL_REASON)
if runner_task is not None:
await asyncio.gather(runner_task, return_exceptions=True)
async def _run_turn(
self,
worker: PipelineWorker,
runner_task: asyncio.Task[None],
capture: _TextCaptureProcessor,
executor: MockToolExecutor,
turn: FixedInputTurn,
index: int,
) -> RawTurnResult:
tool_behaviors = [
behavior for behavior in turn.behaviors if behavior.type == "tool_call"
]
executor.set_turn_behaviors(tool_behaviors)
activity_marker = capture.window.activity_count
output_marker = len(capture.outputs)
tool_marker = len(capture.tool_calls)
await worker.queue_frame(
LLMMessagesAppendFrame(
messages=[{"role": "user", "content": turn.user_input}],
run_llm=True,
)
)
await _wait_for_quiescence(capture, runner_task, activity_marker)
if executor.unmocked_calls:
raise TestExecutionError(
code="UNMOCKED_TOOL_CALL",
message="本轮调用了未配置 Mock 的工具: "
+ "".join(executor.unmocked_calls),
stage="tool",
)
return RawTurnResult(
id=turn.id,
index=index,
user_input=turn.user_input,
assistant_reply="\n\n".join(capture.outputs[output_marker:]).strip(),
tool_calls=list(capture.tool_calls[tool_marker:]),
)

386
backend/test_schemas.py Normal file
View File

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

View File

@@ -66,15 +66,10 @@ export type DebugTestCase = {
failAtTurn?: number; failAtTurn?: number;
}; };
/** 测试集Debug Drawer 只按「当前助手关联」浏览,不做管理 */ /** 测试集Debug Drawer 只浏览和选择,不做管理 */
export type DebugTestSuite = { export type DebugTestSuite = {
id: string; id: string;
name: string; name: string;
/**
* 关联助手 id。MVP 用 `"*"` 表示「当前正在调试的助手都可见」;
* 接上真实 API 后改成具体 assistantId 列表。
*/
assistantIds: string[];
caseIds: string[]; caseIds: string[];
}; };
@@ -295,15 +290,11 @@ export const MOCK_TEST_CASES: DebugTestCase[] = [
), ),
]; ];
/** /** 测试集MVP mock测试集本身不绑定助手。 */
* 当前助手关联的测试集MVP mock
* `"*"` = 任意正在调试的助手都可见,避免把「系统全部测试集」甩进 Drawer。
*/
export const MOCK_TEST_SUITES: DebugTestSuite[] = [ export const MOCK_TEST_SUITES: DebugTestSuite[] = [
{ {
id: "suite-accident-basic", id: "suite-accident-basic",
name: "事故快处基础流程", name: "事故快处基础流程",
assistantIds: ["*"],
caseIds: [ caseIds: [
"tc-dual-car", "tc-dual-car",
"tc-no-injury", "tc-no-injury",
@@ -314,7 +305,6 @@ export const MOCK_TEST_SUITES: DebugTestSuite[] = [
{ {
id: "suite-clarify", id: "suite-clarify",
name: "异常输入与澄清", name: "异常输入与澄清",
assistantIds: ["*"],
caseIds: [ caseIds: [
"tc-hello", "tc-hello",
"tc-noise", "tc-noise",
@@ -326,13 +316,11 @@ export const MOCK_TEST_SUITES: DebugTestSuite[] = [
{ {
id: "suite-tools", id: "suite-tools",
name: "工具与系统联动", name: "工具与系统联动",
assistantIds: ["*"],
caseIds: ["tc-injury-transfer", "tc-transfer-tool", "tc-end-call-tool"], caseIds: ["tc-injury-transfer", "tc-transfer-tool", "tc-end-call-tool"],
}, },
{ {
id: "suite-realtime-voice", id: "suite-realtime-voice",
name: "实时语音交互", name: "实时语音交互",
assistantIds: ["*"],
caseIds: ["tc-hello", "tc-noise", "tc-barge-in", "tc-long-silence"], caseIds: ["tc-hello", "tc-noise", "tc-barge-in", "tc-long-silence"],
}, },
]; ];
@@ -347,15 +335,6 @@ function findSuite(id: string | null): DebugTestSuite | null {
return MOCK_TEST_SUITES.find((item) => item.id === id) ?? null; return MOCK_TEST_SUITES.find((item) => item.id === id) ?? null;
} }
/** 只返回当前助手关联的测试集 */
function suitesForAssistant(assistantId: string | null): DebugTestSuite[] {
return MOCK_TEST_SUITES.filter((suite) => {
if (suite.assistantIds.includes("*")) return true;
if (!assistantId) return false;
return suite.assistantIds.includes(assistantId);
});
}
function casesInSuites(suites: DebugTestSuite[]): DebugTestCase[] { function casesInSuites(suites: DebugTestSuite[]): DebugTestCase[] {
const seen = new Set<string>(); const seen = new Set<string>();
const cases: DebugTestCase[] = []; const cases: DebugTestCase[] = [];
@@ -657,23 +636,18 @@ function TestCasePickerPopover({
onOpenChange, onOpenChange,
selectedId, selectedId,
onSelect, onSelect,
assistantId,
trigger, trigger,
}: { }: {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
selectedId: string | null; selectedId: string | null;
onSelect: (id: string) => void; onSelect: (id: string) => void;
assistantId: string | null;
trigger: React.ReactNode; trigger: React.ReactNode;
}) { }) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [activeSuiteId, setActiveSuiteId] = useState<string | null>(null); const [activeSuiteId, setActiveSuiteId] = useState<string | null>(null);
const suites = useMemo( const suites = MOCK_TEST_SUITES;
() => suitesForAssistant(assistantId),
[assistantId],
);
const scopedCases = useMemo(() => casesInSuites(suites), [suites]); const scopedCases = useMemo(() => casesInSuites(suites), [suites]);
const activeSuite = findSuite(activeSuiteId); const activeSuite = findSuite(activeSuiteId);
@@ -810,7 +784,7 @@ function TestCasePickerPopover({
</div> </div>
{suites.length === 0 ? ( {suites.length === 0 ? (
<div className="rounded-xl border border-dashed border-hairline-strong px-3 py-6 text-center text-xs text-muted-foreground"> <div className="rounded-xl border border-dashed border-hairline-strong px-3 py-6 text-center text-xs text-muted-foreground">
</div> </div>
) : ( ) : (
suites.map((suite) => ( suites.map((suite) => (
@@ -851,7 +825,6 @@ function TestCasePickerPopover({
export function AutoTestRunBar({ export function AutoTestRunBar({
state, state,
assistantId,
onSelectCase, onSelectCase,
onClearCase, onClearCase,
onStart, onStart,
@@ -859,7 +832,6 @@ export function AutoTestRunBar({
onRerun, onRerun,
}: { }: {
state: AutoTestRunState; state: AutoTestRunState;
assistantId: string | null;
onSelectCase: (id: string) => void; onSelectCase: (id: string) => void;
onClearCase: () => void; onClearCase: () => void;
onStart: () => void; onStart: () => void;
@@ -877,7 +849,6 @@ export function AutoTestRunBar({
onOpenChange={setPickerOpen} onOpenChange={setPickerOpen}
selectedId={state.selectedId} selectedId={state.selectedId}
onSelect={onSelectCase} onSelect={onSelectCase}
assistantId={assistantId}
trigger={ trigger={
<button <button
type="button" type="button"
@@ -985,7 +956,6 @@ export function AutoTestRunBar({
onOpenChange={setPickerOpen} onOpenChange={setPickerOpen}
selectedId={state.selectedId} selectedId={state.selectedId}
onSelect={onSelectCase} onSelect={onSelectCase}
assistantId={assistantId}
trigger={ trigger={
<button <button
type="button" type="button"

View File

@@ -334,7 +334,7 @@ export function DebugDrawer({
)} )}
</div> </div>
{debugMode === "auto" ? ( {debugMode === "auto" ? (
<AutoTestPanel assistantId={assistantId} autoTest={autoTest} /> <AutoTestPanel autoTest={autoTest} />
) : ( ) : (
<DebugVoicePanel <DebugVoicePanel
view={view} view={view}
@@ -355,10 +355,8 @@ export function DebugDrawer({
} }
function AutoTestPanel({ function AutoTestPanel({
assistantId,
autoTest, autoTest,
}: { }: {
assistantId: string | null;
autoTest: ReturnType<typeof useAutoTestRunner>; autoTest: ReturnType<typeof useAutoTestRunner>;
}) { }) {
const { const {
@@ -379,7 +377,6 @@ function AutoTestPanel({
<div className="shrink-0 border-t border-hairline bg-card p-3"> <div className="shrink-0 border-t border-hairline bg-card p-3">
<AutoTestRunBar <AutoTestRunBar
state={state} state={state}
assistantId={assistantId}
onSelectCase={selectCase} onSelectCase={selectCase}
onClearCase={clearCase} onClearCase={clearCase}
onStart={start} onStart={start}

View File

@@ -3,7 +3,6 @@
/** /**
* 批量测试用例详情面板。 * 批量测试用例详情面板。
* 与 prompt mode DebugDrawer overlay 相同:作为右侧 flex 半屏,无遮罩/模糊。 * 与 prompt mode DebugDrawer overlay 相同:作为右侧 flex 半屏,无遮罩/模糊。
* 当前展示前端 mock 数据,后续可直接替换为真实执行事件与校验结果。
*/ */
import { import {
@@ -79,7 +78,7 @@ export function BatchCaseDetailDrawer({
{item.name} {item.name}
</h2> </h2>
<p className="truncate text-xs text-muted-soft"> <p className="truncate text-xs text-muted-soft">
{assistantName} · Mock {assistantName} · Pipeline
</p> </p>
</div> </div>
<BatchCaseStatusBadge status={item.status} /> <BatchCaseStatusBadge status={item.status} />
@@ -99,12 +98,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">
{item.turns.length} ·{" "}
{item.turns.reduce(
(sum, turn) => sum + turn.evaluations.length,
0,
) + item.overallCriteria.length}{" "}
·
</p> </p>
</section> </section>
<ExecutionTimeline status={item.status} /> <ExecutionTimeline status={item.status} />
@@ -288,7 +282,7 @@ function ToolCallCard({ toolCall }: { toolCall: BatchToolCallRecord }) {
</div> </div>
<div className="mt-2 grid gap-2 sm:grid-cols-2"> <div className="mt-2 grid gap-2 sm:grid-cols-2">
<CodeResult label="参数" value={toolCall.argumentsJson} /> <CodeResult label="参数" value={toolCall.argumentsJson} />
<CodeResult label="Mock 返回" value={toolCall.resultJson} /> <CodeResult label="工具返回" value={toolCall.resultJson} />
</div> </div>
</div> </div>
); );
@@ -401,7 +395,7 @@ function ExecutionTimeline({ status }: { status: "waiting" | "running" }) {
}, },
{ {
label: "请求助手响应", label: "请求助手响应",
detail: isRunning ? "生成中Mock" : "尚未开始", detail: isRunning ? "Pipeline 生成中" : "尚未开始",
state: isRunning ? "active" : "pending", state: isRunning ? "active" : "pending",
}, },
{ {

View File

@@ -1,16 +1,13 @@
"use client"; "use client";
/** /** 批量测试已完成结果视图。 */
* 批量测试 — 已完成结果视图MVP mock
*/
import { ChevronRight } from "lucide-react"; import { ChevronDown, ChevronRight, Settings2 } from "lucide-react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { BatchCaseDetailDrawer } from "@/components/batch-test/batch-case-detail-drawer"; import { BatchCaseDetailDrawer } from "@/components/batch-test/batch-case-detail-drawer";
import { import {
BatchCaseStatusBadge, BatchCaseStatusBadge,
BatchPhasePill,
getBatchCaseStatusLabel, getBatchCaseStatusLabel,
} from "@/components/batch-test/batch-case-status"; } from "@/components/batch-test/batch-case-status";
import { import {
@@ -50,13 +47,8 @@ export function BatchRunCompletedView({
const detailOpen = Boolean(selectedCase); const detailOpen = Boolean(selectedCase);
const counts = countByStatus(run.cases); const counts = countByStatus(run.cases);
const judged = counts.pass + counts.fail + counts.error;
const total = run.cases.length; const total = run.cases.length;
const passRate = judged === 0 ? 0 : Math.round((counts.pass / judged) * 100); const outcome = getRunOutcome(run, counts);
const failRate =
judged === 0 ? 0 : Math.round((counts.fail / judged) * 100);
const errorRate =
judged === 0 ? 0 : Math.round((counts.error / judged) * 100);
const visibleCases = const visibleCases =
filter === "fail" filter === "fail"
? run.cases.filter((item) => item.status === "fail") ? run.cases.filter((item) => item.status === "fail")
@@ -93,26 +85,96 @@ export function BatchRunCompletedView({
> >
{/* 结果总览 */} {/* 结果总览 */}
<section className="rounded-2xl border border-hairline bg-card p-5 shadow-sm"> <section className="rounded-2xl border border-hairline bg-card p-5 shadow-sm">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-start justify-between gap-3">
<h2 className="text-base font-medium text-foreground"> <div>
{run.title} <h2 className="text-base font-medium text-foreground">
</h2> {run.title}
<BatchPhasePill phase="completed" /> </h2>
{run.finishedAt && (
<p className="mt-1.5 text-xs text-muted-soft">
{formatRunTime(run.finishedAt)}
{run.stopReason ? ` · ${stopReasonLabel(run)}` : ""}
</p>
)}
</div>
<span
className={cn(
"inline-flex shrink-0 items-center rounded-full px-3 py-1 text-xs font-medium",
outcome.className,
)}
>
{outcome.label}
</span>
</div> </div>
{run.finishedAt && (
<p className="mt-1.5 text-xs text-muted-soft">
{formatRunTime(run.finishedAt)}
{run.stopReason ? ` · ${stopReasonLabel(run)}` : ""}
</p>
)}
<dl className="mt-4 grid gap-3 rounded-xl border border-hairline bg-canvas-soft/60 px-4 py-3 text-sm sm:grid-cols-2 lg:grid-cols-4"> <div className="mt-5 rounded-xl border border-hairline bg-canvas-soft/60 p-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-xs font-medium tracking-wide text-muted-soft uppercase">
</p>
<p className="mt-1 font-display text-3xl text-ink">
<span className="tabular-nums">{counts.pass}</span>
<span className="mx-1.5 text-muted-soft">/</span>
<span className="tabular-nums">{total}</span>
<span className="ml-2 text-2xl"></span>
</p>
</div>
<div className="grid grid-cols-3 gap-x-5 gap-y-2 text-sm sm:shrink-0">
<ResultCount tone="success" label="通过" count={counts.pass} />
<ResultCount
tone="destructive"
label="断言失败"
count={counts.fail}
/>
<ResultCount
tone="warning"
label="执行错误"
count={counts.error}
/>
{counts.skipped > 0 && (
<ResultCount
tone="muted"
label="未执行"
count={counts.skipped}
/>
)}
</div>
</div>
<ResultDistribution counts={counts} total={total} />
</div>
<dl
className={cn(
"mt-4 grid gap-x-6 gap-y-4 text-sm sm:grid-cols-2",
!detailOpen && "lg:grid-cols-4",
)}
>
<div> <div>
<dt className="text-xs text-muted-foreground"></dt> <dt className="text-xs text-muted-foreground"></dt>
<dd className="mt-1 truncate font-medium text-foreground"> <dd className="mt-1 break-words font-medium text-foreground">
{run.assistantName} {run.assistantName}
</dd> </dd>
</div> </div>
<div className="min-w-0">
<dt className="text-xs text-muted-foreground"></dt>
<dd
className="mt-1 break-words font-medium text-foreground"
title={run.config.evaluatorModelResourceName}
>
{run.config.evaluatorModelResourceName}
</dd>
{run.config.evaluatorModel && (
<dd
className="mt-0.5 break-all font-mono text-[11px] text-muted-soft"
title={run.config.evaluatorModel}
>
{run.config.evaluatorModel}
</dd>
)}
</div>
<div> <div>
<dt className="text-xs text-muted-foreground"></dt> <dt className="text-xs text-muted-foreground"></dt>
<dd className="mt-1 font-medium text-foreground"> <dd className="mt-1 font-medium text-foreground">
@@ -122,70 +184,34 @@ export function BatchRunCompletedView({
<div> <div>
<dt className="text-xs text-muted-foreground"></dt> <dt className="text-xs text-muted-foreground"></dt>
<dd className="mt-1 font-medium text-foreground"> <dd className="mt-1 font-medium text-foreground">
{run.config.concurrency} · {run.config.timeoutSecs} {run.config.concurrency} · {run.config.timeoutSecs}
</dd> <span className="mx-1.5 text-muted-soft">·</span>
</div> {formatRunDuration(run.startedAt, run.finishedAt)}
<div>
<dt className="text-xs text-muted-foreground"> / </dt>
<dd className="mt-1 text-xs font-medium leading-5 text-foreground">
{formatRunDuration(run.startedAt, run.finishedAt)} ·
{BATCH_FAILURE_STRATEGY_LABEL[run.config.failureStrategy]}
<br />
{run.config.errorRetryCount}
{BATCH_ERROR_STRATEGY_LABEL[run.config.errorStrategy]}
</dd> </dd>
</div> </div>
</dl> </dl>
<div className="mt-5 flex flex-col items-center gap-6 sm:flex-row sm:justify-between sm:gap-8"> <details className="group mt-4 border-t border-hairline pt-3">
<div className="text-center sm:text-left"> <summary className="flex w-fit cursor-pointer list-none items-center gap-2 rounded-full px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-canvas-soft hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 [&::-webkit-details-marker]:hidden">
<div className="font-display text-3xl text-ink"> <Settings2 size={13} />
{counts.pass} / {judged || total}
</div> <ChevronDown
<div className="mt-1 text-sm font-medium text-success"> size={13}
{passRate}% className="transition-transform group-open:rotate-180"
</div> />
</summary>
<div className="mt-3 grid gap-3 rounded-xl border border-hairline bg-canvas-soft/50 px-4 py-3 text-xs leading-5 text-muted-foreground sm:grid-cols-2">
<p>
<span className="font-medium text-foreground"></span>
{BATCH_FAILURE_STRATEGY_LABEL[run.config.failureStrategy]}
</p>
<p>
<span className="font-medium text-foreground"></span>
{run.config.errorRetryCount}
{BATCH_ERROR_STRATEGY_LABEL[run.config.errorStrategy]}
</p>
</div> </div>
</details>
<PassRateDonut
pass={counts.pass}
fail={counts.fail}
error={counts.error}
/>
<div className="space-y-2 text-sm">
<LegendRow
tone="success"
label="通过"
count={counts.pass}
percent={passRate}
/>
<LegendRow
tone="destructive"
label="断言失败"
count={counts.fail}
percent={failRate}
/>
<LegendRow
tone="warning"
label="执行错误"
count={counts.error}
percent={errorRate}
/>
{counts.skipped > 0 && (
<LegendRow
tone="muted"
label="未执行"
count={counts.skipped}
percent={
total === 0
? 0
: Math.round((counts.skipped / total) * 100)
}
/>
)}
</div>
</div>
</section> </section>
{/* 用例列表 */} {/* 用例列表 */}
@@ -319,106 +345,53 @@ export function BatchRunCompletedView({
); );
} }
function PassRateDonut({ type BatchCounts = ReturnType<typeof countByStatus>;
pass,
fail,
error,
}: {
pass: number;
fail: number;
error: number;
}) {
const total = pass + fail + error;
const passRatio = total === 0 ? 0 : pass / total;
const size = 112;
const stroke = 14;
const radius = (size - stroke) / 2;
const circumference = 2 * Math.PI * radius;
const passLength = circumference * passRatio;
const failLength = circumference * (total === 0 ? 0 : fail / total);
const errorLength = circumference * (total === 0 ? 0 : error / total);
return ( function getRunOutcome(run: BatchRunSnapshot, counts: BatchCounts) {
<div if (run.status === "cancelled") {
className="relative shrink-0" return {
style={{ width: size, height: size }} label: "运行已停止",
aria-hidden className: "bg-surface-strong text-muted-foreground",
> };
<svg width={size} height={size} className="-rotate-90"> }
<circle if (counts.error > 0) {
cx={size / 2} return {
cy={size / 2} label: "存在执行错误",
r={radius} className: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
fill="none" };
stroke="currentColor" }
strokeWidth={stroke} if (counts.fail > 0) {
className="text-surface-strong" return {
/> label: "存在断言失败",
{total > 0 && ( className: "bg-destructive/10 text-destructive",
<> };
{pass > 0 && ( }
<circle if (counts.skipped > 0) {
cx={size / 2} return {
cy={size / 2} label: "部分未执行",
r={radius} className: "bg-surface-strong text-muted-foreground",
fill="none" };
stroke="currentColor" }
strokeWidth={stroke} if (counts.pass > 0) {
strokeDasharray={`${passLength} ${circumference}`} return {
strokeLinecap="butt" label: "全部通过",
className="text-success" className: "bg-success/10 text-success",
/> };
)} }
{fail > 0 && ( return {
<circle label: "暂无结果",
cx={size / 2} className: "bg-surface-strong text-muted-foreground",
cy={size / 2} };
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeDasharray={`${failLength} ${circumference}`}
strokeDashoffset={-passLength}
strokeLinecap="butt"
className="text-destructive"
/>
)}
{error > 0 && (
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeDasharray={`${errorLength} ${circumference}`}
strokeDashoffset={-(passLength + failLength)}
strokeLinecap="butt"
className="text-amber-500"
/>
)}
</>
)}
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-lg font-medium tabular-nums text-foreground">
{total === 0 ? 0 : Math.round((pass / total) * 100)}%
</span>
</div>
</div>
);
} }
function LegendRow({ function ResultCount({
tone, tone,
label, label,
count, count,
percent,
}: { }: {
tone: "success" | "destructive" | "warning" | "muted"; tone: "success" | "destructive" | "warning" | "muted";
label: string; label: string;
count: number; count: number;
percent: number;
}) { }) {
const dotClass = { const dotClass = {
success: "bg-success", success: "bg-success",
@@ -428,11 +401,48 @@ function LegendRow({
}[tone]; }[tone];
return ( return (
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex min-w-0 items-center gap-2">
<span className={cn("size-2.5 rounded-full", dotClass)} /> <span
<span> className={cn("size-2 shrink-0 rounded-full", dotClass)}
{label} {count}{percent}% aria-hidden
/>
<span className="truncate text-muted-foreground">{label}</span>
<span className="ml-auto font-medium tabular-nums text-foreground">
{count}
</span> </span>
</div> </div>
); );
} }
function ResultDistribution({
counts,
total,
}: {
counts: BatchCounts;
total: number;
}) {
const segments = [
{ key: "pass", count: counts.pass, className: "bg-success" },
{ key: "fail", count: counts.fail, className: "bg-destructive" },
{ key: "error", count: counts.error, className: "bg-amber-500" },
{ key: "skipped", count: counts.skipped, className: "bg-muted-soft" },
].filter((item) => item.count > 0);
return (
<div
className="mt-4 flex h-2 overflow-hidden rounded-full bg-surface-strong"
role="img"
aria-label={`结果分布:通过 ${counts.pass},断言失败 ${counts.fail},执行错误 ${counts.error},未执行 ${counts.skipped}`}
>
{total > 0 &&
segments.map((segment) => (
<span
key={segment.key}
className={segment.className}
style={{ flexBasis: 0, flexGrow: segment.count }}
aria-hidden
/>
))}
</div>
);
}

View File

@@ -1,8 +1,6 @@
"use client"; "use client";
/** /** 批量测试运行中的持久化快照视图。 */
* 批量测试 — 运行中视图MVP mock
*/
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
@@ -67,6 +65,10 @@ export function BatchRunRunningView({ run }: { run: BatchRunSnapshot }) {
</h2> </h2>
<BatchPhasePill phase="running" /> <BatchPhasePill phase="running" />
</div> </div>
<p className="text-xs text-muted-soft">
{run.assistantName} ·
{run.config.evaluatorModelResourceName}
</p>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="h-2.5 min-w-0 flex-1 overflow-hidden rounded-full bg-surface-strong"> <div className="h-2.5 min-w-0 flex-1 overflow-hidden rounded-full bg-surface-strong">

View File

@@ -2,7 +2,7 @@
/** /**
* 批量测试页MVP * 批量测试页MVP
* 三阶段:配置 → 运行中 → 已完成。执行进度为前端 mock后续可换真实引擎 * 三阶段:配置 → 运行中 → 已完成。运行快照由后端持久化并轮询更新
*/ */
import { import {
@@ -35,21 +35,21 @@ import {
import { import {
BATCH_ERROR_STRATEGY_LABEL, BATCH_ERROR_STRATEGY_LABEL,
BATCH_FAILURE_STRATEGY_LABEL, BATCH_FAILURE_STRATEGY_LABEL,
createMockBatchRun,
hasEvaluationFailure,
type BatchErrorStrategy, type BatchErrorStrategy,
type BatchFailureStrategy, type BatchFailureStrategy,
type BatchMockExecutionPlan,
type BatchRunPhase, type BatchRunPhase,
type BatchRunSnapshot, type BatchRunSnapshot,
} from "@/data/batch-run"; } from "@/data/batch-run";
import type { TestCase, TestSuite } from "@/data/test-suites";
import { import {
listTestCases, assistantsApi,
listTestSuites, batchRunsApi,
type TestCase, modelResourcesApi,
type TestSuite, testCasesApi,
} from "@/data/test-suites"; testSuitesApi,
import { assistantsApi, type Assistant } from "@/lib/api"; type Assistant,
type ModelResource,
} from "@/lib/api";
const CONCURRENCY_OPTIONS = ["1", "2", "3", "5", "10"] as const; const CONCURRENCY_OPTIONS = ["1", "2", "3", "5", "10"] as const;
@@ -82,7 +82,7 @@ const BATCH_SECTIONS = [
{ id: "settings", label: "运行设置" }, { id: "settings", label: "运行设置" },
] as const; ] as const;
const TICK_MS = 900; const RUN_POLL_MS = 1000;
function getAppScrollContainer(): HTMLElement | null { function getAppScrollContainer(): HTMLElement | null {
return document.querySelector<HTMLElement>(".app-content"); return document.querySelector<HTMLElement>(".app-content");
@@ -104,29 +104,38 @@ function buildRunTitle(
return "批量测试"; return "批量测试";
} }
function loadTestScope() { function groupCasesBySuite(suites: TestSuite[], cases: TestCase[]) {
const suites = listTestSuites();
const casesBySuite: Record<string, TestCase[]> = {}; const casesBySuite: Record<string, TestCase[]> = {};
for (const suite of suites) { for (const suite of suites) {
const items = listTestCases(suite.id); casesBySuite[suite.id] = cases
casesBySuite[suite.id] = items; .filter((item) => item.suiteId === suite.id)
.sort((left, right) => left.sortOrder - right.sortOrder);
} }
return casesBySuite;
return { suites, casesBySuite };
} }
export function BatchTestPage() { export function BatchTestPage() {
const router = useRouter(); const router = useRouter();
const [phase, setPhase] = useState<BatchRunPhase>("config"); const [phase, setPhase] = useState<BatchRunPhase>("config");
const [run, setRun] = useState<BatchRunSnapshot | null>(null); const [run, setRun] = useState<BatchRunSnapshot | null>(null);
const mockExecutionPlanRef = useRef<BatchMockExecutionPlan>({}); const [runError, setRunError] = useState("");
const [starting, setStarting] = useState(false);
const [stopping, setStopping] = useState(false);
const [assistants, setAssistants] = useState<Assistant[]>([]); const [assistants, setAssistants] = useState<Assistant[]>([]);
const [loadingAssistants, setLoadingAssistants] = useState(true); const [loadingAssistants, setLoadingAssistants] = useState(true);
const [assistantId, setAssistantId] = useState(""); const [assistantId, setAssistantId] = useState("");
const [evaluatorModels, setEvaluatorModels] = useState<ModelResource[]>([]);
const [loadingEvaluatorModels, setLoadingEvaluatorModels] = useState(true);
const [evaluatorModelResourceId, setEvaluatorModelResourceId] = useState("");
const [{ suites, casesBySuite }] = useState(loadTestScope); const [suites, setSuites] = useState<TestSuite[]>([]);
const [casesBySuite, setCasesBySuite] = useState<Record<string, TestCase[]>>(
{},
);
const [loadingScope, setLoadingScope] = useState(true);
const [scopeError, setScopeError] = useState("");
const [selectedCaseIds, setSelectedCaseIds] = useState<Set<string>>( const [selectedCaseIds, setSelectedCaseIds] = useState<Set<string>>(
() => new Set(), () => new Set(),
); );
@@ -249,102 +258,84 @@ export function BatchTestPage() {
})(); })();
}, []); }, []);
// mock 执行引擎:按并发推进用例状态
useEffect(() => { useEffect(() => {
if (phase !== "running" || !run?.startedAt) return; void (async () => {
try {
function tick(settleRunning: boolean) { const resources = (await modelResourcesApi.list()).filter(
setRun((prev) => { (item) => item.capability === "LLM" && item.enabled,
if (!prev || prev.finishedAt) return prev;
let cases = prev.cases.map((item) => ({ ...item }));
// 1) 结算上一拍仍在运行的用例(首拍只启动,不结算)
let sawFail = false;
let sawError = false;
if (settleRunning) {
for (const item of cases) {
if (item.status !== "running") continue;
const plannedError = mockExecutionPlanRef.current[item.id];
if (plannedError) {
if (
plannedError.retryable &&
item.attemptCount < item.maxAttempts
) {
item.status = "waiting";
} else {
item.status = "error";
item.executionError = plannedError;
sawError = true;
}
continue;
}
item.status = hasEvaluationFailure(item) ? "fail" : "pass";
if (item.status === "fail") sawFail = true;
}
}
const stopReason =
sawError && prev.config.errorStrategy === "stop_on_error"
? "execution_error"
: sawFail && prev.config.failureStrategy === "stop_on_fail"
? "assertion_failure"
: null;
// 配置为停止时,剩余等待/运行中的用例统一标记为未执行。
if (stopReason) {
cases = cases.map((item) =>
item.status === "waiting" || item.status === "running"
? { ...item, status: "skipped" as const }
: item,
);
return {
...prev,
cases,
finishedAt: new Date().toISOString(),
stopReason,
};
}
// 2) 按并发补齐运行中
const limit = prev.config.concurrency;
let running = cases.filter((item) => item.status === "running").length;
for (const item of cases) {
if (running >= limit) break;
if (item.status !== "waiting") continue;
item.status = "running";
item.attemptCount += 1;
running += 1;
}
const pending = cases.some(
(item) => item.status === "waiting" || item.status === "running",
); );
if (!pending) { setEvaluatorModels(resources);
return { const defaultResource =
...prev, resources.find((item) => item.isDefault) ?? resources[0];
cases, if (defaultResource) setEvaluatorModelResourceId(defaultResource.id);
finishedAt: new Date().toISOString(), } catch {
stopReason: null, // 模型资源为空或加载失败时,运行前检查会阻止启动。
}; } finally {
} setLoadingEvaluatorModels(false);
}
})();
}, []);
return { ...prev, cases }; useEffect(() => {
}); void (async () => {
try {
setScopeError("");
const [loadedSuites, loadedCases] = await Promise.all([
testSuitesApi.list(),
testCasesApi.list(),
]);
setSuites(loadedSuites);
setCasesBySuite(groupCasesBySuite(loadedSuites, loadedCases));
} catch (error) {
setScopeError(
error instanceof Error ? error.message : "加载测试范围失败",
);
} finally {
setLoadingScope(false);
}
})();
}, []);
useEffect(() => {
if (phase !== "running" || !run?.id) return;
let disposed = false;
let timer = 0;
async function poll() {
let finished = false;
try {
const snapshot = await batchRunsApi.get(run!.id);
if (disposed) return;
setRun(snapshot);
setRunError("");
if (
snapshot.finishedAt ||
snapshot.status === "completed" ||
snapshot.status === "cancelled"
) {
finished = true;
setStopping(false);
setPhase("completed");
}
} catch (error) {
if (!disposed) {
setRunError(
error instanceof Error ? error.message : "刷新运行结果失败",
);
}
} finally {
if (!disposed && !finished) {
timer = window.setTimeout(() => void poll(), RUN_POLL_MS);
}
}
} }
// 立刻拉起第一批,再按节拍结算/推进 void poll();
tick(false); return () => {
const timer = window.setInterval(() => tick(true), TICK_MS); disposed = true;
return () => window.clearInterval(timer); window.clearTimeout(timer);
}, [phase, run?.startedAt]); };
}, [phase, run?.id]);
// 执行引擎只负责写入完成快照;页面阶段在快照稳定后统一切换。
useEffect(() => {
if (phase !== "running" || !run?.finishedAt) return;
const timer = window.setTimeout(() => setPhase("completed"), 0);
return () => window.clearTimeout(timer);
}, [phase, run?.finishedAt]);
const selectedCases = useMemo(() => { const selectedCases = useMemo(() => {
const ordered: TestCase[] = []; const ordered: TestCase[] = [];
@@ -364,9 +355,13 @@ export function BatchTestPage() {
const canStart = const canStart =
Boolean(assistantId) && Boolean(assistantId) &&
Boolean(evaluatorModelResourceId) &&
selectedCount > 0 && selectedCount > 0 &&
timeoutValid && timeoutValid &&
!loadingAssistants && !loadingAssistants &&
!loadingEvaluatorModels &&
!loadingScope &&
!starting &&
phase === "config"; phase === "config";
const runReadiness: { const runReadiness: {
@@ -375,60 +370,90 @@ export function BatchTestPage() {
actionable?: boolean; actionable?: boolean;
} = loadingAssistants } = loadingAssistants
? { label: "正在加载助手…" } ? { label: "正在加载助手…" }
: assistants.length === 0 : loadingEvaluatorModels
? { label: "暂无可用助手", target: "target", actionable: true } ? { label: "正在加载评估模型…" }
: !assistantId : loadingScope
? { label: "请选择被测助手", target: "target", actionable: true } ? { label: "正在加载测试范围…" }
: selectedCount === 0 : scopeError
? { label: "请选择测试用例", target: "scope", actionable: true } ? { label: "测试范围加载失败", target: "scope", actionable: true }
: !timeoutValid : assistants.length === 0
? { ? { label: "暂无可用助手", target: "target", actionable: true }
label: "请修正超时时间", : !assistantId
target: "settings", ? { label: "请选择被测助手", target: "target", actionable: true }
actionable: true, : evaluatorModels.length === 0
} ? { label: "暂无可用评估模型", target: "target", actionable: true }
: { : !evaluatorModelResourceId
label: `${selectedCount} 个用例 · ${concurrency} 并发 · 超时 ${timeoutValue}`, ? { label: "请选择评估模型", target: "target", actionable: true }
}; : selectedCount === 0
? { label: "请选择测试用例", target: "scope", actionable: true }
: !timeoutValid
? {
label: "请修正超时时间",
target: "settings",
actionable: true,
}
: {
label: `${selectedCount} 个用例 · ${concurrency} 并发 · 超时 ${timeoutValue}`,
};
function startRun(cases: TestCase[]) { async function startRun(cases: TestCase[]) {
const assistantName = if (
assistants.find((item) => item.id === assistantId)?.name ?? "助手"; !assistantId ||
const { snapshot, executionPlan } = createMockBatchRun({ !evaluatorModelResourceId ||
title: buildRunTitle(cases, suites), cases.length === 0 ||
assistantName, starting
cases, ) {
concurrency: Number(concurrency) || 3, return;
timeoutSecs: timeoutValue, }
failureStrategy: failStrategy, setStarting(true);
errorRetryCount: errorRetryValue, setRunError("");
errorStrategy, try {
}); const snapshot = await batchRunsApi.create({
mockExecutionPlanRef.current = executionPlan; assistantId,
setRun(snapshot); evaluatorModelResourceId,
setPhase("running"); caseIds: cases.map((item) => item.id),
title: buildRunTitle(cases, suites),
config: {
concurrency: Number(concurrency) || 3,
timeoutSecs: timeoutValue,
failureStrategy: failStrategy,
errorRetryCount: errorRetryValue,
errorStrategy,
},
});
setRun(snapshot);
setPhase("running");
} catch (error) {
setRunError(error instanceof Error ? error.message : "启动批量测试失败");
} finally {
setStarting(false);
}
} }
function handleStart() { function handleStart() {
if (!canStart) return; if (!canStart) return;
startRun(selectedCases); void startRun(selectedCases);
} }
function handleStop() { async function handleStop() {
setRun((prev) => { if (!run || stopping) return;
if (!prev) return prev; setStopping(true);
return { setRunError("");
...prev, try {
stopReason: "manual", const snapshot = await batchRunsApi.cancel(run.id);
finishedAt: new Date().toISOString(), setRun(snapshot);
cases: prev.cases.map((item) => if (
item.status === "waiting" || item.status === "running" snapshot.finishedAt ||
? { ...item, status: "skipped" } snapshot.status === "completed" ||
: item, snapshot.status === "cancelled"
), ) {
}; setPhase("completed");
}); setStopping(false);
setPhase("completed"); }
} catch (error) {
setRunError(error instanceof Error ? error.message : "停止批量测试失败");
setStopping(false);
}
} }
function sourceCasesByIds(ids: string[]): TestCase[] { function sourceCasesByIds(ids: string[]): TestCase[] {
@@ -445,7 +470,7 @@ export function BatchTestPage() {
setRun(null); setRun(null);
return; return;
} }
startRun(cases); void startRun(cases);
} }
function handleRerunFailed() { function handleRerunFailed() {
@@ -457,12 +482,12 @@ export function BatchTestPage() {
) )
.map((item) => item.id), .map((item) => item.id),
); );
if (failedCases.length > 0) startRun(failedCases); if (failedCases.length > 0) void startRun(failedCases);
} }
function handleRerunCase(caseId: string) { function handleRerunCase(caseId: string) {
const [item] = sourceCasesByIds([caseId]); const [item] = sourceCasesByIds([caseId]);
if (item) startRun([item]); if (item) void startRun([item]);
} }
function handleEditCase(caseId: string) { function handleEditCase(caseId: string) {
@@ -474,6 +499,7 @@ export function BatchTestPage() {
function handleBackToConfig() { function handleBackToConfig() {
setPhase("config"); setPhase("config");
setRun(null); setRun(null);
setRunError("");
} }
if (phase === "running" && run) { if (phase === "running" && run) {
@@ -485,13 +511,26 @@ export function BatchTestPage() {
<Button <Button
variant="outline" variant="outline"
className="gap-2 rounded-full border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive" className="gap-2 rounded-full border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={handleStop} disabled={stopping}
onClick={() => void handleStop()}
> >
<Square size={14} className="fill-current" /> {stopping ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Square size={14} className="fill-current" />
)}
{stopping ? "正在停止…" : "停止运行"}
</Button> </Button>
} }
> >
{runError && (
<p
className="border-b border-destructive/20 bg-destructive/5 px-5 py-2 text-sm text-destructive"
role="alert"
>
{runError}
</p>
)}
<BatchRunRunningView run={run} /> <BatchRunRunningView run={run} />
</ListPageLayout> </ListPageLayout>
); );
@@ -517,7 +556,7 @@ export function BatchTestPage() {
<Button <Button
variant="outline" variant="outline"
className="gap-2 rounded-full border-hairline-strong" className="gap-2 rounded-full border-hairline-strong"
disabled={failedCount === 0} disabled={failedCount === 0 || starting}
onClick={handleRerunFailed} onClick={handleRerunFailed}
> >
<RotateCcw size={15} /> <RotateCcw size={15} />
@@ -525,14 +564,27 @@ export function BatchTestPage() {
</Button> </Button>
<Button <Button
className="gap-2 rounded-full px-4" className="gap-2 rounded-full px-4"
disabled={starting}
onClick={handleRerunAll} onClick={handleRerunAll}
> >
<RotateCcw size={15} /> {starting ? (
<Loader2 size={15} className="animate-spin" />
) : (
<RotateCcw size={15} />
)}
{starting ? "正在启动…" : "重跑全部"}
</Button> </Button>
</div> </div>
} }
> >
{runError && (
<p
className="border-b border-destructive/20 bg-destructive/5 px-5 py-2 text-sm text-destructive"
role="alert"
>
{runError}
</p>
)}
<BatchRunCompletedView <BatchRunCompletedView
run={run} run={run}
onEditCase={handleEditCase} onEditCase={handleEditCase}
@@ -545,7 +597,7 @@ export function BatchTestPage() {
return ( return (
<ListPageLayout <ListPageLayout
title="批量测试" title="批量测试"
description="配置一次批量运行:选择被测助手、测试范围与基础运行参数。" description="配置一次批量运行:选择被测助手、评估模型、测试范围与运行参数。"
className="max-w-[880px]" className="max-w-[880px]"
topbarAction={ topbarAction={
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -578,8 +630,12 @@ export function BatchTestPage() {
aria-describedby="batch-run-readiness" aria-describedby="batch-run-readiness"
onClick={handleStart} onClick={handleStart}
> >
<Play size={16} /> {starting ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Play size={16} />
)}
{starting ? "正在启动…" : "开始批量测试"}
</Button> </Button>
</div> </div>
} }
@@ -593,6 +649,15 @@ export function BatchTestPage() {
} }
/> />
{runError && (
<p
className="rounded-xl border border-destructive/20 bg-destructive/5 px-3 py-2 text-sm text-destructive"
role="alert"
>
{runError}
</p>
)}
<div className="space-y-4"> <div className="space-y-4">
<section <section
ref={(element) => { ref={(element) => {
@@ -603,36 +668,77 @@ export function BatchTestPage() {
<SectionCard <SectionCard
icon={<Target size={15} />} icon={<Target size={15} />}
title="运行目标" title="运行目标"
description="选择本次批量测试要对齐的助手配置" description="分别选择生成回复的助手和判断结果的 LLM"
> >
<label className="block space-y-2"> <div className="grid gap-5 sm:grid-cols-2">
<span className="text-sm font-medium text-foreground"> <label className="block space-y-2">
<span className="text-sm font-medium text-foreground">
</span>
{loadingAssistants ? ( </span>
<div className="flex items-center gap-2 text-sm text-muted-foreground"> {loadingAssistants ? (
<Loader2 size={16} className="animate-spin" /> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
</div>
) : assistants.length === 0 ? ( </div>
<p className="rounded-xl border border-dashed border-hairline-strong bg-canvas-soft/50 px-3 py-4 text-sm text-muted-foreground"> ) : assistants.length === 0 ? (
<p className="rounded-xl border border-dashed border-hairline-strong bg-canvas-soft/50 px-3 py-4 text-sm text-muted-foreground">
</p>
) : ( </p>
<Select value={assistantId} onValueChange={setAssistantId}> ) : (
<SelectTrigger className="w-full border-hairline-strong bg-background"> <Select value={assistantId} onValueChange={setAssistantId}>
<SelectValue placeholder="选择被测助手" /> <SelectTrigger className="w-full border-hairline-strong bg-background">
</SelectTrigger> <SelectValue placeholder="选择被测助手" />
<SelectContent> </SelectTrigger>
{assistants.map((item) => ( <SelectContent>
<SelectItem key={item.id} value={item.id}> {assistants.map((item) => (
{item.name} <SelectItem key={item.id} value={item.id}>
</SelectItem> {item.name}
))} </SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
)} </Select>
</label> )}
</label>
<label className="block space-y-2">
<span className="text-sm font-medium text-foreground">
</span>
{loadingEvaluatorModels ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
</div>
) : evaluatorModels.length === 0 ? (
<p className="rounded-xl border border-dashed border-hairline-strong bg-canvas-soft/50 px-3 py-4 text-sm text-muted-foreground">
LLM
</p>
) : (
<Select
value={evaluatorModelResourceId}
onValueChange={setEvaluatorModelResourceId}
>
<SelectTrigger className="w-full border-hairline-strong bg-background">
<SelectValue placeholder="选择评估模型" />
</SelectTrigger>
<SelectContent>
{evaluatorModels.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
{typeof item.values.modelId === "string" &&
item.values.modelId
? ` · ${item.values.modelId}`
: ""}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<span className="block text-xs leading-5 text-muted-soft">
LLM
</span>
</label>
</div>
</SectionCard> </SectionCard>
</section> </section>
@@ -647,12 +753,26 @@ export function BatchTestPage() {
title="选择测试范围" title="选择测试范围"
description="搜索测试集或用例,按需组合本次运行范围" description="搜索测试集或用例,按需组合本次运行范围"
> >
<TestScopeSelector {loadingScope ? (
suites={suites} <div className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
casesBySuite={casesBySuite} <Loader2 size={16} className="animate-spin" />
selectedCaseIds={selectedCaseIds}
onSelectionChange={setSelectedCaseIds} </div>
/> ) : scopeError ? (
<p
className="rounded-xl border border-destructive/20 bg-destructive/5 px-3 py-3 text-sm text-destructive"
role="alert"
>
{scopeError}
</p>
) : (
<TestScopeSelector
suites={suites}
casesBySuite={casesBySuite}
selectedCaseIds={selectedCaseIds}
onSelectionChange={setSelectedCaseIds}
/>
)}
</SectionCard> </SectionCard>
</section> </section>

View File

@@ -48,41 +48,24 @@ import {
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { ListToolbar } from "@/components/ui/list-toolbar"; import { ListToolbar } from "@/components/ui/list-toolbar";
import { SearchInput } from "@/components/ui/search-input"; import { SearchInput } from "@/components/ui/search-input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
cloneOverallCriteria, cloneOverallCriteria,
cloneTurns, cloneTurns,
createEmptyFixedInputTurn, createEmptyFixedInputTurn,
createTestCase,
createTestSuite,
DEFAULT_INPUT_MODE, DEFAULT_INPUT_MODE,
duplicateTestCase,
duplicateTestSuite,
getTestCaseValidationMessage, getTestCaseValidationMessage,
getTestSuite,
listTestCases,
listTestSuites,
normalizeOverallCriteria, normalizeOverallCriteria,
removeTestCase,
removeTestCases,
removeTestSuite,
reorderTestCases,
suiteCaseStats,
TEST_CASE_INPUT_MODE_LABEL, TEST_CASE_INPUT_MODE_LABEL,
TEST_CASE_INPUT_MODE_SHORT_LABEL, TEST_CASE_INPUT_MODE_SHORT_LABEL,
updateTestCase,
updateTestSuite,
type TestCase, type TestCase,
type TestCaseInputMode, type TestCaseInputMode,
type TestSuite, type TestSuite,
} from "@/data/test-suites"; } from "@/data/test-suites";
import { assistantsApi, type Assistant } from "@/lib/api"; import {
testCasesApi,
testSuitesApi,
type TestCaseWrite,
} from "@/lib/api";
// 路由驱动: // 路由驱动:
// /test/cases → list // /test/cases → list
@@ -109,16 +92,33 @@ export function TestCasesPage(props: TestCasesPageProps) {
function SuiteListView() { function SuiteListView() {
const router = useRouter(); const router = useRouter();
const [suites, setSuites] = useState<TestSuite[]>(() => listTestSuites()); const [suites, setSuites] = useState<TestSuite[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState("");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
async function reloadSuites() {
try {
setLoadError("");
setSuites(await testSuitesApi.list());
} catch (error) {
setLoadError(error instanceof Error ? error.message : "加载测试集失败");
} finally {
setLoading(false);
}
}
useEffect(() => {
void reloadSuites();
}, []);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const keyword = search.trim().toLowerCase(); const keyword = search.trim().toLowerCase();
return suites.filter((suite) => { return suites.filter((suite) => {
if (!keyword) return true; if (!keyword) return true;
return [suite.name, suite.assistantName, suite.id] return [suite.name, suite.description, suite.id]
.join(" ") .join(" ")
.toLowerCase() .toLowerCase()
.includes(keyword); .includes(keyword);
@@ -136,13 +136,16 @@ function SuiteListView() {
router.push(`/test/cases/${suite.id}`); router.push(`/test/cases/${suite.id}`);
} }
function duplicateSuite(suite: TestSuite) { async function duplicateSuite(suite: TestSuite) {
const copied = duplicateTestSuite(suite.id); try {
if (!copied) return; await testSuitesApi.duplicate(suite.id);
setSuites(listTestSuites()); await reloadSuites();
} catch (error) {
setLoadError(error instanceof Error ? error.message : "复制测试集失败");
}
} }
function removeSuite(suite: TestSuite) { async function removeSuite(suite: TestSuite) {
if ( if (
!window.confirm( !window.confirm(
`确定删除测试集“${suite.name}”及其全部测试用例吗?`, `确定删除测试集“${suite.name}”及其全部测试用例吗?`,
@@ -151,9 +154,14 @@ function SuiteListView() {
return; return;
} }
setDeletingId(suite.id); setDeletingId(suite.id);
removeTestSuite(suite.id); try {
setSuites(listTestSuites()); await testSuitesApi.remove(suite.id);
setDeletingId(null); await reloadSuites();
} catch (error) {
setLoadError(error instanceof Error ? error.message : "删除测试集失败");
} finally {
setDeletingId(null);
}
} }
return ( return (
@@ -186,14 +194,26 @@ function SuiteListView() {
} }
/> />
{loadError && (
<p className="mb-3 text-sm text-destructive" role="alert">
{loadError}
</p>
)}
<DataList<TestSuite> <DataList<TestSuite>
rows={paginated} rows={paginated}
rowKey={(suite) => suite.id} rowKey={(suite) => suite.id}
onRowClick={openSuite} onRowClick={openSuite}
empty={{ empty={{
title: suites.length === 0 ? "暂无测试集" : "未找到匹配的测试集", title: loading
? "正在加载测试集…"
: suites.length === 0
? "暂无测试集"
: "未找到匹配的测试集",
description: description:
suites.length === 0 loading
? "请稍候。"
: suites.length === 0
? "点击右上角「新建测试集」开始。" ? "点击右上角「新建测试集」开始。"
: "请调整关键词后再试。", : "请调整关键词后再试。",
}} }}
@@ -222,19 +242,12 @@ function SuiteListView() {
</> </>
), ),
}, },
{
key: "assistant",
header: "关联助手",
width: "md:w-[160px]",
cellClassName: "text-muted-foreground",
cell: (suite) => suite.assistantName || "—",
},
{ {
key: "caseCount", key: "caseCount",
header: "用例数", header: "用例数",
width: "md:w-[96px]", width: "md:w-[96px]",
cellClassName: "tabular-nums text-muted-foreground", cellClassName: "tabular-nums text-muted-foreground",
cell: (suite) => suiteCaseStats(suite.id).total, cell: (suite) => suite.caseCount ?? 0,
}, },
{ {
key: "actions", key: "actions",
@@ -313,42 +326,31 @@ function SuiteListView() {
function SuiteCreateView() { function SuiteCreateView() {
const router = useRouter(); const router = useRouter();
const [assistants, setAssistants] = useState<Assistant[]>([]);
const [loadingAssistants, setLoadingAssistants] = useState(true);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [assistantName, setAssistantName] = useState("");
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState("");
useEffect(() => { async function confirmCreate() {
void (async () => {
try {
const list = await assistantsApi.list();
setAssistants(list);
if (list[0]) setAssistantName(list[0].name);
} catch {
setAssistantName("视频快处助手");
} finally {
setLoadingAssistants(false);
}
})();
}, []);
function confirmCreate() {
if (!name.trim() || creating) return; if (!name.trim() || creating) return;
setCreating(true); setCreating(true);
const saved = createTestSuite({ setCreateError("");
name, try {
description: "", const saved = await testSuitesApi.create({
assistantName, name: name.trim(),
}); description: "",
router.push(`/test/cases/${saved.id}`); });
router.push(`/test/cases/${saved.id}`);
} catch (error) {
setCreateError(error instanceof Error ? error.message : "创建测试集失败");
setCreating(false);
}
} }
return ( return (
<ListPageLayout <ListPageLayout
title="新建测试集" title="新建测试集"
className="max-w-[1180px]" className="max-w-[1180px]"
description="测试集固定输入用例的业务分组。确认后进入用例编辑。" description="测试集只保存固定输入和预期;被测助手在批量运行时选择。"
action={ action={
<Button <Button
variant="outline" variant="outline"
@@ -375,39 +377,11 @@ function SuiteCreateView() {
</label> </label>
</ListPageSection> </ListPageSection>
<ListPageSection> {createError && (
<div> <p className="text-sm text-destructive" role="alert">
<div className="mb-2 text-sm font-medium text-foreground"> {createError}
</p>
</div> )}
{loadingAssistants ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
</div>
) : assistants.length > 0 ? (
<Select value={assistantName} onValueChange={setAssistantName}>
<SelectTrigger className="w-full border-hairline-strong bg-background">
<SelectValue placeholder="选择关联助手" />
</SelectTrigger>
<SelectContent>
{assistants.map((item) => (
<SelectItem key={item.id} value={item.name}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={assistantName}
onChange={(event) => setAssistantName(event.target.value)}
placeholder="助手名称"
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
)}
</div>
</ListPageSection>
<div className="flex items-center justify-end gap-3"> <div className="flex items-center justify-end gap-3">
<Button <Button
@@ -476,64 +450,67 @@ function SuiteDetailView({
initialCaseId?: string; initialCaseId?: string;
}) { }) {
const router = useRouter(); const router = useRouter();
const [initial] = useState(() => { const [suite, setSuite] = useState<TestSuite | null>(null);
const initialCases = listTestCases(suiteId); const [cases, setCases] = useState<TestCase[]>([]);
const initialCase = const [selectedId, setSelectedId] = useState<string | null>(null);
initialCases.find((item) => item.id === initialCaseId) ??
initialCases[0] ??
null;
const initialDraft = initialCase ? caseToDraft(initialCase) : null;
return {
suite: getTestSuite(suiteId),
cases: initialCases,
selectedId: initialCase?.id ?? null,
draft: initialDraft,
};
});
const [suite, setSuite] = useState<TestSuite | null>(initial.suite);
const [cases, setCases] = useState<TestCase[]>(initial.cases);
const [selectedId, setSelectedId] = useState<string | null>(
initial.selectedId,
);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [draft, setDraft] = useState<CaseEditorDraft | null>(initial.draft); const [draft, setDraft] = useState<CaseEditorDraft | null>(null);
const [savedSnapshot, setSavedSnapshot] = useState<CaseEditorDraft | null>( const [savedSnapshot, setSavedSnapshot] = useState<CaseEditorDraft | null>(null);
initial.draft,
);
const [statusMessage, setStatusMessage] = useState(""); const [statusMessage, setStatusMessage] = useState("");
const [selectionMode, setSelectionMode] = useState(false); const [selectionMode, setSelectionMode] = useState(false);
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set()); const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
function reload(preferId?: string | null) { async function reload(preferId?: string | null) {
const nextSuite = getTestSuite(suiteId); try {
const nextCases = listTestCases(suiteId); const [nextSuite, nextCases] = await Promise.all([
setSuite(nextSuite); testSuitesApi.get(suiteId),
setCases(nextCases); testCasesApi.list(suiteId),
]);
setSuite(nextSuite);
setCases(nextCases);
const nextSelected = const nextSelected =
(preferId && nextCases.some((item) => item.id === preferId) (preferId && nextCases.some((item) => item.id === preferId)
? preferId ? preferId
: null) ?? : null) ??
(selectedId && nextCases.some((item) => item.id === selectedId) (selectedId && nextCases.some((item) => item.id === selectedId)
? selectedId ? selectedId
: null) ?? : null) ??
nextCases[0]?.id ?? nextCases[0]?.id ??
null; null;
setSelectedId(nextSelected); setSelectedId(nextSelected);
if (nextSelected) { if (nextSelected) {
const item = nextCases.find((caseItem) => caseItem.id === nextSelected); const item = nextCases.find((caseItem) => caseItem.id === nextSelected);
if (item) { if (item) {
const nextDraft = caseToDraft(item); const nextDraft = caseToDraft(item);
setDraft(nextDraft); setDraft(nextDraft);
setSavedSnapshot(nextDraft); setSavedSnapshot(nextDraft);
}
} else {
setDraft(null);
setSavedSnapshot(null);
} }
} else { setStatusMessage("");
} catch (error) {
setSuite(null);
setCases([]);
setDraft(null); setDraft(null);
setSavedSnapshot(null); setSavedSnapshot(null);
setStatusMessage(error instanceof Error ? error.message : "加载测试集失败");
} finally {
setLoading(false);
} }
} }
useEffect(() => {
void reload(initialCaseId ?? null);
// suiteId changes by remount; the initial request only runs once per view.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [suiteId, initialCaseId]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const keyword = search.trim().toLowerCase(); const keyword = search.trim().toLowerCase();
return cases.filter((item) => { return cases.filter((item) => {
@@ -558,7 +535,10 @@ function SuiteDetailView({
? getTestCaseValidationMessage(draft) ? getTestCaseValidationMessage(draft)
: "请先选择测试用例"; : "请先选择测试用例";
const canSave = const canSave =
Boolean(draft?.name.trim()) && dirty && validationMessage === null; Boolean(draft?.name.trim()) &&
dirty &&
validationMessage === null &&
!saving;
useEffect(() => { useEffect(() => {
if (!dirty) return; if (!dirty) return;
@@ -629,39 +609,36 @@ function SuiteDetailView({
setStatusMessage(""); setStatusMessage("");
} }
function handleSave() { async function handleSave() {
if (!draft || validationMessage) return; if (!draft || validationMessage || saving) return;
const patch = { const body: TestCaseWrite = {
name: draft.name.trim() || "未命名用例", name: draft.name.trim() || "未命名用例",
description:
cases.find((item) => item.id === selectedId)?.description ?? "",
inputMode: draft.inputMode, inputMode: draft.inputMode,
contextTurns: draft.contextTurns, contextTurns: draft.contextTurns,
turns: draft.turns, turns: draft.turns,
overallCriteria: normalizeOverallCriteria(draft.overallCriteria), overallCriteria: normalizeOverallCriteria(draft.overallCriteria),
}; };
let saved: TestCase | null; setSaving(true);
if (selectedId) { setStatusMessage("保存中…");
saved = updateTestCase(selectedId, patch); try {
} else { const saved = selectedId
const created = createTestCase({ ? await testCasesApi.update(selectedId, body)
suiteId, : await testCasesApi.create(suiteId, body);
name: patch.name, await reload(saved.id);
}); setStatusMessage("已保存");
saved = created ? updateTestCase(created.id, patch) : null; window.setTimeout(() => setStatusMessage(""), 2000);
} catch (error) {
setStatusMessage(error instanceof Error ? error.message : "保存测试用例失败");
} finally {
setSaving(false);
} }
if (!saved) return;
const nextDraft = caseToDraft(saved);
setSelectedId(saved.id);
setCases(listTestCases(suiteId));
setSuite(getTestSuite(suiteId));
setDraft(nextDraft);
setSavedSnapshot(nextDraft);
setStatusMessage("已保存");
window.setTimeout(() => setStatusMessage(""), 2000);
} }
function handleCancelNewCase() { function handleCancelNewCase() {
reload(); void reload();
setStatusMessage(""); setStatusMessage("");
} }
@@ -672,28 +649,34 @@ function SuiteDetailView({
router.push("/test/cases"); router.push("/test/cases");
} }
function handleDuplicateCase(item: TestCase) { async function handleDuplicateCase(item: TestCase) {
if (dirty && !window.confirm("当前用例有未保存修改,复制将丢弃。继续?")) { if (dirty && !window.confirm("当前用例有未保存修改,复制将丢弃。继续?")) {
return; return;
} }
const copied = duplicateTestCase(item.id); try {
if (!copied) return; const copied = await testCasesApi.duplicate(item.id);
reload(copied.id); await reload(copied.id);
setStatusMessage(""); } catch (error) {
setStatusMessage(error instanceof Error ? error.message : "复制测试用例失败");
}
} }
function handleDeleteCase(item: TestCase) { async function handleDeleteCase(item: TestCase) {
if (!window.confirm(`确定删除测试用例“${item.name}”吗?`)) return; if (!window.confirm(`确定删除测试用例“${item.name}”吗?`)) return;
removeTestCase(item.id); try {
setCheckedIds((prev) => { await testCasesApi.remove(item.id);
const next = new Set(prev); setCheckedIds((prev) => {
next.delete(item.id); const next = new Set(prev);
return next; next.delete(item.id);
}); return next;
reload(selectedId === item.id ? null : selectedId); });
await reload(selectedId === item.id ? null : selectedId);
} catch (error) {
setStatusMessage(error instanceof Error ? error.message : "删除测试用例失败");
}
} }
function handleDeleteSelected() { async function handleDeleteSelected() {
if (!selectedId || !draft) return; if (!selectedId || !draft) return;
if ( if (
!window.confirm( !window.confirm(
@@ -702,8 +685,12 @@ function SuiteDetailView({
) { ) {
return; return;
} }
removeTestCase(selectedId); try {
reload(null); await testCasesApi.remove(selectedId);
await reload(null);
} catch (error) {
setStatusMessage(error instanceof Error ? error.message : "删除测试用例失败");
}
} }
function handleEnterSelectionMode() { function handleEnterSelectionMode() {
@@ -743,32 +730,66 @@ function SuiteDetailView({
}); });
} }
function handleBulkDelete() { async function handleBulkDelete() {
const ids = filtered const ids = filtered
.filter((item) => checkedIds.has(item.id)) .filter((item) => checkedIds.has(item.id))
.map((item) => item.id); .map((item) => item.id);
if (ids.length === 0) return; if (ids.length === 0) return;
if (!window.confirm(`确定删除选中的 ${ids.length} 个测试用例吗?`)) return; if (!window.confirm(`确定删除选中的 ${ids.length} 个测试用例吗?`)) return;
removeTestCases(ids); try {
handleExitSelectionMode(); await testCasesApi.bulkRemove(ids);
reload(selectedId && ids.includes(selectedId) ? null : selectedId); handleExitSelectionMode();
await reload(selectedId && ids.includes(selectedId) ? null : selectedId);
} catch (error) {
setStatusMessage(error instanceof Error ? error.message : "批量删除测试用例失败");
}
} }
function handleReorder(orderedIds: string[]) { async function handleReorder(orderedIds: string[]) {
reorderTestCases(suiteId, orderedIds); const order = new Map(orderedIds.map((id, index) => [id, index]));
setCases(listTestCases(suiteId)); setCases((current) =>
[...current].sort(
(left, right) =>
(order.get(left.id) ?? Number.MAX_SAFE_INTEGER) -
(order.get(right.id) ?? Number.MAX_SAFE_INTEGER),
),
);
try {
await testCasesApi.reorder(suiteId, orderedIds);
} catch (error) {
setStatusMessage(error instanceof Error ? error.message : "调整用例顺序失败");
await reload(selectedId);
}
} }
function renameSuite(nextName: string) { async function renameSuite(nextName: string) {
if (!suite || nextName === suite.name) return; if (!suite || nextName === suite.name) return;
const saved = updateTestSuite(suite.id, { name: nextName }); try {
if (saved) setSuite(saved); const saved = await testSuitesApi.update(suite.id, {
name: nextName,
description: suite.description,
});
setSuite(saved);
} catch (error) {
setStatusMessage(error instanceof Error ? error.message : "重命名测试集失败");
}
}
if (loading) {
return (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
</div>
);
} }
if (!suite) { if (!suite) {
return ( return (
<div className="mx-auto flex w-full max-w-[1280px] flex-col gap-4 py-16"> <div className="mx-auto flex w-full max-w-[1280px] flex-col gap-4 py-16">
<div className="font-medium text-destructive"></div> <div className="font-medium text-destructive">
{statusMessage || "测试集不存在"}
</div>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -791,7 +812,7 @@ function SuiteDetailView({
/> />
<EditableTitle <EditableTitle
value={suite.name} value={suite.name}
onChange={renameSuite} onChange={(value) => void renameSuite(value)}
placeholder="未命名测试集" placeholder="未命名测试集"
editLabel="测试集名称" editLabel="测试集名称"
/> />
@@ -813,14 +834,14 @@ function SuiteDetailView({
onSearchChange={setSearch} onSearchChange={setSearch}
onSelectCase={selectCase} onSelectCase={selectCase}
onCreateCase={handleCreateCase} onCreateCase={handleCreateCase}
onDuplicateCase={handleDuplicateCase} onDuplicateCase={(item) => void handleDuplicateCase(item)}
onDeleteCase={handleDeleteCase} onDeleteCase={(item) => void handleDeleteCase(item)}
onEnterSelectionMode={handleEnterSelectionMode} onEnterSelectionMode={handleEnterSelectionMode}
onExitSelectionMode={handleExitSelectionMode} onExitSelectionMode={handleExitSelectionMode}
onToggleChecked={handleToggleChecked} onToggleChecked={handleToggleChecked}
onToggleSelectAll={handleToggleSelectAll} onToggleSelectAll={handleToggleSelectAll}
onBulkDelete={handleBulkDelete} onBulkDelete={() => void handleBulkDelete()}
onReorder={handleReorder} onReorder={(ids) => void handleReorder(ids)}
/> />
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background"> <main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background">
@@ -868,7 +889,7 @@ function SuiteDetailView({
className="gap-1.5" className="gap-1.5"
disabled={!canSave} disabled={!canSave}
title={validationMessage ?? undefined} title={validationMessage ?? undefined}
onClick={handleSave} onClick={() => void handleSave()}
> >
<Save size={14} /> <Save size={14} />
@@ -882,7 +903,9 @@ function SuiteDetailView({
: "border-hairline-strong text-muted-foreground hover:text-foreground" : "border-hairline-strong text-muted-foreground hover:text-foreground"
} }
onClick={ onClick={
selectedId ? handleDeleteSelected : handleCancelNewCase selectedId
? () => void handleDeleteSelected()
: handleCancelNewCase
} }
> >
{selectedId ? <Trash2 size={14} /> : <X size={14} />} {selectedId ? <Trash2 size={14} /> : <X size={14} />}

View File

@@ -122,25 +122,6 @@ function editorSections(): {
]; ];
} }
/** API 不可用时的兜底工具,保证下拉可选 */
const FALLBACK_TOOLS: EditorToolOption[] = [
{
id: "mock_transfer_to_human",
functionName: "transfer_to_human",
label: "转人工",
parameters: [
{ name: "reason", description: "转人工原因" },
{ name: "target", description: "转接目标" },
],
},
{
id: "mock_end_conversation",
functionName: "end_conversation",
label: "结束会话",
parameters: [{ name: "reason", description: "结束原因" }],
},
];
function toolParametersFromDefinition(tool: Tool): EditorToolOption["parameters"] { function toolParametersFromDefinition(tool: Tool): EditorToolOption["parameters"] {
const definition = tool.definition; const definition = tool.definition;
if (definition.type === "http" || definition.type === "client") { if (definition.type === "http" || definition.type === "client") {
@@ -208,9 +189,8 @@ export function NextReplyEditorBody({
const sections = editorSections(); const sections = editorSections();
const tools = useMemo(() => { const tools = useMemo(() => {
if (availableTools && availableTools.length > 0) return availableTools; if (availableTools) return availableTools;
if (loadedTools.length > 0) return loadedTools; return loadedTools;
return FALLBACK_TOOLS;
}, [availableTools, loadedTools]); }, [availableTools, loadedTools]);
const sensors = useSensors( const sensors = useSensors(
@@ -221,16 +201,15 @@ export function NextReplyEditorBody({
); );
useEffect(() => { useEffect(() => {
if (availableTools && availableTools.length > 0) return; if (availableTools) return;
let cancelled = false; let cancelled = false;
void (async () => { void (async () => {
try { try {
const list = await toolsApi.list(); const list = await toolsApi.list();
if (cancelled) return; if (cancelled) return;
const mapped = list.map(toEditorToolOption); setLoadedTools(list.map(toEditorToolOption));
setLoadedTools(mapped.length > 0 ? mapped : FALLBACK_TOOLS);
} catch { } catch {
if (!cancelled) setLoadedTools(FALLBACK_TOOLS); if (!cancelled) setLoadedTools([]);
} }
})(); })();
return () => { return () => {
@@ -1464,14 +1443,20 @@ function ToolCallBehaviorEditor({
<SelectValue placeholder="选择工具…" /> <SelectValue placeholder="选择工具…" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{tools.map((tool) => ( {tools.length === 0 ? (
<SelectItem key={tool.id} value={tool.id}> <div className="px-2 py-3 text-xs text-muted-foreground">
{tool.functionName}
{tool.label && tool.label !== tool.functionName </div>
? ` · ${tool.label}` ) : (
: ""} tools.map((tool) => (
</SelectItem> <SelectItem key={tool.id} value={tool.id}>
))} {tool.functionName}
{tool.label && tool.label !== tool.functionName
? ` · ${tool.label}`
: ""}
</SelectItem>
))
)}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>

View File

@@ -1,14 +1,4 @@
/** /** 批量测试运行的前后端共享结果契约与纯展示辅助函数。 */
* 批量测试运行 — 前端 mock。
* 真实执行引擎接入前,用本地状态模拟进度与结果。
*/
import type {
ExpectedBehavior,
ReplyExpectedBehavior,
TestCase,
ToolCallExpectedBehavior,
} from "@/data/test-suites";
export type BatchCaseStatus = export type BatchCaseStatus =
| "waiting" | "waiting"
@@ -34,24 +24,13 @@ export const BATCH_ERROR_STRATEGY_LABEL: Record<BatchErrorStrategy, string> = {
stop_on_error: "标记错误并停止", stop_on_error: "标记错误并停止",
}; };
type BatchExecutionError = { export type BatchExecutionError = {
code: string; code: string;
message: string; message: string;
stage: "pipeline" | "model" | "tool" | "evaluation"; stage: "pipeline" | "model" | "tool" | "evaluation";
retryable: boolean; retryable: boolean;
}; };
export type BatchRunCase = {
id: string;
name: string;
status: BatchCaseStatus;
turns: BatchTurnResult[];
overallCriteria: BatchEvaluationResult[];
attemptCount: number;
maxAttempts: number;
executionError: BatchExecutionError | null;
};
export type BatchEvaluationResult = { export type BatchEvaluationResult = {
id: string; id: string;
label: string; label: string;
@@ -80,13 +59,30 @@ export type BatchTurnResult = {
evaluations: BatchEvaluationResult[]; evaluations: BatchEvaluationResult[];
}; };
export type BatchRunCase = {
id: string;
name: string;
status: BatchCaseStatus;
turns: BatchTurnResult[];
overallCriteria: BatchEvaluationResult[];
attemptCount: number;
maxAttempts: number;
executionError: BatchExecutionError | null;
};
export type BatchRunPhase = "config" | "running" | "completed"; export type BatchRunPhase = "config" | "running" | "completed";
export type BatchRunStatus = "queued" | "running" | "completed" | "cancelled";
export type BatchRunSnapshot = { export type BatchRunSnapshot = {
id: string;
status: BatchRunStatus;
title: string; title: string;
assistantName: string; assistantName: string;
config: { config: {
suiteCount: number; suiteCount: number;
evaluatorModelResourceId: string;
evaluatorModelResourceName: string;
evaluatorModel: string;
concurrency: number; concurrency: number;
timeoutSecs: number; timeoutSecs: number;
failureStrategy: BatchFailureStrategy; failureStrategy: BatchFailureStrategy;
@@ -99,246 +95,6 @@ export type BatchRunSnapshot = {
stopReason: "manual" | "assertion_failure" | "execution_error" | null; stopReason: "manual" | "assertion_failure" | "execution_error" | null;
}; };
export type BatchMockExecutionPlan = Record<string, BatchExecutionError>;
/** 预定部分用例失败,方便演示失败展开态 */
function shouldFail(index: number, item: TestCase): boolean {
if (item.lastResult === "fail") return true;
return index % 4 === 2;
}
/** 稳定产生少量执行错误,便于检查重试和继续/停止流程。 */
function shouldError(index: number): boolean {
return index % 5 === 3;
}
function replyExpectation(behavior: ReplyExpectedBehavior): string {
if (behavior.assertionType === "llm") return behavior.llmCriteria;
const relation = behavior.negateKeywords
? "不应包含"
: behavior.keywordMatchMode === "all"
? "应包含全部"
: "应至少包含其一";
return `${relation}${behavior.keywords.join("、")}`;
}
function toolExpectation(behavior: ToolCallExpectedBehavior): string {
if (behavior.expectation === "not_called") {
return `不应调用 ${behavior.functionName}`;
}
const count =
behavior.maxCalls === null
? `${behavior.minCalls} 次以上`
: behavior.minCalls === behavior.maxCalls
? `${behavior.minCalls}`
: `${behavior.minCalls}${behavior.maxCalls}`;
return `应调用 ${behavior.functionName} ${count}`;
}
function mockAssistantReply(behaviors: ExpectedBehavior[]): string {
const positiveKeyword = behaviors.find(
(behavior): behavior is ReplyExpectedBehavior =>
behavior.type === "reply" &&
behavior.assertionType === "keyword" &&
!behavior.negateKeywords &&
behavior.keywords.length > 0,
);
if (positiveKeyword) {
return `好的,我已了解。请继续说明${positiveKeyword.keywords.slice(0, 2).join("和")}`;
}
return "好的,我已记录当前信息,我们继续处理。";
}
function mockToolArguments(behavior: ToolCallExpectedBehavior): string {
const entries = behavior.paramAssertions.map((item) => [
item.name,
item.matchMode === "exact" ? item.value : `mock_${item.name}`,
]);
return JSON.stringify(Object.fromEntries(entries), null, 2);
}
function createBehaviorEvaluation(
behavior: ExpectedBehavior,
fail: boolean,
): {
evaluation: BatchEvaluationResult;
toolCalls: BatchToolCallRecord[];
} {
if (behavior.type === "reply") {
const reason = fail
? behavior.assertionType === "keyword"
? "实际回复未满足关键词规则。"
: "LLM 判断认为回复未达到该项语义要求。"
: "";
return {
evaluation: {
id: behavior.id,
label:
behavior.assertionType === "keyword"
? "回复 · 关键词"
: "回复 · LLM 判断",
kind: "reply",
status: fail ? "fail" : "pass",
expected: replyExpectation(behavior),
actual: fail
? "已为您转接人工处理。"
: "实际回复满足本条内容要求。",
reason,
},
toolCalls: [],
};
}
const shouldRecordCall =
behavior.expectation === "called" ? !fail : fail;
const toolCalls: BatchToolCallRecord[] = shouldRecordCall
? [
{
id: `call_${behavior.id}`,
functionName: behavior.functionName,
argumentsJson: mockToolArguments(behavior),
resultJson: behavior.mockResponse.body,
outcome: behavior.mockResponse.outcome,
durationMs: behavior.mockResponse.delayMs + 84,
},
]
: [];
const actual = shouldRecordCall
? `实际调用 1 次 ${behavior.functionName}`
: `实际调用 0 次 ${behavior.functionName}`;
const reason = fail
? behavior.expectation === "not_called"
? "检测到本轮不应发生的工具调用。"
: "未检测到满足次数要求的工具调用。"
: "";
return {
evaluation: {
id: behavior.id,
label: `工具 · ${behavior.functionName}`,
kind: "tool_call",
status: fail ? "fail" : "pass",
expected: toolExpectation(behavior),
actual,
reason,
},
toolCalls,
};
}
function createCaseResult(
item: TestCase,
caseIndex: number,
maxAttempts: number,
): { result: BatchRunCase; plannedError: BatchExecutionError | null } {
const plannedError = shouldError(caseIndex);
const plannedFailure = !plannedError && shouldFail(caseIndex, item);
let failureAssigned = false;
const turns = item.turns.map((turn, turnIndex) => {
const toolCalls: BatchToolCallRecord[] = [];
const evaluations = turn.behaviors.map((behavior) => {
const fail = plannedFailure && !failureAssigned;
if (fail) failureAssigned = true;
const result = createBehaviorEvaluation(behavior, fail);
toolCalls.push(...result.toolCalls);
return result.evaluation;
});
return {
id: turn.id,
index: turnIndex,
userInput: turn.userInput,
assistantReply: mockAssistantReply(turn.behaviors),
toolCalls,
evaluations,
};
});
const overallCriteria = item.overallCriteria.map((criterion) => {
const fail = plannedFailure && !failureAssigned;
if (fail) failureAssigned = true;
return {
id: criterion.id,
label: criterion.name,
kind: "overall" as const,
status: fail ? ("fail" as const) : ("pass" as const),
expected: criterion.criteria,
actual: fail
? "整段对话未完整达到该业务目标。"
: "整段对话达到该业务目标。",
reason: fail ? "LLM 判断认为整段对话未满足该项标准。" : "",
};
});
return {
result: {
id: item.id,
name: item.name,
status: "waiting",
turns,
overallCriteria,
attemptCount: 0,
maxAttempts,
executionError: null,
},
plannedError: plannedError
? {
code: "PIPELINE_TIMEOUT",
message: "等待 pipeline 完成回复时超过单用例超时时间。",
stage: "pipeline",
retryable: true,
}
: null,
};
}
export function createMockBatchRun(input: {
title: string;
assistantName: string;
cases: TestCase[];
concurrency: number;
timeoutSecs: number;
failureStrategy: BatchFailureStrategy;
errorRetryCount: number;
errorStrategy: BatchErrorStrategy;
}): {
snapshot: BatchRunSnapshot;
executionPlan: BatchMockExecutionPlan;
} {
const preparedCases = input.cases.map((item, index) =>
createCaseResult(item, index, input.errorRetryCount + 1),
);
const executionPlan = Object.fromEntries(
preparedCases.flatMap(({ result, plannedError }) =>
plannedError ? [[result.id, plannedError]] : [],
),
);
return {
snapshot: {
title: input.title,
assistantName: input.assistantName,
config: {
suiteCount: new Set(input.cases.map((item) => item.suiteId)).size,
concurrency: input.concurrency,
timeoutSecs: input.timeoutSecs,
failureStrategy: input.failureStrategy,
errorRetryCount: input.errorRetryCount,
errorStrategy: input.errorStrategy,
},
startedAt: new Date().toISOString(),
finishedAt: null,
stopReason: null,
cases: preparedCases.map(({ result }) => result),
},
executionPlan,
};
}
export function hasEvaluationFailure(item: BatchRunCase): boolean {
const evaluations = [
...item.turns.flatMap((turn) => turn.evaluations),
...item.overallCriteria,
];
return evaluations.some((evaluation) => evaluation.status === "fail");
}
export function firstEvaluationFailureReason(item: BatchRunCase): string { export function firstEvaluationFailureReason(item: BatchRunCase): string {
const evaluations = [ const evaluations = [
...item.turns.flatMap((turn) => turn.evaluations), ...item.turns.flatMap((turn) => turn.evaluations),
@@ -358,15 +114,13 @@ export function countByStatus(cases: BatchRunCase[]) {
waiting: 0, waiting: 0,
skipped: 0, skipped: 0,
}; };
for (const item of cases) { for (const item of cases) counts[item.status] += 1;
counts[item.status] += 1;
}
return counts; return counts;
} }
export function formatRunTime(iso: string): string { export function formatRunTime(iso: string): string {
const date = new Date(iso); const date = new Date(iso);
const pad = (n: number) => String(n).padStart(2, "0"); const pad = (value: number) => String(value).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
} }

View File

@@ -1,5 +1,5 @@
/** /**
* 测试集 / 测试用例 — 管理页用的本地 mock * 测试集 / 测试用例的前后端共享契约与编辑器辅助函数
* 两层Test Suite → Test Case。 * 两层Test Suite → Test Case。
* MVP 仅运行固定文字脚本;其它输入模式保留为产品路线提示。 * MVP 仅运行固定文字脚本;其它输入模式保留为产品路线提示。
*/ */
@@ -12,7 +12,7 @@ export type TestCaseInputMode =
| "user_sim_text" | "user_sim_text"
| "user_sim_voice"; | "user_sim_voice";
type TestCaseResult = "pass" | "fail" | "not_run"; export type TestCaseResult = "pass" | "fail" | "not_run";
export type AssertionType = "keyword" | "llm"; export type AssertionType = "keyword" | "llm";
@@ -105,8 +105,9 @@ export type TestSuite = {
id: string; id: string;
name: string; name: string;
description: string; description: string;
/** 关联助手展示名MVP 直接存文案,接 API 后可改成 assistantId */ caseCount: number;
assistantName: string; passedCount: number;
runCount: number;
updatedAt: string; updatedAt: string;
}; };
@@ -160,12 +161,60 @@ export function getTestCaseValidationMessage(
} }
if (context.role === "tool_call" || context.role === "tool_result") { if (context.role === "tool_call" || context.role === "tool_result") {
try { try {
JSON.parse(context.content); const payload = JSON.parse(context.content);
if (
context.role === "tool_call" &&
(payload === null || Array.isArray(payload) || typeof payload !== "object")
) {
return `上下文第 ${index + 1} 条 Tool Call 参数必须是 JSON 对象`;
}
} catch { } catch {
return `上下文第 ${index + 1} 条工具数据必须是有效 JSON`; return `上下文第 ${index + 1} 条工具数据必须是有效 JSON`;
} }
} }
} }
const pendingToolCalls: Array<{ id: string | null; toolName: string }> = [];
const seenToolCallIds = new Set<string>();
for (let index = 0; index < item.contextTurns.length; index += 1) {
const context = item.contextTurns[index];
if (context.role === "tool_call") {
const id = context.toolCallId?.trim() || null;
if (id && seenToolCallIds.has(id)) {
return `上下文第 ${index + 1} 条 Tool Call ID 重复`;
}
if (id) seenToolCallIds.add(id);
pendingToolCalls.push({ id, toolName: context.toolName?.trim() ?? "" });
continue;
}
if (context.role !== "tool_result") continue;
const resultId = context.toolCallId?.trim() || null;
let matchedIndex = -1;
for (
let pendingIndex = pendingToolCalls.length - 1;
pendingIndex >= 0;
pendingIndex -= 1
) {
const pending = pendingToolCalls[pendingIndex];
if (
resultId ? pending.id === resultId : pending.toolName === context.toolName?.trim()
) {
matchedIndex = pendingIndex;
break;
}
}
if (matchedIndex < 0) {
return `上下文第 ${index + 1} 条 Tool Result 没有匹配的 Tool Call`;
}
const [matched] = pendingToolCalls.splice(matchedIndex, 1);
if (matched.toolName !== context.toolName?.trim()) {
return `上下文第 ${index + 1} 条 Tool Result 的工具名称不匹配`;
}
}
if (pendingToolCalls.length > 0) {
return `上下文 Tool Call 缺少对应的 Tool Result${pendingToolCalls
.map((item) => item.toolName)
.join("、")}`;
}
const emptyTurnIndex = item.turns.findIndex( const emptyTurnIndex = item.turns.findIndex(
(turn) => !turn.userInput.trim(), (turn) => !turn.userInput.trim(),
); );
@@ -311,26 +360,19 @@ export function getExpectedBehaviorValidationMessage(
return null; return null;
} }
let turnSeq = 1; function newEditorId(prefix: string) {
let behaviorSeq = 1; const randomPart = globalThis.crypto?.randomUUID
let criterionSeq = 1; ? globalThis.crypto.randomUUID().replaceAll("-", "")
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
function nextTurnId() { return `${prefix}_${randomPart}`;
const id = `turn_${String(turnSeq).padStart(4, "0")}`;
turnSeq += 1;
return id;
} }
function nextBehaviorId() { function nextBehaviorId() {
const id = `beh_${String(behaviorSeq).padStart(4, "0")}`; return newEditorId("beh");
behaviorSeq += 1;
return id;
} }
function nextCriterionId() { function nextCriterionId() {
const id = `criterion_${String(criterionSeq).padStart(4, "0")}`; return newEditorId("criterion");
criterionSeq += 1;
return id;
} }
export function createOverallCriterion( export function createOverallCriterion(
@@ -365,7 +407,7 @@ export function createEmptyFixedInputTurn(
userInput = "", userInput = "",
): FixedInputTurn { ): FixedInputTurn {
return { return {
id: nextTurnId(), id: newEditorId("turn"),
userInput, userInput,
behaviors: [], behaviors: [],
}; };
@@ -435,10 +477,6 @@ function cloneBehavior(behavior: ExpectedBehavior): ExpectedBehavior {
}; };
} }
function cloneBehaviorWithNewId(behavior: ExpectedBehavior): ExpectedBehavior {
return { ...cloneBehavior(behavior), id: nextBehaviorId() };
}
export function cloneTurns(turns: FixedInputTurn[]): FixedInputTurn[] { export function cloneTurns(turns: FixedInputTurn[]): FixedInputTurn[] {
return turns.map((turn) => ({ return turns.map((turn) => ({
id: turn.id, id: turn.id,
@@ -447,480 +485,6 @@ export function cloneTurns(turns: FixedInputTurn[]): FixedInputTurn[] {
})); }));
} }
/** 新建用例时复制轮次并分配新 id */
function cloneTurnsWithNewIds(turns: FixedInputTurn[]): FixedInputTurn[] {
return turns.map((turn) => ({
id: nextTurnId(),
userInput: turn.userInput,
behaviors: turn.behaviors.map(cloneBehaviorWithNewId),
}));
}
const INITIAL_SUITES: TestSuite[] = [
{
id: "suite_001",
name: "事故基础流程",
description: "核心业务流程和正常事故处理",
assistantName: "视频快处助手",
updatedAt: "2026-08-05T10:20:00+08:00",
},
{
id: "suite_002",
name: "异常输入与澄清",
description: "模糊表达、误打断、主动唤醒等",
assistantName: "视频快处助手",
updatedAt: "2026-08-04T16:40:00+08:00",
},
{
id: "suite_003",
name: "实时语音交互",
description: "打断、延迟、VAD、语音链路等",
assistantName: "视频快处助手",
updatedAt: "2026-08-03T09:15:00+08:00",
},
];
type RawCaseSeed = Omit<TestCase, "sortOrder" | "inputMode" | "overallCriteria"> & {
inputMode?: TestCaseInputMode;
overallCriteria?: OverallCriterion[];
};
function seedReplyTurn(
seedId: string,
userInput: string,
expectation: Omit<ReplyExpectedBehavior, "id" | "type">,
): FixedInputTurn {
return {
id: `turn_seed_${seedId}`,
userInput,
behaviors: [
{
id: `beh_seed_${seedId}`,
type: "reply",
...expectation,
},
],
};
}
const RAW_CASE_SEEDS: RawCaseSeed[] = [
{
id: "tc_001",
suiteId: "suite_001",
name: "正常双车事故开场",
description: "开场后用户补充事故经过",
lastResult: "pass",
contextTurns: [],
turns: [
seedReplyTurn("001", "我这里刚刚撞了一下。", {
assertionType: "keyword",
keywords: ["经过", "描述", "受伤"],
keywordMatchMode: "any",
negateKeywords: false,
llmCriteria: "",
}),
],
updatedAt: "2026-08-05T10:18:00+08:00",
},
{
id: "tc_002",
suiteId: "suite_001",
name: "有人伤转人工",
description: "用户提到人伤时应引导转人工",
lastResult: "pass",
contextTurns: [],
turns: [
seedReplyTurn("002", "有人受伤了,流血不止。", {
assertionType: "llm",
keywords: [],
keywordMatchMode: "any",
negateKeywords: false,
llmCriteria:
"Agent 应识别人员受伤风险,明确告知将转接人工,并避免继续推进普通快处流程。",
}),
],
overallCriteria: [
{
id: "criterion_seed_001",
type: "llm",
name: "人伤流程正确",
criteria:
"整段对话应正确识别人员受伤场景,及时并准确转接人工处理。",
},
{
id: "criterion_seed_002",
type: "llm",
name: "不推进普通快处",
criteria:
"确认存在人员受伤后,不应继续引导用户进入普通事故快处流程。",
},
],
updatedAt: "2026-08-05T10:12:00+08:00",
},
{
id: "tc_101",
suiteId: "suite_002",
name: "用户说“喂”",
description: "验证主动唤醒回复且业务状态不推进",
lastResult: "pass",
contextTurns: [],
turns: [
seedReplyTurn("101", "喂", {
assertionType: "keyword",
keywords: ["我在", "请说", "继续"],
keywordMatchMode: "any",
negateKeywords: false,
llmCriteria: "",
}),
],
updatedAt: "2026-08-04T16:35:00+08:00",
},
{
id: "tc_102",
suiteId: "suite_002",
name: "用户只说“嗯”",
description: "短促确认不应误推进流程",
lastResult: "fail",
contextTurns: [],
turns: [
seedReplyTurn("102", "嗯", {
assertionType: "llm",
keywords: [],
keywordMatchMode: "any",
negateKeywords: false,
llmCriteria:
"Agent 应当回应用户的主动唤醒,同时继续引导用户描述事故情况,不应该无响应。",
}),
],
updatedAt: "2026-08-04T16:20:00+08:00",
},
{
id: "tc_103",
suiteId: "suite_002",
name: "模糊事故描述",
description: "地点含糊时应主动澄清",
lastResult: "not_run",
contextTurns: [],
turns: [
seedReplyTurn("103", "就在那边……撞了一下。", {
assertionType: "keyword",
keywords: ["哪里", "路口", "路名", "再说"],
keywordMatchMode: "any",
negateKeywords: false,
llmCriteria: "",
}),
],
updatedAt: "2026-08-04T15:30:00+08:00",
},
{
id: "tc_201",
suiteId: "suite_003",
name: "用户打断播报",
description: "播报中打断后正确切换聆听并承接",
lastResult: "pass",
contextTurns: [],
turns: [
seedReplyTurn("201", "等一下,对方走了。", {
assertionType: "llm",
keywords: [],
keywordMatchMode: "any",
negateKeywords: false,
llmCriteria:
"Agent 应立即停止原播报思路,确认已听到用户新信息,并围绕「对方离开」继续询问。",
}),
],
updatedAt: "2026-08-03T09:10:00+08:00",
},
];
/** 按套件出现顺序写入 sortOrder并补齐可选字段。 */
const INITIAL_CASES: TestCase[] = (() => {
const counters = new Map<string, number>();
return RAW_CASE_SEEDS.map((item) => {
const order = counters.get(item.suiteId) ?? 0;
counters.set(item.suiteId, order + 1);
return {
...item,
inputMode: item.inputMode ?? DEFAULT_INPUT_MODE,
turns: cloneTurns(item.turns),
overallCriteria: cloneOverallCriteria(item.overallCriteria),
sortOrder: order,
};
});
})();
/** 会话内可变的 mock 仓库(刷新页面会重置) */
let suites = [...INITIAL_SUITES];
let cases = structuredClone(INITIAL_CASES);
let suiteSeq = 4;
let caseSeq = 300;
function nowIso() {
return new Date().toISOString();
}
function nextSuiteId() {
const id = `suite_${String(suiteSeq).padStart(3, "0")}`;
suiteSeq += 1;
return id;
}
function nextCaseId() {
const id = `tc_${String(caseSeq).padStart(3, "0")}`;
caseSeq += 1;
return id;
}
export function listTestSuites(): TestSuite[] {
return [...suites].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
);
}
export function getTestSuite(id: string): TestSuite | null {
return suites.find((item) => item.id === id) ?? null;
}
function getTestCase(id: string): TestCase | null {
return cases.find((item) => item.id === id) ?? null;
}
export function listTestCases(suiteId: string): TestCase[] {
return cases
.filter((item) => item.suiteId === suiteId)
.sort(
(a, b) =>
a.sortOrder - b.sortOrder || a.name.localeCompare(b.name, "zh-CN"),
);
}
export function suiteCaseStats(suiteId: string): {
total: number;
passed: number;
run: number;
} {
const items = cases.filter((item) => item.suiteId === suiteId);
const run = items.filter((item) => item.lastResult !== "not_run");
const passed = run.filter((item) => item.lastResult === "pass");
return { total: items.length, passed: passed.length, run: run.length };
}
export function createTestSuite(input: {
name: string;
description: string;
assistantName: string;
}): TestSuite {
const suite: TestSuite = {
id: nextSuiteId(),
name: input.name.trim(),
description: input.description.trim(),
assistantName: input.assistantName.trim() || "未关联助手",
updatedAt: nowIso(),
};
suites = [suite, ...suites];
return suite;
}
export function updateTestSuite(
id: string,
patch: Partial<Pick<TestSuite, "name" | "description" | "assistantName">>,
): TestSuite | null {
const index = suites.findIndex((item) => item.id === id);
if (index < 0) return null;
const next = {
...suites[index],
...patch,
updatedAt: nowIso(),
};
suites = [...suites.slice(0, index), next, ...suites.slice(index + 1)];
return next;
}
export function removeTestSuite(id: string): boolean {
const before = suites.length;
suites = suites.filter((item) => item.id !== id);
cases = cases.filter((item) => item.suiteId !== id);
return suites.length < before;
}
/** 复制测试集及其全部用例;名称加「(副本)」 */
export function duplicateTestSuite(id: string): TestSuite | null {
const source = getTestSuite(id);
if (!source) return null;
const copied = createTestSuite({
name: `${source.name}(副本)`,
description: source.description,
assistantName: source.assistantName,
});
const sourceCases = listTestCases(id);
const clonedCases: TestCase[] = sourceCases.map((item, index) => {
const turns = cloneTurnsWithNewIds(item.turns);
return {
id: nextCaseId(),
suiteId: copied.id,
name: item.name,
description: item.description,
inputMode: item.inputMode,
lastResult: "not_run" as const,
contextTurns: item.contextTurns.map((turn) => ({ ...turn })),
turns,
overallCriteria: cloneOverallCriteria(item.overallCriteria),
sortOrder: index,
updatedAt: nowIso(),
};
});
cases = [...cases, ...clonedCases];
return copied;
}
export function createTestCase(input: {
suiteId: string;
name?: string;
description?: string;
}): TestCase | null {
if (!getTestSuite(input.suiteId)) return null;
const maxOrder = cases
.filter((item) => item.suiteId === input.suiteId)
.reduce((max, item) => Math.max(max, item.sortOrder), -1);
const turns = [createEmptyFixedInputTurn()];
const item: TestCase = {
id: nextCaseId(),
suiteId: input.suiteId,
name: (input.name ?? "未命名用例").trim() || "未命名用例",
description: (input.description ?? "").trim(),
inputMode: DEFAULT_INPUT_MODE,
lastResult: "not_run",
contextTurns: [],
turns,
overallCriteria: [],
sortOrder: maxOrder + 1,
updatedAt: nowIso(),
};
cases = [...cases, item];
updateTestSuite(input.suiteId, {});
return item;
}
/** 复制单条用例;名称加「(副本)」,排在同套件末尾 */
export function duplicateTestCase(id: string): TestCase | null {
const source = getTestCase(id);
if (!source || !getTestSuite(source.suiteId)) return null;
const maxOrder = cases
.filter((item) => item.suiteId === source.suiteId)
.reduce((max, item) => Math.max(max, item.sortOrder), -1);
const turns = cloneTurnsWithNewIds(source.turns);
const copied: TestCase = {
id: nextCaseId(),
suiteId: source.suiteId,
name: `${source.name}(副本)`,
description: source.description,
inputMode: source.inputMode,
lastResult: "not_run",
contextTurns: source.contextTurns.map((turn) => ({ ...turn })),
turns,
overallCriteria: cloneOverallCriteria(source.overallCriteria),
sortOrder: maxOrder + 1,
updatedAt: nowIso(),
};
cases = [...cases, copied];
updateTestSuite(source.suiteId, {});
return copied;
}
type TestCasePatch = Partial<
Pick<
TestCase,
| "name"
| "description"
| "inputMode"
| "contextTurns"
| "turns"
| "overallCriteria"
| "lastResult"
>
>;
export function updateTestCase(
id: string,
patch: TestCasePatch,
): TestCase | null {
const index = cases.findIndex((item) => item.id === id);
if (index < 0) return null;
const current = cases[index];
const nextTurns =
patch.turns !== undefined
? cloneTurns(
patch.turns.length > 0 ? patch.turns : [createEmptyFixedInputTurn()],
)
: current.turns;
const nextOverallCriteria =
patch.overallCriteria !== undefined
? cloneOverallCriteria(patch.overallCriteria)
: cloneOverallCriteria(current.overallCriteria);
const next: TestCase = {
...current,
...patch,
turns: nextTurns,
overallCriteria: nextOverallCriteria,
updatedAt: nowIso(),
};
cases = [...cases.slice(0, index), next, ...cases.slice(index + 1)];
updateTestSuite(next.suiteId, {});
return next;
}
export function removeTestCase(id: string): boolean {
const existing = cases.find((item) => item.id === id);
if (!existing) return false;
cases = cases.filter((item) => item.id !== id);
renumberSortOrder(existing.suiteId);
updateTestSuite(existing.suiteId, {});
return true;
}
/** 批量删除;返回受影响的 suiteId若有 */
export function removeTestCases(ids: string[]): string | null {
const idSet = new Set(ids);
const affected = cases.find((item) => idSet.has(item.id));
if (!affected) return null;
const suiteId = affected.suiteId;
cases = cases.filter((item) => !idSet.has(item.id));
renumberSortOrder(suiteId);
updateTestSuite(suiteId, {});
return suiteId;
}
/**
* 按给定 id 顺序写回 sortOrder应包含该套件全部用例 id
* 用于拖拽结束后持久化顺序。
*/
export function reorderTestCases(suiteId: string, orderedIds: string[]): void {
const orderMap = new Map(orderedIds.map((id, index) => [id, index]));
cases = cases.map((item) => {
if (item.suiteId !== suiteId) return item;
const nextOrder = orderMap.get(item.id);
if (nextOrder === undefined) return item;
return { ...item, sortOrder: nextOrder };
});
updateTestSuite(suiteId, {});
}
function renumberSortOrder(suiteId: string) {
const ordered = cases
.filter((item) => item.suiteId === suiteId)
.sort((a, b) => a.sortOrder - b.sortOrder);
const orderMap = new Map(ordered.map((item, index) => [item.id, index]));
cases = cases.map((item) => {
if (item.suiteId !== suiteId) return item;
const nextOrder = orderMap.get(item.id);
return nextOrder === undefined ? item : { ...item, sortOrder: nextOrder };
});
}
export function formatUpdatedAt(value?: string | null) { export function formatUpdatedAt(value?: string | null) {
if (!value) return "—"; if (!value) return "—";
const date = new Date(value); const date = new Date(value);

View File

@@ -6,6 +6,15 @@
*/ */
import { loginPathWithReturnTo } from "@/lib/auth-redirect"; import { loginPathWithReturnTo } from "@/lib/auth-redirect";
import type { BatchRunSnapshot } from "@/data/batch-run";
import type {
ContextTurn,
FixedInputTurn,
OverallCriterion,
TestCase,
TestCaseInputMode,
TestSuite,
} from "@/data/test-suites";
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
@@ -769,3 +778,94 @@ export const webrtcApi = {
iceServers: () => iceServers: () =>
request<{ iceServers: IceServerConfig[] }>("/api/webrtc/ice-servers"), request<{ iceServers: IceServerConfig[] }>("/api/webrtc/ice-servers"),
}; };
// ---------- 测试用例与批量运行 ----------
export type TestSuiteWrite = {
name: string;
description: string;
};
export type TestCaseWrite = {
name: string;
description: string;
inputMode: TestCaseInputMode;
contextTurns: ContextTurn[];
turns: FixedInputTurn[];
overallCriteria: OverallCriterion[];
};
export const testSuitesApi = {
list: () => request<TestSuite[]>("/api/test-suites"),
get: (id: string) => request<TestSuite>(`/api/test-suites/${id}`),
create: (body: TestSuiteWrite) =>
request<TestSuite>("/api/test-suites", {
method: "POST",
body: JSON.stringify(body),
}),
update: (id: string, body: TestSuiteWrite) =>
request<TestSuite>(`/api/test-suites/${id}`, {
method: "PUT",
body: JSON.stringify(body),
}),
duplicate: (id: string) =>
request<TestSuite>(`/api/test-suites/${id}/duplicate`, { method: "POST" }),
remove: (id: string) =>
request<{ ok: boolean }>(`/api/test-suites/${id}`, { method: "DELETE" }),
};
export const testCasesApi = {
list: (suiteId?: string) =>
request<TestCase[]>(
`/api/test-cases${suiteId ? `?suiteId=${encodeURIComponent(suiteId)}` : ""}`,
),
get: (id: string) => request<TestCase>(`/api/test-cases/${id}`),
create: (suiteId: string, body: TestCaseWrite) =>
request<TestCase>(`/api/test-suites/${suiteId}/cases`, {
method: "POST",
body: JSON.stringify(body),
}),
update: (id: string, body: TestCaseWrite) =>
request<TestCase>(`/api/test-cases/${id}`, {
method: "PUT",
body: JSON.stringify(body),
}),
duplicate: (id: string) =>
request<TestCase>(`/api/test-cases/${id}/duplicate`, { method: "POST" }),
remove: (id: string) =>
request<{ ok: boolean }>(`/api/test-cases/${id}`, { method: "DELETE" }),
bulkRemove: (caseIds: string[]) =>
request<{ ok: boolean; deleted: number }>("/api/test-cases/bulk-delete", {
method: "POST",
body: JSON.stringify({ caseIds }),
}),
reorder: (suiteId: string, caseIds: string[]) =>
request<{ ok: boolean }>(`/api/test-suites/${suiteId}/case-order`, {
method: "PUT",
body: JSON.stringify({ caseIds }),
}),
};
export const batchRunsApi = {
create: (body: {
assistantId: string;
evaluatorModelResourceId: string;
caseIds: string[];
title?: string;
config: {
concurrency: number;
timeoutSecs: number;
failureStrategy: "continue" | "stop_on_fail";
errorRetryCount: number;
errorStrategy: "continue" | "stop_on_error";
};
}) =>
request<BatchRunSnapshot>("/api/test-runs", {
method: "POST",
body: JSON.stringify(body),
}),
get: (id: string) => request<BatchRunSnapshot>(`/api/test-runs/${id}`),
cancel: (id: string) =>
request<BatchRunSnapshot>(`/api/test-runs/${id}/cancel`, {
method: "POST",
}),
};