From 3faecab6ac30915165e334854139491dd87f7568 Mon Sep 17 00:00:00 2001 From: Nelson <1475262689@qq.com> Date: Wed, 12 Aug 2026 17:00:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20Agent=20Excel=20?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ai_knowledge_base_v2/.env.example | 3 + ai_knowledge_base_v2/.gitignore | 3 +- .../apps/admin-web/src/App.vue | 1 + .../src/components/AgentBatchTestPanel.vue | 225 ++++++++++++++ .../src/components/AgentManagementView.vue | 26 +- .../apps/admin-web/src/services/api.ts | 15 + .../apps/admin-web/src/styles.css | 35 +++ .../apps/admin-web/src/types/api.ts | 38 +++ .../versions/0033_agent_batch_tests.py | 74 +++++ .../apps/backend/app/api/admin_agent_batch.py | 157 ++++++++++ .../apps/backend/app/api/router.py | 2 + .../apps/backend/app/core/config.py | 3 + .../apps/backend/app/core/dependencies.py | 2 + ai_knowledge_base_v2/apps/backend/app/main.py | 6 +- .../apps/backend/app/models/__init__.py | 3 + .../apps/backend/app/models/agent_batch.py | 71 +++++ .../app/services/admin_permission_service.py | 2 +- .../app/services/agent_batch_test_service.py | 274 ++++++++++++++++++ .../app/services/agent_batch_test_worker.py | 227 +++++++++++++++ .../app/services/agent_debug_service.py | 17 ++ .../backend/tests/test_admin_permissions.py | 1 + .../backend/tests/test_agent_batch_tests.py | 125 ++++++++ ai_knowledge_base_v2/design-qa.md | 17 ++ .../agent-batch-test-2026-08-12.png | Bin 0 -> 72697 bytes ai_knowledge_base_v2/docker-compose.dev.yml | 3 + ai_knowledge_base_v2/docker-compose.prod.yml | 3 + 26 files changed, 1327 insertions(+), 6 deletions(-) create mode 100644 ai_knowledge_base_v2/apps/admin-web/src/components/AgentBatchTestPanel.vue create mode 100644 ai_knowledge_base_v2/apps/backend/alembic/versions/0033_agent_batch_tests.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/api/admin_agent_batch.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/models/agent_batch.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/services/agent_batch_test_service.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/services/agent_batch_test_worker.py create mode 100644 ai_knowledge_base_v2/apps/backend/tests/test_agent_batch_tests.py create mode 100644 ai_knowledge_base_v2/development_records/ui-evidence/agent-batch-test-2026-08-12.png diff --git a/ai_knowledge_base_v2/.env.example b/ai_knowledge_base_v2/.env.example index 1f5e989..d204700 100644 --- a/ai_knowledge_base_v2/.env.example +++ b/ai_knowledge_base_v2/.env.example @@ -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) diff --git a/ai_knowledge_base_v2/.gitignore b/ai_knowledge_base_v2/.gitignore index 2eea525..c2509c4 100644 --- a/ai_knowledge_base_v2/.gitignore +++ b/ai_knowledge_base_v2/.gitignore @@ -1 +1,2 @@ -.env \ No newline at end of file +.env +outputs/ diff --git a/ai_knowledge_base_v2/apps/admin-web/src/App.vue b/ai_knowledge_base_v2/apps/admin-web/src/App.vue index 2b78a7c..a676323 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/App.vue +++ b/ai_knowledge_base_v2/apps/admin-web/src/App.vue @@ -759,6 +759,7 @@ async function clearFeishuCache() { diff --git a/ai_knowledge_base_v2/apps/admin-web/src/components/AgentBatchTestPanel.vue b/ai_knowledge_base_v2/apps/admin-web/src/components/AgentBatchTestPanel.vue new file mode 100644 index 0000000..e153f15 --- /dev/null +++ b/ai_knowledge_base_v2/apps/admin-web/src/components/AgentBatchTestPanel.vue @@ -0,0 +1,225 @@ + + + diff --git a/ai_knowledge_base_v2/apps/admin-web/src/components/AgentManagementView.vue b/ai_knowledge_base_v2/apps/admin-web/src/components/AgentManagementView.vue index 71c5157..81de989 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/components/AgentManagementView.vue +++ b/ai_knowledge_base_v2/apps/admin-web/src/components/AgentManagementView.vue @@ -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) { -
+
@@ -651,6 +665,14 @@ function errorMessage(error: unknown, fallback: string) { + + + +

主提示词历史

查看完整内容或恢复任意版本。恢复操作会作为一个新版本记录。

diff --git a/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts b/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts index 60878d4..7f208cf 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts +++ b/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts @@ -8,6 +8,8 @@ import type { AgentDebugStreamComplete, AgentGenerationConfig, AgentRuntimeConfig, + AgentBatchJob, + AgentBatchJobDetail, AiLogRecord, ApiResponse, ChatDetail, @@ -259,6 +261,19 @@ export const api = { agentRuntimeConfig: () => request("/admin/agent/runtime-config"), saveAgentRuntimeConfig: (payload: AgentGenerationConfig) => request("/admin/agent/runtime-config", { method: "PUT", body: JSON.stringify(payload) }), + downloadAgentBatchTemplate: () => download("/admin/agent/batch/template", "Agent批量测试导入模板.xlsx"), + createAgentBatch: (file: File, config: Record) => { + const formData = new FormData(); + formData.append("file", file); + formData.append("configJson", JSON.stringify(config)); + return upload("/admin/agent/batch/jobs", formData); + }, + agentBatchJobs: (query: { page?: number; pageSize?: number } = {}) => + request>(`/admin/agent/batch/jobs${queryString(query)}`), + agentBatchJob: (id: number, query: { page?: number; pageSize?: number } = {}) => + request(`/admin/agent/batch/jobs/${id}${queryString(query)}`), + exportAgentBatch: (id: number) => download(`/admin/agent/batch/jobs/${id}/export`, `Agent批量测试结果_${id}.xlsx`), + cancelAgentBatch: (id: number) => request(`/admin/agent/batch/jobs/${id}/cancel`, { method: "POST", body: "{}" }), models: () => request("/admin/model/list"), createModel: (payload: Record) => request("/admin/model", { method: "POST", body: JSON.stringify(payload) }), diff --git a/ai_knowledge_base_v2/apps/admin-web/src/styles.css b/ai_knowledge_base_v2/apps/admin-web/src/styles.css index b397616..6fbb711 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/styles.css +++ b/ai_knowledge_base_v2/apps/admin-web/src/styles.css @@ -975,6 +975,10 @@ textarea { align-items: start; } +.agent-workbench.agent-batch-mode { grid-template-columns: minmax(0, 1fr); } +.agent-workbench.agent-batch-mode > .agent-preview-panel, +.agent-workbench.agent-batch-mode > .agent-trace-panel { display: none; } + .agent-page-head { display: flex; align-items: flex-start; @@ -1049,6 +1053,33 @@ textarea { margin-top: 16px; } +.agent-batch-panel { display: grid; gap: 16px; } +.agent-batch-create-card { display: grid; gap: 16px; padding: 16px; border: 1px solid #dfe8e5; border-radius: 10px; background: #f8fbfa; } +.agent-batch-config-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; } +.agent-batch-config-summary span { min-width: 0; padding: 11px 12px; border: 1px solid #e1e9e6; border-radius: 8px; background: #fff; } +.agent-batch-config-summary small, .agent-batch-config-summary strong { display: block; } +.agent-batch-config-summary small { margin-bottom: 4px; color: #7a8c85; font-size: 11px; } +.agent-batch-config-summary strong { overflow: hidden; color: #2b4038; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; } +.agent-batch-upload-row { display: flex; align-items: flex-end; gap: 10px; flex-wrap: wrap; } +.agent-batch-name { display: grid; gap: 6px; width: 220px; color: #536860; font-size: 12px; } +.agent-batch-name input { height: 40px; padding: 0 12px; border: 1px solid #d8e2df; border-radius: 6px; outline: none; background: #fff; color: #263832; } +.agent-batch-name input:focus { border-color: #2f9479; box-shadow: 0 0 0 2px rgba(47, 148, 121, .12); } +.agent-batch-file { position: relative; display: grid; min-width: 260px; flex: 1; gap: 3px; min-height: 40px; padding: 7px 12px; border: 1px dashed #b9cac4; border-radius: 7px; background: #fff; cursor: pointer; } +.agent-batch-file.selected { border-style: solid; border-color: #62aa95; background: #f3faf7; } +.agent-batch-file input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; } +.agent-batch-file span { overflow: hidden; color: #315148; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } +.agent-batch-file small { color: #82918c; font-size: 11px; } +.agent-batch-list-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.agent-batch-list-head h4, .agent-batch-list-head p { margin: 0; } +.agent-batch-list-head h4 { margin-bottom: 4px; color: #263832; font-size: 15px; } +.agent-batch-list-head p { color: #71827c; font-size: 12px; } +.agent-batch-cell-note { display: block; margin-top: 4px; overflow: hidden; color: #7b8c86; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } +.agent-batch-detail { min-height: 260px; } +.agent-batch-detail-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; margin-bottom: 14px; padding: 13px 14px; border-radius: 8px; background: #f5f8f7; } +.agent-batch-detail-head div { display: grid; gap: 4px; min-width: 0; } +.agent-batch-detail-head span { overflow: hidden; color: #71827c; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } +.agent-batch-answer { max-height: 104px; overflow: auto; white-space: pre-wrap; word-break: break-word; line-height: 1.6; } + .agent-advanced-settings { margin-top: 16px; border: 1px solid #e2eae7; @@ -2315,6 +2346,10 @@ textarea { .agent-debug-context-grid { grid-template-columns: 1fr; } + + .agent-batch-config-summary { grid-template-columns: 1fr; } + .agent-batch-name, .agent-batch-file { width: 100%; min-width: 0; } + .agent-batch-upload-row > .el-button { flex: 1; } } @media (max-width: 860px) { diff --git a/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts b/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts index 07e1a8e..2719119 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts +++ b/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts @@ -392,6 +392,44 @@ export interface AgentDebugStreamComplete { questionType?: string; } +export type AgentBatchStatus = "pending" | "running" | "completed" | "completed_with_errors" | "cancelled"; + +export interface AgentBatchJob { + id: number; + name: string; + originalFilename: string; + status: AgentBatchStatus; + totalCount: number; + processedCount: number; + successCount: number; + failedCount: number; + progress: number; + modelId: number; + modelName: string; + knowledgeIds: number[]; + knowledgeNames: string[]; + createdAt: string; + startedAt?: string | null; + finishedAt?: string | null; +} + +export interface AgentBatchItem { + id: number; + rowNumber: number; + externalNo?: string | null; + question: string; + answer?: string | null; + status: "pending" | "running" | "success" | "failed" | "cancelled"; + errorMessage?: string | null; + modelName?: string | null; + retrieveCount?: number | null; + durationMs?: number | null; +} + +export interface AgentBatchJobDetail extends AgentBatchJob { + items: PageResult; +} + export interface KnowledgeVersion { id: number; knowledgeId: number; diff --git a/ai_knowledge_base_v2/apps/backend/alembic/versions/0033_agent_batch_tests.py b/ai_knowledge_base_v2/apps/backend/alembic/versions/0033_agent_batch_tests.py new file mode 100644 index 0000000..d77da8a --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/alembic/versions/0033_agent_batch_tests.py @@ -0,0 +1,74 @@ +"""add persistent Agent batch tests + +Revision ID: 0033_agent_batch_tests +Revises: 0032_message_feedback +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "0033_agent_batch_tests" +down_revision = "0032_message_feedback" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "sys_agent_batch_test", + sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True), + sa.Column("name", sa.String(160), nullable=False), + sa.Column("original_filename", sa.String(255), nullable=False), + sa.Column("status", sa.String(20), nullable=False, server_default="pending"), + sa.Column("total_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("success_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("failed_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("processed_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("prompt_content", sa.Text(), nullable=False), + sa.Column("model_id", sa.BigInteger(), sa.ForeignKey("sys_model.id"), nullable=False), + sa.Column("model_name", sa.String(100), nullable=False), + sa.Column("knowledge_ids", sa.Text(), nullable=False), + sa.Column("knowledge_names", sa.Text(), nullable=False), + sa.Column("generation_config", sa.Text(), nullable=False), + sa.Column("created_by", sa.BigInteger(), sa.ForeignKey("sys_admin.id"), nullable=False), + sa.Column("started_at", sa.DateTime(), nullable=True), + sa.Column("finished_at", sa.DateTime(), nullable=True), + sa.Column("cancelled_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_agent_batch_test_creator_created", "sys_agent_batch_test", ["created_by", "created_at"]) + op.create_index("ix_agent_batch_test_status_created", "sys_agent_batch_test", ["status", "created_at"]) + op.create_table( + "sys_agent_batch_test_item", + sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True), + sa.Column("batch_id", sa.BigInteger(), sa.ForeignKey("sys_agent_batch_test.id", ondelete="CASCADE"), nullable=False), + sa.Column("row_number", sa.Integer(), nullable=False), + sa.Column("external_no", sa.String(100), nullable=True), + sa.Column("question", sa.Text(), nullable=False), + sa.Column("answer", sa.Text(), nullable=True), + sa.Column("status", sa.String(20), nullable=False, server_default="pending"), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("model_name", sa.String(100), nullable=True), + sa.Column("knowledge_ids", sa.Text(), nullable=True), + sa.Column("retrieve_count", sa.Integer(), nullable=True), + sa.Column("duration_ms", sa.Integer(), nullable=True), + sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("max_attempts", sa.Integer(), nullable=False, server_default="2"), + sa.Column("next_run_at", sa.DateTime(), nullable=True), + sa.Column("locked_at", sa.DateTime(), nullable=True), + sa.Column("locked_by", sa.String(120), nullable=True), + sa.Column("started_at", sa.DateTime(), nullable=True), + sa.Column("finished_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_agent_batch_item_job_order", "sys_agent_batch_test_item", ["batch_id", "row_number"]) + op.create_index("ix_agent_batch_item_claim", "sys_agent_batch_test_item", ["status", "next_run_at", "id"]) + op.create_index("ix_agent_batch_item_stale", "sys_agent_batch_test_item", ["status", "locked_at"]) + + +def downgrade() -> None: + op.drop_table("sys_agent_batch_test_item") + op.drop_table("sys_agent_batch_test") diff --git a/ai_knowledge_base_v2/apps/backend/app/api/admin_agent_batch.py b/ai_knowledge_base_v2/apps/backend/app/api/admin_agent_batch.py new file mode 100644 index 0000000..2307f94 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/api/admin_agent_batch.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime +from typing import Annotated +from urllib.parse import quote + +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status +from fastapi.responses import StreamingResponse +from sqlalchemy import func, select, update +from sqlalchemy.orm import Session + +from app.api.pagination import page_result +from app.core.database import get_db +from app.core.dependencies import get_current_admin +from app.core.responses import api_success +from app.models.admin import Admin +from app.models.agent_batch import AgentBatchTest, AgentBatchTestItem +from app.services.admin_service import OperationLogService +from app.services.agent_batch_test_service import AgentBatchTestService, item_dict, job_dict + + +router = APIRouter() + + +@router.get("/agent/batch/template") +def download_batch_template( + current_admin: Admin = Depends(get_current_admin), +) -> StreamingResponse: + return StreamingResponse( + AgentBatchTestService.template_workbook(), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": "attachment; filename*=UTF-8''Agent_batch_test_template.xlsx"}, + ) + + +@router.post("/agent/batch/jobs") +def create_batch_job( + file: Annotated[UploadFile, File(...)], + configJson: Annotated[str, Form(...)], + db: Session = Depends(get_db), + current_admin: Admin = Depends(get_current_admin), +) -> dict: + try: + config = json.loads(configJson) + except json.JSONDecodeError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="批量测试配置格式不正确") from exc + if not isinstance(config, dict): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="批量测试配置格式不正确") + rows = AgentBatchTestService.parse_questions(file) + job = AgentBatchTestService.create_job( + db, + admin=current_admin, + filename=file.filename or "Agent批量测试.xlsx", + rows=rows, + config=config, + ) + OperationLogService.write(db, admin_id=current_admin.id, module="agent", action="batch_create", target_id=job.id) + db.commit() + return api_success(job_dict(job)) + + +@router.get("/agent/batch/jobs") +def list_batch_jobs( + page: int = Query(default=1, ge=1), + pageSize: int = Query(default=10, ge=10, le=100), + db: Session = Depends(get_db), + current_admin: Admin = Depends(get_current_admin), +) -> dict: + total = db.scalar(select(func.count(AgentBatchTest.id))) or 0 + rows = list( + db.scalars( + select(AgentBatchTest) + .order_by(AgentBatchTest.created_at.desc(), AgentBatchTest.id.desc()) + .offset((page - 1) * pageSize) + .limit(pageSize) + ) + ) + return api_success(page_result([job_dict(row) for row in rows], total=total, page=page, page_size=pageSize)) + + +@router.get("/agent/batch/jobs/{job_id}") +def get_batch_job( + job_id: int, + page: int = Query(default=1, ge=1), + pageSize: int = Query(default=20, ge=10, le=100), + db: Session = Depends(get_db), + current_admin: Admin = Depends(get_current_admin), +) -> dict: + job = _job_or_404(db, job_id) + total = db.scalar(select(func.count(AgentBatchTestItem.id)).where(AgentBatchTestItem.batch_id == job.id)) or 0 + items = list( + db.scalars( + select(AgentBatchTestItem) + .where(AgentBatchTestItem.batch_id == job.id) + .order_by(AgentBatchTestItem.row_number.asc(), AgentBatchTestItem.id.asc()) + .offset((page - 1) * pageSize) + .limit(pageSize) + ) + ) + return api_success({**job_dict(job), "items": page_result([item_dict(item) for item in items], total=total, page=page, page_size=pageSize)}) + + +@router.get("/agent/batch/jobs/{job_id}/export") +def export_batch_job( + job_id: int, + db: Session = Depends(get_db), + current_admin: Admin = Depends(get_current_admin), +) -> StreamingResponse: + job = _job_or_404(db, job_id) + items = list( + db.scalars( + select(AgentBatchTestItem) + .where(AgentBatchTestItem.batch_id == job.id) + .order_by(AgentBatchTestItem.row_number.asc(), AgentBatchTestItem.id.asc()) + ) + ) + filename = quote(f"Agent批量测试结果_{job.id}.xlsx") + OperationLogService.write(db, admin_id=current_admin.id, module="agent", action="batch_export", target_id=job.id) + db.commit() + return StreamingResponse( + AgentBatchTestService.export_workbook(job, items), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"}, + ) + + +@router.post("/agent/batch/jobs/{job_id}/cancel") +def cancel_batch_job( + job_id: int, + db: Session = Depends(get_db), + current_admin: Admin = Depends(get_current_admin), +) -> dict: + job = _job_or_404(db, job_id) + if job.status not in {"pending", "running"}: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="当前任务已结束,不能取消") + now = datetime.now(UTC).replace(tzinfo=None) + job.status = "cancelled" + job.cancelled_at = now + job.finished_at = now + db.add(job) + db.execute( + update(AgentBatchTestItem) + .where(AgentBatchTestItem.batch_id == job.id, AgentBatchTestItem.status == "pending") + .values(status="cancelled", finished_at=now, next_run_at=None) + ) + OperationLogService.write(db, admin_id=current_admin.id, module="agent", action="batch_cancel", target_id=job.id) + db.commit() + db.refresh(job) + return api_success(job_dict(job)) + + +def _job_or_404(db: Session, job_id: int) -> AgentBatchTest: + job = db.get(AgentBatchTest, job_id) + if job is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="批量测试任务不存在") + return job diff --git a/ai_knowledge_base_v2/apps/backend/app/api/router.py b/ai_knowledge_base_v2/apps/backend/app/api/router.py index 948cb4b..327f93b 100644 --- a/ai_knowledge_base_v2/apps/backend/app/api/router.py +++ b/ai_knowledge_base_v2/apps/backend/app/api/router.py @@ -7,6 +7,7 @@ from app.api import ( admin_auth, admin_content_generation, admin_agent_records, + admin_agent_batch, admin_dashboard, admin_entitlements, admin_knowledge, @@ -38,6 +39,7 @@ api_router.include_router(admin_management.router, prefix="/admin", tags=["admin guard = [Depends(enforce_admin_access)] api_router.include_router(admin_content_generation.router, prefix="/admin", tags=["admin-content-generation"], dependencies=guard) api_router.include_router(admin_agent_records.router, prefix="/admin", tags=["admin-agent-records"], dependencies=guard) +api_router.include_router(admin_agent_batch.router, prefix="/admin", tags=["admin-agent-batch"], dependencies=guard) api_router.include_router(admin_dashboard.router, prefix="/admin", tags=["admin"], dependencies=guard) api_router.include_router(admin_entitlements.router, prefix="/admin", tags=["admin-entitlements"], dependencies=guard) api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"], dependencies=guard) diff --git a/ai_knowledge_base_v2/apps/backend/app/core/config.py b/ai_knowledge_base_v2/apps/backend/app/core/config.py index 964df60..899ebc6 100644 --- a/ai_knowledge_base_v2/apps/backend/app/core/config.py +++ b/ai_knowledge_base_v2/apps/backend/app/core/config.py @@ -80,6 +80,9 @@ class Settings(BaseSettings): topic_settlement_poll_seconds: int = 2 topic_settlement_stale_minutes: int = 30 topic_settlement_max_attempts: int = 3 + agent_batch_worker_enabled: bool = True + agent_batch_poll_seconds: int = 2 + agent_batch_stale_minutes: int = 30 bootstrap_admin_username: str = "" bootstrap_admin_password: str = "" bootstrap_admin_name: str = "系统管理员" diff --git a/ai_knowledge_base_v2/apps/backend/app/core/dependencies.py b/ai_knowledge_base_v2/apps/backend/app/core/dependencies.py index 160625c..547b219 100644 --- a/ai_knowledge_base_v2/apps/backend/app/core/dependencies.py +++ b/ai_knowledge_base_v2/apps/backend/app/core/dependencies.py @@ -100,6 +100,8 @@ def enforce_admin_access( permission = "users.delete" if method == "DELETE" else ("users.view" if method == "GET" else ("users.create" if method == "POST" and (path in {"user", "user/import", "user/import/excel"}) else "users.edit")) elif path.startswith("knowledge"): permission = "knowledge.delete" if method == "DELETE" else ("knowledge.view" if method == "GET" else ("knowledge.publish" if path.endswith("open-status") or path.endswith("lifecycle") else "knowledge.edit")) + elif path.startswith("agent/batch"): + permission = "prompt.batch" elif path.startswith("prompt") or path.startswith("agent/"): permission = "prompt.view" if method == "GET" else "prompt.edit" elif path.startswith("model"): diff --git a/ai_knowledge_base_v2/apps/backend/app/main.py b/ai_knowledge_base_v2/apps/backend/app/main.py index 247571d..e2041b9 100644 --- a/ai_knowledge_base_v2/apps/backend/app/main.py +++ b/ai_knowledge_base_v2/apps/backend/app/main.py @@ -15,6 +15,7 @@ from app.services.secret_service import SecretService from app.services.maintenance_service import MaintenanceService from app.services.periodic_report_worker import PeriodicReportWorker from app.services.topic_settlement_worker import TopicSettlementWorker +from app.services.agent_batch_test_worker import AgentBatchTestWorker @asynccontextmanager @@ -26,12 +27,13 @@ async def lifespan(app: FastAPI): maintenance_task = asyncio.create_task(MaintenanceService.run_forever()) periodic_report_task = asyncio.create_task(PeriodicReportWorker.run_forever()) topic_settlement_task = asyncio.create_task(TopicSettlementWorker.run_forever()) + agent_batch_task = asyncio.create_task(AgentBatchTestWorker.run_forever()) try: yield finally: - for task in (maintenance_task, periodic_report_task, topic_settlement_task): + for task in (maintenance_task, periodic_report_task, topic_settlement_task, agent_batch_task): task.cancel() - for task in (maintenance_task, periodic_report_task, topic_settlement_task): + for task in (maintenance_task, periodic_report_task, topic_settlement_task, agent_batch_task): try: await task except asyncio.CancelledError: diff --git a/ai_knowledge_base_v2/apps/backend/app/models/__init__.py b/ai_knowledge_base_v2/apps/backend/app/models/__init__.py index dd5e7ed..cb604f6 100644 --- a/ai_knowledge_base_v2/apps/backend/app/models/__init__.py +++ b/ai_knowledge_base_v2/apps/backend/app/models/__init__.py @@ -1,4 +1,5 @@ from app.models.admin import Admin, Role +from app.models.agent_batch import AgentBatchTest, AgentBatchTestItem from app.models.ai_config import ContentGenerationConfig, ModelConfig, Prompt, SystemConfig from app.models.base import Base from app.models.chat import ChatMessage, ChatSession, TopicSession @@ -29,6 +30,8 @@ from app.models.user import User __all__ = [ "Admin", + "AgentBatchTest", + "AgentBatchTestItem", "AiRequestLog", "Base", "ChatMessage", diff --git a/ai_knowledge_base_v2/apps/backend/app/models/agent_batch.py b/ai_knowledge_base_v2/apps/backend/app/models/agent_batch.py new file mode 100644 index 0000000..975d0ef --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/models/agent_batch.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite") + + +class AgentBatchTest(Base): + __tablename__ = "sys_agent_batch_test" + __table_args__ = ( + Index("ix_agent_batch_test_creator_created", "created_by", "created_at"), + Index("ix_agent_batch_test_status_created", "status", "created_at"), + ) + + id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(160), nullable=False) + original_filename: Mapped[str] = mapped_column(String(255), nullable=False) + status: Mapped[str] = mapped_column(String(20), default="pending", nullable=False) + total_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + success_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + failed_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + processed_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + prompt_content: Mapped[str] = mapped_column(Text, nullable=False) + model_id: Mapped[int] = mapped_column(ForeignKey("sys_model.id"), nullable=False) + model_name: Mapped[str] = mapped_column(String(100), nullable=False) + knowledge_ids: Mapped[str] = mapped_column(Text, nullable=False) + knowledge_names: Mapped[str] = mapped_column(Text, nullable=False) + generation_config: Mapped[str] = mapped_column(Text, nullable=False) + created_by: Mapped[int] = mapped_column(ForeignKey("sys_admin.id"), nullable=False) + started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + cancelled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False) + + +class AgentBatchTestItem(Base): + __tablename__ = "sys_agent_batch_test_item" + __table_args__ = ( + Index("ix_agent_batch_item_job_order", "batch_id", "row_number"), + Index("ix_agent_batch_item_claim", "status", "next_run_at", "id"), + Index("ix_agent_batch_item_stale", "status", "locked_at"), + ) + + id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True) + batch_id: Mapped[int] = mapped_column(ForeignKey("sys_agent_batch_test.id", ondelete="CASCADE"), nullable=False) + row_number: Mapped[int] = mapped_column(Integer, nullable=False) + external_no: Mapped[str | None] = mapped_column(String(100), nullable=True) + question: Mapped[str] = mapped_column(Text, nullable=False) + answer: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column(String(20), default="pending", nullable=False) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + model_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + knowledge_ids: Mapped[str | None] = mapped_column(Text, nullable=True) + retrieve_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True) + attempt_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + max_attempts: Mapped[int] = mapped_column(Integer, default=2, nullable=False) + next_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + locked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + locked_by: Mapped[str | None] = mapped_column(String(120), nullable=True) + started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False) diff --git a/ai_knowledge_base_v2/apps/backend/app/services/admin_permission_service.py b/ai_knowledge_base_v2/apps/backend/app/services/admin_permission_service.py index 61420ae..cd30ba3 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/admin_permission_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/admin_permission_service.py @@ -12,7 +12,7 @@ PERMISSION_TREE = [ {"code": "users", "name": "用户管理", "children": [{"code": "users.view", "name": "查看用户"}, {"code": "users.create", "name": "新增/导入"}, {"code": "users.edit", "name": "编辑/权益续期"}, {"code": "users.delete", "name": "删除用户"}]}, {"code": "entitlements", "name": "权益管理", "children": [{"code": "entitlements.view", "name": "查看权益"}, {"code": "entitlements.edit", "name": "编辑权益"}]}, {"code": "knowledge", "name": "知识库管理", "children": [{"code": "knowledge.view", "name": "查看知识库"}, {"code": "knowledge.edit", "name": "新增/编辑/同步"}, {"code": "knowledge.publish", "name": "开放/归档"}, {"code": "knowledge.delete", "name": "删除知识库"}]}, - {"code": "prompt", "name": "Agent 管理", "children": [{"code": "prompt.view", "name": "查看 Agent"}, {"code": "prompt.edit", "name": "编辑/测试 Agent"}]}, + {"code": "prompt", "name": "Agent 管理", "children": [{"code": "prompt.view", "name": "查看 Agent"}, {"code": "prompt.edit", "name": "编辑/单条测试 Agent"}, {"code": "prompt.batch", "name": "批量测试/导出"}]}, {"code": "models", "name": "模型管理", "children": [{"code": "models.view", "name": "查看模型"}, {"code": "models.edit", "name": "新增/编辑/测试"}, {"code": "models.delete", "name": "删除模型"}]}, {"code": "content-generation", "name": "内容生成", "children": [{"code": "content-generation.view", "name": "查看配置"}, {"code": "content-generation.edit", "name": "编辑/测试配置"}]}, {"code": "configs", "name": "系统配置", "children": [{"code": "configs.view", "name": "查看配置"}, {"code": "configs.edit", "name": "修改配置"}]}, diff --git a/ai_knowledge_base_v2/apps/backend/app/services/agent_batch_test_service.py b/ai_knowledge_base_v2/apps/backend/app/services/agent_batch_test_service.py new file mode 100644 index 0000000..c10ba01 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/services/agent_batch_test_service.py @@ -0,0 +1,274 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime +from io import BytesIO + +from fastapi import HTTPException, UploadFile, status +from openpyxl import Workbook, load_workbook +from openpyxl.styles import Alignment, Font, PatternFill +from openpyxl.worksheet.table import Table, TableStyleInfo +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.models.admin import Admin +from app.models.agent_batch import AgentBatchTest, AgentBatchTestItem +from app.models.ai_config import ModelConfig +from app.models.knowledge import Knowledge + + +MAX_BATCH_QUESTIONS = 200 +MAX_QUESTION_LENGTH = 2000 +TEMPLATE_HEADERS = ["序号", "问题"] + + +class AgentBatchTestService: + @staticmethod + def parse_questions(file: UploadFile) -> list[dict]: + filename = file.filename or "" + if not filename.lower().endswith(".xlsx"): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请上传 .xlsx 格式的 Excel 文件") + content = file.file.read() + if not content: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Excel 文件为空") + if len(content) > 5 * 1024 * 1024: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Excel 文件不能超过 5MB") + try: + workbook = load_workbook(BytesIO(content), read_only=True, data_only=False) + except Exception as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Excel 文件解析失败,请重新下载模板填写") from exc + sheet = workbook.active + if sheet.max_row > 1000: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Excel 行数异常,请删除多余空行后重试") + headers = [str(value or "").strip() for value in next(sheet.iter_rows(min_row=1, max_row=1, values_only=True), ())] + header_map = {name: index for index, name in enumerate(headers) if name} + if "问题" not in header_map: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="模板缺少必填列:问题") + rows: list[dict] = [] + seen: set[str] = set() + for row_number, values in enumerate(sheet.iter_rows(min_row=2, max_row=min(sheet.max_row, 1000), values_only=True), start=2): + raw_question = _value_at(values, header_map["问题"]) + if isinstance(raw_question, str) and raw_question.startswith("="): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"第 {row_number} 行问题不能使用 Excel 公式") + question = _cell_text(raw_question) + external_no = _cell_text(_value_at(values, header_map.get("序号"))) or str(row_number - 1) + if not question: + continue + if len(question) > MAX_QUESTION_LENGTH: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"第 {row_number} 行问题超过 {MAX_QUESTION_LENGTH} 字") + normalized = " ".join(question.split()) + if normalized in seen: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"第 {row_number} 行问题与前面重复") + seen.add(normalized) + rows.append({"rowNumber": row_number, "externalNo": external_no[:100], "question": question}) + if len(rows) > MAX_BATCH_QUESTIONS: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"单次最多导入 {MAX_BATCH_QUESTIONS} 个问题") + if not rows: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Excel 中没有可测试的问题") + return rows + + @staticmethod + def create_job( + db: Session, + *, + admin: Admin, + filename: str, + rows: list[dict], + config: dict, + ) -> AgentBatchTest: + prompt_content = str(config.get("promptContent") or "").strip() + if not prompt_content: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="主提示词不能为空") + try: + model_id = int(config.get("modelId")) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请选择批量测试模型") from exc + model = db.get(ModelConfig, model_id) + if model is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="批量测试模型不存在") + knowledge_ids = _positive_int_list(config.get("knowledgeIds")) + if knowledge_ids: + found = list(db.scalars(select(Knowledge).where(Knowledge.id.in_(knowledge_ids)))) + if len(found) != len(knowledge_ids): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="所选知识库中包含已删除的数据") + knowledge_names = [item.name for item in sorted(found, key=lambda item: knowledge_ids.index(item.id))] + else: + knowledge_names = ["全部正式开放知识库"] + generation_config = { + "temperature": config.get("temperature"), + "topP": config.get("topP"), + "topK": config.get("topK"), + "presencePenalty": config.get("presencePenalty"), + "frequencyPenalty": config.get("frequencyPenalty"), + "maxToken": config.get("maxToken") or 8192, + "streamEnabled": int(config.get("streamEnabled", 1)), + "reasoningVisible": 0, + "responseDepth": int(config.get("responseDepth", 35)), + } + job = AgentBatchTest( + name=str(config.get("name") or filename.rsplit(".", 1)[0] or "Agent 批量测试")[:160], + original_filename=filename[:255], + total_count=len(rows), + prompt_content=prompt_content, + model_id=model.id, + model_name=model.display_name or model.model_name, + knowledge_ids=json.dumps(knowledge_ids, ensure_ascii=False), + knowledge_names=json.dumps(knowledge_names, ensure_ascii=False), + generation_config=json.dumps(generation_config, ensure_ascii=False), + created_by=admin.id, + ) + db.add(job) + db.flush() + db.add_all([ + AgentBatchTestItem( + batch_id=job.id, + row_number=row["rowNumber"], + external_no=row["externalNo"], + question=row["question"], + ) + for row in rows + ]) + db.flush() + return job + + @staticmethod + def template_workbook() -> BytesIO: + workbook = Workbook() + sheet = workbook.active + sheet.title = "批量测试问题" + sheet.append(TEMPLATE_HEADERS) + sheet.append([1, "请填写需要测试的问题,保留一行一个问题。"]) + sheet.append([2, "例如:遇到情绪焦虑时,可以先做什么?"]) + _style_sheet(sheet, widths=(12, 72), table_ref="A1:B3", table_name="AgentBatchQuestionTable") + note = workbook.create_sheet("填写说明") + note.append(["填写规则", "说明"]) + note.append(["必填列", "问题"]) + note.append(["单次数量", f"最多 {MAX_BATCH_QUESTIONS} 个问题"]) + note.append(["问题长度", f"每个问题最多 {MAX_QUESTION_LENGTH} 字"]) + note.append(["重复问题", "同一文件内不允许重复"]) + note.append(["不要修改", "不要修改工作表名称和表头;可以删除示例行后填写"]) + _style_sheet(note, widths=(18, 72), table_ref="A1:B6", table_name="AgentBatchInstructions") + stream = BytesIO() + workbook.save(stream) + stream.seek(0) + return stream + + @staticmethod + def export_workbook(job: AgentBatchTest, items: list[AgentBatchTestItem]) -> BytesIO: + workbook = Workbook() + sheet = workbook.active + sheet.title = "批量测试结果" + headers = ["序号", "问题", "答案", "状态", "失败原因", "实际模型", "召回数量", "耗时(秒)"] + sheet.append(headers) + status_labels = {"success": "成功", "failed": "失败", "cancelled": "已取消", "pending": "等待中", "running": "生成中"} + for item in items: + sheet.append([ + item.external_no or item.row_number - 1, + _excel_safe(item.question), + _excel_safe(item.answer or ""), + status_labels.get(item.status, item.status), + _excel_safe(item.error_message or ""), + _excel_safe(item.model_name or job.model_name), + item.retrieve_count, + round(item.duration_ms / 1000, 2) if item.duration_ms is not None else None, + ]) + end_row = max(2, len(items) + 1) + _style_sheet(sheet, widths=(12, 42, 80, 12, 36, 22, 12, 12), table_ref=f"A1:H{end_row}", table_name="AgentBatchResultTable") + for row in sheet.iter_rows(min_row=2, max_row=end_row): + row[1].alignment = Alignment(vertical="top", wrap_text=True) + row[2].alignment = Alignment(vertical="top", wrap_text=True) + row[4].alignment = Alignment(vertical="top", wrap_text=True) + summary = workbook.create_sheet("任务信息") + summary.append(["项目", "内容"]) + summary.append(["任务名称", job.name]) + summary.append(["任务状态", job.status]) + summary.append(["问题总数", job.total_count]) + summary.append(["成功", job.success_count]) + summary.append(["失败", job.failed_count]) + summary.append(["测试模型", job.model_name]) + summary.append(["知识库", "、".join(json.loads(job.knowledge_names or "[]"))]) + summary.append(["创建时间", job.created_at]) + summary.append(["完成时间", job.finished_at]) + _style_sheet(summary, widths=(20, 86), table_ref="A1:B10", table_name="AgentBatchSummary") + summary.column_dimensions["B"].width = 86 + stream = BytesIO() + workbook.save(stream) + stream.seek(0) + return stream + + +def job_dict(job: AgentBatchTest) -> dict: + return { + "id": job.id, + "name": job.name, + "originalFilename": job.original_filename, + "status": job.status, + "totalCount": job.total_count, + "processedCount": job.processed_count, + "successCount": job.success_count, + "failedCount": job.failed_count, + "progress": round(job.processed_count * 100 / job.total_count) if job.total_count else 0, + "modelId": job.model_id, + "modelName": job.model_name, + "knowledgeIds": json.loads(job.knowledge_ids or "[]"), + "knowledgeNames": json.loads(job.knowledge_names or "[]"), + "createdAt": job.created_at, + "startedAt": job.started_at, + "finishedAt": job.finished_at, + } + + +def item_dict(item: AgentBatchTestItem) -> dict: + return { + "id": item.id, + "rowNumber": item.row_number, + "externalNo": item.external_no, + "question": item.question, + "answer": item.answer, + "status": item.status, + "errorMessage": item.error_message, + "modelName": item.model_name, + "retrieveCount": item.retrieve_count, + "durationMs": item.duration_ms, + } + + +def _style_sheet(sheet, *, widths: tuple[int, ...], table_ref: str, table_name: str) -> None: + sheet.freeze_panes = "A2" + sheet.sheet_view.showGridLines = False + header_fill = PatternFill("solid", fgColor="176B57") + for cell in sheet[1]: + cell.fill = header_fill + cell.font = Font(color="FFFFFF", bold=True) + cell.alignment = Alignment(vertical="center") + sheet.row_dimensions[1].height = 28 + for index, width in enumerate(widths, start=1): + sheet.column_dimensions[chr(64 + index)].width = width + table = Table(displayName=table_name, ref=table_ref) + table.tableStyleInfo = TableStyleInfo(name="TableStyleMedium4", showRowStripes=True, showFirstColumn=False, showLastColumn=False) + sheet.add_table(table) + + +def _cell_text(value) -> str: + if value is None: + return "" + return str(value).strip() + + +def _value_at(row: tuple, index: int | None): + return row[index] if index is not None and index < len(row) else None + + +def _positive_int_list(value) -> list[int]: + if not isinstance(value, list): + return [] + try: + result = list(dict.fromkeys(int(item) for item in value if int(item) > 0)) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="知识库参数格式不正确") from exc + return result + + +def _excel_safe(value: str) -> str: + text = str(value or "") + return f"'{text}" if text.lstrip().startswith(("=", "+", "-", "@")) else text diff --git a/ai_knowledge_base_v2/apps/backend/app/services/agent_batch_test_worker.py b/ai_knowledge_base_v2/apps/backend/app/services/agent_batch_test_worker.py new file mode 100644 index 0000000..4d42beb --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/services/agent_batch_test_worker.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import asyncio +import json +import logging +import os +import socket +import time +import uuid +from datetime import UTC, datetime, timedelta + +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session + +from app.core.config import get_settings +from app.core.database import SessionLocal +from app.models.admin import Admin +from app.models.agent_batch import AgentBatchTest, AgentBatchTestItem +from app.schemas.admin import AgentDebugRequest +from app.services.agent_debug_service import AgentDebugService + + +logger = logging.getLogger(__name__) + + +class AgentBatchTestWorker: + """Persistent, restart-safe queue for Agent batch questions.""" + + @classmethod + async def run_forever(cls) -> None: + settings = get_settings() + if not settings.agent_batch_worker_enabled: + logger.info("agent batch test worker disabled") + return + worker_id = f"{socket.gethostname()}:{os.getpid()}:{uuid.uuid4().hex[:8]}" + while True: + try: + processed = await cls.run_once(worker_id) + except Exception: + processed = False + logger.exception("agent batch test worker iteration failed") + await asyncio.sleep(0 if processed else max(1, settings.agent_batch_poll_seconds)) + + @classmethod + async def run_once(cls, worker_id: str) -> bool: + now = _now() + with SessionLocal() as db: + recovered_batch_ids = cls.recover_stale_items(db, now=now) + db.commit() + for batch_id in recovered_batch_ids: + cls.refresh_job(db, batch_id) + with SessionLocal() as db: + item_id = cls.claim_next(db, worker_id=worker_id, now=now) + if item_id is None: + return False + await cls.execute_claimed(item_id=item_id, worker_id=worker_id) + return True + + @staticmethod + def claim_next(db: Session, *, worker_id: str, now: datetime | None = None) -> int | None: + current = now or _now() + item = db.scalar( + select(AgentBatchTestItem) + .join(AgentBatchTest, AgentBatchTest.id == AgentBatchTestItem.batch_id) + .where( + AgentBatchTest.status.in_(("pending", "running")), + AgentBatchTestItem.status == "pending", + AgentBatchTestItem.attempt_count < AgentBatchTestItem.max_attempts, + or_(AgentBatchTestItem.next_run_at.is_(None), AgentBatchTestItem.next_run_at <= current), + ) + .order_by(AgentBatchTestItem.batch_id.asc(), AgentBatchTestItem.row_number.asc()) + .with_for_update(skip_locked=True) + .limit(1) + ) + if item is None: + db.rollback() + return None + job = db.get(AgentBatchTest, item.batch_id) + item.status = "running" + item.attempt_count += 1 + item.locked_at = current + item.locked_by = worker_id + item.started_at = current + if job is not None and job.status == "pending": + job.status = "running" + job.started_at = current + db.add(job) + db.add(item) + db.commit() + return item.id + + @classmethod + async def execute_claimed(cls, *, item_id: int, worker_id: str) -> None: + with SessionLocal() as db: + item = db.get(AgentBatchTestItem, item_id) + if item is None or item.status != "running" or item.locked_by != worker_id: + return + job = db.get(AgentBatchTest, item.batch_id) + admin = db.get(Admin, job.created_by) if job is not None else None + if job is None or admin is None: + cls._finish_item(db, item, error="批量任务或创建管理员不存在") + return + if job.status == "cancelled": + item.status = "cancelled" + item.finished_at = _now() + item.locked_at = None + item.locked_by = None + db.add(item) + db.commit() + return + payload = _payload_from(job, item) + started = time.monotonic() + try: + result = await AgentDebugService.run_complete(payload, db, admin) + duration_ms = round((time.monotonic() - started) * 1000) + item.answer = result["answer"] + item.model_name = result.get("modelName") or job.model_name + item.knowledge_ids = json.dumps(result.get("knowledgeIds") or [], ensure_ascii=False) + item.retrieve_count = result.get("retrieveCount") + item.duration_ms = duration_ms + item.status = "success" + item.error_message = None + item.finished_at = _now() + item.next_run_at = None + item.locked_at = None + item.locked_by = None + db.add(item) + db.commit() + except Exception as exc: + db.rollback() + item = db.get(AgentBatchTestItem, item_id) + if item is None: + return + item.duration_ms = round((time.monotonic() - started) * 1000) + if item.attempt_count < item.max_attempts: + item.status = "pending" + item.next_run_at = _now() + timedelta(seconds=15 * item.attempt_count) + item.finished_at = None + else: + item.status = "failed" + item.next_run_at = None + item.finished_at = _now() + item.error_message = (str(exc) or "Agent 生成失败")[:2000] + item.locked_at = None + item.locked_by = None + db.add(item) + db.commit() + cls.refresh_job(db, job.id) + + @staticmethod + def refresh_job(db: Session, batch_id: int) -> None: + job = db.get(AgentBatchTest, batch_id) + if job is None or job.status == "cancelled": + return + counts = dict( + db.execute( + select(AgentBatchTestItem.status, func.count(AgentBatchTestItem.id)) + .where(AgentBatchTestItem.batch_id == batch_id) + .group_by(AgentBatchTestItem.status) + ).all() + ) + job.success_count = counts.get("success", 0) + job.failed_count = counts.get("failed", 0) + job.processed_count = job.success_count + job.failed_count + if job.processed_count >= job.total_count: + job.status = "completed" if job.failed_count == 0 else "completed_with_errors" + job.finished_at = _now() + else: + job.status = "running" + db.add(job) + db.commit() + + @staticmethod + def recover_stale_items(db: Session, *, now: datetime | None = None) -> set[int]: + current = now or _now() + stale_before = current - timedelta(minutes=max(5, get_settings().agent_batch_stale_minutes)) + items = list( + db.scalars( + select(AgentBatchTestItem) + .where( + AgentBatchTestItem.status == "running", + AgentBatchTestItem.locked_at.is_not(None), + AgentBatchTestItem.locked_at < stale_before, + ) + .order_by(AgentBatchTestItem.id.asc()) + .limit(100) + .with_for_update(skip_locked=True) + ) + ) + affected_batch_ids: set[int] = set() + for item in items: + affected_batch_ids.add(item.batch_id) + item.status = "pending" if item.attempt_count < item.max_attempts else "failed" + item.error_message = "任务执行进程中断,系统已自动恢复" if item.status == "pending" else "任务执行进程连续中断" + item.next_run_at = current if item.status == "pending" else None + item.finished_at = current if item.status == "failed" else None + item.locked_at = None + item.locked_by = None + db.add(item) + return affected_batch_ids + + @classmethod + def _finish_item(cls, db: Session, item: AgentBatchTestItem, *, error: str) -> None: + item.status = "failed" + item.error_message = error + item.finished_at = _now() + item.locked_at = None + item.locked_by = None + db.add(item) + db.commit() + cls.refresh_job(db, item.batch_id) + + +def _payload_from(job: AgentBatchTest, item: AgentBatchTestItem) -> AgentDebugRequest: + generation = json.loads(job.generation_config or "{}") + return AgentDebugRequest( + promptContent=job.prompt_content, + modelId=job.model_id, + knowledgeIds=json.loads(job.knowledge_ids or "[]"), + question=item.question, + history=[], + **generation, + ) + + +def _now() -> datetime: + return datetime.now(UTC).replace(tzinfo=None) diff --git a/ai_knowledge_base_v2/apps/backend/app/services/agent_debug_service.py b/ai_knowledge_base_v2/apps/backend/app/services/agent_debug_service.py index 8af23ad..9768621 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/agent_debug_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/agent_debug_service.py @@ -283,3 +283,20 @@ class AgentDebugService: except Exception as exc: db.rollback() yield {"type": "error", "message": str(exc) or "Agent 调试失败"} + + @classmethod + async def run_complete(cls, payload: AgentDebugRequest, db: Session, current_admin: Admin) -> dict: + """Consume the same routed debug stream used by the UI and return one complete answer.""" + answer_parts: list[str] = [] + completion: dict = {} + async for event in cls.stream(payload, db, current_admin): + if event.get("type") == "content": + answer_parts.append(str(event.get("content") or "")) + elif event.get("type") == "complete": + completion = event + elif event.get("type") == "error": + raise RuntimeError(str(event.get("message") or "Agent 生成失败")) + answer = "".join(answer_parts).strip() + if not answer: + raise RuntimeError("模型未返回正式回答") + return {"answer": answer, **completion} diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_admin_permissions.py b/ai_knowledge_base_v2/apps/backend/tests/test_admin_permissions.py index b23b4fa..30858fe 100644 --- a/ai_knowledge_base_v2/apps/backend/tests/test_admin_permissions.py +++ b/ai_knowledge_base_v2/apps/backend/tests/test_admin_permissions.py @@ -13,6 +13,7 @@ def test_super_admin_has_all_permissions() -> None: admin = Admin(id=1, username="root", password="unused", name="root", status=1, is_super_admin=1) assert permissions_for(admin) == ALL_PERMISSION_CODES assert {"feedback.view", "feedback.detail", "feedback.export", "feedback.delete"} <= ALL_PERMISSION_CODES + assert "prompt.batch" in ALL_PERMISSION_CODES def test_role_permissions_are_restricted_to_catalog() -> None: diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_agent_batch_tests.py b/ai_knowledge_base_v2/apps/backend/tests/test_agent_batch_tests.py new file mode 100644 index 0000000..46b3768 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/tests/test_agent_batch_tests.py @@ -0,0 +1,125 @@ +from io import BytesIO +import json + +import pytest +from fastapi import HTTPException, UploadFile +from openpyxl import Workbook, load_workbook +from sqlalchemy import create_engine, select +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool + +from app.models import Base +from app.models.admin import Admin +from app.models.agent_batch import AgentBatchTestItem +from app.models.ai_config import ModelConfig +from app.services.agent_batch_test_service import AgentBatchTestService, MAX_BATCH_QUESTIONS + + +def _database() -> Session: + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(engine) + return Session(engine) + + +def _admin() -> Admin: + return Admin(id=1, username="admin", password="hash", name="系统管理员", status=1) + + +def _model() -> ModelConfig: + return ModelConfig( + id=1, + provider="mock", + display_name="批量测试模型", + api_type="openai_compatible", + model_name="batch-model", + api_url="", + api_key="", + auth_type="bearer", + enabled=1, + ) + + +def test_template_can_be_downloaded_and_imported() -> None: + stream = AgentBatchTestService.template_workbook() + workbook = load_workbook(stream, data_only=True) + + assert workbook.sheetnames == ["批量测试问题", "填写说明"] + assert [cell.value for cell in workbook["批量测试问题"][1]] == ["序号", "问题"] + assert workbook["填写说明"]["B3"].value == f"最多 {MAX_BATCH_QUESTIONS} 个问题" + + upload = UploadFile(filename="批量测试.xlsx", file=BytesIO(stream.getvalue())) + rows = AgentBatchTestService.parse_questions(upload) + assert len(rows) == 2 + assert rows[0]["externalNo"] == "1" + assert "测试的问题" in rows[0]["question"] + + +def test_create_job_snapshots_config_and_export_keeps_failed_rows() -> None: + with _database() as db: + admin = _admin() + model = _model() + db.add_all([admin, model]) + db.commit() + job = AgentBatchTestService.create_job( + db, + admin=admin, + filename="回归问题.xlsx", + rows=[ + {"rowNumber": 2, "externalNo": "Q-1", "question": "第一个问题"}, + {"rowNumber": 3, "externalNo": "Q-2", "question": "第二个问题"}, + ], + config={ + "name": "提示词回归测试", + "promptContent": "只根据知识库回答", + "modelId": model.id, + "knowledgeIds": [], + "temperature": 0.2, + "maxToken": 8192, + "responseDepth": 35, + }, + ) + items = list(db.scalars(select(AgentBatchTestItem).order_by(AgentBatchTestItem.row_number))) + items[0].status = "success" + items[0].answer = "第一个答案" + items[0].model_name = "批量测试模型" + items[0].retrieve_count = 3 + items[0].duration_ms = 1250 + items[1].status = "failed" + items[1].error_message = "供应商超时" + job.status = "completed_with_errors" + job.processed_count = 2 + job.success_count = 1 + job.failed_count = 1 + db.commit() + + assert json.loads(job.generation_config)["maxToken"] == 8192 + assert json.loads(job.knowledge_names) == ["全部正式开放知识库"] + + exported = AgentBatchTestService.export_workbook(job, items) + workbook = load_workbook(exported, data_only=True) + sheet = workbook["批量测试结果"] + assert sheet["C2"].value == "第一个答案" + assert sheet["D2"].value == "成功" + assert sheet["D3"].value == "失败" + assert sheet["E3"].value == "供应商超时" + assert sheet.freeze_panes == "A2" + + +def test_import_rejects_excel_formula_question() -> None: + workbook = Workbook() + sheet = workbook.active + sheet.append(["序号", "问题"]) + sheet.append([1, '=HYPERLINK("https://example.com","问题")']) + stream = BytesIO() + workbook.save(stream) + stream.seek(0) + + with pytest.raises(HTTPException) as exc: + AgentBatchTestService.parse_questions(UploadFile(filename="危险问题.xlsx", file=stream)) + + assert exc.value.status_code == 400 + assert "不能使用 Excel 公式" in exc.value.detail diff --git a/ai_knowledge_base_v2/design-qa.md b/ai_knowledge_base_v2/design-qa.md index 4cb159a..d47d690 100644 --- a/ai_knowledge_base_v2/design-qa.md +++ b/ai_knowledge_base_v2/design-qa.md @@ -1,3 +1,20 @@ +# Agent 批量测试页面验收(2026-08-12) + +- 实现截图:`development_records/ui-evidence/agent-batch-test-2026-08-12.png` +- 验收地址:`http://127.0.0.1:8080/` +- 页面状态:系统管理员已登录 / Agent 管理 / 批量测试 / 2 条真实问题已完成 +- 视口:1280 × 720 CSS px;页面 `scrollWidth=clientWidth=1280`。 +- 批量测试页切换为 1012px 全宽工作区,隐藏与该任务无关的单条调试预览和检索追踪,上传区、配置快照、进度表格和操作按钮无需横向滚动。 +- 已真实下载并解析模板,工作表为“批量测试问题、填写说明”,表头为“序号、问题”,冻结首行且包含格式化表格。 +- 已真实上传模板并创建 2 条问题任务;状态从等待执行、生成中、50% 更新到已完成,最终成功 2、失败 0。 +- 已验证任务详情显示每条问题、完整答案、状态与耗时;结果导出包含问题、答案、失败原因、实际模型、召回数量和耗时。 +- 浏览器控制台错误和警告:0;无横向滚动。 +- `npm --prefix apps/admin-web run build`:通过;后端相关测试:31 passed。 + +final result: passed + +--- + # 反馈管理日期筛选控件修复验收(2026-08-11) ## 对照范围 diff --git a/ai_knowledge_base_v2/development_records/ui-evidence/agent-batch-test-2026-08-12.png b/ai_knowledge_base_v2/development_records/ui-evidence/agent-batch-test-2026-08-12.png new file mode 100644 index 0000000000000000000000000000000000000000..814c47d06abccc6cbad9192b3a7fdaa8f2fb863c GIT binary patch literal 72697 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