feat: 支持 Agent 并发批测与洞察导出

This commit is contained in:
2026-08-13 17:25:22 +08:00
parent 3faecab6ac
commit f952d6dc58
22 changed files with 666 additions and 44 deletions

View File

@@ -3,9 +3,10 @@ from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from types import SimpleNamespace
from openpyxl import load_workbook
from app.api.admin_agent_records import attention_list, retrieval_logs
from app.api.admin_records import ai_logs, chat_detail, chat_messages, question_insights, refresh_question_insights
from app.api.admin_records import ai_logs, chat_detail, chat_messages, export_question_insights, question_insights, refresh_question_insights
from app.api.admin_users import list_users, user_operation_detail, user_topic_options
from app.models import Base
from app.models.chat import ChatMessage, ChatSession, TopicSession
@@ -14,6 +15,9 @@ from app.models.insight import QuestionInsightCleanedQuestion
from app.models.knowledge import HumanAttentionRecord, KnowledgeRetrievalLog
from app.models.logs import AiRequestLog
from app.models.user import User
from app.services.question_insight_export_service import QuestionInsightExportService
from fastapi import HTTPException
import pytest
def _database() -> Session:
@@ -211,3 +215,24 @@ def test_question_insights_clean_and_cluster_similar_user_questions():
assert data["items"][0]["category"] == "homework"
assert data["items"][0]["noHitCount"] == 1
assert data["items"][0]["needsKnowledgeFollowUp"] is True
workbook = load_workbook(QuestionInsightExportService.build(data), data_only=True)
assert workbook.sheetnames == ["洞察结果", "相似问法与样例", "统计说明"]
assert workbook["洞察结果"]["B2"].value == data["items"][0]["title"]
assert workbook["洞察结果"]["D2"].value == 2
assert workbook["洞察结果"]["J2"].value == ""
assert workbook["相似问法与样例"].max_row > 2
assert workbook["统计说明"]["B10"].value == 1
def test_question_insight_export_rejects_reversed_date_range():
with _database() as db, pytest.raises(HTTPException) as exc:
export_question_insights(
dateFrom=datetime(2026, 8, 2),
dateTo=datetime(2026, 8, 1),
minCount=2,
maxMessages=100,
db=db,
current_admin=SimpleNamespace(id=1),
)
assert exc.value.status_code == 400

View File

@@ -4,7 +4,9 @@ import json
import pytest
from fastapi import HTTPException
from starlette.requests import Request
from app.core.dependencies import enforce_admin_access
from app.models.admin import Admin, Role
from app.services.admin_permission_service import ALL_PERMISSION_CODES, permissions_for, require_permission
@@ -24,3 +26,24 @@ def test_role_permissions_are_restricted_to_catalog() -> None:
with pytest.raises(HTTPException) as exc:
require_permission(admin, "users.delete")
assert exc.value.status_code == 403
def test_agent_options_only_require_agent_view_permission() -> None:
role = Role(code="agent_tester", name="Agent 测试员", permissions=json.dumps(["prompt.view", "prompt.batch"]))
admin = Admin(
id=3,
username="agent-tester",
password="hash",
name="Agent 测试员",
status=1,
must_change_password=0,
is_super_admin=0,
role=role,
)
request = Request({"type": "http", "method": "GET", "path": "/api/admin/agent/knowledge-options", "headers": []})
assert enforce_admin_access(request, admin) is admin
knowledge_request = Request({"type": "http", "method": "GET", "path": "/api/admin/knowledge/options", "headers": []})
with pytest.raises(HTTPException) as exc:
enforce_admin_access(knowledge_request, admin)
assert exc.value.status_code == 403

View File

