feat: 支持 Agent 并发批测与洞察导出

This commit is contained in:
2026-08-13 17:25:22 +08:00
parent 3faecab6ac
commit f952d6dc58
22 changed files with 666 additions and 44 deletions

View File

@@ -35,6 +35,8 @@ TOPIC_SETTLEMENT_MAX_ATTEMPTS=3
AGENT_BATCH_WORKER_ENABLED=true
AGENT_BATCH_POLL_SECONDS=2
AGENT_BATCH_STALE_MINUTES=30
# 单个后端进程同时执行的批量测试问题上限;任务自身还受页面选择的 2-10 路限制。
AGENT_BATCH_WORKER_CONCURRENCY=10
# ============================================
# 内网穿透frpc sidecar

View File

@@ -15,6 +15,8 @@ const props = defineProps<{
const fileInput = ref<HTMLInputElement | null>(null);
const selectedFile = ref<File | null>(null);
const taskName = ref("");
const executionMode = ref<"sequential" | "concurrent">("sequential");
const concurrencyLimit = ref(5);
const creating = ref(false);
const loading = ref(false);
const exportingId = ref<number | null>(null);
@@ -81,7 +83,12 @@ async function createJob() {
if (!String(props.config.modelId || "")) return ElMessage.warning("请先在调试配置中选择模型");
creating.value = true;
try {
const created = await api.createAgentBatch(selectedFile.value, { ...props.config, name: taskName.value.trim() });
const created = await api.createAgentBatch(selectedFile.value, {
...props.config,
name: taskName.value.trim(),
executionMode: executionMode.value,
concurrencyLimit: executionMode.value === "sequential" ? 1 : concurrencyLimit.value,
});
ElMessage.success(`已创建批量测试任务,共 ${created.totalCount} 个问题`);
clearFile();
taskName.value = "";
@@ -154,6 +161,10 @@ function statusType(status: string) {
return "warning";
}
function executionLabel(job: AgentBatchJob) {
return job.executionMode === "concurrent" ? `并发 · ${job.concurrencyLimit}` : "逐条串行";
}
function formatDate(value?: string | null) {
return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "—";
}
@@ -170,7 +181,7 @@ function errorMessage(error: unknown, fallback: string) {
<template>
<div class="agent-batch-panel">
<div class="agent-section-intro">
<div><h3>Excel 批量测试</h3><p>使用当前编辑框中的提示词调试模型知识库和生成参数创建配置快照后台逐条生成答案</p></div>
<div><h3>Excel 批量测试</h3><p>普通管理员仅看到自己的任务超级管理员可统一审计创建时会固化当前提示词模型知识库和生成参数</p></div>
<el-button @click="downloadTemplate">下载导入模板</el-button>
</div>
@@ -180,6 +191,14 @@ function errorMessage(error: unknown, fallback: string) {
<span><small>知识库</small><strong>{{ knowledgeSummary }}</strong></span>
<span><small>单次上限</small><strong>200 个问题</strong></span>
</div>
<div class="agent-batch-mode-setting">
<div class="agent-batch-mode-copy"><strong>执行方式</strong><span>串行适合日常回归并发适合压测吞吐与稳定性并发数会被系统安全上限约束</span></div>
<el-radio-group v-model="executionMode" class="agent-batch-mode-options">
<el-radio-button value="sequential">逐条串行</el-radio-button>
<el-radio-button value="concurrent">并发测试</el-radio-button>
</el-radio-group>
<label v-if="executionMode === 'concurrent'" class="agent-batch-concurrency"><span>并发数</span><el-input-number v-model="concurrencyLimit" :min="2" :max="10" controls-position="right" /></label>
</div>
<div class="agent-batch-upload-row">
<label class="agent-batch-name"><span>任务名称</span><input v-model="taskName" maxlength="160" placeholder="例如8月主提示词回归测试" /></label>
<label class="agent-batch-file" :class="{ selected: selectedFile }">
@@ -197,6 +216,7 @@ function errorMessage(error: unknown, fallback: string) {
<el-table-column label="任务" min-width="190"><template #default="{ row }"><strong>{{ row.name }}</strong><small class="agent-batch-cell-note">#{{ row.id }} · {{ formatDate(row.createdAt) }}</small></template></el-table-column>
<el-table-column label="配置快照" min-width="180"><template #default="{ row }"><span>{{ row.modelName }}</span><small class="agent-batch-cell-note">{{ row.knowledgeNames.join('、') }}</small></template></el-table-column>
<el-table-column label="进度" min-width="190"><template #default="{ row }"><el-progress :percentage="row.progress" :status="row.status === 'completed' ? 'success' : undefined" /><small class="agent-batch-cell-note">{{ row.processedCount }}/{{ row.totalCount }} · 成功 {{ row.successCount }} · 失败 {{ row.failedCount }}</small></template></el-table-column>
<el-table-column label="执行方式" width="118"><template #default="{ row }"><el-tag type="info">{{ executionLabel(row) }}</el-tag></template></el-table-column>
<el-table-column label="状态" width="108"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row)">查看</el-button><el-button link type="primary" :loading="exportingId === row.id" @click="exportJob(row)">导出</el-button><el-button v-if="['pending','running'].includes(row.status)" link type="danger" @click="cancelJob(row)">取消</el-button></template></el-table-column>
</el-table>
@@ -207,7 +227,7 @@ function errorMessage(error: unknown, fallback: string) {
<div v-loading="detailLoading" class="agent-batch-detail">
<template v-if="selectedJob">
<div class="agent-batch-detail-head">
<div><strong>{{ selectedJob.name }}</strong><span>{{ selectedJob.modelName }} · {{ selectedJob.knowledgeNames.join('、') }}</span></div>
<div><strong>{{ selectedJob.name }}</strong><span>{{ selectedJob.modelName }} · {{ selectedJob.knowledgeNames.join('、') }} · {{ executionLabel(selectedJob) }}</span></div>
<el-tag :type="statusType(selectedJob.status)">{{ statusLabel(selectedJob.status) }}</el-tag>
</div>
<el-table :data="selectedJob.items.items" stripe max-height="520">

View File

@@ -122,7 +122,7 @@ async function load() {
loading.value = true;
try {
const [promptResult, modelRows, knowledgeRows, formalConfig] = await Promise.all([
api.prompt(), api.models(), api.knowledgeOptions(), api.agentRuntimeConfig(),
api.prompt(), api.agentModelOptions(), api.agentKnowledgeOptions(), api.agentRuntimeConfig(),
]);
applyPrompt(promptResult);
models.value = modelRows;

View File

@@ -14,6 +14,7 @@ import ChatDetailDrawer from "./ChatDetailDrawer.vue";
type RecordTab = "chats" | "questionInsights" | "aiLogs" | "operationLogs";
const loading = ref(false);
const insightExporting = ref(false);
const activeTab = ref<RecordTab>("chats");
const chats = ref<ChatRecord[]>([]);
const ssoClients = ref<SsoClientItem[]>([]);
@@ -175,6 +176,32 @@ async function resetInsightFilters() {
});
await refreshInsights();
}
async function exportInsights() {
const dateFrom = formatDateTime(insightFilters.dateFrom, "start");
const dateTo = formatDateTime(insightFilters.dateTo, "end");
if (dateFrom && dateTo && new Date(dateFrom.replace(" ", "T")) > new Date(dateTo.replace(" ", "T"))) {
return ElMessage.warning("开始时间不能晚于结束时间");
}
insightExporting.value = true;
try {
const fromLabel = insightFilters.dateFrom.slice(0, 10).replaceAll("-", "") || "全部";
const toLabel = insightFilters.dateTo.slice(0, 10).replaceAll("-", "") || "全部";
await api.exportQuestionInsights(
{
dateFrom,
dateTo,
minCount: insightFilters.minCount,
maxMessages: insightFilters.maxMessages,
},
`问题洞察_${fromLabel}_${toLabel}.xlsx`,
);
ElMessage.success("问题洞察已导出");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "问题洞察导出失败");
} finally {
insightExporting.value = false;
}
}
function openChat(sessionId: number) {
detailSessionId.value = sessionId;
chatDetailOpen.value = true;
@@ -355,6 +382,7 @@ function formatMoney(value?: number | null, currency = "CNY") {
@click="refreshInsights"
>刷新并统计</el-button
><el-button @click="resetInsightFilters">重置</el-button>
<el-button :loading="insightExporting" @click="exportInsights">导出洞察</el-button>
</div>
</div>
<p class="question-insight-help">

View File

@@ -20,6 +20,8 @@ import {
ElOption,
ElPagination,
ElProgress,
ElRadioButton,
ElRadioGroup,
ElSelect,
ElSlider,
ElSwitch,
@@ -54,6 +56,8 @@ import "element-plus/theme-chalk/el-option.css";
import "element-plus/theme-chalk/el-overlay.css";
import "element-plus/theme-chalk/el-pagination.css";
import "element-plus/theme-chalk/el-progress.css";
import "element-plus/theme-chalk/el-radio-button.css";
import "element-plus/theme-chalk/el-radio-group.css";
import "element-plus/theme-chalk/el-popper.css";
import "element-plus/theme-chalk/el-scrollbar.css";
import "element-plus/theme-chalk/el-select.css";
@@ -95,6 +99,8 @@ app.config.globalProperties.$ELEMENT = { locale: zhCn };
ElOption,
ElPagination,
ElProgress,
ElRadioButton,
ElRadioGroup,
ElSelect,
ElSlider,
ElSwitch,
@@ -108,6 +114,9 @@ app.config.globalProperties.$ELEMENT = { locale: zhCn };
].forEach((component) => {
app.use(component);
});
// Element Plus 2.11 exposes radio group/button without an installer; register them explicitly.
app.component("ElRadioButton", ElRadioButton);
app.component("ElRadioGroup", ElRadioGroup);
app.directive("loading", vLoading);
app.mount("#app");

View File

@@ -261,6 +261,8 @@ export const api = {
agentRuntimeConfig: () => request<AgentRuntimeConfig>("/admin/agent/runtime-config"),
saveAgentRuntimeConfig: (payload: AgentGenerationConfig) =>
request<AgentRuntimeConfig>("/admin/agent/runtime-config", { method: "PUT", body: JSON.stringify(payload) }),
agentKnowledgeOptions: () => request<KnowledgeItem[]>("/admin/agent/knowledge-options"),
agentModelOptions: () => request<ModelItem[]>("/admin/agent/model-options"),
downloadAgentBatchTemplate: () => download("/admin/agent/batch/template", "Agent批量测试导入模板.xlsx"),
createAgentBatch: (file: File, config: Record<string, unknown>) => {
const formData = new FormData();
@@ -334,6 +336,8 @@ export const api = {
request<QuestionInsightRefreshResult>(`/admin/question-insights/refresh${queryString(query)}`, {
method: "POST",
}),
exportQuestionInsights: (query: { dateFrom?: string; dateTo?: string; minCount?: number; maxMessages?: number } = {}, filename = "问题洞察.xlsx") =>
download(`/admin/question-insights/export${queryString(query)}`, filename),
retrievalLogs: (query: { page?: number; pageSize?: number } = {}) => request<PageResult<RetrievalLogItem>>(`/admin/retrieval-log/list${queryString(query)}`),
estimateRetrievalCleanup: (before: string) => request<{ before: string; estimatedCount: number }>("/admin/retrieval-log/cleanup/estimate", { method: "POST", body: JSON.stringify({ before }) }),
cleanupRetrievalLogs: (before: string) => request<{ before: string; deleted: number }>("/admin/retrieval-log/cleanup", { method: "POST", body: JSON.stringify({ before }) }),

View File

@@ -1061,6 +1061,14 @@ textarea {
.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-mode-setting { display: flex; align-items: center; gap: 14px; padding: 12px; border: 1px solid #dfe8e5; border-radius: 9px; background: #fff; }
.agent-batch-mode-copy { display: grid; min-width: 240px; flex: 1; gap: 3px; }
.agent-batch-mode-copy strong { color: #2b4038; font-size: 13px; }
.agent-batch-mode-copy span { color: #71827c; font-size: 11px; line-height: 1.5; }
.agent-batch-mode-options { flex: none; }
.agent-batch-concurrency { display: flex; align-items: center; gap: 8px; color: #536860; font-size: 12px; }
.agent-batch-concurrency > span { white-space: nowrap; }
.agent-batch-concurrency .el-input-number { width: 112px; }
.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); }
@@ -2349,6 +2357,11 @@ textarea {
.agent-batch-config-summary { grid-template-columns: 1fr; }
.agent-batch-name, .agent-batch-file { width: 100%; min-width: 0; }
.agent-batch-mode-setting { align-items: stretch; flex-direction: column; }
.agent-batch-mode-copy { min-width: 0; }
.agent-batch-mode-options { display: flex; }
.agent-batch-mode-options .el-radio-button { flex: 1; }
.agent-batch-mode-options .el-radio-button__inner { width: 100%; }
.agent-batch-upload-row > .el-button { flex: 1; }
}

View File

@@ -404,6 +404,8 @@ export interface AgentBatchJob {
successCount: number;
failedCount: number;
progress: number;
executionMode: "sequential" | "concurrent";
concurrencyLimit: number;
modelId: number;
modelName: string;
knowledgeIds: number[];

View File

@@ -53,6 +53,10 @@ TOPIC_SETTLEMENT_WORKER_ENABLED=true
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
AGENT_BATCH_WORKER_CONCURRENCY=10
# 本地开发可以使用开发密码;生产环境必须改成高强度密码,且 APP_ENV=production 时不能使用 admin123456
BOOTSTRAP_ADMIN_USERNAME=admin

View File

@@ -0,0 +1,30 @@
"""add Agent batch execution mode and concurrency limit
Revision ID: 0034_agent_batch_concurrency
Revises: 0033_agent_batch_tests
"""
from alembic import op
import sqlalchemy as sa
revision = "0034_agent_batch_concurrency"
down_revision = "0033_agent_batch_tests"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"sys_agent_batch_test",
sa.Column("execution_mode", sa.String(20), nullable=False, server_default="sequential"),
)
op.add_column(
"sys_agent_batch_test",
sa.Column("concurrency_limit", sa.Integer(), nullable=False, server_default="1"),
)
def downgrade() -> None:
op.drop_column("sys_agent_batch_test", "concurrency_limit")
op.drop_column("sys_agent_batch_test", "execution_mode")

View File

@@ -16,6 +16,8 @@ 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.models.ai_config import ModelConfig
from app.models.knowledge import Knowledge
from app.services.admin_service import OperationLogService
from app.services.agent_batch_test_service import AgentBatchTestService, item_dict, job_dict
@@ -23,6 +25,58 @@ from app.services.agent_batch_test_service import AgentBatchTestService, item_di
router = APIRouter()
@router.get("/agent/model-options")
def agent_model_options(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
"""Safe Agent-only model choices without provider credentials or endpoints."""
items = list(db.scalars(select(ModelConfig).order_by(ModelConfig.is_default.desc(), ModelConfig.id.desc())))
return api_success([
{
"id": item.id,
"displayName": item.display_name,
"modelName": item.model_name,
"temperature": float(item.temperature) if item.temperature is not None else None,
"topP": float(item.top_p) if item.top_p is not None else None,
"topK": item.top_k,
"presencePenalty": float(item.presence_penalty) if item.presence_penalty is not None else None,
"frequencyPenalty": float(item.frequency_penalty) if item.frequency_penalty is not None else None,
"maxToken": item.max_token,
"streamEnabled": item.stream_enabled,
"enabled": item.enabled,
"isDefault": item.is_default,
}
for item in items
])
@router.get("/agent/knowledge-options")
def agent_knowledge_options(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
"""Safe Agent-only knowledge choices; does not grant Knowledge Management access."""
items = list(
db.scalars(
select(Knowledge)
.where(Knowledge.lifecycle_status == "active")
.order_by(Knowledge.id.desc())
)
)
return api_success([
{
"id": item.id,
"name": item.name,
"status": item.status,
"knowledgeType": item.knowledge_type,
"lifecycleStatus": item.lifecycle_status,
"sourceStatus": item.source_status,
}
for item in items
])
@router.get("/agent/batch/template")
def download_batch_template(
current_admin: Admin = Depends(get_current_admin),
@@ -67,10 +121,12 @@ def list_batch_jobs(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
total = db.scalar(select(func.count(AgentBatchTest.id))) or 0
owner_filter = _owner_filter(current_admin)
total = db.scalar(select(func.count(AgentBatchTest.id)).where(*owner_filter)) or 0
rows = list(
db.scalars(
select(AgentBatchTest)
.where(*owner_filter)
.order_by(AgentBatchTest.created_at.desc(), AgentBatchTest.id.desc())
.offset((page - 1) * pageSize)
.limit(pageSize)
@@ -87,7 +143,7 @@ def get_batch_job(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
job = _job_or_404(db, job_id)
job = _job_or_404(db, job_id, current_admin)
total = db.scalar(select(func.count(AgentBatchTestItem.id)).where(AgentBatchTestItem.batch_id == job.id)) or 0
items = list(
db.scalars(
@@ -107,7 +163,7 @@ def export_batch_job(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> StreamingResponse:
job = _job_or_404(db, job_id)
job = _job_or_404(db, job_id, current_admin)
items = list(
db.scalars(
select(AgentBatchTestItem)
@@ -131,7 +187,7 @@ def cancel_batch_job(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
job = _job_or_404(db, job_id)
job = _job_or_404(db, job_id, current_admin)
if job.status not in {"pending", "running"}:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="当前任务已结束,不能取消")
now = datetime.now(UTC).replace(tzinfo=None)
@@ -150,8 +206,12 @@ def cancel_batch_job(
return api_success(job_dict(job))
def _job_or_404(db: Session, job_id: int) -> AgentBatchTest:
def _owner_filter(admin: Admin) -> tuple:
return () if admin.is_super_admin else (AgentBatchTest.created_by == admin.id,)
def _job_or_404(db: Session, job_id: int, admin: Admin) -> AgentBatchTest:
job = db.get(AgentBatchTest, job_id)
if job is None:
if job is None or (not admin.is_super_admin and job.created_by != admin.id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="批量测试任务不存在")
return job

View File

@@ -7,7 +7,7 @@ from io import StringIO
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import Response
from fastapi.responses import Response, StreamingResponse
from sqlalchemy import exists, func, or_, select
from sqlalchemy.orm import Session, load_only
@@ -23,6 +23,7 @@ from app.models.user import User
from app.api.pagination import page_result
from app.services.admin_service import OperationLogService
from app.services.question_insight_service import QuestionInsightService
from app.services.question_insight_export_service import QuestionInsightExportService
from app.services.growth_profile_service import topic_dict, topic_summary_dict
from app.services.help_card_service import help_card_dict
from app.services.share_draft_service import share_draft_dict
@@ -324,6 +325,45 @@ def refresh_question_insights(
return api_success(result)
@router.get("/question-insights/export")
def export_question_insights(
dateFrom: datetime | None = Query(default=None),
dateTo: datetime | None = Query(default=None),
minCount: int = Query(default=2, ge=1, le=50),
maxMessages: int = Query(default=5000, ge=100, le=20000),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> StreamingResponse:
if dateFrom is not None and dateTo is not None and dateFrom > dateTo:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="开始时间不能晚于结束时间")
result = QuestionInsightService.summarize(
db,
date_from=dateFrom,
date_to=dateTo,
min_count=minCount,
max_messages=maxMessages,
page=1,
page_size=maxMessages,
)
OperationLogService.write(
db,
admin_id=current_admin.id,
module="question_insight",
action=f"export:{result['total']}",
)
db.commit()
filename = f"question_insights_{_export_date_label(dateFrom)}_{_export_date_label(dateTo)}.xlsx"
return StreamingResponse(
QuestionInsightExportService.build(result),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
def _export_date_label(value: datetime | None) -> str:
return value.strftime("%Y%m%d") if value is not None else "all"
def _chat_query(
*,
keyword: str,

View File

@@ -83,6 +83,7 @@ class Settings(BaseSettings):
agent_batch_worker_enabled: bool = True
agent_batch_poll_seconds: int = 2
agent_batch_stale_minutes: int = 30
agent_batch_worker_concurrency: int = 10
bootstrap_admin_username: str = ""
bootstrap_admin_password: str = ""
bootstrap_admin_name: str = "系统管理员"

View File

@@ -26,6 +26,8 @@ class AgentBatchTest(Base):
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)
execution_mode: Mapped[str] = mapped_column(String(20), default="sequential", nullable=False)
concurrency_limit: Mapped[int] = mapped_column(Integer, default=1, 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)

View File

@@ -19,6 +19,7 @@ from app.models.knowledge import Knowledge
MAX_BATCH_QUESTIONS = 200
MAX_QUESTION_LENGTH = 2000
MAX_JOB_CONCURRENCY = 10
TEMPLATE_HEADERS = ["序号", "问题"]
@@ -105,10 +106,25 @@ class AgentBatchTestService:
"reasoningVisible": 0,
"responseDepth": int(config.get("responseDepth", 35)),
}
execution_mode = str(config.get("executionMode") or "sequential")
if execution_mode not in {"sequential", "concurrent"}:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="批量测试执行模式不正确")
try:
requested_concurrency = int(config.get("concurrencyLimit") or 5)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="并发数格式不正确") from exc
concurrency_limit = 1 if execution_mode == "sequential" else requested_concurrency
if execution_mode == "concurrent" and not 2 <= concurrency_limit <= MAX_JOB_CONCURRENCY:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"单个任务并发数必须在 2 到 {MAX_JOB_CONCURRENCY} 之间",
)
job = AgentBatchTest(
name=str(config.get("name") or filename.rsplit(".", 1)[0] or "Agent 批量测试")[:160],
original_filename=filename[:255],
total_count=len(rows),
execution_mode=execution_mode,
concurrency_limit=concurrency_limit,
prompt_content=prompt_content,
model_id=model.id,
model_name=model.display_name or model.model_name,
@@ -183,13 +199,14 @@ class AgentBatchTestService:
summary.append(["任务名称", job.name])
summary.append(["任务状态", job.status])
summary.append(["问题总数", job.total_count])
summary.append(["执行方式", "逐条串行" if job.execution_mode == "sequential" else f"并发测试({job.concurrency_limit} 路)"])
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")
_style_sheet(summary, widths=(20, 86), table_ref="A1:B11", table_name="AgentBatchSummary")
summary.column_dimensions["B"].width = 86
stream = BytesIO()
workbook.save(stream)
@@ -205,6 +222,8 @@ def job_dict(job: AgentBatchTest) -> dict:
"status": job.status,
"totalCount": job.total_count,
"processedCount": job.processed_count,
"executionMode": job.execution_mode,
"concurrencyLimit": job.concurrency_limit,
"successCount": job.success_count,
"failedCount": job.failed_count,
"progress": round(job.processed_count * 100 / job.total_count) if job.total_count else 0,

View File

@@ -33,13 +33,43 @@ class AgentBatchTestWorker:
logger.info("agent batch test worker disabled")
return
worker_id = f"{socket.gethostname()}:{os.getpid()}:{uuid.uuid4().hex[:8]}"
active: set[asyncio.Task] = set()
worker_concurrency = max(1, min(settings.agent_batch_worker_concurrency, 50))
next_recovery_at = 0.0
while True:
try:
processed = await cls.run_once(worker_id)
if time.monotonic() >= next_recovery_at:
with SessionLocal() as db:
recovered_batch_ids = cls.recover_stale_items(db)
db.commit()
for batch_id in recovered_batch_ids:
cls.refresh_job(db, batch_id)
next_recovery_at = time.monotonic() + 60
active = {task for task in active if not task.done()}
claimed = False
while len(active) < worker_concurrency:
with SessionLocal() as db:
item_id = cls.claim_next(db, worker_id=worker_id)
if item_id is None:
break
claimed = True
task = asyncio.create_task(cls.execute_claimed(item_id=item_id, worker_id=worker_id))
task.add_done_callback(cls._log_task_failure)
active.add(task)
if active:
await asyncio.wait(
active,
timeout=max(1, settings.agent_batch_poll_seconds),
return_when=asyncio.FIRST_COMPLETED,
)
processed = True
else:
processed = claimed
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))
if not processed:
await asyncio.sleep(max(1, settings.agent_batch_poll_seconds))
@classmethod
async def run_once(cls, worker_id: str) -> bool:
@@ -59,35 +89,64 @@ class AgentBatchTestWorker:
@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),
jobs = list(
db.scalars(
select(AgentBatchTest)
.where(AgentBatchTest.status.in_(("pending", "running")))
.order_by(AgentBatchTest.created_at.asc(), AgentBatchTest.id.asc())
.limit(50)
.with_for_update(skip_locked=True)
)
.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
for job in jobs:
running_count = db.scalar(
select(func.count(AgentBatchTestItem.id)).where(
AgentBatchTestItem.batch_id == job.id,
AgentBatchTestItem.status == "running",
)
) or 0
job_limit = 1 if job.execution_mode == "sequential" else max(1, job.concurrency_limit)
if running_count >= job_limit:
continue
item = db.scalar(
select(AgentBatchTestItem)
.where(
AgentBatchTestItem.batch_id == job.id,
AgentBatchTestItem.status == "pending",
AgentBatchTestItem.attempt_count < AgentBatchTestItem.max_attempts,
or_(AgentBatchTestItem.next_run_at.is_(None), AgentBatchTestItem.next_run_at <= current),
)
.order_by(AgentBatchTestItem.row_number.asc(), AgentBatchTestItem.id.asc())
.with_for_update(skip_locked=True)
.limit(1)
)
if item is None:
continue
item.status = "running"
item.attempt_count += 1
item.locked_at = current
item.locked_by = worker_id
item.started_at = current
if job.status == "pending":
job.status = "running"
job.started_at = current
db.add(job)
db.add(item)
db.commit()
return item.id
db.rollback()
return None
@staticmethod
def _log_task_failure(task: asyncio.Task) -> None:
if task.cancelled():
return
error = task.exception()
if error is not None:
logger.error(
"agent batch test execution task failed",
exc_info=(type(error), error, error.__traceback__),
)
@classmethod
async def execute_claimed(cls, *, item_id: int, worker_id: str) -> None:

View File

@@ -0,0 +1,167 @@
from __future__ import annotations
from datetime import datetime
from io import BytesIO
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.worksheet.table import Table, TableStyleInfo
class QuestionInsightExportService:
"""Render a complete question-insight snapshot as an operator-friendly workbook."""
@staticmethod
def build(result: dict) -> BytesIO:
workbook = Workbook()
insight_sheet = workbook.active
insight_sheet.title = "洞察结果"
_append_insights(insight_sheet, result.get("items") or [])
sample_sheet = workbook.create_sheet("相似问法与样例")
_append_samples(sample_sheet, result.get("items") or [])
summary_sheet = workbook.create_sheet("统计说明")
_append_summary(summary_sheet, result)
stream = BytesIO()
workbook.save(stream)
stream.seek(0)
return stream
def _append_insights(sheet, items: list[dict]) -> None:
headers = [
"排名", "问题组", "分类", "出现次数", "用户数", "会话数", "关联AI请求",
"无知识命中", "失败请求", "需补知识/查召回", "建议动作", "高频词",
"首次出现", "最后出现",
]
sheet.append(headers)
for item in items:
sheet.append([
item.get("rank"),
_excel_safe(item.get("title")),
_excel_safe(item.get("categoryLabel")),
item.get("count", 0),
item.get("userCount", 0),
item.get("sessionCount", 0),
item.get("aiRequestCount", 0),
item.get("noHitCount", 0),
item.get("failedCount", 0),
"" if item.get("needsKnowledgeFollowUp") else "",
_excel_safe(item.get("suggestedAction")),
_excel_safe("".join(item.get("topTerms") or [])),
_excel_datetime(item.get("firstSeenAt")),
_excel_datetime(item.get("lastSeenAt")),
])
_style_table(
sheet,
widths=(8, 44, 14, 12, 10, 10, 14, 12, 12, 18, 54, 36, 20, 20),
table_name="QuestionInsightResults",
)
for row in sheet.iter_rows(min_row=2):
for index in (1, 10, 11):
row[index].alignment = Alignment(vertical="top", wrap_text=True)
for index in (12, 13):
row[index].number_format = "yyyy-mm-dd hh:mm:ss"
def _append_samples(sheet, items: list[dict]) -> None:
headers = [
"问题组排名", "问题组", "记录类型", "相似问法/清洗后问题", "出现次数",
"用户", "手机号", "用户ID", "会话ID", "消息ID", "原始问题", "提问时间",
]
sheet.append(headers)
for item in items:
for variant in item.get("variants") or []:
sheet.append([
item.get("rank"), _excel_safe(item.get("title")), "相似问法",
_excel_safe(variant.get("text")), variant.get("count", 0),
"", "", "", "", "", "", "",
])
for sample in item.get("samples") or []:
sheet.append([
item.get("rank"), _excel_safe(item.get("title")), "原始样例",
_excel_safe(sample.get("cleaned")), "",
_excel_safe(sample.get("userName")), _excel_safe(sample.get("userPhone")),
sample.get("userId"), sample.get("sessionId"), sample.get("messageId"),
_excel_safe(sample.get("raw")), _excel_datetime(sample.get("createdAt")),
])
_style_table(
sheet,
widths=(12, 38, 12, 46, 12, 16, 18, 12, 12, 12, 72, 20),
table_name="QuestionInsightSamples",
)
for row in sheet.iter_rows(min_row=2):
for index in (1, 3, 10):
row[index].alignment = Alignment(vertical="top", wrap_text=True)
row[11].number_format = "yyyy-mm-dd hh:mm:ss"
def _append_summary(sheet, result: dict) -> None:
range_info = result.get("range") or {}
summary = result.get("summary") or {}
sheet.append(["项目", "内容"])
rows = [
("开始时间", range_info.get("dateFrom") or "未限制"),
("结束时间", range_info.get("dateTo") or "未限制"),
("单次清洗上限", range_info.get("maxMessages", 0)),
("最低频次", summary.get("minCount", 0)),
("纳入清洗消息", summary.get("scannedMessages", 0)),
("有效问题", summary.get("cleanedQuestions", 0)),
("过滤低价值", summary.get("filteredMessages", 0)),
("全部问题组", summary.get("clusterCount", 0)),
("导出问题组", summary.get("visibleClusterCount", 0)),
("清洗规则版本", summary.get("cleanerVersion", "")),
("导出时间", datetime.now()),
("说明", "导出结果按所选日期范围和最低频次生成,包含全部符合条件的问题组,不受页面分页影响。"),
]
for label, value in rows:
sheet.append([label, _excel_safe(value) if isinstance(value, str) else _excel_datetime(value)])
_style_table(sheet, widths=(22, 88), table_name="QuestionInsightSummary")
for row in sheet.iter_rows(min_row=2):
row[1].alignment = Alignment(vertical="top", wrap_text=True)
if isinstance(row[1].value, datetime):
row[1].number_format = "yyyy-mm-dd hh:mm:ss"
def _style_table(sheet, *, widths: tuple[int, ...], 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(horizontal="center", vertical="center", wrap_text=True)
sheet.row_dimensions[1].height = 30
for index, width in enumerate(widths, start=1):
sheet.column_dimensions[_column_letter(index)].width = width
if sheet.max_row > 1:
ref = f"A1:{_column_letter(len(widths))}{sheet.max_row}"
table = Table(displayName=table_name, ref=ref)
table.tableStyleInfo = TableStyleInfo(
name="TableStyleMedium4",
showRowStripes=True,
showFirstColumn=False,
showLastColumn=False,
)
sheet.add_table(table)
def _column_letter(index: int) -> str:
result = ""
while index:
index, remainder = divmod(index - 1, 26)
result = chr(65 + remainder) + result
return result
def _excel_safe(value: object | None) -> str:
text = str(value or "")
return f"'{text}" if text.lstrip().startswith(("=", "+", "-", "@")) else text
def _excel_datetime(value: object) -> object:
if isinstance(value, datetime) and value.tzinfo is not None:
return value.replace(tzinfo=None)
return value

View File

@@ -3,9 +3,10 @@ from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from types import SimpleNamespace
from openpyxl import load_workbook
from app.api.admin_agent_records import attention_list, retrieval_logs
from app.api.admin_records import ai_logs, chat_detail, chat_messages, question_insights, refresh_question_insights
from app.api.admin_records import ai_logs, chat_detail, chat_messages, export_question_insights, question_insights, refresh_question_insights
from app.api.admin_users import list_users, user_operation_detail, user_topic_options
from app.models import Base
from app.models.chat import ChatMessage, ChatSession, TopicSession
@@ -14,6 +15,9 @@ from app.models.insight import QuestionInsightCleanedQuestion
from app.models.knowledge import HumanAttentionRecord, KnowledgeRetrievalLog
from app.models.logs import AiRequestLog
from app.models.user import User
from app.services.question_insight_export_service import QuestionInsightExportService
from fastapi import HTTPException
import pytest
def _database() -> Session:
@@ -211,3 +215,24 @@ def test_question_insights_clean_and_cluster_similar_user_questions():
assert data["items"][0]["category"] == "homework"
assert data["items"][0]["noHitCount"] == 1
assert data["items"][0]["needsKnowledgeFollowUp"] is True
workbook = load_workbook(QuestionInsightExportService.build(data), data_only=True)
assert workbook.sheetnames == ["洞察结果", "相似问法与样例", "统计说明"]
assert workbook["洞察结果"]["B2"].value == data["items"][0]["title"]
assert workbook["洞察结果"]["D2"].value == 2
assert workbook["洞察结果"]["J2"].value == ""
assert workbook["相似问法与样例"].max_row > 2
assert workbook["统计说明"]["B10"].value == 1
def test_question_insight_export_rejects_reversed_date_range():
with _database() as db, pytest.raises(HTTPException) as exc:
export_question_insights(
dateFrom=datetime(2026, 8, 2),
dateTo=datetime(2026, 8, 1),
minCount=2,
maxMessages=100,
db=db,
current_admin=SimpleNamespace(id=1),
)
assert exc.value.status_code == 400

View File

@@ -4,7 +4,9 @@ import json
import pytest
from fastapi import HTTPException
from starlette.requests import Request
from app.core.dependencies import enforce_admin_access
from app.models.admin import Admin, Role
from app.services.admin_permission_service import ALL_PERMISSION_CODES, permissions_for, require_permission
@@ -24,3 +26,24 @@ def test_role_permissions_are_restricted_to_catalog() -> None:
with pytest.raises(HTTPException) as exc:
require_permission(admin, "users.delete")
assert exc.value.status_code == 403
def test_agent_options_only_require_agent_view_permission() -> None:
role = Role(code="agent_tester", name="Agent 测试员", permissions=json.dumps(["prompt.view", "prompt.batch"]))
admin = Admin(
id=3,
username="agent-tester",
password="hash",
name="Agent 测试员",
status=1,
must_change_password=0,
is_super_admin=0,
role=role,
)
request = Request({"type": "http", "method": "GET", "path": "/api/admin/agent/knowledge-options", "headers": []})
assert enforce_admin_access(request, admin) is admin
knowledge_request = Request({"type": "http", "method": "GET", "path": "/api/admin/knowledge/options", "headers": []})
with pytest.raises(HTTPException) as exc:
enforce_admin_access(knowledge_request, admin)
assert exc.value.status_code == 403

View File

@@ -4,15 +4,17 @@ import json
import pytest
from fastapi import HTTPException, UploadFile
from openpyxl import Workbook, load_workbook
from sqlalchemy import create_engine, select
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.api.admin_agent_batch import _job_or_404, list_batch_jobs
from app.models import Base
from app.models.admin import Admin
from app.models.agent_batch import AgentBatchTestItem
from app.models.agent_batch import AgentBatchTest, AgentBatchTestItem
from app.models.ai_config import ModelConfig
from app.services.agent_batch_test_service import AgentBatchTestService, MAX_BATCH_QUESTIONS
from app.services.agent_batch_test_worker import AgentBatchTestWorker
def _database() -> Session:
@@ -98,6 +100,8 @@ def test_create_job_snapshots_config_and_export_keeps_failed_rows() -> None:
assert json.loads(job.generation_config)["maxToken"] == 8192
assert json.loads(job.knowledge_names) == ["全部正式开放知识库"]
assert job.execution_mode == "sequential"
assert job.concurrency_limit == 1
exported = AgentBatchTestService.export_workbook(job, items)
workbook = load_workbook(exported, data_only=True)
@@ -123,3 +127,111 @@ def test_import_rejects_excel_formula_question() -> None:
assert exc.value.status_code == 400
assert "不能使用 Excel 公式" in exc.value.detail
def test_sequential_job_only_claims_one_running_item() -> None:
with _database() as db:
db.add_all([_admin(), _model()])
db.commit()
job = _create_test_job(db, execution_mode="sequential", concurrency_limit=1)
db.commit()
first_id = AgentBatchTestWorker.claim_next(db, worker_id="worker-a")
assert first_id is not None
assert AgentBatchTestWorker.claim_next(db, worker_id="worker-b") is None
first = db.get(AgentBatchTestItem, first_id)
first.status = "success"
db.commit()
second_id = AgentBatchTestWorker.claim_next(db, worker_id="worker-b")
assert second_id is not None
assert db.get(AgentBatchTestItem, second_id).batch_id == job.id
def test_concurrent_job_respects_per_job_limit() -> None:
with _database() as db:
db.add_all([_admin(), _model()])
db.commit()
_create_test_job(db, execution_mode="concurrent", concurrency_limit=2, question_count=3)
db.commit()
first_id = AgentBatchTestWorker.claim_next(db, worker_id="worker-a")
second_id = AgentBatchTestWorker.claim_next(db, worker_id="worker-b")
assert first_id is not None
assert second_id is not None
assert first_id != second_id
assert AgentBatchTestWorker.claim_next(db, worker_id="worker-c") is None
running = db.scalar(
select(func.count(AgentBatchTestItem.id))
.where(AgentBatchTestItem.status == "running")
)
assert running == 2
def test_jobs_keep_each_admins_knowledge_snapshot_separate() -> None:
with _database() as db:
first_admin = _admin()
second_admin = Admin(id=2, username="reviewer", password="hash", name="评审管理员", status=1)
db.add_all([first_admin, second_admin, _model()])
db.commit()
first = _create_test_job(db, admin=first_admin, knowledge_ids=[])
second = _create_test_job(db, admin=second_admin, knowledge_ids=[])
db.commit()
assert first.created_by == 1
assert second.created_by == 2
assert first.id != second.id
assert first.knowledge_ids == "[]"
assert second.knowledge_ids == "[]"
def test_normal_admin_only_lists_and_opens_own_jobs() -> None:
with _database() as db:
first_admin = _admin()
second_admin = Admin(id=2, username="reviewer", password="hash", name="评审管理员", status=1)
super_admin = Admin(id=3, username="root", password="hash", name="超级管理员", status=1, is_super_admin=1)
db.add_all([first_admin, second_admin, super_admin, _model()])
db.commit()
own_job = _create_test_job(db, admin=first_admin)
other_job = _create_test_job(db, admin=second_admin)
db.commit()
own_result = list_batch_jobs(page=1, pageSize=10, db=db, current_admin=first_admin)["data"]
assert own_result["total"] == 1
assert [item["id"] for item in own_result["items"]] == [own_job.id]
assert _job_or_404(db, own_job.id, first_admin).id == own_job.id
with pytest.raises(HTTPException) as exc:
_job_or_404(db, other_job.id, first_admin)
assert exc.value.status_code == 404
super_result = list_batch_jobs(page=1, pageSize=10, db=db, current_admin=super_admin)["data"]
assert super_result["total"] == 2
assert _job_or_404(db, other_job.id, super_admin).id == other_job.id
def _create_test_job(
db: Session,
*,
admin: Admin | None = None,
execution_mode: str = "sequential",
concurrency_limit: int = 1,
question_count: int = 2,
knowledge_ids: list[int] | None = None,
) -> AgentBatchTest:
return AgentBatchTestService.create_job(
db,
admin=admin or db.get(Admin, 1),
filename="并发测试.xlsx",
rows=[
{"rowNumber": index + 2, "externalNo": str(index + 1), "question": f"问题 {index + 1}"}
for index in range(question_count)
],
config={
"name": "并发稳定性测试",
"promptContent": "根据知识库回答",
"modelId": 1,
"knowledgeIds": knowledge_ids or [],
"executionMode": execution_mode,
"concurrencyLimit": concurrency_limit,
},
)

View File

@@ -60,6 +60,7 @@ services:
AGENT_BATCH_WORKER_ENABLED: "true"
AGENT_BATCH_POLL_SECONDS: "2"
AGENT_BATCH_STALE_MINUTES: "30"
AGENT_BATCH_WORKER_CONCURRENCY: "10"
JWT_SECRET_KEY: local-dev-secret-change-before-production
MOCK_SMS_ENABLED: "true"
MOCK_SMS_CODE: "123456"

View File

@@ -59,6 +59,7 @@ services:
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}
AGENT_BATCH_WORKER_CONCURRENCY: ${AGENT_BATCH_WORKER_CONCURRENCY:-10}
JWT_SECRET_KEY: ${JWT_SECRET_KEY:?必须配置 JWT_SECRET_KEY}
CONFIG_ENCRYPTION_KEY: ${CONFIG_ENCRYPTION_KEY:?必须配置 CONFIG_ENCRYPTION_KEY}
MOCK_SMS_ENABLED: "false"