feat: 增加 Agent Excel 批量测试

This commit is contained in:
2026-08-12 17:00:50 +08:00
parent c4231f268b
commit 3faecab6ac
26 changed files with 1327 additions and 6 deletions

View File

@@ -32,6 +32,9 @@ TOPIC_SETTLEMENT_WORKER_ENABLED=true
TOPIC_SETTLEMENT_POLL_SECONDS=2 TOPIC_SETTLEMENT_POLL_SECONDS=2
TOPIC_SETTLEMENT_STALE_MINUTES=30 TOPIC_SETTLEMENT_STALE_MINUTES=30
TOPIC_SETTLEMENT_MAX_ATTEMPTS=3 TOPIC_SETTLEMENT_MAX_ATTEMPTS=3
AGENT_BATCH_WORKER_ENABLED=true
AGENT_BATCH_POLL_SECONDS=2
AGENT_BATCH_STALE_MINUTES=30
# ============================================ # ============================================
# 内网穿透frpc sidecar # 内网穿透frpc sidecar

View File

@@ -1 +1,2 @@
.env .env
outputs/

View File

@@ -759,6 +759,7 @@ async function clearFeishuCache() {
<template v-if="activeMenu === 'prompt'"> <template v-if="activeMenu === 'prompt'">
<AgentManagementView <AgentManagementView
:preview-knowledge-id="previewKnowledgeId" :preview-knowledge-id="previewKnowledgeId"
:can-batch="can('prompt.batch')"
@consumed-preview="previewKnowledgeId = null" @consumed-preview="previewKnowledgeId = null"
/> />
</template> </template>

View File

@@ -0,0 +1,225 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { api } from "../services/api";
import type { AgentBatchItem, AgentBatchJob, AgentBatchJobDetail } from "../types/api";
import AdminPagination from "./AdminPagination.vue";
const props = defineProps<{
config: Record<string, unknown>;
modelName: string;
knowledgeSummary: string;
}>();
const fileInput = ref<HTMLInputElement | null>(null);
const selectedFile = ref<File | null>(null);
const taskName = ref("");
const creating = ref(false);
const loading = ref(false);
const exportingId = ref<number | null>(null);
const jobs = ref<AgentBatchJob[]>([]);
const pager = reactive({ page: 1, pageSize: 10, total: 0 });
const detailOpen = ref(false);
const detailLoading = ref(false);
const selectedJob = ref<AgentBatchJobDetail | null>(null);
const detailPager = reactive({ page: 1, pageSize: 20, total: 0 });
let pollTimer: number | null = null;
const hasRunningJob = computed(() => jobs.value.some((job) => ["pending", "running"].includes(job.status)));
onMounted(() => void loadJobs());
onBeforeUnmount(stopPolling);
async function loadJobs(page = pager.page, pageSize = pager.pageSize, quiet = false) {
if (!quiet) loading.value = true;
try {
const result = await api.agentBatchJobs({ page, pageSize });
jobs.value = result.items;
Object.assign(pager, { page: result.page, pageSize: result.pageSize, total: result.total });
schedulePolling();
if (selectedJob.value && detailOpen.value) await loadDetail(selectedJob.value.id, detailPager.page, detailPager.pageSize, true);
} catch (error) {
if (!quiet) ElMessage.error(errorMessage(error, "批量测试任务加载失败"));
} finally {
if (!quiet) loading.value = false;
}
}
function schedulePolling() {
stopPolling();
if (!hasRunningJob.value) return;
pollTimer = window.setTimeout(() => void loadJobs(pager.page, pager.pageSize, true), 2500);
}
function stopPolling() {
if (pollTimer !== null) window.clearTimeout(pollTimer);
pollTimer = null;
}
function selectFile(event: Event) {
const input = event.target as HTMLInputElement;
selectedFile.value = input.files?.[0] || null;
if (selectedFile.value && !taskName.value.trim()) taskName.value = selectedFile.value.name.replace(/\.xlsx$/i, "");
}
function clearFile() {
selectedFile.value = null;
if (fileInput.value) fileInput.value.value = "";
}
async function downloadTemplate() {
try {
await api.downloadAgentBatchTemplate();
} catch (error) {
ElMessage.error(errorMessage(error, "模板下载失败"));
}
}
async function createJob() {
if (!selectedFile.value) return ElMessage.warning("请先选择填写好的 Excel 文件");
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() });
ElMessage.success(`已创建批量测试任务,共 ${created.totalCount} 个问题`);
clearFile();
taskName.value = "";
await loadJobs(1, pager.pageSize);
await openDetail(created);
} catch (error) {
ElMessage.error(errorMessage(error, "批量测试任务创建失败"));
} finally {
creating.value = false;
}
}
async function openDetail(job: AgentBatchJob) {
detailOpen.value = true;
detailPager.page = 1;
await loadDetail(job.id, 1, detailPager.pageSize);
}
async function loadDetail(jobId: number, page = detailPager.page, pageSize = detailPager.pageSize, quiet = false) {
if (!quiet) detailLoading.value = true;
try {
const result = await api.agentBatchJob(jobId, { page, pageSize });
selectedJob.value = result;
Object.assign(detailPager, { page: result.items.page, pageSize: result.items.pageSize, total: result.items.total });
} catch (error) {
if (!quiet) ElMessage.error(errorMessage(error, "任务详情加载失败"));
} finally {
if (!quiet) detailLoading.value = false;
}
}
async function exportJob(job: AgentBatchJob) {
exportingId.value = job.id;
try {
await api.exportAgentBatch(job.id);
ElMessage.success("测试结果已导出");
} catch (error) {
ElMessage.error(errorMessage(error, "结果导出失败"));
} finally {
exportingId.value = null;
}
}
async function cancelJob(job: AgentBatchJob) {
try {
await ElMessageBox.confirm("取消后尚未开始的问题不会继续生成,已完成结果仍可导出。", "取消批量测试", {
type: "warning", confirmButtonText: "确认取消", cancelButtonText: "继续执行",
});
} catch {
return;
}
try {
await api.cancelAgentBatch(job.id);
ElMessage.success("批量测试已取消");
await loadJobs(pager.page, pager.pageSize);
} catch (error) {
ElMessage.error(errorMessage(error, "取消失败"));
}
}
function statusLabel(status: string) {
return ({ pending: "等待执行", running: "生成中", completed: "已完成", completed_with_errors: "部分失败", cancelled: "已取消", success: "成功", failed: "失败" } as Record<string, string>)[status] || status;
}
function statusType(status: string) {
if (["completed", "success"].includes(status)) return "success";
if (["completed_with_errors", "failed"].includes(status)) return "danger";
if (status === "running") return "primary";
if (status === "cancelled") return "info";
return "warning";
}
function formatDate(value?: string | null) {
return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "—";
}
function itemDuration(item: AgentBatchItem) {
return item.durationMs == null ? "—" : `${(item.durationMs / 1000).toFixed(1)}`;
}
function errorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
</script>
<template>
<div class="agent-batch-panel">
<div class="agent-section-intro">
<div><h3>Excel 批量测试</h3><p>使用当前编辑框中的提示词调试模型知识库和生成参数创建配置快照后台逐条生成答案</p></div>
<el-button @click="downloadTemplate">下载导入模板</el-button>
</div>
<section class="agent-batch-create-card">
<div class="agent-batch-config-summary">
<span><small>模型</small><strong>{{ modelName }}</strong></span>
<span><small>知识库</small><strong>{{ knowledgeSummary }}</strong></span>
<span><small>单次上限</small><strong>200 个问题</strong></span>
</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 }">
<input ref="fileInput" type="file" accept=".xlsx" @change="selectFile" />
<span>{{ selectedFile?.name || "选择填写好的 Excel 文件" }}</span>
<small>{{ selectedFile ? `${(selectedFile.size / 1024).toFixed(1)} KB` : "仅支持 .xlsx最大 5MB" }}</small>
</label>
<el-button v-if="selectedFile" @click="clearFile">移除</el-button>
<el-button type="primary" :loading="creating" :disabled="!selectedFile" @click="createJob">开始批量测试</el-button>
</div>
</section>
<div class="agent-batch-list-head"><div><h4>测试记录</h4><p>任务在后台执行离开页面不会中断</p></div><el-button :loading="loading" @click="loadJobs()">刷新</el-button></div>
<el-table v-loading="loading" :data="jobs" stripe>
<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="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>
<AdminPagination :page="pager.page" :page-size="pager.pageSize" :total="pager.total" @change="loadJobs" />
</div>
<el-dialog v-model="detailOpen" width="min(1100px, 94vw)" title="批量测试详情" destroy-on-close>
<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>
<el-tag :type="statusType(selectedJob.status)">{{ statusLabel(selectedJob.status) }}</el-tag>
</div>
<el-table :data="selectedJob.items.items" stripe max-height="520">
<el-table-column prop="externalNo" label="序号" width="76" />
<el-table-column prop="question" label="问题" min-width="230" show-overflow-tooltip />
<el-table-column label="答案" min-width="360"><template #default="{ row }"><div class="agent-batch-answer">{{ row.answer || row.errorMessage || statusLabel(row.status) }}</div></template></el-table-column>
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag size="small" :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
<el-table-column label="耗时" width="92"><template #default="{ row }">{{ itemDuration(row) }}</template></el-table-column>
</el-table>
<AdminPagination :page="detailPager.page" :page-size="detailPager.pageSize" :total="detailPager.total" @change="(page, pageSize) => loadDetail(selectedJob!.id, page, pageSize)" />
</template>
</div>
<template #footer><el-button @click="detailOpen = false">关闭</el-button><el-button v-if="selectedJob" type="primary" :loading="exportingId === selectedJob.id" @click="exportJob(selectedJob)">导出 Excel</el-button></template>
</el-dialog>
</template>

