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 0000000..814c47d
Binary files /dev/null and b/ai_knowledge_base_v2/development_records/ui-evidence/agent-batch-test-2026-08-12.png differ
diff --git a/ai_knowledge_base_v2/docker-compose.dev.yml b/ai_knowledge_base_v2/docker-compose.dev.yml
index c92fb27..ad70877 100644
--- a/ai_knowledge_base_v2/docker-compose.dev.yml
+++ b/ai_knowledge_base_v2/docker-compose.dev.yml
@@ -57,6 +57,9 @@ services:
TOPIC_SETTLEMENT_POLL_SECONDS: "2"
TOPIC_SETTLEMENT_STALE_MINUTES: "30"
TOPIC_SETTLEMENT_MAX_ATTEMPTS: "3"
+ AGENT_BATCH_WORKER_ENABLED: "true"
+ AGENT_BATCH_POLL_SECONDS: "2"
+ AGENT_BATCH_STALE_MINUTES: "30"
JWT_SECRET_KEY: local-dev-secret-change-before-production
MOCK_SMS_ENABLED: "true"
MOCK_SMS_CODE: "123456"
diff --git a/ai_knowledge_base_v2/docker-compose.prod.yml b/ai_knowledge_base_v2/docker-compose.prod.yml
index 4a6abec..8c31217 100644
--- a/ai_knowledge_base_v2/docker-compose.prod.yml
+++ b/ai_knowledge_base_v2/docker-compose.prod.yml
@@ -56,6 +56,9 @@ services:
TOPIC_SETTLEMENT_POLL_SECONDS: ${TOPIC_SETTLEMENT_POLL_SECONDS:-2}
TOPIC_SETTLEMENT_STALE_MINUTES: ${TOPIC_SETTLEMENT_STALE_MINUTES:-30}
TOPIC_SETTLEMENT_MAX_ATTEMPTS: ${TOPIC_SETTLEMENT_MAX_ATTEMPTS:-3}
+ AGENT_BATCH_WORKER_ENABLED: ${AGENT_BATCH_WORKER_ENABLED:-true}
+ AGENT_BATCH_POLL_SECONDS: ${AGENT_BATCH_POLL_SECONDS:-2}
+ AGENT_BATCH_STALE_MINUTES: ${AGENT_BATCH_STALE_MINUTES:-30}
JWT_SECRET_KEY: ${JWT_SECRET_KEY:?必须配置 JWT_SECRET_KEY}
CONFIG_ENCRYPTION_KEY: ${CONFIG_ENCRYPTION_KEY:?必须配置 CONFIG_ENCRYPTION_KEY}
MOCK_SMS_ENABLED: "false"