diff --git a/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts b/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts
index 60878d4..7f208cf 100644
--- a/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts
+++ b/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts
@@ -8,6 +8,8 @@ import type {
AgentDebugStreamComplete,
AgentGenerationConfig,
AgentRuntimeConfig,
+ AgentBatchJob,
+ AgentBatchJobDetail,
AiLogRecord,
ApiResponse,
ChatDetail,
@@ -259,6 +261,19 @@ export const api = {
agentRuntimeConfig: () => request
("/admin/agent/runtime-config"),
saveAgentRuntimeConfig: (payload: AgentGenerationConfig) =>
request("/admin/agent/runtime-config", { method: "PUT", body: JSON.stringify(payload) }),
+ downloadAgentBatchTemplate: () => download("/admin/agent/batch/template", "Agent批量测试导入模板.xlsx"),
+ createAgentBatch: (file: File, config: Record) => {
+ const formData = new FormData();
+ formData.append("file", file);
+ formData.append("configJson", JSON.stringify(config));
+ return upload("/admin/agent/batch/jobs", formData);
+ },
+ agentBatchJobs: (query: { page?: number; pageSize?: number } = {}) =>
+ request>(`/admin/agent/batch/jobs${queryString(query)}`),
+ agentBatchJob: (id: number, query: { page?: number; pageSize?: number } = {}) =>
+ request(`/admin/agent/batch/jobs/${id}${queryString(query)}`),
+ exportAgentBatch: (id: number) => download(`/admin/agent/batch/jobs/${id}/export`, `Agent批量测试结果_${id}.xlsx`),
+ cancelAgentBatch: (id: number) => request(`/admin/agent/batch/jobs/${id}/cancel`, { method: "POST", body: "{}" }),
models: () => request("/admin/model/list"),
createModel: (payload: Record) =>
request("/admin/model", { method: "POST", body: JSON.stringify(payload) }),
diff --git a/ai_knowledge_base_v2/apps/admin-web/src/styles.css b/ai_knowledge_base_v2/apps/admin-web/src/styles.css
index b397616..6fbb711 100644
--- a/ai_knowledge_base_v2/apps/admin-web/src/styles.css
+++ b/ai_knowledge_base_v2/apps/admin-web/src/styles.css
@@ -975,6 +975,10 @@ textarea {
align-items: start;
}
+.agent-workbench.agent-batch-mode { grid-template-columns: minmax(0, 1fr); }
+.agent-workbench.agent-batch-mode > .agent-preview-panel,
+.agent-workbench.agent-batch-mode > .agent-trace-panel { display: none; }
+
.agent-page-head {
display: flex;
align-items: flex-start;
@@ -1049,6 +1053,33 @@ textarea {
margin-top: 16px;
}
+.agent-batch-panel { display: grid; gap: 16px; }
+.agent-batch-create-card { display: grid; gap: 16px; padding: 16px; border: 1px solid #dfe8e5; border-radius: 10px; background: #f8fbfa; }
+.agent-batch-config-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
+.agent-batch-config-summary span { min-width: 0; padding: 11px 12px; border: 1px solid #e1e9e6; border-radius: 8px; background: #fff; }
+.agent-batch-config-summary small, .agent-batch-config-summary strong { display: block; }
+.agent-batch-config-summary small { margin-bottom: 4px; color: #7a8c85; font-size: 11px; }
+.agent-batch-config-summary strong { overflow: hidden; color: #2b4038; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
+.agent-batch-upload-row { display: flex; align-items: flex-end; gap: 10px; flex-wrap: wrap; }
+.agent-batch-name { display: grid; gap: 6px; width: 220px; color: #536860; font-size: 12px; }
+.agent-batch-name input { height: 40px; padding: 0 12px; border: 1px solid #d8e2df; border-radius: 6px; outline: none; background: #fff; color: #263832; }
+.agent-batch-name input:focus { border-color: #2f9479; box-shadow: 0 0 0 2px rgba(47, 148, 121, .12); }
+.agent-batch-file { position: relative; display: grid; min-width: 260px; flex: 1; gap: 3px; min-height: 40px; padding: 7px 12px; border: 1px dashed #b9cac4; border-radius: 7px; background: #fff; cursor: pointer; }
+.agent-batch-file.selected { border-style: solid; border-color: #62aa95; background: #f3faf7; }
+.agent-batch-file input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
+.agent-batch-file span { overflow: hidden; color: #315148; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
+.agent-batch-file small { color: #82918c; font-size: 11px; }
+.agent-batch-list-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
+.agent-batch-list-head h4, .agent-batch-list-head p { margin: 0; }
+.agent-batch-list-head h4 { margin-bottom: 4px; color: #263832; font-size: 15px; }
+.agent-batch-list-head p { color: #71827c; font-size: 12px; }
+.agent-batch-cell-note { display: block; margin-top: 4px; overflow: hidden; color: #7b8c86; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
+.agent-batch-detail { min-height: 260px; }
+.agent-batch-detail-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; margin-bottom: 14px; padding: 13px 14px; border-radius: 8px; background: #f5f8f7; }
+.agent-batch-detail-head div { display: grid; gap: 4px; min-width: 0; }
+.agent-batch-detail-head span { overflow: hidden; color: #71827c; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
+.agent-batch-answer { max-height: 104px; overflow: auto; white-space: pre-wrap; word-break: break-word; line-height: 1.6; }
+
.agent-advanced-settings {
margin-top: 16px;
border: 1px solid #e2eae7;
@@ -2315,6 +2346,10 @@ textarea {
.agent-debug-context-grid {
grid-template-columns: 1fr;
}
+
+ .agent-batch-config-summary { grid-template-columns: 1fr; }
+ .agent-batch-name, .agent-batch-file { width: 100%; min-width: 0; }
+ .agent-batch-upload-row > .el-button { flex: 1; }
}
@media (max-width: 860px) {
diff --git a/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts b/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts
index 07e1a8e..2719119 100644
--- a/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts
+++ b/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts
@@ -392,6 +392,44 @@ export interface AgentDebugStreamComplete {
questionType?: string;
}
+export type AgentBatchStatus = "pending" | "running" | "completed" | "completed_with_errors" | "cancelled";
+
+export interface AgentBatchJob {
+ id: number;
+ name: string;
+ originalFilename: string;
+ status: AgentBatchStatus;
+ totalCount: number;
+ processedCount: number;
+ successCount: number;
+ failedCount: number;
+ progress: number;
+ modelId: number;
+ modelName: string;
+ knowledgeIds: number[];
+ knowledgeNames: string[];
+ createdAt: string;
+ startedAt?: string | null;
+ finishedAt?: string | null;
+}
+
+export interface AgentBatchItem {
+ id: number;
+ rowNumber: number;
+ externalNo?: string | null;
+ question: string;
+ answer?: string | null;
+ status: "pending" | "running" | "success" | "failed" | "cancelled";
+ errorMessage?: string | null;
+ modelName?: string | null;
+ retrieveCount?: number | null;
+ durationMs?: number | null;
+}
+
+export interface AgentBatchJobDetail extends AgentBatchJob {
+ items: PageResult;
+}
+
export interface KnowledgeVersion {
id: number;
knowledgeId: number;
diff --git a/ai_knowledge_base_v2/apps/backend/alembic/versions/0033_agent_batch_tests.py b/ai_knowledge_base_v2/apps/backend/alembic/versions/0033_agent_batch_tests.py
new file mode 100644
index 0000000..d77da8a
--- /dev/null
+++ b/ai_knowledge_base_v2/apps/backend/alembic/versions/0033_agent_batch_tests.py
@@ -0,0 +1,74 @@
+"""add persistent Agent batch tests
+
+Revision ID: 0033_agent_batch_tests
+Revises: 0032_message_feedback
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = "0033_agent_batch_tests"
+down_revision = "0032_message_feedback"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "sys_agent_batch_test",
+ sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
+ sa.Column("name", sa.String(160), nullable=False),
+ sa.Column("original_filename", sa.String(255), nullable=False),
+ sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
+ sa.Column("total_count", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column("success_count", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column("failed_count", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column("processed_count", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column("prompt_content", sa.Text(), nullable=False),
+ sa.Column("model_id", sa.BigInteger(), sa.ForeignKey("sys_model.id"), nullable=False),
+ sa.Column("model_name", sa.String(100), nullable=False),
+ sa.Column("knowledge_ids", sa.Text(), nullable=False),
+ sa.Column("knowledge_names", sa.Text(), nullable=False),
+ sa.Column("generation_config", sa.Text(), nullable=False),
+ sa.Column("created_by", sa.BigInteger(), sa.ForeignKey("sys_admin.id"), nullable=False),
+ sa.Column("started_at", sa.DateTime(), nullable=True),
+ sa.Column("finished_at", sa.DateTime(), nullable=True),
+ sa.Column("cancelled_at", sa.DateTime(), nullable=True),
+ sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
+ sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
+ )
+ op.create_index("ix_agent_batch_test_creator_created", "sys_agent_batch_test", ["created_by", "created_at"])
+ op.create_index("ix_agent_batch_test_status_created", "sys_agent_batch_test", ["status", "created_at"])
+ op.create_table(
+ "sys_agent_batch_test_item",
+ sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
+ sa.Column("batch_id", sa.BigInteger(), sa.ForeignKey("sys_agent_batch_test.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("row_number", sa.Integer(), nullable=False),
+ sa.Column("external_no", sa.String(100), nullable=True),
+ sa.Column("question", sa.Text(), nullable=False),
+ sa.Column("answer", sa.Text(), nullable=True),
+ sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
+ sa.Column("error_message", sa.Text(), nullable=True),
+ sa.Column("model_name", sa.String(100), nullable=True),
+ sa.Column("knowledge_ids", sa.Text(), nullable=True),
+ sa.Column("retrieve_count", sa.Integer(), nullable=True),
+ sa.Column("duration_ms", sa.Integer(), nullable=True),
+ sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column("max_attempts", sa.Integer(), nullable=False, server_default="2"),
+ sa.Column("next_run_at", sa.DateTime(), nullable=True),
+ sa.Column("locked_at", sa.DateTime(), nullable=True),
+ sa.Column("locked_by", sa.String(120), nullable=True),
+ sa.Column("started_at", sa.DateTime(), nullable=True),
+ sa.Column("finished_at", sa.DateTime(), nullable=True),
+ sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
+ sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
+ )
+ op.create_index("ix_agent_batch_item_job_order", "sys_agent_batch_test_item", ["batch_id", "row_number"])
+ op.create_index("ix_agent_batch_item_claim", "sys_agent_batch_test_item", ["status", "next_run_at", "id"])
+ op.create_index("ix_agent_batch_item_stale", "sys_agent_batch_test_item", ["status", "locked_at"])
+
+
+def downgrade() -> None:
+ op.drop_table("sys_agent_batch_test_item")
+ op.drop_table("sys_agent_batch_test")
diff --git a/ai_knowledge_base_v2/apps/backend/app/api/admin_agent_batch.py b/ai_knowledge_base_v2/apps/backend/app/api/admin_agent_batch.py
new file mode 100644
index 0000000..2307f94
--- /dev/null
+++ b/ai_knowledge_base_v2/apps/backend/app/api/admin_agent_batch.py
@@ -0,0 +1,157 @@
+from __future__ import annotations
+
+import json
+from datetime import UTC, datetime
+from typing import Annotated
+from urllib.parse import quote
+
+from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status
+from fastapi.responses import StreamingResponse
+from sqlalchemy import func, select, update
+from sqlalchemy.orm import Session
+
+from app.api.pagination import page_result
+from app.core.database import get_db
+from app.core.dependencies import get_current_admin
+from app.core.responses import api_success
+from app.models.admin import Admin
+from app.models.agent_batch import AgentBatchTest, AgentBatchTestItem
+from app.services.admin_service import OperationLogService
+from app.services.agent_batch_test_service import AgentBatchTestService, item_dict, job_dict
+
+
+router = APIRouter()
+
+
+@router.get("/agent/batch/template")
+def download_batch_template(
+ current_admin: Admin = Depends(get_current_admin),
+) -> StreamingResponse:
+ return StreamingResponse(
+ AgentBatchTestService.template_workbook(),
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ headers={"Content-Disposition": "attachment; filename*=UTF-8''Agent_batch_test_template.xlsx"},
+ )
+
+
+@router.post("/agent/batch/jobs")
+def create_batch_job(
+ file: Annotated[UploadFile, File(...)],
+ configJson: Annotated[str, Form(...)],
+ db: Session = Depends(get_db),
+ current_admin: Admin = Depends(get_current_admin),
+) -> dict:
+ try:
+ config = json.loads(configJson)
+ except json.JSONDecodeError as exc:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="批量测试配置格式不正确") from exc
+ if not isinstance(config, dict):
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="批量测试配置格式不正确")
+ rows = AgentBatchTestService.parse_questions(file)
+ job = AgentBatchTestService.create_job(
+ db,
+ admin=current_admin,
+ filename=file.filename or "Agent批量测试.xlsx",
+ rows=rows,
+ config=config,
+ )
+ OperationLogService.write(db, admin_id=current_admin.id, module="agent", action="batch_create", target_id=job.id)
+ db.commit()
+ return api_success(job_dict(job))
+
+
+@router.get("/agent/batch/jobs")
+def list_batch_jobs(
+ page: int = Query(default=1, ge=1),
+ pageSize: int = Query(default=10, ge=10, le=100),
+ db: Session = Depends(get_db),
+ current_admin: Admin = Depends(get_current_admin),
+) -> dict:
+ total = db.scalar(select(func.count(AgentBatchTest.id))) or 0
+ rows = list(
+ db.scalars(
+ select(AgentBatchTest)
+ .order_by(AgentBatchTest.created_at.desc(), AgentBatchTest.id.desc())
+ .offset((page - 1) * pageSize)
+ .limit(pageSize)
+ )
+ )
+ return api_success(page_result([job_dict(row) for row in rows], total=total, page=page, page_size=pageSize))
+
+
+@router.get("/agent/batch/jobs/{job_id}")
+def get_batch_job(
+ job_id: int,
+ page: int = Query(default=1, ge=1),
+ pageSize: int = Query(default=20, ge=10, le=100),
+ db: Session = Depends(get_db),
+ current_admin: Admin = Depends(get_current_admin),
+) -> dict:
+ job = _job_or_404(db, job_id)
+ total = db.scalar(select(func.count(AgentBatchTestItem.id)).where(AgentBatchTestItem.batch_id == job.id)) or 0
+ items = list(
+ db.scalars(
+ select(AgentBatchTestItem)
+ .where(AgentBatchTestItem.batch_id == job.id)
+ .order_by(AgentBatchTestItem.row_number.asc(), AgentBatchTestItem.id.asc())
+ .offset((page - 1) * pageSize)
+ .limit(pageSize)
+ )
+ )
+ return api_success({**job_dict(job), "items": page_result([item_dict(item) for item in items], total=total, page=page, page_size=pageSize)})
+
+
+@router.get("/agent/batch/jobs/{job_id}/export")
+def export_batch_job(
+ job_id: int,
+ db: Session = Depends(get_db),
+ current_admin: Admin = Depends(get_current_admin),
+) -> StreamingResponse:
+ job = _job_or_404(db, job_id)
+ items = list(
+ db.scalars(
+ select(AgentBatchTestItem)
+ .where(AgentBatchTestItem.batch_id == job.id)
+ .order_by(AgentBatchTestItem.row_number.asc(), AgentBatchTestItem.id.asc())
+ )
+ )
+ filename = quote(f"Agent批量测试结果_{job.id}.xlsx")
+ OperationLogService.write(db, admin_id=current_admin.id, module="agent", action="batch_export", target_id=job.id)
+ db.commit()
+ return StreamingResponse(
+ AgentBatchTestService.export_workbook(job, items),
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ headers={"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"},
+ )
+
+
+@router.post("/agent/batch/jobs/{job_id}/cancel")
+def cancel_batch_job(
+ job_id: int,
+ db: Session = Depends(get_db),
+ current_admin: Admin = Depends(get_current_admin),
+) -> dict:
+ job = _job_or_404(db, job_id)
+ if job.status not in {"pending", "running"}:
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="当前任务已结束,不能取消")
+ now = datetime.now(UTC).replace(tzinfo=None)
+ job.status = "cancelled"
+ job.cancelled_at = now
+ job.finished_at = now
+ db.add(job)
+ db.execute(
+ update(AgentBatchTestItem)
+ .where(AgentBatchTestItem.batch_id == job.id, AgentBatchTestItem.status == "pending")
+ .values(status="cancelled", finished_at=now, next_run_at=None)
+ )
+ OperationLogService.write(db, admin_id=current_admin.id, module="agent", action="batch_cancel", target_id=job.id)
+ db.commit()
+ db.refresh(job)
+ return api_success(job_dict(job))
+
+
+def _job_or_404(db: Session, job_id: int) -> AgentBatchTest:
+ job = db.get(AgentBatchTest, job_id)
+ if job is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="批量测试任务不存在")
+ return job
diff --git a/ai_knowledge_base_v2/apps/backend/app/api/router.py b/ai_knowledge_base_v2/apps/backend/app/api/router.py
index 948cb4b..327f93b 100644
--- a/ai_knowledge_base_v2/apps/backend/app/api/router.py
+++ b/ai_knowledge_base_v2/apps/backend/app/api/router.py
@@ -7,6 +7,7 @@ from app.api import (
admin_auth,
admin_content_generation,
admin_agent_records,
+ admin_agent_batch,
admin_dashboard,
admin_entitlements,
admin_knowledge,
@@ -38,6 +39,7 @@ api_router.include_router(admin_management.router, prefix="/admin", tags=["admin
guard = [Depends(enforce_admin_access)]
api_router.include_router(admin_content_generation.router, prefix="/admin", tags=["admin-content-generation"], dependencies=guard)
api_router.include_router(admin_agent_records.router, prefix="/admin", tags=["admin-agent-records"], dependencies=guard)
+api_router.include_router(admin_agent_batch.router, prefix="/admin", tags=["admin-agent-batch"], dependencies=guard)
api_router.include_router(admin_dashboard.router, prefix="/admin", tags=["admin"], dependencies=guard)
api_router.include_router(admin_entitlements.router, prefix="/admin", tags=["admin-entitlements"], dependencies=guard)
api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"], dependencies=guard)
diff --git a/ai_knowledge_base_v2/apps/backend/app/core/config.py b/ai_knowledge_base_v2/apps/backend/app/core/config.py
index 964df60..899ebc6 100644
--- a/ai_knowledge_base_v2/apps/backend/app/core/config.py
+++ b/ai_knowledge_base_v2/apps/backend/app/core/config.py
@@ -80,6 +80,9 @@ class Settings(BaseSettings):
topic_settlement_poll_seconds: int = 2
topic_settlement_stale_minutes: int = 30
topic_settlement_max_attempts: int = 3
+ agent_batch_worker_enabled: bool = True
+ agent_batch_poll_seconds: int = 2
+ agent_batch_stale_minutes: int = 30
bootstrap_admin_username: str = ""
bootstrap_admin_password: str = ""
bootstrap_admin_name: str = "系统管理员"
diff --git a/ai_knowledge_base_v2/apps/backend/app/core/dependencies.py b/ai_knowledge_base_v2/apps/backend/app/core/dependencies.py
index 160625c..547b219 100644
--- a/ai_knowledge_base_v2/apps/backend/app/core/dependencies.py
+++ b/ai_knowledge_base_v2/apps/backend/app/core/dependencies.py
@@ -100,6 +100,8 @@ def enforce_admin_access(
permission = "users.delete" if method == "DELETE" else ("users.view" if method == "GET" else ("users.create" if method == "POST" and (path in {"user", "user/import", "user/import/excel"}) else "users.edit"))
elif path.startswith("knowledge"):
permission = "knowledge.delete" if method == "DELETE" else ("knowledge.view" if method == "GET" else ("knowledge.publish" if path.endswith("open-status") or path.endswith("lifecycle") else "knowledge.edit"))
+ elif path.startswith("agent/batch"):
+ permission = "prompt.batch"
elif path.startswith("prompt") or path.startswith("agent/"):
permission = "prompt.view" if method == "GET" else "prompt.edit"
elif path.startswith("model"):
diff --git a/ai_knowledge_base_v2/apps/backend/app/main.py b/ai_knowledge_base_v2/apps/backend/app/main.py
index 247571d..e2041b9 100644
--- a/ai_knowledge_base_v2/apps/backend/app/main.py
+++ b/ai_knowledge_base_v2/apps/backend/app/main.py
@@ -15,6 +15,7 @@ from app.services.secret_service import SecretService
from app.services.maintenance_service import MaintenanceService
from app.services.periodic_report_worker import PeriodicReportWorker
from app.services.topic_settlement_worker import TopicSettlementWorker
+from app.services.agent_batch_test_worker import AgentBatchTestWorker
@asynccontextmanager
@@ -26,12 +27,13 @@ async def lifespan(app: FastAPI):
maintenance_task = asyncio.create_task(MaintenanceService.run_forever())
periodic_report_task = asyncio.create_task(PeriodicReportWorker.run_forever())
topic_settlement_task = asyncio.create_task(TopicSettlementWorker.run_forever())
+ agent_batch_task = asyncio.create_task(AgentBatchTestWorker.run_forever())
try:
yield
finally:
- for task in (maintenance_task, periodic_report_task, topic_settlement_task):
+ for task in (maintenance_task, periodic_report_task, topic_settlement_task, agent_batch_task):
task.cancel()
- for task in (maintenance_task, periodic_report_task, topic_settlement_task):
+ for task in (maintenance_task, periodic_report_task, topic_settlement_task, agent_batch_task):
try:
await task
except asyncio.CancelledError:
diff --git a/ai_knowledge_base_v2/apps/backend/app/models/__init__.py b/ai_knowledge_base_v2/apps/backend/app/models/__init__.py
index dd5e7ed..cb604f6 100644
--- a/ai_knowledge_base_v2/apps/backend/app/models/__init__.py
+++ b/ai_knowledge_base_v2/apps/backend/app/models/__init__.py
@@ -1,4 +1,5 @@
from app.models.admin import Admin, Role
+from app.models.agent_batch import AgentBatchTest, AgentBatchTestItem
from app.models.ai_config import ContentGenerationConfig, ModelConfig, Prompt, SystemConfig
from app.models.base import Base
from app.models.chat import ChatMessage, ChatSession, TopicSession
@@ -29,6 +30,8 @@ from app.models.user import User
__all__ = [
"Admin",
+ "AgentBatchTest",
+ "AgentBatchTestItem",
"AiRequestLog",
"Base",
"ChatMessage",
diff --git a/ai_knowledge_base_v2/apps/backend/app/models/agent_batch.py b/ai_knowledge_base_v2/apps/backend/app/models/agent_batch.py
new file mode 100644
index 0000000..975d0ef
--- /dev/null
+++ b/ai_knowledge_base_v2/apps/backend/app/models/agent_batch.py
@@ -0,0 +1,71 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, Text, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.models.base import Base
+
+
+PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite")
+
+
+class AgentBatchTest(Base):
+ __tablename__ = "sys_agent_batch_test"
+ __table_args__ = (
+ Index("ix_agent_batch_test_creator_created", "created_by", "created_at"),
+ Index("ix_agent_batch_test_status_created", "status", "created_at"),
+ )
+
+ id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
+ name: Mapped[str] = mapped_column(String(160), nullable=False)
+ original_filename: Mapped[str] = mapped_column(String(255), nullable=False)
+ status: Mapped[str] = mapped_column(String(20), default="pending", nullable=False)
+ total_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
+ success_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
+ failed_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
+ processed_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
+ prompt_content: Mapped[str] = mapped_column(Text, nullable=False)
+ model_id: Mapped[int] = mapped_column(ForeignKey("sys_model.id"), nullable=False)
+ model_name: Mapped[str] = mapped_column(String(100), nullable=False)
+ knowledge_ids: Mapped[str] = mapped_column(Text, nullable=False)
+ knowledge_names: Mapped[str] = mapped_column(Text, nullable=False)
+ generation_config: Mapped[str] = mapped_column(Text, nullable=False)
+ created_by: Mapped[int] = mapped_column(ForeignKey("sys_admin.id"), nullable=False)
+ started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+ finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+ cancelled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
+
+
+class AgentBatchTestItem(Base):
+ __tablename__ = "sys_agent_batch_test_item"
+ __table_args__ = (
+ Index("ix_agent_batch_item_job_order", "batch_id", "row_number"),
+ Index("ix_agent_batch_item_claim", "status", "next_run_at", "id"),
+ Index("ix_agent_batch_item_stale", "status", "locked_at"),
+ )
+
+ id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
+ batch_id: Mapped[int] = mapped_column(ForeignKey("sys_agent_batch_test.id", ondelete="CASCADE"), nullable=False)
+ row_number: Mapped[int] = mapped_column(Integer, nullable=False)
+ external_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ question: Mapped[str] = mapped_column(Text, nullable=False)
+ answer: Mapped[str | None] = mapped_column(Text, nullable=True)
+ status: Mapped[str] = mapped_column(String(20), default="pending", nullable=False)
+ error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
+ model_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ knowledge_ids: Mapped[str | None] = mapped_column(Text, nullable=True)
+ retrieve_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ attempt_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
+ max_attempts: Mapped[int] = mapped_column(Integer, default=2, nullable=False)
+ next_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+ locked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+ locked_by: Mapped[str | None] = mapped_column(String(120), nullable=True)
+ started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+ finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
diff --git a/ai_knowledge_base_v2/apps/backend/app/services/admin_permission_service.py b/ai_knowledge_base_v2/apps/backend/app/services/admin_permission_service.py
index 61420ae..cd30ba3 100644
--- a/ai_knowledge_base_v2/apps/backend/app/services/admin_permission_service.py
+++ b/ai_knowledge_base_v2/apps/backend/app/services/admin_permission_service.py
@@ -12,7 +12,7 @@ PERMISSION_TREE = [
{"code": "users", "name": "用户管理", "children": [{"code": "users.view", "name": "查看用户"}, {"code": "users.create", "name": "新增/导入"}, {"code": "users.edit", "name": "编辑/权益续期"}, {"code": "users.delete", "name": "删除用户"}]},
{"code": "entitlements", "name": "权益管理", "children": [{"code": "entitlements.view", "name": "查看权益"}, {"code": "entitlements.edit", "name": "编辑权益"}]},
{"code": "knowledge", "name": "知识库管理", "children": [{"code": "knowledge.view", "name": "查看知识库"}, {"code": "knowledge.edit", "name": "新增/编辑/同步"}, {"code": "knowledge.publish", "name": "开放/归档"}, {"code": "knowledge.delete", "name": "删除知识库"}]},
- {"code": "prompt", "name": "Agent 管理", "children": [{"code": "prompt.view", "name": "查看 Agent"}, {"code": "prompt.edit", "name": "编辑/测试 Agent"}]},
+ {"code": "prompt", "name": "Agent 管理", "children": [{"code": "prompt.view", "name": "查看 Agent"}, {"code": "prompt.edit", "name": "编辑/单条测试 Agent"}, {"code": "prompt.batch", "name": "批量测试/导出"}]},
{"code": "models", "name": "模型管理", "children": [{"code": "models.view", "name": "查看模型"}, {"code": "models.edit", "name": "新增/编辑/测试"}, {"code": "models.delete", "name": "删除模型"}]},
{"code": "content-generation", "name": "内容生成", "children": [{"code": "content-generation.view", "name": "查看配置"}, {"code": "content-generation.edit", "name": "编辑/测试配置"}]},
{"code": "configs", "name": "系统配置", "children": [{"code": "configs.view", "name": "查看配置"}, {"code": "configs.edit", "name": "修改配置"}]},
diff --git a/ai_knowledge_base_v2/apps/backend/app/services/agent_batch_test_service.py b/ai_knowledge_base_v2/apps/backend/app/services/agent_batch_test_service.py
new file mode 100644
index 0000000..c10ba01
--- /dev/null
+++ b/ai_knowledge_base_v2/apps/backend/app/services/agent_batch_test_service.py
@@ -0,0 +1,274 @@
+from __future__ import annotations
+
+import json
+from datetime import UTC, datetime
+from io import BytesIO
+
+from fastapi import HTTPException, UploadFile, status
+from openpyxl import Workbook, load_workbook
+from openpyxl.styles import Alignment, Font, PatternFill
+from openpyxl.worksheet.table import Table, TableStyleInfo
+from sqlalchemy import func, select
+from sqlalchemy.orm import Session
+
+from app.models.admin import Admin
+from app.models.agent_batch import AgentBatchTest, AgentBatchTestItem
+from app.models.ai_config import ModelConfig
+from app.models.knowledge import Knowledge
+
+
+MAX_BATCH_QUESTIONS = 200
+MAX_QUESTION_LENGTH = 2000
+TEMPLATE_HEADERS = ["序号", "问题"]
+
+
+class AgentBatchTestService:
+ @staticmethod
+ def parse_questions(file: UploadFile) -> list[dict]:
+ filename = file.filename or ""
+ if not filename.lower().endswith(".xlsx"):
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请上传 .xlsx 格式的 Excel 文件")
+ content = file.file.read()
+ if not content:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Excel 文件为空")
+ if len(content) > 5 * 1024 * 1024:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Excel 文件不能超过 5MB")
+ try:
+ workbook = load_workbook(BytesIO(content), read_only=True, data_only=False)
+ except Exception as exc:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Excel 文件解析失败,请重新下载模板填写") from exc
+ sheet = workbook.active
+ if sheet.max_row > 1000:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Excel 行数异常,请删除多余空行后重试")
+ headers = [str(value or "").strip() for value in next(sheet.iter_rows(min_row=1, max_row=1, values_only=True), ())]
+ header_map = {name: index for index, name in enumerate(headers) if name}
+ if "问题" not in header_map:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="模板缺少必填列:问题")
+ rows: list[dict] = []
+ seen: set[str] = set()
+ for row_number, values in enumerate(sheet.iter_rows(min_row=2, max_row=min(sheet.max_row, 1000), values_only=True), start=2):
+ raw_question = _value_at(values, header_map["问题"])
+ if isinstance(raw_question, str) and raw_question.startswith("="):
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"第 {row_number} 行问题不能使用 Excel 公式")
+ question = _cell_text(raw_question)
+ external_no = _cell_text(_value_at(values, header_map.get("序号"))) or str(row_number - 1)
+ if not question:
+ continue
+ if len(question) > MAX_QUESTION_LENGTH:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"第 {row_number} 行问题超过 {MAX_QUESTION_LENGTH} 字")
+ normalized = " ".join(question.split())
+ if normalized in seen:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"第 {row_number} 行问题与前面重复")
+ seen.add(normalized)
+ rows.append({"rowNumber": row_number, "externalNo": external_no[:100], "question": question})
+ if len(rows) > MAX_BATCH_QUESTIONS:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"单次最多导入 {MAX_BATCH_QUESTIONS} 个问题")
+ if not rows:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Excel 中没有可测试的问题")
+ return rows
+
+ @staticmethod
+ def create_job(
+ db: Session,
+ *,
+ admin: Admin,
+ filename: str,
+ rows: list[dict],
+ config: dict,
+ ) -> AgentBatchTest:
+ prompt_content = str(config.get("promptContent") or "").strip()
+ if not prompt_content:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="主提示词不能为空")
+ try:
+ model_id = int(config.get("modelId"))
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请选择批量测试模型") from exc
+ model = db.get(ModelConfig, model_id)
+ if model is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="批量测试模型不存在")
+ knowledge_ids = _positive_int_list(config.get("knowledgeIds"))
+ if knowledge_ids:
+ found = list(db.scalars(select(Knowledge).where(Knowledge.id.in_(knowledge_ids))))
+ if len(found) != len(knowledge_ids):
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="所选知识库中包含已删除的数据")
+ knowledge_names = [item.name for item in sorted(found, key=lambda item: knowledge_ids.index(item.id))]
+ else:
+ knowledge_names = ["全部正式开放知识库"]
+ generation_config = {
+ "temperature": config.get("temperature"),
+ "topP": config.get("topP"),
+ "topK": config.get("topK"),
+ "presencePenalty": config.get("presencePenalty"),
+ "frequencyPenalty": config.get("frequencyPenalty"),
+ "maxToken": config.get("maxToken") or 8192,
+ "streamEnabled": int(config.get("streamEnabled", 1)),
+ "reasoningVisible": 0,
+ "responseDepth": int(config.get("responseDepth", 35)),
+ }
+ job = AgentBatchTest(
+ name=str(config.get("name") or filename.rsplit(".", 1)[0] or "Agent 批量测试")[:160],
+ original_filename=filename[:255],
+ total_count=len(rows),
+ prompt_content=prompt_content,
+ model_id=model.id,
+ model_name=model.display_name or model.model_name,
+ knowledge_ids=json.dumps(knowledge_ids, ensure_ascii=False),
+ knowledge_names=json.dumps(knowledge_names, ensure_ascii=False),
+ generation_config=json.dumps(generation_config, ensure_ascii=False),
+ created_by=admin.id,
+ )
+ db.add(job)
+ db.flush()
+ db.add_all([
+ AgentBatchTestItem(
+ batch_id=job.id,
+ row_number=row["rowNumber"],
+ external_no=row["externalNo"],
+ question=row["question"],
+ )
+ for row in rows
+ ])
+ db.flush()
+ return job
+
+ @staticmethod
+ def template_workbook() -> BytesIO:
+ workbook = Workbook()
+ sheet = workbook.active
+ sheet.title = "批量测试问题"
+ sheet.append(TEMPLATE_HEADERS)
+ sheet.append([1, "请填写需要测试的问题,保留一行一个问题。"])
+ sheet.append([2, "例如:遇到情绪焦虑时,可以先做什么?"])
+ _style_sheet(sheet, widths=(12, 72), table_ref="A1:B3", table_name="AgentBatchQuestionTable")
+ note = workbook.create_sheet("填写说明")
+ note.append(["填写规则", "说明"])
+ note.append(["必填列", "问题"])
+ note.append(["单次数量", f"最多 {MAX_BATCH_QUESTIONS} 个问题"])
+ note.append(["问题长度", f"每个问题最多 {MAX_QUESTION_LENGTH} 字"])
+ note.append(["重复问题", "同一文件内不允许重复"])
+ note.append(["不要修改", "不要修改工作表名称和表头;可以删除示例行后填写"])
+ _style_sheet(note, widths=(18, 72), table_ref="A1:B6", table_name="AgentBatchInstructions")
+ stream = BytesIO()
+ workbook.save(stream)
+ stream.seek(0)
+ return stream
+
+ @staticmethod
+ def export_workbook(job: AgentBatchTest, items: list[AgentBatchTestItem]) -> BytesIO:
+ workbook = Workbook()
+ sheet = workbook.active
+ sheet.title = "批量测试结果"
+ headers = ["序号", "问题", "答案", "状态", "失败原因", "实际模型", "召回数量", "耗时(秒)"]
+ sheet.append(headers)
+ status_labels = {"success": "成功", "failed": "失败", "cancelled": "已取消", "pending": "等待中", "running": "生成中"}
+ for item in items:
+ sheet.append([
+ item.external_no or item.row_number - 1,
+ _excel_safe(item.question),
+ _excel_safe(item.answer or ""),
+ status_labels.get(item.status, item.status),
+ _excel_safe(item.error_message or ""),
+ _excel_safe(item.model_name or job.model_name),
+ item.retrieve_count,
+ round(item.duration_ms / 1000, 2) if item.duration_ms is not None else None,
+ ])
+ end_row = max(2, len(items) + 1)
+ _style_sheet(sheet, widths=(12, 42, 80, 12, 36, 22, 12, 12), table_ref=f"A1:H{end_row}", table_name="AgentBatchResultTable")
+ for row in sheet.iter_rows(min_row=2, max_row=end_row):
+ row[1].alignment = Alignment(vertical="top", wrap_text=True)
+ row[2].alignment = Alignment(vertical="top", wrap_text=True)
+ row[4].alignment = Alignment(vertical="top", wrap_text=True)
+ summary = workbook.create_sheet("任务信息")
+ summary.append(["项目", "内容"])
+ summary.append(["任务名称", job.name])
+ summary.append(["任务状态", job.status])
+ summary.append(["问题总数", job.total_count])
+ summary.append(["成功", job.success_count])
+ summary.append(["失败", job.failed_count])
+ summary.append(["测试模型", job.model_name])
+ summary.append(["知识库", "、".join(json.loads(job.knowledge_names or "[]"))])
+ summary.append(["创建时间", job.created_at])
+ summary.append(["完成时间", job.finished_at])
+ _style_sheet(summary, widths=(20, 86), table_ref="A1:B10", table_name="AgentBatchSummary")
+ summary.column_dimensions["B"].width = 86
+ stream = BytesIO()
+ workbook.save(stream)
+ stream.seek(0)
+ return stream
+
+
+def job_dict(job: AgentBatchTest) -> dict:
+ return {
+ "id": job.id,
+ "name": job.name,
+ "originalFilename": job.original_filename,
+ "status": job.status,
+ "totalCount": job.total_count,
+ "processedCount": job.processed_count,
+ "successCount": job.success_count,
+ "failedCount": job.failed_count,
+ "progress": round(job.processed_count * 100 / job.total_count) if job.total_count else 0,
+ "modelId": job.model_id,
+ "modelName": job.model_name,
+ "knowledgeIds": json.loads(job.knowledge_ids or "[]"),
+ "knowledgeNames": json.loads(job.knowledge_names or "[]"),
+ "createdAt": job.created_at,
+ "startedAt": job.started_at,
+ "finishedAt": job.finished_at,
+ }
+
+
+def item_dict(item: AgentBatchTestItem) -> dict:
+ return {
+ "id": item.id,
+ "rowNumber": item.row_number,
+ "externalNo": item.external_no,
+ "question": item.question,
+ "answer": item.answer,
+ "status": item.status,
+ "errorMessage": item.error_message,
+ "modelName": item.model_name,
+ "retrieveCount": item.retrieve_count,
+ "durationMs": item.duration_ms,
+ }
+
+
+def _style_sheet(sheet, *, widths: tuple[int, ...], table_ref: str, table_name: str) -> None:
+ sheet.freeze_panes = "A2"
+ sheet.sheet_view.showGridLines = False
+ header_fill = PatternFill("solid", fgColor="176B57")
+ for cell in sheet[1]:
+ cell.fill = header_fill
+ cell.font = Font(color="FFFFFF", bold=True)
+ cell.alignment = Alignment(vertical="center")
+ sheet.row_dimensions[1].height = 28
+ for index, width in enumerate(widths, start=1):
+ sheet.column_dimensions[chr(64 + index)].width = width
+ table = Table(displayName=table_name, ref=table_ref)
+ table.tableStyleInfo = TableStyleInfo(name="TableStyleMedium4", showRowStripes=True, showFirstColumn=False, showLastColumn=False)
+ sheet.add_table(table)
+
+
+def _cell_text(value) -> str:
+ if value is None:
+ return ""
+ return str(value).strip()
+
+
+def _value_at(row: tuple, index: int | None):
+ return row[index] if index is not None and index < len(row) else None
+
+
+def _positive_int_list(value) -> list[int]:
+ if not isinstance(value, list):
+ return []
+ try:
+ result = list(dict.fromkeys(int(item) for item in value if int(item) > 0))
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="知识库参数格式不正确") from exc
+ return result
+
+
+def _excel_safe(value: str) -> str:
+ text = str(value or "")
+ return f"'{text}" if text.lstrip().startswith(("=", "+", "-", "@")) else text
diff --git a/ai_knowledge_base_v2/apps/backend/app/services/agent_batch_test_worker.py b/ai_knowledge_base_v2/apps/backend/app/services/agent_batch_test_worker.py
new file mode 100644
index 0000000..4d42beb
--- /dev/null
+++ b/ai_knowledge_base_v2/apps/backend/app/services/agent_batch_test_worker.py
@@ -0,0 +1,227 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import os
+import socket
+import time
+import uuid
+from datetime import UTC, datetime, timedelta
+
+from sqlalchemy import func, or_, select
+from sqlalchemy.orm import Session
+
+from app.core.config import get_settings
+from app.core.database import SessionLocal
+from app.models.admin import Admin
+from app.models.agent_batch import AgentBatchTest, AgentBatchTestItem
+from app.schemas.admin import AgentDebugRequest
+from app.services.agent_debug_service import AgentDebugService
+
+
+logger = logging.getLogger(__name__)
+
+
+class AgentBatchTestWorker:
+ """Persistent, restart-safe queue for Agent batch questions."""
+
+ @classmethod
+ async def run_forever(cls) -> None:
+ settings = get_settings()
+ if not settings.agent_batch_worker_enabled:
+ logger.info("agent batch test worker disabled")
+ return
+ worker_id = f"{socket.gethostname()}:{os.getpid()}:{uuid.uuid4().hex[:8]}"
+ while True:
+ try:
+ processed = await cls.run_once(worker_id)
+ except Exception:
+ processed = False
+ logger.exception("agent batch test worker iteration failed")
+ await asyncio.sleep(0 if processed else max(1, settings.agent_batch_poll_seconds))
+
+ @classmethod
+ async def run_once(cls, worker_id: str) -> bool:
+ now = _now()
+ with SessionLocal() as db:
+ recovered_batch_ids = cls.recover_stale_items(db, now=now)
+ db.commit()
+ for batch_id in recovered_batch_ids:
+ cls.refresh_job(db, batch_id)
+ with SessionLocal() as db:
+ item_id = cls.claim_next(db, worker_id=worker_id, now=now)
+ if item_id is None:
+ return False
+ await cls.execute_claimed(item_id=item_id, worker_id=worker_id)
+ return True
+
+ @staticmethod
+ def claim_next(db: Session, *, worker_id: str, now: datetime | None = None) -> int | None:
+ current = now or _now()
+ item = db.scalar(
+ select(AgentBatchTestItem)
+ .join(AgentBatchTest, AgentBatchTest.id == AgentBatchTestItem.batch_id)
+ .where(
+ AgentBatchTest.status.in_(("pending", "running")),
+ AgentBatchTestItem.status == "pending",
+ AgentBatchTestItem.attempt_count < AgentBatchTestItem.max_attempts,
+ or_(AgentBatchTestItem.next_run_at.is_(None), AgentBatchTestItem.next_run_at <= current),
+ )
+ .order_by(AgentBatchTestItem.batch_id.asc(), AgentBatchTestItem.row_number.asc())
+ .with_for_update(skip_locked=True)
+ .limit(1)
+ )
+ if item is None:
+ db.rollback()
+ return None
+ job = db.get(AgentBatchTest, item.batch_id)
+ item.status = "running"
+ item.attempt_count += 1
+ item.locked_at = current
+ item.locked_by = worker_id
+ item.started_at = current
+ if job is not None and job.status == "pending":
+ job.status = "running"
+ job.started_at = current
+ db.add(job)
+ db.add(item)
+ db.commit()
+ return item.id
+
+ @classmethod
+ async def execute_claimed(cls, *, item_id: int, worker_id: str) -> None:
+ with SessionLocal() as db:
+ item = db.get(AgentBatchTestItem, item_id)
+ if item is None or item.status != "running" or item.locked_by != worker_id:
+ return
+ job = db.get(AgentBatchTest, item.batch_id)
+ admin = db.get(Admin, job.created_by) if job is not None else None
+ if job is None or admin is None:
+ cls._finish_item(db, item, error="批量任务或创建管理员不存在")
+ return
+ if job.status == "cancelled":
+ item.status = "cancelled"
+ item.finished_at = _now()
+ item.locked_at = None
+ item.locked_by = None
+ db.add(item)
+ db.commit()
+ return
+ payload = _payload_from(job, item)
+ started = time.monotonic()
+ try:
+ result = await AgentDebugService.run_complete(payload, db, admin)
+ duration_ms = round((time.monotonic() - started) * 1000)
+ item.answer = result["answer"]
+ item.model_name = result.get("modelName") or job.model_name
+ item.knowledge_ids = json.dumps(result.get("knowledgeIds") or [], ensure_ascii=False)
+ item.retrieve_count = result.get("retrieveCount")
+ item.duration_ms = duration_ms
+ item.status = "success"
+ item.error_message = None
+ item.finished_at = _now()
+ item.next_run_at = None
+ item.locked_at = None
+ item.locked_by = None
+ db.add(item)
+ db.commit()
+ except Exception as exc:
+ db.rollback()
+ item = db.get(AgentBatchTestItem, item_id)
+ if item is None:
+ return
+ item.duration_ms = round((time.monotonic() - started) * 1000)
+ if item.attempt_count < item.max_attempts:
+ item.status = "pending"
+ item.next_run_at = _now() + timedelta(seconds=15 * item.attempt_count)
+ item.finished_at = None
+ else:
+ item.status = "failed"
+ item.next_run_at = None
+ item.finished_at = _now()
+ item.error_message = (str(exc) or "Agent 生成失败")[:2000]
+ item.locked_at = None
+ item.locked_by = None
+ db.add(item)
+ db.commit()
+ cls.refresh_job(db, job.id)
+
+ @staticmethod
+ def refresh_job(db: Session, batch_id: int) -> None:
+ job = db.get(AgentBatchTest, batch_id)
+ if job is None or job.status == "cancelled":
+ return
+ counts = dict(
+ db.execute(
+ select(AgentBatchTestItem.status, func.count(AgentBatchTestItem.id))
+ .where(AgentBatchTestItem.batch_id == batch_id)
+ .group_by(AgentBatchTestItem.status)
+ ).all()
+ )
+ job.success_count = counts.get("success", 0)
+ job.failed_count = counts.get("failed", 0)
+ job.processed_count = job.success_count + job.failed_count
+ if job.processed_count >= job.total_count:
+ job.status = "completed" if job.failed_count == 0 else "completed_with_errors"
+ job.finished_at = _now()
+ else:
+ job.status = "running"
+ db.add(job)
+ db.commit()
+
+ @staticmethod
+ def recover_stale_items(db: Session, *, now: datetime | None = None) -> set[int]:
+ current = now or _now()
+ stale_before = current - timedelta(minutes=max(5, get_settings().agent_batch_stale_minutes))
+ items = list(
+ db.scalars(
+ select(AgentBatchTestItem)
+ .where(
+ AgentBatchTestItem.status == "running",
+ AgentBatchTestItem.locked_at.is_not(None),
+ AgentBatchTestItem.locked_at < stale_before,
+ )
+ .order_by(AgentBatchTestItem.id.asc())
+ .limit(100)
+ .with_for_update(skip_locked=True)
+ )
+ )
+ affected_batch_ids: set[int] = set()
+ for item in items:
+ affected_batch_ids.add(item.batch_id)
+ item.status = "pending" if item.attempt_count < item.max_attempts else "failed"
+ item.error_message = "任务执行进程中断,系统已自动恢复" if item.status == "pending" else "任务执行进程连续中断"
+ item.next_run_at = current if item.status == "pending" else None
+ item.finished_at = current if item.status == "failed" else None
+ item.locked_at = None
+ item.locked_by = None
+ db.add(item)
+ return affected_batch_ids
+
+ @classmethod
+ def _finish_item(cls, db: Session, item: AgentBatchTestItem, *, error: str) -> None:
+ item.status = "failed"
+ item.error_message = error
+ item.finished_at = _now()
+ item.locked_at = None
+ item.locked_by = None
+ db.add(item)
+ db.commit()
+ cls.refresh_job(db, item.batch_id)
+
+
+def _payload_from(job: AgentBatchTest, item: AgentBatchTestItem) -> AgentDebugRequest:
+ generation = json.loads(job.generation_config or "{}")
+ return AgentDebugRequest(
+ promptContent=job.prompt_content,
+ modelId=job.model_id,
+ knowledgeIds=json.loads(job.knowledge_ids or "[]"),
+ question=item.question,
+ history=[],
+ **generation,
+ )
+
+
+def _now() -> datetime:
+ return datetime.now(UTC).replace(tzinfo=None)
diff --git a/ai_knowledge_base_v2/apps/backend/app/services/agent_debug_service.py b/ai_knowledge_base_v2/apps/backend/app/services/agent_debug_service.py
index 8af23ad..9768621 100644
--- a/ai_knowledge_base_v2/apps/backend/app/services/agent_debug_service.py
+++ b/ai_knowledge_base_v2/apps/backend/app/services/agent_debug_service.py
@@ -283,3 +283,20 @@ class AgentDebugService:
except Exception as exc:
db.rollback()
yield {"type": "error", "message": str(exc) or "Agent 调试失败"}
+
+ @classmethod
+ async def run_complete(cls, payload: AgentDebugRequest, db: Session, current_admin: Admin) -> dict:
+ """Consume the same routed debug stream used by the UI and return one complete answer."""
+ answer_parts: list[str] = []
+ completion: dict = {}
+ async for event in cls.stream(payload, db, current_admin):
+ if event.get("type") == "content":
+ answer_parts.append(str(event.get("content") or ""))
+ elif event.get("type") == "complete":
+ completion = event
+ elif event.get("type") == "error":
+ raise RuntimeError(str(event.get("message") or "Agent 生成失败"))
+ answer = "".join(answer_parts).strip()
+ if not answer:
+ raise RuntimeError("模型未返回正式回答")
+ return {"answer": answer, **completion}
diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_admin_permissions.py b/ai_knowledge_base_v2/apps/backend/tests/test_admin_permissions.py
index b23b4fa..30858fe 100644
--- a/ai_knowledge_base_v2/apps/backend/tests/test_admin_permissions.py
+++ b/ai_knowledge_base_v2/apps/backend/tests/test_admin_permissions.py
@@ -13,6 +13,7 @@ def test_super_admin_has_all_permissions() -> None:
admin = Admin(id=1, username="root", password="unused", name="root", status=1, is_super_admin=1)
assert permissions_for(admin) == ALL_PERMISSION_CODES
assert {"feedback.view", "feedback.detail", "feedback.export", "feedback.delete"} <= ALL_PERMISSION_CODES
+ assert "prompt.batch" in ALL_PERMISSION_CODES
def test_role_permissions_are_restricted_to_catalog() -> None:
diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_agent_batch_tests.py b/ai_knowledge_base_v2/apps/backend/tests/test_agent_batch_tests.py
new file mode 100644
index 0000000..46b3768
--- /dev/null
+++ b/ai_knowledge_base_v2/apps/backend/tests/test_agent_batch_tests.py
@@ -0,0 +1,125 @@
+from io import BytesIO
+import json
+
+import pytest
+from fastapi import HTTPException, UploadFile
+from openpyxl import Workbook, load_workbook
+from sqlalchemy import create_engine, select
+from sqlalchemy.orm import Session
+from sqlalchemy.pool import StaticPool
+
+from app.models import Base
+from app.models.admin import Admin
+from app.models.agent_batch import AgentBatchTestItem
+from app.models.ai_config import ModelConfig
+from app.services.agent_batch_test_service import AgentBatchTestService, MAX_BATCH_QUESTIONS
+
+
+def _database() -> Session:
+ engine = create_engine(
+ "sqlite:///:memory:",
+ connect_args={"check_same_thread": False},
+ poolclass=StaticPool,
+ )
+ Base.metadata.create_all(engine)
+ return Session(engine)
+
+
+def _admin() -> Admin:
+ return Admin(id=1, username="admin", password="hash", name="系统管理员", status=1)
+
+
+def _model() -> ModelConfig:
+ return ModelConfig(
+ id=1,
+ provider="mock",
+ display_name="批量测试模型",
+ api_type="openai_compatible",
+ model_name="batch-model",
+ api_url="",
+ api_key="",
+ auth_type="bearer",
+ enabled=1,
+ )
+
+
+def test_template_can_be_downloaded_and_imported() -> None:
+ stream = AgentBatchTestService.template_workbook()
+ workbook = load_workbook(stream, data_only=True)
+
+ assert workbook.sheetnames == ["批量测试问题", "填写说明"]
+ assert [cell.value for cell in workbook["批量测试问题"][1]] == ["序号", "问题"]
+ assert workbook["填写说明"]["B3"].value == f"最多 {MAX_BATCH_QUESTIONS} 个问题"
+
+ upload = UploadFile(filename="批量测试.xlsx", file=BytesIO(stream.getvalue()))
+ rows = AgentBatchTestService.parse_questions(upload)
+ assert len(rows) == 2
+ assert rows[0]["externalNo"] == "1"
+ assert "测试的问题" in rows[0]["question"]
+
+
+def test_create_job_snapshots_config_and_export_keeps_failed_rows() -> None:
+ with _database() as db:
+ admin = _admin()
+ model = _model()
+ db.add_all([admin, model])
+ db.commit()
+ job = AgentBatchTestService.create_job(
+ db,
+ admin=admin,
+ filename="回归问题.xlsx",
+ rows=[
+ {"rowNumber": 2, "externalNo": "Q-1", "question": "第一个问题"},
+ {"rowNumber": 3, "externalNo": "Q-2", "question": "第二个问题"},
+ ],
+ config={
+ "name": "提示词回归测试",
+ "promptContent": "只根据知识库回答",
+ "modelId": model.id,
+ "knowledgeIds": [],
+ "temperature": 0.2,
+ "maxToken": 8192,
+ "responseDepth": 35,
+ },
+ )
+ items = list(db.scalars(select(AgentBatchTestItem).order_by(AgentBatchTestItem.row_number)))
+ items[0].status = "success"
+ items[0].answer = "第一个答案"
+ items[0].model_name = "批量测试模型"
+ items[0].retrieve_count = 3
+ items[0].duration_ms = 1250
+ items[1].status = "failed"
+ items[1].error_message = "供应商超时"
+ job.status = "completed_with_errors"
+ job.processed_count = 2
+ job.success_count = 1
+ job.failed_count = 1
+ db.commit()
+
+ assert json.loads(job.generation_config)["maxToken"] == 8192
+ assert json.loads(job.knowledge_names) == ["全部正式开放知识库"]
+
+ exported = AgentBatchTestService.export_workbook(job, items)
+ workbook = load_workbook(exported, data_only=True)
+ sheet = workbook["批量测试结果"]
+ assert sheet["C2"].value == "第一个答案"
+ assert sheet["D2"].value == "成功"
+ assert sheet["D3"].value == "失败"
+ assert sheet["E3"].value == "供应商超时"
+ assert sheet.freeze_panes == "A2"
+
+
+def test_import_rejects_excel_formula_question() -> None:
+ workbook = Workbook()
+ sheet = workbook.active
+ sheet.append(["序号", "问题"])
+ sheet.append([1, '=HYPERLINK("https://example.com","问题")'])
+ stream = BytesIO()
+ workbook.save(stream)
+ stream.seek(0)
+
+ with pytest.raises(HTTPException) as exc:
+ AgentBatchTestService.parse_questions(UploadFile(filename="危险问题.xlsx", file=stream))
+
+ assert exc.value.status_code == 400
+ assert "不能使用 Excel 公式" in exc.value.detail
diff --git a/ai_knowledge_base_v2/design-qa.md b/ai_knowledge_base_v2/design-qa.md
index 4cb159a..d47d690 100644
--- a/ai_knowledge_base_v2/design-qa.md
+++ b/ai_knowledge_base_v2/design-qa.md
@@ -1,3 +1,20 @@
+# Agent 批量测试页面验收(2026-08-12)
+
+- 实现截图:`development_records/ui-evidence/agent-batch-test-2026-08-12.png`
+- 验收地址:`http://127.0.0.1:8080/`
+- 页面状态:系统管理员已登录 / Agent 管理 / 批量测试 / 2 条真实问题已完成
+- 视口:1280 × 720 CSS px;页面 `scrollWidth=clientWidth=1280`。
+- 批量测试页切换为 1012px 全宽工作区,隐藏与该任务无关的单条调试预览和检索追踪,上传区、配置快照、进度表格和操作按钮无需横向滚动。
+- 已真实下载并解析模板,工作表为“批量测试问题、填写说明”,表头为“序号、问题”,冻结首行且包含格式化表格。
+- 已真实上传模板并创建 2 条问题任务;状态从等待执行、生成中、50% 更新到已完成,最终成功 2、失败 0。
+- 已验证任务详情显示每条问题、完整答案、状态与耗时;结果导出包含问题、答案、失败原因、实际模型、召回数量和耗时。
+- 浏览器控制台错误和警告:0;无横向滚动。
+- `npm --prefix apps/admin-web run build`:通过;后端相关测试:31 passed。
+
+final result: passed
+
+---
+
# 反馈管理日期筛选控件修复验收(2026-08-11)
## 对照范围
diff --git a/ai_knowledge_base_v2/development_records/ui-evidence/agent-batch-test-2026-08-12.png b/ai_knowledge_base_v2/development_records/ui-evidence/agent-batch-test-2026-08-12.png
new file mode 100644
index 0000000000000000000000000000000000000000..814c47d06abccc6cbad9192b3a7fdaa8f2fb863c
GIT binary patch
literal 72697
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