@@ -4,15 +4,17 @@ import json
import pytest
from fastapi import HTTPException, UploadFile
from openpyxl import Workbook, load_workbook
from sqlalchemy import create_engine, select
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.api.admin_agent_batch import _job_or_404, list_batch_jobs
from app.models import Base
from app.models.admin import Admin
from app.models.agent_batch import AgentBatchTestItem
from app.models.agent_batch import AgentBatchTest, AgentBatchTestItem
from app.models.ai_config import ModelConfig
from app.services.agent_batch_test_service import AgentBatchTestService, MAX_BATCH_QUESTIONS
from app.services.agent_batch_test_worker import AgentBatchTestWorker
def _database() -> Session:
@@ -98,6 +100,8 @@ def test_create_job_snapshots_config_and_export_keeps_failed_rows() -> None:
assert json.loads(job.generation_config)["maxToken"] == 8192
assert json.loads(job.knowledge_names) == ["全部正式开放知识库"]
assert job.execution_mode == "sequential"
assert job.concurrency_limit == 1
exported = AgentBatchTestService.export_workbook(job, items)
workbook = load_workbook(exported, data_only=True)
@@ -123,3 +127,111 @@ def test_import_rejects_excel_formula_question() -> None:
assert exc.value.status_code == 400
assert "不能使用 Excel 公式" in exc.value.detail
def test_sequential_job_only_claims_one_running_item() -> None:
with _database() as db:
db.add_all([_admin(), _model()])
db.commit()
job = _create_test_job(db, execution_mode="sequential", concurrency_limit=1)
db.commit()
first_id = AgentBatchTestWorker.claim_next(db, worker_id="worker-a")
assert first_id is not None
assert AgentBatchTestWorker.claim_next(db, worker_id="worker-b") is None
first = db.get(AgentBatchTestItem, first_id)
first.status = "success"
db.commit()
second_id = AgentBatchTestWorker.claim_next(db, worker_id="worker-b")
assert second_id is not None
assert db.get(AgentBatchTestItem, second_id).batch_id == job.id
def test_concurrent_job_respects_per_job_limit() -> None:
with _database() as db:
db.add_all([_admin(), _model()])
db.commit()
_create_test_job(db, execution_mode="concurrent", concurrency_limit=2, question_count=3)
db.commit()
first_id = AgentBatchTestWorker.claim_next(db, worker_id="worker-a")
second_id = AgentBatchTestWorker.claim_next(db, worker_id="worker-b")
assert first_id is not None
assert second_id is not None
assert first_id != second_id
assert AgentBatchTestWorker.claim_next(db, worker_id="worker-c") is None
running = db.scalar(
select(func.count(AgentBatchTestItem.id))
.where(AgentBatchTestItem.status == "running")
)
assert running == 2
def test_jobs_keep_each_admins_knowledge_snapshot_separate() -> None:
with _database() as db:
first_admin = _admin()
second_admin = Admin(id=2, username="reviewer", password="hash", name="评审管理员", status=1)
db.add_all([first_admin, second_admin, _model()])
db.commit()
first = _create_test_job(db, admin=first_admin, knowledge_ids=[])
second = _create_test_job(db, admin=second_admin, knowledge_ids=[])
db.commit()
assert first.created_by == 1
assert second.created_by == 2
assert first.id != second.id
assert first.knowledge_ids == "[]"
assert second.knowledge_ids == "[]"
def test_normal_admin_only_lists_and_opens_own_jobs() -> None:
with _database() as db:
first_admin = _admin()
second_admin = Admin(id=2, username="reviewer", password="hash", name="评审管理员", status=1)
super_admin = Admin(id=3, username="root", password="hash", name="超级管理员", status=1, is_super_admin=1)
db.add_all([first_admin, second_admin, super_admin, _model()])
db.commit()
own_job = _create_test_job(db, admin=first_admin)
other_job = _create_test_job(db, admin=second_admin)
db.commit()
own_result = list_batch_jobs(page=1, pageSize=10, db=db, current_admin=first_admin)["data"]
assert own_result["total"] == 1
assert [item["id"] for item in own_result["items"]] == [own_job.id]
assert _job_or_404(db, own_job.id, first_admin).id == own_job.id
with pytest.raises(HTTPException) as exc:
_job_or_404(db, other_job.id, first_admin)
assert exc.value.status_code == 404
super_result = list_batch_jobs(page=1, pageSize=10, db=db, current_admin=super_admin)["data"]
assert super_result["total"] == 2
assert _job_or_404(db, other_job.id, super_admin).id == other_job.id
def _create_test_job(
db: Session,
*,
admin: Admin | None = None,
execution_mode: str = "sequential",
concurrency_limit: int = 1,
question_count: int = 2,
knowledge_ids: list[int] | None = None,
) -> AgentBatchTest:
return AgentBatchTestService.create_job(
db,
admin=admin or db.get(Admin, 1),
filename="并发测试.xlsx",
rows=[
{"rowNumber": index + 2, "externalNo": str(index + 1), "question": f"问题 {index + 1}"}
for index in range(question_count)
],
config={
"name": "并发稳定性测试",
"promptContent": "根据知识库回答",
"modelId": 1,
"knowledgeIds": knowledge_ids or [],
"executionMode": execution_mode,
"concurrencyLimit": concurrency_limit,
},
)