feat: 支持 Agent 并发批测与洞察导出
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 }) }),
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -404,6 +404,8 @@ export interface AgentBatchJob {
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
progress: number;
|
||||
executionMode: "sequential" | "concurrent";
|
||||
concurrencyLimit: number;
|
||||
modelId: number;
|
||||
modelName: string;
|
||||
knowledgeIds: number[];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = "系统管理员"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user