feat: 支持 Agent 并发批测与洞察导出
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user