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 0000000..814c47d Binary files /dev/null and b/ai_knowledge_base_v2/development_records/ui-evidence/agent-batch-test-2026-08-12.png differ diff --git a/ai_knowledge_base_v2/docker-compose.dev.yml b/ai_knowledge_base_v2/docker-compose.dev.yml index c92fb27..ad70877 100644 --- a/ai_knowledge_base_v2/docker-compose.dev.yml +++ b/ai_knowledge_base_v2/docker-compose.dev.yml @@ -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" diff --git a/ai_knowledge_base_v2/docker-compose.prod.yml b/ai_knowledge_base_v2/docker-compose.prod.yml index 4a6abec..8c31217 100644 --- a/ai_knowledge_base_v2/docker-compose.prod.yml +++ b/ai_knowledge_base_v2/docker-compose.prod.yml @@ -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"