feat: 增加 Agent Excel 批量测试
This commit is contained in:
@@ -32,6 +32,9 @@ 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
|
||||
|
||||
# ============================================
|
||||
# 内网穿透(frpc sidecar)
|
||||
|
||||
1
ai_knowledge_base_v2/.gitignore
vendored
1
ai_knowledge_base_v2/.gitignore
vendored
@@ -1 +1,2 @@
|
||||
.env
|
||||
outputs/
|
||||
|
||||
@@ -759,6 +759,7 @@ async function clearFeishuCache() {
|
||||
<template v-if="activeMenu === 'prompt'">
|
||||
<AgentManagementView
|
||||
:preview-knowledge-id="previewKnowledgeId"
|
||||
:can-batch="can('prompt.batch')"
|
||||
@consumed-preview="previewKnowledgeId = null"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
@@ -5,11 +5,12 @@ import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
|
||||
import { api, streamDebugAgent } from "../services/api";
|
||||
import type { AdminUser, AgentRuntimeConfig, KnowledgeItem, ModelItem, PromptDetail, PromptHistoryItem, TopicSessionRecord } from "../types/api";
|
||||
import AgentGenerationParameters from "./AgentGenerationParameters.vue";
|
||||
import AgentBatchTestPanel from "./AgentBatchTestPanel.vue";
|
||||
import AgentResponseDepthControl from "./AgentResponseDepthControl.vue";
|
||||
import AdminPagination from "./AdminPagination.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 loading = ref(false);
|
||||
@@ -97,6 +98,19 @@ const selectedDebugTopicLabel = computed(() => {
|
||||
const topic = debugTopics.value.find((item) => item.id === 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);
|
||||
|
||||
@@ -481,7 +495,7 @@ function errorMessage(error: unknown, fallback: string) {
|
||||
</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">
|
||||
<el-tabs v-model="activeConfigTab" class="agent-config-tabs">
|
||||
<el-tab-pane label="主提示词" name="prompt">
|
||||
@@ -651,6 +665,14 @@ function errorMessage(error: unknown, fallback: string) {
|
||||
</el-form>
|
||||
</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">
|
||||
<div class="agent-section-intro"><div><h3>主提示词历史</h3><p>查看完整内容或恢复任意版本。恢复操作会作为一个新版本记录。</p></div></div>
|
||||
<div v-loading="historyLoading" class="prompt-history-list">
|
||||
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
AgentDebugStreamComplete,
|
||||
AgentGenerationConfig,
|
||||
AgentRuntimeConfig,
|
||||
AgentBatchJob,
|
||||
AgentBatchJobDetail,
|
||||
AiLogRecord,
|
||||
ApiResponse,
|
||||
ChatDetail,
|
||||
@@ -259,6 +261,19 @@ 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) }),
|
||||
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"),
|
||||
createModel: (payload: Record<string, unknown>) =>
|
||||
request<ModelItem>("/admin/model", { method: "POST", body: JSON.stringify(payload) }),
|
||||
|
||||
@@ -975,6 +975,10 @@ textarea {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.agent-workbench.agent-batch-mode { grid-template-columns: minmax(0, 1fr); }
|
||||
.agent-workbench.agent-batch-mode > .agent-preview-panel,
|
||||
.agent-workbench.agent-batch-mode > .agent-trace-panel { display: none; }
|
||||
|
||||
.agent-page-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -1049,6 +1053,33 @@ textarea {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.agent-batch-panel { display: grid; gap: 16px; }
|
||||
.agent-batch-create-card { display: grid; gap: 16px; padding: 16px; border: 1px solid #dfe8e5; border-radius: 10px; background: #f8fbfa; }
|
||||
.agent-batch-config-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
|
||||
.agent-batch-config-summary span { min-width: 0; padding: 11px 12px; border: 1px solid #e1e9e6; border-radius: 8px; background: #fff; }
|
||||
.agent-batch-config-summary small, .agent-batch-config-summary strong { display: block; }
|
||||
.agent-batch-config-summary small { margin-bottom: 4px; color: #7a8c85; font-size: 11px; }
|
||||
.agent-batch-config-summary strong { overflow: hidden; color: #2b4038; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.agent-batch-upload-row { display: flex; align-items: flex-end; gap: 10px; flex-wrap: wrap; }
|
||||
.agent-batch-name { display: grid; gap: 6px; width: 220px; color: #536860; font-size: 12px; }
|
||||
.agent-batch-name input { height: 40px; padding: 0 12px; border: 1px solid #d8e2df; border-radius: 6px; outline: none; background: #fff; color: #263832; }
|
||||
.agent-batch-name input:focus { border-color: #2f9479; box-shadow: 0 0 0 2px rgba(47, 148, 121, .12); }
|
||||
.agent-batch-file { position: relative; display: grid; min-width: 260px; flex: 1; gap: 3px; min-height: 40px; padding: 7px 12px; border: 1px dashed #b9cac4; border-radius: 7px; background: #fff; cursor: pointer; }
|
||||
.agent-batch-file.selected { border-style: solid; border-color: #62aa95; background: #f3faf7; }
|
||||
.agent-batch-file input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
|
||||
.agent-batch-file span { overflow: hidden; color: #315148; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.agent-batch-file small { color: #82918c; font-size: 11px; }
|
||||
.agent-batch-list-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.agent-batch-list-head h4, .agent-batch-list-head p { margin: 0; }
|
||||
.agent-batch-list-head h4 { margin-bottom: 4px; color: #263832; font-size: 15px; }
|
||||
.agent-batch-list-head p { color: #71827c; font-size: 12px; }
|
||||
.agent-batch-cell-note { display: block; margin-top: 4px; overflow: hidden; color: #7b8c86; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.agent-batch-detail { min-height: 260px; }
|
||||
.agent-batch-detail-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; margin-bottom: 14px; padding: 13px 14px; border-radius: 8px; background: #f5f8f7; }
|
||||
.agent-batch-detail-head div { display: grid; gap: 4px; min-width: 0; }
|
||||
.agent-batch-detail-head span { overflow: hidden; color: #71827c; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.agent-batch-answer { max-height: 104px; overflow: auto; white-space: pre-wrap; word-break: break-word; line-height: 1.6; }
|
||||
|
||||
.agent-advanced-settings {
|
||||
margin-top: 16px;
|
||||
border: 1px solid #e2eae7;
|
||||
@@ -2315,6 +2346,10 @@ textarea {
|
||||
.agent-debug-context-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.agent-batch-config-summary { grid-template-columns: 1fr; }
|
||||
.agent-batch-name, .agent-batch-file { width: 100%; min-width: 0; }
|
||||
.agent-batch-upload-row > .el-button { flex: 1; }
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
|
||||
@@ -392,6 +392,44 @@ export interface AgentDebugStreamComplete {
|
||||
questionType?: string;
|
||||
}
|
||||
|
||||
export type AgentBatchStatus = "pending" | "running" | "completed" | "completed_with_errors" | "cancelled";
|
||||
|
||||
export interface AgentBatchJob {
|
||||
id: number;
|
||||
name: string;
|
||||
originalFilename: string;
|
||||
status: AgentBatchStatus;
|
||||
totalCount: number;
|
||||
processedCount: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
progress: number;
|
||||
modelId: number;
|
||||
modelName: string;
|
||||
knowledgeIds: number[];
|
||||
knowledgeNames: string[];
|
||||
createdAt: string;
|
||||
startedAt?: string | null;
|
||||
finishedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface AgentBatchItem {
|
||||
id: number;
|
||||
rowNumber: number;
|
||||
externalNo?: string | null;
|
||||
question: string;
|
||||
answer?: string | null;
|
||||
status: "pending" | "running" | "success" | "failed" | "cancelled";
|
||||
errorMessage?: string | null;
|
||||
modelName?: string | null;
|
||||
retrieveCount?: number | null;
|
||||
durationMs?: number | null;
|
||||
}
|
||||
|
||||
export interface AgentBatchJobDetail extends AgentBatchJob {
|
||||
items: PageResult<AgentBatchItem>;
|
||||
}
|
||||
|
||||
export interface KnowledgeVersion {
|
||||
id: number;
|
||||
knowledgeId: number;
|
||||
|
||||
@@ -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")
|
||||
157
ai_knowledge_base_v2/apps/backend/app/api/admin_agent_batch.py
Normal file
157
ai_knowledge_base_v2/apps/backend/app/api/admin_agent_batch.py
Normal 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
|
||||
@@ -7,6 +7,7 @@ from app.api import (
|
||||
admin_auth,
|
||||
admin_content_generation,
|
||||
admin_agent_records,
|
||||
admin_agent_batch,
|
||||
admin_dashboard,
|
||||
admin_entitlements,
|
||||
admin_knowledge,
|
||||
@@ -38,6 +39,7 @@ api_router.include_router(admin_management.router, prefix="/admin", tags=["admin
|
||||
guard = [Depends(enforce_admin_access)]
|
||||
api_router.include_router(admin_content_generation.router, prefix="/admin", tags=["admin-content-generation"], dependencies=guard)
|
||||
api_router.include_router(admin_agent_records.router, prefix="/admin", tags=["admin-agent-records"], dependencies=guard)
|
||||
api_router.include_router(admin_agent_batch.router, prefix="/admin", tags=["admin-agent-batch"], dependencies=guard)
|
||||
api_router.include_router(admin_dashboard.router, prefix="/admin", tags=["admin"], dependencies=guard)
|
||||
api_router.include_router(admin_entitlements.router, prefix="/admin", tags=["admin-entitlements"], dependencies=guard)
|
||||
api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"], dependencies=guard)
|
||||
|
||||
@@ -80,6 +80,9 @@ class Settings(BaseSettings):
|
||||
topic_settlement_poll_seconds: int = 2
|
||||
topic_settlement_stale_minutes: int = 30
|
||||
topic_settlement_max_attempts: int = 3
|
||||
agent_batch_worker_enabled: bool = True
|
||||
agent_batch_poll_seconds: int = 2
|
||||
agent_batch_stale_minutes: int = 30
|
||||
bootstrap_admin_username: str = ""
|
||||
bootstrap_admin_password: str = ""
|
||||
bootstrap_admin_name: str = "系统管理员"
|
||||
|
||||
@@ -100,6 +100,8 @@ def enforce_admin_access(
|
||||
permission = "users.delete" if method == "DELETE" else ("users.view" if method == "GET" else ("users.create" if method == "POST" and (path in {"user", "user/import", "user/import/excel"}) else "users.edit"))
|
||||
elif path.startswith("knowledge"):
|
||||
permission = "knowledge.delete" if method == "DELETE" else ("knowledge.view" if method == "GET" else ("knowledge.publish" if path.endswith("open-status") or path.endswith("lifecycle") else "knowledge.edit"))
|
||||
elif path.startswith("agent/batch"):
|
||||
permission = "prompt.batch"
|
||||
elif path.startswith("prompt") or path.startswith("agent/"):
|
||||
permission = "prompt.view" if method == "GET" else "prompt.edit"
|
||||
elif path.startswith("model"):
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.services.secret_service import SecretService
|
||||
from app.services.maintenance_service import MaintenanceService
|
||||
from app.services.periodic_report_worker import PeriodicReportWorker
|
||||
from app.services.topic_settlement_worker import TopicSettlementWorker
|
||||
from app.services.agent_batch_test_worker import AgentBatchTestWorker
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -26,12 +27,13 @@ async def lifespan(app: FastAPI):
|
||||
maintenance_task = asyncio.create_task(MaintenanceService.run_forever())
|
||||
periodic_report_task = asyncio.create_task(PeriodicReportWorker.run_forever())
|
||||
topic_settlement_task = asyncio.create_task(TopicSettlementWorker.run_forever())
|
||||
agent_batch_task = asyncio.create_task(AgentBatchTestWorker.run_forever())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for task in (maintenance_task, periodic_report_task, topic_settlement_task):
|
||||
for task in (maintenance_task, periodic_report_task, topic_settlement_task, agent_batch_task):
|
||||
task.cancel()
|
||||
for task in (maintenance_task, periodic_report_task, topic_settlement_task):
|
||||
for task in (maintenance_task, periodic_report_task, topic_settlement_task, agent_batch_task):
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from app.models.admin import Admin, Role
|
||||
from app.models.agent_batch import AgentBatchTest, AgentBatchTestItem
|
||||
from app.models.ai_config import ContentGenerationConfig, ModelConfig, Prompt, SystemConfig
|
||||
from app.models.base import Base
|
||||
from app.models.chat import ChatMessage, ChatSession, TopicSession
|
||||
@@ -29,6 +30,8 @@ from app.models.user import User
|
||||
|
||||
__all__ = [
|
||||
"Admin",
|
||||
"AgentBatchTest",
|
||||
"AgentBatchTestItem",
|
||||
"AiRequestLog",
|
||||
"Base",
|
||||
"ChatMessage",
|
||||
|
||||
71
ai_knowledge_base_v2/apps/backend/app/models/agent_batch.py
Normal file
71
ai_knowledge_base_v2/apps/backend/app/models/agent_batch.py
Normal 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)
|
||||
@@ -12,7 +12,7 @@ PERMISSION_TREE = [
|
||||
{"code": "users", "name": "用户管理", "children": [{"code": "users.view", "name": "查看用户"}, {"code": "users.create", "name": "新增/导入"}, {"code": "users.edit", "name": "编辑/权益续期"}, {"code": "users.delete", "name": "删除用户"}]},
|
||||
{"code": "entitlements", "name": "权益管理", "children": [{"code": "entitlements.view", "name": "查看权益"}, {"code": "entitlements.edit", "name": "编辑权益"}]},
|
||||
{"code": "knowledge", "name": "知识库管理", "children": [{"code": "knowledge.view", "name": "查看知识库"}, {"code": "knowledge.edit", "name": "新增/编辑/同步"}, {"code": "knowledge.publish", "name": "开放/归档"}, {"code": "knowledge.delete", "name": "删除知识库"}]},
|
||||
{"code": "prompt", "name": "Agent 管理", "children": [{"code": "prompt.view", "name": "查看 Agent"}, {"code": "prompt.edit", "name": "编辑/测试 Agent"}]},
|
||||
{"code": "prompt", "name": "Agent 管理", "children": [{"code": "prompt.view", "name": "查看 Agent"}, {"code": "prompt.edit", "name": "编辑/单条测试 Agent"}, {"code": "prompt.batch", "name": "批量测试/导出"}]},
|
||||
{"code": "models", "name": "模型管理", "children": [{"code": "models.view", "name": "查看模型"}, {"code": "models.edit", "name": "新增/编辑/测试"}, {"code": "models.delete", "name": "删除模型"}]},
|
||||
{"code": "content-generation", "name": "内容生成", "children": [{"code": "content-generation.view", "name": "查看配置"}, {"code": "content-generation.edit", "name": "编辑/测试配置"}]},
|
||||
{"code": "configs", "name": "系统配置", "children": [{"code": "configs.view", "name": "查看配置"}, {"code": "configs.edit", "name": "修改配置"}]},
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -283,3 +283,20 @@ class AgentDebugService:
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
yield {"type": "error", "message": str(exc) or "Agent 调试失败"}
|
||||
|
||||
@classmethod
|
||||
async def run_complete(cls, payload: AgentDebugRequest, db: Session, current_admin: Admin) -> dict:
|
||||
"""Consume the same routed debug stream used by the UI and return one complete answer."""
|
||||
answer_parts: list[str] = []
|
||||
completion: dict = {}
|
||||
async for event in cls.stream(payload, db, current_admin):
|
||||
if event.get("type") == "content":
|
||||
answer_parts.append(str(event.get("content") or ""))
|
||||
elif event.get("type") == "complete":
|
||||
completion = event
|
||||
elif event.get("type") == "error":
|
||||
raise RuntimeError(str(event.get("message") or "Agent 生成失败"))
|
||||
answer = "".join(answer_parts).strip()
|
||||
if not answer:
|
||||
raise RuntimeError("模型未返回正式回答")
|
||||
return {"answer": answer, **completion}
|
||||
|
||||
@@ -13,6 +13,7 @@ def test_super_admin_has_all_permissions() -> None:
|
||||
admin = Admin(id=1, username="root", password="unused", name="root", status=1, is_super_admin=1)
|
||||
assert permissions_for(admin) == ALL_PERMISSION_CODES
|
||||
assert {"feedback.view", "feedback.detail", "feedback.export", "feedback.delete"} <= ALL_PERMISSION_CODES
|
||||
assert "prompt.batch" in ALL_PERMISSION_CODES
|
||||
|
||||
|
||||
def test_role_permissions_are_restricted_to_catalog() -> None:
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
## 对照范围
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
@@ -57,6 +57,9 @@ services:
|
||||
TOPIC_SETTLEMENT_POLL_SECONDS: "2"
|
||||
TOPIC_SETTLEMENT_STALE_MINUTES: "30"
|
||||
TOPIC_SETTLEMENT_MAX_ATTEMPTS: "3"
|
||||
AGENT_BATCH_WORKER_ENABLED: "true"
|
||||
AGENT_BATCH_POLL_SECONDS: "2"
|
||||
AGENT_BATCH_STALE_MINUTES: "30"
|
||||
JWT_SECRET_KEY: local-dev-secret-change-before-production
|
||||
MOCK_SMS_ENABLED: "true"
|
||||
MOCK_SMS_CODE: "123456"
|
||||
|
||||
@@ -56,6 +56,9 @@ services:
|
||||
TOPIC_SETTLEMENT_POLL_SECONDS: ${TOPIC_SETTLEMENT_POLL_SECONDS:-2}
|
||||
TOPIC_SETTLEMENT_STALE_MINUTES: ${TOPIC_SETTLEMENT_STALE_MINUTES:-30}
|
||||
TOPIC_SETTLEMENT_MAX_ATTEMPTS: ${TOPIC_SETTLEMENT_MAX_ATTEMPTS:-3}
|
||||
AGENT_BATCH_WORKER_ENABLED: ${AGENT_BATCH_WORKER_ENABLED:-true}
|
||||
AGENT_BATCH_POLL_SECONDS: ${AGENT_BATCH_POLL_SECONDS:-2}
|
||||
AGENT_BATCH_STALE_MINUTES: ${AGENT_BATCH_STALE_MINUTES:-30}
|
||||
JWT_SECRET_KEY: ${JWT_SECRET_KEY:?必须配置 JWT_SECRET_KEY}
|
||||
CONFIG_ENCRYPTION_KEY: ${CONFIG_ENCRYPTION_KEY:?必须配置 CONFIG_ENCRYPTION_KEY}
|
||||
MOCK_SMS_ENABLED: "false"
|
||||
|
||||
Reference in New Issue
Block a user