View File

@@ -5,11 +5,12 @@ import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
import { api, streamDebugAgent } from "../services/api"; import { api, streamDebugAgent } from "../services/api";
import type { AdminUser, AgentRuntimeConfig, KnowledgeItem, ModelItem, PromptDetail, PromptHistoryItem, TopicSessionRecord } from "../types/api"; import type { AdminUser, AgentRuntimeConfig, KnowledgeItem, ModelItem, PromptDetail, PromptHistoryItem, TopicSessionRecord } from "../types/api";
import AgentGenerationParameters from "./AgentGenerationParameters.vue"; import AgentGenerationParameters from "./AgentGenerationParameters.vue";
import AgentBatchTestPanel from "./AgentBatchTestPanel.vue";
import AgentResponseDepthControl from "./AgentResponseDepthControl.vue"; import AgentResponseDepthControl from "./AgentResponseDepthControl.vue";
import AdminPagination from "./AdminPagination.vue"; import AdminPagination from "./AdminPagination.vue";
import StreamingMarkdownMessage from "./StreamingMarkdownMessage.vue"; import StreamingMarkdownMessage from "./StreamingMarkdownMessage.vue";
const props = defineProps<{ previewKnowledgeId?: number | null }>(); const props = withDefaults(defineProps<{ previewKnowledgeId?: number | null; canBatch?: boolean }>(), { canBatch: false });
const emit = defineEmits<{ consumedPreview: [] }>(); const emit = defineEmits<{ consumedPreview: [] }>();
const loading = ref(false); const loading = ref(false);
@@ -97,6 +98,19 @@ const selectedDebugTopicLabel = computed(() => {
const topic = debugTopics.value.find((item) => item.id === agentForm.topicSessionId); const topic = debugTopics.value.find((item) => item.id === agentForm.topicSessionId);
return topic ? topic.title : `主题 #${agentForm.topicSessionId}`; return topic ? topic.title : `主题 #${agentForm.topicSessionId}`;
}); });
const batchConfig = computed(() => ({
promptContent: promptContent.value,
modelId: agentForm.modelId,
knowledgeIds: [...agentForm.knowledgeIds],
temperature: agentForm.temperature,
topP: agentForm.topP,
topK: agentForm.topK,
presencePenalty: agentForm.presencePenalty,
frequencyPenalty: agentForm.frequencyPenalty,
maxToken: agentForm.maxToken,
streamEnabled: agentForm.streamEnabled,
responseDepth: agentForm.responseDepth,
}));
onMounted(load); onMounted(load);
@@ -481,7 +495,7 @@ function errorMessage(error: unknown, fallback: string) {
</div> </div>
</div> </div>
<section v-loading="loading" class="agent-workbench agent-workbench-v2"> <section v-loading="loading" class="agent-workbench agent-workbench-v2" :class="{ 'agent-batch-mode': activeConfigTab === 'batch' }">
<div class="agent-config-panel agent-control-panel"> <div class="agent-config-panel agent-control-panel">
<el-tabs v-model="activeConfigTab" class="agent-config-tabs"> <el-tabs v-model="activeConfigTab" class="agent-config-tabs">
<el-tab-pane label="主提示词" name="prompt"> <el-tab-pane label="主提示词" name="prompt">
@@ -651,6 +665,14 @@ function errorMessage(error: unknown, fallback: string) {
</el-form> </el-form>
</el-tab-pane> </el-tab-pane>
<el-tab-pane v-if="props.canBatch" label="批量测试" name="batch" lazy>
<AgentBatchTestPanel
:config="batchConfig"
:model-name="selectedModelName"
:knowledge-summary="selectedKnowledgeSummary"
/>
</el-tab-pane>
<el-tab-pane label="历史版本" name="history"> <el-tab-pane label="历史版本" name="history">
<div class="agent-section-intro"><div><h3>主提示词历史</h3><p>查看完整内容或恢复任意版本恢复操作会作为一个新版本记录</p></div></div> <div class="agent-section-intro"><div><h3>主提示词历史</h3><p>查看完整内容或恢复任意版本恢复操作会作为一个新版本记录</p></div></div>
<div v-loading="historyLoading" class="prompt-history-list"> <div v-loading="historyLoading" class="prompt-history-list">

View File

@@ -8,6 +8,8 @@ import type {
AgentDebugStreamComplete, AgentDebugStreamComplete,
AgentGenerationConfig, AgentGenerationConfig,
AgentRuntimeConfig, AgentRuntimeConfig,
AgentBatchJob,
AgentBatchJobDetail,
AiLogRecord, AiLogRecord,
ApiResponse, ApiResponse,
ChatDetail, ChatDetail,
@@ -259,6 +261,19 @@ export const api = {
agentRuntimeConfig: () => request<AgentRuntimeConfig>("/admin/agent/runtime-config"), agentRuntimeConfig: () => request<AgentRuntimeConfig>("/admin/agent/runtime-config"),
saveAgentRuntimeConfig: (payload: AgentGenerationConfig) => saveAgentRuntimeConfig: (payload: AgentGenerationConfig) =>
request<AgentRuntimeConfig>("/admin/agent/runtime-config", { method: "PUT", body: JSON.stringify(payload) }), request<AgentRuntimeConfig>("/admin/agent/runtime-config", { method: "PUT", body: JSON.stringify(payload) }),
downloadAgentBatchTemplate: () => download("/admin/agent/batch/template", "Agent批量测试导入模板.xlsx"),
createAgentBatch: (file: File, config: Record<string, unknown>) => {
const formData = new FormData();
formData.append("file", file);
formData.append("configJson", JSON.stringify(config));
return upload<AgentBatchJob>("/admin/agent/batch/jobs", formData);
},
agentBatchJobs: (query: { page?: number; pageSize?: number } = {}) =>
request<PageResult<AgentBatchJob>>(`/admin/agent/batch/jobs${queryString(query)}`),
agentBatchJob: (id: number, query: { page?: number; pageSize?: number } = {}) =>
request<AgentBatchJobDetail>(`/admin/agent/batch/jobs/${id}${queryString(query)}`),
exportAgentBatch: (id: number) => download(`/admin/agent/batch/jobs/${id}/export`, `Agent批量测试结果_${id}.xlsx`),
cancelAgentBatch: (id: number) => request<AgentBatchJob>(`/admin/agent/batch/jobs/${id}/cancel`, { method: "POST", body: "{}" }),
models: () => request<ModelItem[]>("/admin/model/list"), models: () => request<ModelItem[]>("/admin/model/list"),
createModel: (payload: Record<string, unknown>) => createModel: (payload: Record<string, unknown>) =>
request<ModelItem>("/admin/model", { method: "POST", body: JSON.stringify(payload) }), request<ModelItem>("/admin/model", { method: "POST", body: JSON.stringify(payload) }),

View File

@@ -975,6 +975,10 @@ textarea {
align-items: start; 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 { .agent-page-head {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -1049,6 +1053,33 @@ textarea {
margin-top: 16px; 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 { .agent-advanced-settings {
margin-top: 16px; margin-top: 16px;
border: 1px solid #e2eae7; border: 1px solid #e2eae7;
@@ -2315,6 +2346,10 @@ textarea {
.agent-debug-context-grid { .agent-debug-context-grid {
grid-template-columns: 1fr; 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) { @media (max-width: 860px) {

View File

@@ -392,6 +392,44 @@ export interface AgentDebugStreamComplete {
questionType?: string; 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<AgentBatchItem>;
}
export interface KnowledgeVersion { export interface KnowledgeVersion {
id: number; id: number;
knowledgeId: number; knowledgeId: number;

View File

@@ -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")

View File

@@ -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

View File

@@ -7,6 +7,7 @@ from app.api import (
admin_auth, admin_auth,
admin_content_generation, admin_content_generation,
admin_agent_records, admin_agent_records,
admin_agent_batch,
admin_dashboard, admin_dashboard,
admin_entitlements, admin_entitlements,
admin_knowledge, admin_knowledge,
@@ -38,6 +39,7 @@ api_router.include_router(admin_management.router, prefix="/admin", tags=["admin
guard = [Depends(enforce_admin_access)] 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_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_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_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_entitlements.router, prefix="/admin", tags=["admin-entitlements"], dependencies=guard)
api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"], dependencies=guard) api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"], dependencies=guard)

View File

@@ -80,6 +80,9 @@ class Settings(BaseSettings):
topic_settlement_poll_seconds: int = 2 topic_settlement_poll_seconds: int = 2
topic_settlement_stale_minutes: int = 30 topic_settlement_stale_minutes: int = 30
topic_settlement_max_attempts: int = 3 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_username: str = ""
bootstrap_admin_password: str = "" bootstrap_admin_password: str = ""
bootstrap_admin_name: str = "系统管理员" bootstrap_admin_name: str = "系统管理员"

View File

@@ -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")) 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"): 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")) 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/"): elif path.startswith("prompt") or path.startswith("agent/"):
permission = "prompt.view" if method == "GET" else "prompt.edit" permission = "prompt.view" if method == "GET" else "prompt.edit"
elif path.startswith("model"): elif path.startswith("model"):

View File

@@ -15,6 +15,7 @@ from app.services.secret_service import SecretService
from app.services.maintenance_service import MaintenanceService from app.services.maintenance_service import MaintenanceService
from app.services.periodic_report_worker import PeriodicReportWorker from app.services.periodic_report_worker import PeriodicReportWorker
from app.services.topic_settlement_worker import TopicSettlementWorker from app.services.topic_settlement_worker import TopicSettlementWorker
from app.services.agent_batch_test_worker import AgentBatchTestWorker
@asynccontextmanager @asynccontextmanager
@@ -26,12 +27,13 @@ async def lifespan(app: FastAPI):
maintenance_task = asyncio.create_task(MaintenanceService.run_forever()) maintenance_task = asyncio.create_task(MaintenanceService.run_forever())
periodic_report_task = asyncio.create_task(PeriodicReportWorker.run_forever()) periodic_report_task = asyncio.create_task(PeriodicReportWorker.run_forever())
topic_settlement_task = asyncio.create_task(TopicSettlementWorker.run_forever()) topic_settlement_task = asyncio.create_task(TopicSettlementWorker.run_forever())
agent_batch_task = asyncio.create_task(AgentBatchTestWorker.run_forever())
try: try:
yield yield
finally: 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() 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: try:
await task await task
except asyncio.CancelledError: except asyncio.CancelledError:

View File

@@ -1,4 +1,5 @@
from app.models.admin import Admin, Role 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.ai_config import ContentGenerationConfig, ModelConfig, Prompt, SystemConfig
from app.models.base import Base from app.models.base import Base
from app.models.chat import ChatMessage, ChatSession, TopicSession from app.models.chat import ChatMessage, ChatSession, TopicSession
@@ -29,6 +30,8 @@ from app.models.user import User
__all__ = [ __all__ = [
"Admin", "Admin",
"AgentBatchTest",
"AgentBatchTestItem",
"AiRequestLog", "AiRequestLog",
"Base", "Base",
"ChatMessage", "ChatMessage",

View File

@@ -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)

View File

@@ -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": "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": "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": "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": "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": "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": "修改配置"}]}, {"code": "configs", "name": "系统配置", "children": [{"code": "configs.view", "name": "查看配置"}, {"code": "configs.edit", "name": "修改配置"}]},

View File

@@ -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

View File

@@ -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)

View File

@@ -283,3 +283,20 @@ class AgentDebugService:
except Exception as exc: except Exception as exc:
db.rollback() db.rollback()
yield {"type": "error", "message": str(exc) or "Agent 调试失败"} 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}

View File

@@ -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) admin = Admin(id=1, username="root", password="unused", name="root", status=1, is_super_admin=1)
assert permissions_for(admin) == ALL_PERMISSION_CODES assert permissions_for(admin) == ALL_PERMISSION_CODES
assert {"feedback.view", "feedback.detail", "feedback.export", "feedback.delete"} <= 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: def test_role_permissions_are_restricted_to_catalog() -> None:

View File

@@ -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

View File

@@ -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 # 反馈管理日期筛选控件修复验收2026-08-11
## 对照范围 ## 对照范围

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

View File

@@ -57,6 +57,9 @@ services:
TOPIC_SETTLEMENT_POLL_SECONDS: "2" TOPIC_SETTLEMENT_POLL_SECONDS: "2"
TOPIC_SETTLEMENT_STALE_MINUTES: "30" TOPIC_SETTLEMENT_STALE_MINUTES: "30"
TOPIC_SETTLEMENT_MAX_ATTEMPTS: "3" 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 JWT_SECRET_KEY: local-dev-secret-change-before-production
MOCK_SMS_ENABLED: "true" MOCK_SMS_ENABLED: "true"
MOCK_SMS_CODE: "123456" MOCK_SMS_CODE: "123456"

View File

@@ -56,6 +56,9 @@ services:
TOPIC_SETTLEMENT_POLL_SECONDS: ${TOPIC_SETTLEMENT_POLL_SECONDS:-2} TOPIC_SETTLEMENT_POLL_SECONDS: ${TOPIC_SETTLEMENT_POLL_SECONDS:-2}
TOPIC_SETTLEMENT_STALE_MINUTES: ${TOPIC_SETTLEMENT_STALE_MINUTES:-30} TOPIC_SETTLEMENT_STALE_MINUTES: ${TOPIC_SETTLEMENT_STALE_MINUTES:-30}
TOPIC_SETTLEMENT_MAX_ATTEMPTS: ${TOPIC_SETTLEMENT_MAX_ATTEMPTS:-3} 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} JWT_SECRET_KEY: ${JWT_SECRET_KEY:?必须配置 JWT_SECRET_KEY}
CONFIG_ENCRYPTION_KEY: ${CONFIG_ENCRYPTION_KEY:?必须配置 CONFIG_ENCRYPTION_KEY} CONFIG_ENCRYPTION_KEY: ${CONFIG_ENCRYPTION_KEY:?必须配置 CONFIG_ENCRYPTION_KEY}
MOCK_SMS_ENABLED: "false" MOCK_SMS_ENABLED: "false"