feat: 支持 Agent 并发批测与洞察导出
This commit is contained in:
@@ -16,6 +16,8 @@ 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.models.ai_config import ModelConfig
|
||||
from app.models.knowledge import Knowledge
|
||||
from app.services.admin_service import OperationLogService
|
||||
from app.services.agent_batch_test_service import AgentBatchTestService, item_dict, job_dict
|
||||
|
||||
@@ -23,6 +25,58 @@ from app.services.agent_batch_test_service import AgentBatchTestService, item_di
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/agent/model-options")
|
||||
def agent_model_options(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
"""Safe Agent-only model choices without provider credentials or endpoints."""
|
||||
items = list(db.scalars(select(ModelConfig).order_by(ModelConfig.is_default.desc(), ModelConfig.id.desc())))
|
||||
return api_success([
|
||||
{
|
||||
"id": item.id,
|
||||
"displayName": item.display_name,
|
||||
"modelName": item.model_name,
|
||||
"temperature": float(item.temperature) if item.temperature is not None else None,
|
||||
"topP": float(item.top_p) if item.top_p is not None else None,
|
||||
"topK": item.top_k,
|
||||
"presencePenalty": float(item.presence_penalty) if item.presence_penalty is not None else None,
|
||||
"frequencyPenalty": float(item.frequency_penalty) if item.frequency_penalty is not None else None,
|
||||
"maxToken": item.max_token,
|
||||
"streamEnabled": item.stream_enabled,
|
||||
"enabled": item.enabled,
|
||||
"isDefault": item.is_default,
|
||||
}
|
||||
for item in items
|
||||
])
|
||||
|
||||
|
||||
@router.get("/agent/knowledge-options")
|
||||
def agent_knowledge_options(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
"""Safe Agent-only knowledge choices; does not grant Knowledge Management access."""
|
||||
items = list(
|
||||
db.scalars(
|
||||
select(Knowledge)
|
||||
.where(Knowledge.lifecycle_status == "active")
|
||||
.order_by(Knowledge.id.desc())
|
||||
)
|
||||
)
|
||||
return api_success([
|
||||
{
|
||||
"id": item.id,
|
||||
"name": item.name,
|
||||
"status": item.status,
|
||||
"knowledgeType": item.knowledge_type,
|
||||
"lifecycleStatus": item.lifecycle_status,
|
||||
"sourceStatus": item.source_status,
|
||||
}
|
||||
for item in items
|
||||
])
|
||||
|
||||
|
||||
@router.get("/agent/batch/template")
|
||||
def download_batch_template(
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
@@ -67,10 +121,12 @@ def list_batch_jobs(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
total = db.scalar(select(func.count(AgentBatchTest.id))) or 0
|
||||
owner_filter = _owner_filter(current_admin)
|
||||
total = db.scalar(select(func.count(AgentBatchTest.id)).where(*owner_filter)) or 0
|
||||
rows = list(
|
||||
db.scalars(
|
||||
select(AgentBatchTest)
|
||||
.where(*owner_filter)
|
||||
.order_by(AgentBatchTest.created_at.desc(), AgentBatchTest.id.desc())
|
||||
.offset((page - 1) * pageSize)
|
||||
.limit(pageSize)
|
||||
@@ -87,7 +143,7 @@ def get_batch_job(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
job = _job_or_404(db, job_id)
|
||||
job = _job_or_404(db, job_id, current_admin)
|
||||
total = db.scalar(select(func.count(AgentBatchTestItem.id)).where(AgentBatchTestItem.batch_id == job.id)) or 0
|
||||
items = list(
|
||||
db.scalars(
|
||||
@@ -107,7 +163,7 @@ def export_batch_job(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> StreamingResponse:
|
||||
job = _job_or_404(db, job_id)
|
||||
job = _job_or_404(db, job_id, current_admin)
|
||||
items = list(
|
||||
db.scalars(
|
||||
select(AgentBatchTestItem)
|
||||
@@ -131,7 +187,7 @@ def cancel_batch_job(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
job = _job_or_404(db, job_id)
|
||||
job = _job_or_404(db, job_id, current_admin)
|
||||
if job.status not in {"pending", "running"}:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="当前任务已结束,不能取消")
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
@@ -150,8 +206,12 @@ def cancel_batch_job(
|
||||
return api_success(job_dict(job))
|
||||
|
||||
|
||||
def _job_or_404(db: Session, job_id: int) -> AgentBatchTest:
|
||||
def _owner_filter(admin: Admin) -> tuple:
|
||||
return () if admin.is_super_admin else (AgentBatchTest.created_by == admin.id,)
|
||||
|
||||
|
||||
def _job_or_404(db: Session, job_id: int, admin: Admin) -> AgentBatchTest:
|
||||
job = db.get(AgentBatchTest, job_id)
|
||||
if job is None:
|
||||
if job is None or (not admin.is_super_admin and job.created_by != admin.id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="批量测试任务不存在")
|
||||
return job
|
||||
|
||||
@@ -7,7 +7,7 @@ from io import StringIO
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.responses import Response
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from sqlalchemy import exists, func, or_, select
|
||||
from sqlalchemy.orm import Session, load_only
|
||||
|
||||
@@ -23,6 +23,7 @@ from app.models.user import User
|
||||
from app.api.pagination import page_result
|
||||
from app.services.admin_service import OperationLogService
|
||||
from app.services.question_insight_service import QuestionInsightService
|
||||
from app.services.question_insight_export_service import QuestionInsightExportService
|
||||
from app.services.growth_profile_service import topic_dict, topic_summary_dict
|
||||
from app.services.help_card_service import help_card_dict
|
||||
from app.services.share_draft_service import share_draft_dict
|
||||
@@ -324,6 +325,45 @@ def refresh_question_insights(
|
||||
return api_success(result)
|
||||
|
||||
|
||||
@router.get("/question-insights/export")
|
||||
def export_question_insights(
|
||||
dateFrom: datetime | None = Query(default=None),
|
||||
dateTo: datetime | None = Query(default=None),
|
||||
minCount: int = Query(default=2, ge=1, le=50),
|
||||
maxMessages: int = Query(default=5000, ge=100, le=20000),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> StreamingResponse:
|
||||
if dateFrom is not None and dateTo is not None and dateFrom > dateTo:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="开始时间不能晚于结束时间")
|
||||
result = QuestionInsightService.summarize(
|
||||
db,
|
||||
date_from=dateFrom,
|
||||
date_to=dateTo,
|
||||
min_count=minCount,
|
||||
max_messages=maxMessages,
|
||||
page=1,
|
||||
page_size=maxMessages,
|
||||
)
|
||||
OperationLogService.write(
|
||||
db,
|
||||
admin_id=current_admin.id,
|
||||
module="question_insight",
|
||||
action=f"export:{result['total']}",
|
||||
)
|
||||
db.commit()
|
||||
filename = f"question_insights_{_export_date_label(dateFrom)}_{_export_date_label(dateTo)}.xlsx"
|
||||
return StreamingResponse(
|
||||
QuestionInsightExportService.build(result),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
def _export_date_label(value: datetime | None) -> str:
|
||||
return value.strftime("%Y%m%d") if value is not None else "all"
|
||||
|
||||
|
||||
def _chat_query(
|
||||
*,
|
||||
keyword: str,
|
||||
|
||||
@@ -83,6 +83,7 @@ class Settings(BaseSettings):
|
||||
agent_batch_worker_enabled: bool = True
|
||||
agent_batch_poll_seconds: int = 2
|
||||
agent_batch_stale_minutes: int = 30
|
||||
agent_batch_worker_concurrency: int = 10
|
||||
bootstrap_admin_username: str = ""
|
||||
bootstrap_admin_password: str = ""
|
||||
bootstrap_admin_name: str = "系统管理员"
|
||||
|
||||
@@ -26,6 +26,8 @@ class AgentBatchTest(Base):
|
||||
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)
|
||||
execution_mode: Mapped[str] = mapped_column(String(20), default="sequential", nullable=False)
|
||||
concurrency_limit: Mapped[int] = mapped_column(Integer, default=1, 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)
|
||||
|
||||
@@ -19,6 +19,7 @@ from app.models.knowledge import Knowledge
|
||||
|
||||
MAX_BATCH_QUESTIONS = 200
|
||||
MAX_QUESTION_LENGTH = 2000
|
||||
MAX_JOB_CONCURRENCY = 10
|
||||
TEMPLATE_HEADERS = ["序号", "问题"]
|
||||
|
||||
|
||||
@@ -105,10 +106,25 @@ class AgentBatchTestService:
|
||||
"reasoningVisible": 0,
|
||||
"responseDepth": int(config.get("responseDepth", 35)),
|
||||
}
|
||||
execution_mode = str(config.get("executionMode") or "sequential")
|
||||
if execution_mode not in {"sequential", "concurrent"}:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="批量测试执行模式不正确")
|
||||
try:
|
||||
requested_concurrency = int(config.get("concurrencyLimit") or 5)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="并发数格式不正确") from exc
|
||||
concurrency_limit = 1 if execution_mode == "sequential" else requested_concurrency
|
||||
if execution_mode == "concurrent" and not 2 <= concurrency_limit <= MAX_JOB_CONCURRENCY:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"单个任务并发数必须在 2 到 {MAX_JOB_CONCURRENCY} 之间",
|
||||
)
|
||||
job = AgentBatchTest(
|
||||
name=str(config.get("name") or filename.rsplit(".", 1)[0] or "Agent 批量测试")[:160],
|
||||
original_filename=filename[:255],
|
||||
total_count=len(rows),
|
||||
execution_mode=execution_mode,
|
||||
concurrency_limit=concurrency_limit,
|
||||
prompt_content=prompt_content,
|
||||
model_id=model.id,
|
||||
model_name=model.display_name or model.model_name,
|
||||
@@ -183,13 +199,14 @@ class AgentBatchTestService:
|
||||
summary.append(["任务名称", job.name])
|
||||
summary.append(["任务状态", job.status])
|
||||
summary.append(["问题总数", job.total_count])
|
||||
summary.append(["执行方式", "逐条串行" if job.execution_mode == "sequential" else f"并发测试({job.concurrency_limit} 路)"])
|
||||
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")
|
||||
_style_sheet(summary, widths=(20, 86), table_ref="A1:B11", table_name="AgentBatchSummary")
|
||||
summary.column_dimensions["B"].width = 86
|
||||
stream = BytesIO()
|
||||
workbook.save(stream)
|
||||
@@ -205,6 +222,8 @@ def job_dict(job: AgentBatchTest) -> dict:
|
||||
"status": job.status,
|
||||
"totalCount": job.total_count,
|
||||
"processedCount": job.processed_count,
|
||||
"executionMode": job.execution_mode,
|
||||
"concurrencyLimit": job.concurrency_limit,
|
||||
"successCount": job.success_count,
|
||||
"failedCount": job.failed_count,
|
||||
"progress": round(job.processed_count * 100 / job.total_count) if job.total_count else 0,
|
||||
|
||||
@@ -33,13 +33,43 @@ class AgentBatchTestWorker:
|
||||
logger.info("agent batch test worker disabled")
|
||||
return
|
||||
worker_id = f"{socket.gethostname()}:{os.getpid()}:{uuid.uuid4().hex[:8]}"
|
||||
active: set[asyncio.Task] = set()
|
||||
worker_concurrency = max(1, min(settings.agent_batch_worker_concurrency, 50))
|
||||
next_recovery_at = 0.0
|
||||
while True:
|
||||
try:
|
||||
processed = await cls.run_once(worker_id)
|
||||
if time.monotonic() >= next_recovery_at:
|
||||
with SessionLocal() as db:
|
||||
recovered_batch_ids = cls.recover_stale_items(db)
|
||||
db.commit()
|
||||
for batch_id in recovered_batch_ids:
|
||||
cls.refresh_job(db, batch_id)
|
||||
next_recovery_at = time.monotonic() + 60
|
||||
active = {task for task in active if not task.done()}
|
||||
claimed = False
|
||||
while len(active) < worker_concurrency:
|
||||
with SessionLocal() as db:
|
||||
item_id = cls.claim_next(db, worker_id=worker_id)
|
||||
if item_id is None:
|
||||
break
|
||||
claimed = True
|
||||
task = asyncio.create_task(cls.execute_claimed(item_id=item_id, worker_id=worker_id))
|
||||
task.add_done_callback(cls._log_task_failure)
|
||||
active.add(task)
|
||||
if active:
|
||||
await asyncio.wait(
|
||||
active,
|
||||
timeout=max(1, settings.agent_batch_poll_seconds),
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
processed = True
|
||||
else:
|
||||
processed = claimed
|
||||
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))
|
||||
if not processed:
|
||||
await asyncio.sleep(max(1, settings.agent_batch_poll_seconds))
|
||||
|
||||
@classmethod
|
||||
async def run_once(cls, worker_id: str) -> bool:
|
||||
@@ -59,35 +89,64 @@ class AgentBatchTestWorker:
|
||||
@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),
|
||||
jobs = list(
|
||||
db.scalars(
|
||||
select(AgentBatchTest)
|
||||
.where(AgentBatchTest.status.in_(("pending", "running")))
|
||||
.order_by(AgentBatchTest.created_at.asc(), AgentBatchTest.id.asc())
|
||||
.limit(50)
|
||||
.with_for_update(skip_locked=True)
|
||||
)
|
||||
.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
|
||||
for job in jobs:
|
||||
running_count = db.scalar(
|
||||
select(func.count(AgentBatchTestItem.id)).where(
|
||||
AgentBatchTestItem.batch_id == job.id,
|
||||
AgentBatchTestItem.status == "running",
|
||||
)
|
||||
) or 0
|
||||
job_limit = 1 if job.execution_mode == "sequential" else max(1, job.concurrency_limit)
|
||||
if running_count >= job_limit:
|
||||
continue
|
||||
item = db.scalar(
|
||||
select(AgentBatchTestItem)
|
||||
.where(
|
||||
AgentBatchTestItem.batch_id == job.id,
|
||||
AgentBatchTestItem.status == "pending",
|
||||
AgentBatchTestItem.attempt_count < AgentBatchTestItem.max_attempts,
|
||||
or_(AgentBatchTestItem.next_run_at.is_(None), AgentBatchTestItem.next_run_at <= current),
|
||||
)
|
||||
.order_by(AgentBatchTestItem.row_number.asc(), AgentBatchTestItem.id.asc())
|
||||
.with_for_update(skip_locked=True)
|
||||
.limit(1)
|
||||
)
|
||||
if item is None:
|
||||
continue
|
||||
item.status = "running"
|
||||
item.attempt_count += 1
|
||||
item.locked_at = current
|
||||
item.locked_by = worker_id
|
||||
item.started_at = current
|
||||
if job.status == "pending":
|
||||
job.status = "running"
|
||||
job.started_at = current
|
||||
db.add(job)
|
||||
db.add(item)
|
||||
db.commit()
|
||||
return item.id
|
||||
db.rollback()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _log_task_failure(task: asyncio.Task) -> None:
|
||||
if task.cancelled():
|
||||
return
|
||||
error = task.exception()
|
||||
if error is not None:
|
||||
logger.error(
|
||||
"agent batch test execution task failed",
|
||||
exc_info=(type(error), error, error.__traceback__),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def execute_claimed(cls, *, item_id: int, worker_id: str) -> None:
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Alignment, Font, PatternFill
|
||||
from openpyxl.worksheet.table import Table, TableStyleInfo
|
||||
|
||||
|
||||
class QuestionInsightExportService:
|
||||
"""Render a complete question-insight snapshot as an operator-friendly workbook."""
|
||||
|
||||
@staticmethod
|
||||
def build(result: dict) -> BytesIO:
|
||||
workbook = Workbook()
|
||||
insight_sheet = workbook.active
|
||||
insight_sheet.title = "洞察结果"
|
||||
_append_insights(insight_sheet, result.get("items") or [])
|
||||
|
||||
sample_sheet = workbook.create_sheet("相似问法与样例")
|
||||
_append_samples(sample_sheet, result.get("items") or [])
|
||||
|
||||
summary_sheet = workbook.create_sheet("统计说明")
|
||||
_append_summary(summary_sheet, result)
|
||||
|
||||
stream = BytesIO()
|
||||
workbook.save(stream)
|
||||
stream.seek(0)
|
||||
return stream
|
||||
|
||||
|
||||
def _append_insights(sheet, items: list[dict]) -> None:
|
||||
headers = [
|
||||
"排名", "问题组", "分类", "出现次数", "用户数", "会话数", "关联AI请求",
|
||||
"无知识命中", "失败请求", "需补知识/查召回", "建议动作", "高频词",
|
||||
"首次出现", "最后出现",
|
||||
]
|
||||
sheet.append(headers)
|
||||
for item in items:
|
||||
sheet.append([
|
||||
item.get("rank"),
|
||||
_excel_safe(item.get("title")),
|
||||
_excel_safe(item.get("categoryLabel")),
|
||||
item.get("count", 0),
|
||||
item.get("userCount", 0),
|
||||
item.get("sessionCount", 0),
|
||||
item.get("aiRequestCount", 0),
|
||||
item.get("noHitCount", 0),
|
||||
item.get("failedCount", 0),
|
||||
"是" if item.get("needsKnowledgeFollowUp") else "否",
|
||||
_excel_safe(item.get("suggestedAction")),
|
||||
_excel_safe("、".join(item.get("topTerms") or [])),
|
||||
_excel_datetime(item.get("firstSeenAt")),
|
||||
_excel_datetime(item.get("lastSeenAt")),
|
||||
])
|
||||
_style_table(
|
||||
sheet,
|
||||
widths=(8, 44, 14, 12, 10, 10, 14, 12, 12, 18, 54, 36, 20, 20),
|
||||
table_name="QuestionInsightResults",
|
||||
)
|
||||
for row in sheet.iter_rows(min_row=2):
|
||||
for index in (1, 10, 11):
|
||||
row[index].alignment = Alignment(vertical="top", wrap_text=True)
|
||||
for index in (12, 13):
|
||||
row[index].number_format = "yyyy-mm-dd hh:mm:ss"
|
||||
|
||||
|
||||
def _append_samples(sheet, items: list[dict]) -> None:
|
||||
headers = [
|
||||
"问题组排名", "问题组", "记录类型", "相似问法/清洗后问题", "出现次数",
|
||||
"用户", "手机号", "用户ID", "会话ID", "消息ID", "原始问题", "提问时间",
|
||||
]
|
||||
sheet.append(headers)
|
||||
for item in items:
|
||||
for variant in item.get("variants") or []:
|
||||
sheet.append([
|
||||
item.get("rank"), _excel_safe(item.get("title")), "相似问法",
|
||||
_excel_safe(variant.get("text")), variant.get("count", 0),
|
||||
"", "", "", "", "", "", "",
|
||||
])
|
||||
for sample in item.get("samples") or []:
|
||||
sheet.append([
|
||||
item.get("rank"), _excel_safe(item.get("title")), "原始样例",
|
||||
_excel_safe(sample.get("cleaned")), "",
|
||||
_excel_safe(sample.get("userName")), _excel_safe(sample.get("userPhone")),
|
||||
sample.get("userId"), sample.get("sessionId"), sample.get("messageId"),
|
||||
_excel_safe(sample.get("raw")), _excel_datetime(sample.get("createdAt")),
|
||||
])
|
||||
_style_table(
|
||||
sheet,
|
||||
widths=(12, 38, 12, 46, 12, 16, 18, 12, 12, 12, 72, 20),
|
||||
table_name="QuestionInsightSamples",
|
||||
)
|
||||
for row in sheet.iter_rows(min_row=2):
|
||||
for index in (1, 3, 10):
|
||||
row[index].alignment = Alignment(vertical="top", wrap_text=True)
|
||||
row[11].number_format = "yyyy-mm-dd hh:mm:ss"
|
||||
|
||||
|
||||
def _append_summary(sheet, result: dict) -> None:
|
||||
range_info = result.get("range") or {}
|
||||
summary = result.get("summary") or {}
|
||||
sheet.append(["项目", "内容"])
|
||||
rows = [
|
||||
("开始时间", range_info.get("dateFrom") or "未限制"),
|
||||
("结束时间", range_info.get("dateTo") or "未限制"),
|
||||
("单次清洗上限", range_info.get("maxMessages", 0)),
|
||||
("最低频次", summary.get("minCount", 0)),
|
||||
("纳入清洗消息", summary.get("scannedMessages", 0)),
|
||||
("有效问题", summary.get("cleanedQuestions", 0)),
|
||||
("过滤低价值", summary.get("filteredMessages", 0)),
|
||||
("全部问题组", summary.get("clusterCount", 0)),
|
||||
("导出问题组", summary.get("visibleClusterCount", 0)),
|
||||
("清洗规则版本", summary.get("cleanerVersion", "")),
|
||||
("导出时间", datetime.now()),
|
||||
("说明", "导出结果按所选日期范围和最低频次生成,包含全部符合条件的问题组,不受页面分页影响。"),
|
||||
]
|
||||
for label, value in rows:
|
||||
sheet.append([label, _excel_safe(value) if isinstance(value, str) else _excel_datetime(value)])
|
||||
_style_table(sheet, widths=(22, 88), table_name="QuestionInsightSummary")
|
||||
for row in sheet.iter_rows(min_row=2):
|
||||
row[1].alignment = Alignment(vertical="top", wrap_text=True)
|
||||
if isinstance(row[1].value, datetime):
|
||||
row[1].number_format = "yyyy-mm-dd hh:mm:ss"
|
||||
|
||||
|
||||
def _style_table(sheet, *, widths: tuple[int, ...], 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(horizontal="center", vertical="center", wrap_text=True)
|
||||
sheet.row_dimensions[1].height = 30
|
||||
for index, width in enumerate(widths, start=1):
|
||||
sheet.column_dimensions[_column_letter(index)].width = width
|
||||
if sheet.max_row > 1:
|
||||
ref = f"A1:{_column_letter(len(widths))}{sheet.max_row}"
|
||||
table = Table(displayName=table_name, ref=ref)
|
||||
table.tableStyleInfo = TableStyleInfo(
|
||||
name="TableStyleMedium4",
|
||||
showRowStripes=True,
|
||||
showFirstColumn=False,
|
||||
showLastColumn=False,
|
||||
)
|
||||
sheet.add_table(table)
|
||||
|
||||
|
||||
def _column_letter(index: int) -> str:
|
||||
result = ""
|
||||
while index:
|
||||
index, remainder = divmod(index - 1, 26)
|
||||
result = chr(65 + remainder) + result
|
||||
return result
|
||||
|
||||
|
||||
def _excel_safe(value: object | None) -> str:
|
||||
text = str(value or "")
|
||||
return f"'{text}" if text.lstrip().startswith(("=", "+", "-", "@")) else text
|
||||
|
||||
|
||||
def _excel_datetime(value: object) -> object:
|
||||
if isinstance(value, datetime) and value.tzinfo is not None:
|
||||
return value.replace(tzinfo=None)
|
||||
return value
|
||||
Reference in New Issue
Block a user