644 lines
22 KiB
Python
644 lines
22 KiB
Python
"""
|
|
Monitoring API for AI Service Admin.
|
|
[AC-AISVC-97~AC-AISVC-100] Intent rule and prompt template monitoring.
|
|
[AC-AISVC-103, AC-AISVC-104] Flow monitoring.
|
|
[AC-AISVC-106, AC-AISVC-107] Guardrail monitoring.
|
|
[AC-AISVC-108~AC-AISVC-110] Conversation tracking and export.
|
|
"""
|
|
|
|
import asyncio
|
|
import csv
|
|
import json
|
|
import logging
|
|
import uuid
|
|
from datetime import datetime, timedelta
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
|
from fastapi.responses import StreamingResponse
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import desc, func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.database import get_session
|
|
from app.models.entities import (
|
|
ChatMessage,
|
|
ExportTask,
|
|
ExportTaskStatus,
|
|
FlowInstance,
|
|
FlowTestRecord,
|
|
IntentRule,
|
|
PromptTemplate,
|
|
)
|
|
from app.services.monitoring.flow_monitor import FlowMonitor
|
|
from app.services.monitoring.guardrail_monitor import GuardrailMonitor
|
|
from app.services.monitoring.intent_monitor import IntentMonitor
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/admin/monitoring", tags=["Monitoring"])
|
|
|
|
|
|
def get_tenant_id(x_tenant_id: str = Header(..., alias="X-Tenant-Id")) -> str:
|
|
"""Extract tenant ID from header."""
|
|
if not x_tenant_id:
|
|
raise HTTPException(status_code=400, detail="X-Tenant-Id header is required")
|
|
return x_tenant_id
|
|
|
|
|
|
@router.get("/intent-rules")
|
|
async def get_intent_rule_stats(
|
|
tenant_id: str = Depends(get_tenant_id),
|
|
start_date: datetime | None = Query(None, description="Start date filter"),
|
|
end_date: datetime | None = Query(None, description="End date filter"),
|
|
response_type: str | None = Query(None, description="Response type filter"),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> dict[str, Any]:
|
|
"""
|
|
[AC-AISVC-97] Get aggregated statistics for all intent rules.
|
|
"""
|
|
logger.info(
|
|
f"[AC-AISVC-97] Getting intent rule stats for tenant={tenant_id}, "
|
|
f"start={start_date}, end={end_date}"
|
|
)
|
|
|
|
monitor = IntentMonitor(session)
|
|
result = await monitor.get_rule_stats(tenant_id, start_date, end_date, response_type)
|
|
|
|
return result.to_dict()
|
|
|
|
|
|
@router.get("/intent-rules/{rule_id}/hits")
|
|
async def get_intent_rule_hits(
|
|
rule_id: uuid.UUID,
|
|
tenant_id: str = Depends(get_tenant_id),
|
|
page: int = Query(1, ge=1, description="Page number"),
|
|
page_size: int = Query(20, ge=1, le=100, description="Page size"),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> dict[str, Any]:
|
|
"""
|
|
[AC-AISVC-98] Get hit records for a specific intent rule.
|
|
"""
|
|
logger.info(
|
|
f"[AC-AISVC-98] Getting intent rule hits for tenant={tenant_id}, "
|
|
f"rule_id={rule_id}, page={page}"
|
|
)
|
|
|
|
monitor = IntentMonitor(session)
|
|
records, total = await monitor.get_rule_hits(tenant_id, rule_id, page, page_size)
|
|
|
|
return {
|
|
"data": [r.to_dict() for r in records],
|
|
"page": page,
|
|
"pageSize": page_size,
|
|
"total": total,
|
|
}
|
|
|
|
|
|
@router.get("/script-flows")
|
|
async def get_flow_stats(
|
|
tenant_id: str = Depends(get_tenant_id),
|
|
start_date: datetime | None = Query(None, description="Start date filter"),
|
|
end_date: datetime | None = Query(None, description="End date filter"),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> dict[str, Any]:
|
|
"""
|
|
[AC-AISVC-103] Get aggregated statistics for all script flows.
|
|
"""
|
|
logger.info(
|
|
f"[AC-AISVC-103] Getting flow stats for tenant={tenant_id}, "
|
|
f"start={start_date}, end={end_date}"
|
|
)
|
|
|
|
monitor = FlowMonitor(session)
|
|
result = await monitor.get_flow_stats(tenant_id, start_date, end_date)
|
|
|
|
return result.to_dict()
|
|
|
|
|
|
@router.get("/script-flows/{flow_id}/executions")
|
|
async def get_flow_executions(
|
|
flow_id: uuid.UUID,
|
|
tenant_id: str = Depends(get_tenant_id),
|
|
page: int = Query(1, ge=1, description="Page number"),
|
|
page_size: int = Query(20, ge=1, le=100, description="Page size"),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> dict[str, Any]:
|
|
"""
|
|
[AC-AISVC-104] Get execution records for a specific flow.
|
|
"""
|
|
logger.info(
|
|
f"[AC-AISVC-104] Getting flow executions for tenant={tenant_id}, "
|
|
f"flow_id={flow_id}, page={page}"
|
|
)
|
|
|
|
monitor = FlowMonitor(session)
|
|
records, total = await monitor.get_flow_executions(tenant_id, flow_id, page, page_size)
|
|
|
|
return {
|
|
"data": [r.to_dict() for r in records],
|
|
"page": page,
|
|
"pageSize": page_size,
|
|
"total": total,
|
|
}
|
|
|
|
|
|
@router.get("/guardrails")
|
|
async def get_guardrail_stats(
|
|
tenant_id: str = Depends(get_tenant_id),
|
|
category: str | None = Query(None, description="Category filter"),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> dict[str, Any]:
|
|
"""
|
|
[AC-AISVC-106] Get aggregated statistics for all guardrails.
|
|
"""
|
|
logger.info(
|
|
f"[AC-AISVC-106] Getting guardrail stats for tenant={tenant_id}, "
|
|
f"category={category}"
|
|
)
|
|
|
|
monitor = GuardrailMonitor(session)
|
|
result = await monitor.get_guardrail_stats(tenant_id, category)
|
|
|
|
return result.to_dict()
|
|
|
|
|
|
@router.get("/guardrails/{word_id}/blocks")
|
|
async def get_guardrail_blocks(
|
|
word_id: uuid.UUID,
|
|
tenant_id: str = Depends(get_tenant_id),
|
|
page: int = Query(1, ge=1, description="Page number"),
|
|
page_size: int = Query(20, ge=1, le=100, description="Page size"),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> dict[str, Any]:
|
|
"""
|
|
[AC-AISVC-107] Get block records for a specific forbidden word.
|
|
"""
|
|
logger.info(
|
|
f"[AC-AISVC-107] Getting guardrail blocks for tenant={tenant_id}, "
|
|
f"word_id={word_id}, page={page}"
|
|
)
|
|
|
|
monitor = GuardrailMonitor(session)
|
|
records, total = await monitor.get_word_blocks(tenant_id, word_id, page, page_size)
|
|
|
|
return {
|
|
"data": [r.to_dict() for r in records],
|
|
"page": page,
|
|
"pageSize": page_size,
|
|
"total": total,
|
|
}
|
|
|
|
|
|
@router.get("/conversations")
|
|
async def list_conversations(
|
|
tenant_id: str = Depends(get_tenant_id),
|
|
session_id: str | None = Query(None, description="Filter by session ID"),
|
|
start_date: datetime | None = Query(None, description="Start date filter"),
|
|
end_date: datetime | None = Query(None, description="End date filter"),
|
|
has_flow: bool | None = Query(None, description="Filter by flow involvement"),
|
|
has_guardrail: bool | None = Query(None, description="Filter by guardrail trigger"),
|
|
page: int = Query(1, ge=1, description="Page number"),
|
|
page_size: int = Query(20, ge=1, le=100, description="Page size"),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> dict[str, Any]:
|
|
"""
|
|
[AC-AISVC-108] List conversations with filters.
|
|
Returns paginated list of conversations with basic info.
|
|
"""
|
|
logger.info(
|
|
f"[AC-AISVC-108] Listing conversations for tenant={tenant_id}, "
|
|
f"session={session_id}, page={page}"
|
|
)
|
|
|
|
stmt = (
|
|
select(ChatMessage)
|
|
.where(
|
|
ChatMessage.tenant_id == tenant_id,
|
|
ChatMessage.role == "user",
|
|
)
|
|
)
|
|
|
|
if session_id:
|
|
stmt = stmt.where(ChatMessage.session_id == session_id)
|
|
if start_date:
|
|
stmt = stmt.where(ChatMessage.created_at >= start_date)
|
|
if end_date:
|
|
stmt = stmt.where(ChatMessage.created_at <= end_date)
|
|
if has_flow is not None:
|
|
if has_flow:
|
|
stmt = stmt.where(ChatMessage.flow_instance_id.is_not(None))
|
|
else:
|
|
stmt = stmt.where(ChatMessage.flow_instance_id.is_(None))
|
|
if has_guardrail is not None:
|
|
if has_guardrail:
|
|
stmt = stmt.where(ChatMessage.guardrail_triggered.is_(True))
|
|
else:
|
|
stmt = stmt.where(ChatMessage.guardrail_triggered.is_(False))
|
|
|
|
count_stmt = select(func.count()).select_from(stmt.subquery())
|
|
total_result = await session.execute(count_stmt)
|
|
total = total_result.scalar() or 0
|
|
|
|
stmt = stmt.order_by(desc(ChatMessage.created_at))
|
|
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
|
|
|
|
result = await session.execute(stmt)
|
|
messages = result.scalars().all()
|
|
|
|
conversations = []
|
|
for msg in messages:
|
|
assistant_stmt = select(ChatMessage).where(
|
|
ChatMessage.tenant_id == tenant_id,
|
|
ChatMessage.session_id == msg.session_id,
|
|
ChatMessage.role == "assistant",
|
|
ChatMessage.created_at > msg.created_at,
|
|
).order_by(ChatMessage.created_at).limit(1)
|
|
assistant_result = await session.execute(assistant_stmt)
|
|
assistant_msg = assistant_result.scalar_one_or_none()
|
|
|
|
user_msg_display = (
|
|
msg.content[:200] + "..." if len(msg.content) > 200 else msg.content
|
|
)
|
|
ai_reply_display = None
|
|
if assistant_msg:
|
|
ai_reply_display = (
|
|
assistant_msg.content[:200] + "..."
|
|
if len(assistant_msg.content) > 200
|
|
else assistant_msg.content
|
|
)
|
|
|
|
conversations.append({
|
|
"id": str(msg.id),
|
|
"sessionId": msg.session_id,
|
|
"userMessage": user_msg_display,
|
|
"aiReply": ai_reply_display,
|
|
"hasFlow": msg.flow_instance_id is not None,
|
|
"hasGuardrail": msg.guardrail_triggered,
|
|
"createdAt": msg.created_at.isoformat(),
|
|
})
|
|
|
|
return {
|
|
"data": conversations,
|
|
"page": page,
|
|
"pageSize": page_size,
|
|
"total": total,
|
|
}
|
|
|
|
|
|
@router.get("/conversations/{message_id}")
|
|
async def get_conversation_detail(
|
|
message_id: uuid.UUID,
|
|
tenant_id: str = Depends(get_tenant_id),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> dict[str, Any]:
|
|
"""
|
|
[AC-AISVC-109] Get conversation detail with execution chain.
|
|
Returns detailed execution steps for debugging.
|
|
"""
|
|
logger.info(
|
|
f"[AC-AISVC-109] Getting conversation detail for tenant={tenant_id}, "
|
|
f"message_id={message_id}"
|
|
)
|
|
|
|
user_msg_stmt = select(ChatMessage).where(
|
|
ChatMessage.id == message_id,
|
|
ChatMessage.tenant_id == tenant_id,
|
|
ChatMessage.role == "user",
|
|
)
|
|
result = await session.execute(user_msg_stmt)
|
|
user_msg = result.scalar_one_or_none()
|
|
|
|
if not user_msg:
|
|
raise HTTPException(status_code=404, detail="Conversation not found")
|
|
|
|
assistant_stmt = select(ChatMessage).where(
|
|
ChatMessage.tenant_id == tenant_id,
|
|
ChatMessage.session_id == user_msg.session_id,
|
|
ChatMessage.role == "assistant",
|
|
ChatMessage.created_at > user_msg.created_at,
|
|
).order_by(ChatMessage.created_at).limit(1)
|
|
assistant_result = await session.execute(assistant_stmt)
|
|
assistant_msg = assistant_result.scalar_one_or_none()
|
|
|
|
triggered_rules = []
|
|
if user_msg.intent_rule_id:
|
|
rule_stmt = select(IntentRule).where(IntentRule.id == user_msg.intent_rule_id)
|
|
rule_result = await session.execute(rule_stmt)
|
|
rule = rule_result.scalar_one_or_none()
|
|
if rule:
|
|
triggered_rules.append({
|
|
"id": str(rule.id),
|
|
"name": rule.name,
|
|
"responseType": rule.response_type,
|
|
})
|
|
|
|
used_template = None
|
|
if assistant_msg and assistant_msg.prompt_template_id:
|
|
template_stmt = select(PromptTemplate).where(
|
|
PromptTemplate.id == assistant_msg.prompt_template_id
|
|
)
|
|
template_result = await session.execute(template_stmt)
|
|
template = template_result.scalar_one_or_none()
|
|
if template:
|
|
used_template = {
|
|
"id": str(template.id),
|
|
"name": template.name,
|
|
}
|
|
|
|
used_flow = None
|
|
if user_msg.flow_instance_id:
|
|
flow_stmt = select(FlowInstance).where(
|
|
FlowInstance.id == user_msg.flow_instance_id
|
|
)
|
|
flow_result = await session.execute(flow_stmt)
|
|
flow_instance = flow_result.scalar_one_or_none()
|
|
if flow_instance:
|
|
used_flow = {
|
|
"id": str(flow_instance.id),
|
|
"flowId": str(flow_instance.flow_id),
|
|
"status": flow_instance.status,
|
|
"currentStep": flow_instance.current_step,
|
|
}
|
|
|
|
execution_steps = None
|
|
test_record_stmt = select(FlowTestRecord).where(
|
|
FlowTestRecord.session_id == user_msg.session_id,
|
|
).order_by(desc(FlowTestRecord.created_at)).limit(1)
|
|
test_result = await session.execute(test_record_stmt)
|
|
test_record = test_result.scalar_one_or_none()
|
|
if test_record:
|
|
execution_steps = test_record.steps
|
|
|
|
return {
|
|
"conversationId": str(user_msg.id),
|
|
"sessionId": user_msg.session_id,
|
|
"userMessage": user_msg.content,
|
|
"aiReply": assistant_msg.content if assistant_msg else None,
|
|
"triggeredRules": triggered_rules,
|
|
"usedTemplate": used_template,
|
|
"usedFlow": used_flow,
|
|
"executionTimeMs": assistant_msg.latency_ms if assistant_msg else None,
|
|
"confidence": None,
|
|
"shouldTransfer": False,
|
|
"guardrailTriggered": user_msg.guardrail_triggered,
|
|
"guardrailWords": user_msg.guardrail_words,
|
|
"executionSteps": execution_steps,
|
|
"createdAt": user_msg.created_at.isoformat(),
|
|
}
|
|
|
|
|
|
class ExportRequest(BaseModel):
|
|
"""Export request schema."""
|
|
|
|
format: str = "json"
|
|
session_id: str | None = None
|
|
start_date: datetime | None = None
|
|
end_date: datetime | None = None
|
|
|
|
|
|
@router.post("/conversations/export")
|
|
async def export_conversations(
|
|
request: ExportRequest,
|
|
tenant_id: str = Depends(get_tenant_id),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> dict[str, Any]:
|
|
"""
|
|
[AC-AISVC-110] Export conversations to file.
|
|
Supports JSON and CSV formats.
|
|
"""
|
|
logger.info(
|
|
f"[AC-AISVC-110] Exporting conversations for tenant={tenant_id}, "
|
|
f"format={request.format}"
|
|
)
|
|
|
|
export_task = ExportTask(
|
|
tenant_id=tenant_id,
|
|
status=ExportTaskStatus.PROCESSING.value,
|
|
format=request.format,
|
|
filters={
|
|
"session_id": request.session_id,
|
|
"start_date": request.start_date.isoformat() if request.start_date else None,
|
|
"end_date": request.end_date.isoformat() if request.end_date else None,
|
|
},
|
|
expires_at=datetime.utcnow() + timedelta(hours=24),
|
|
)
|
|
session.add(export_task)
|
|
await session.commit()
|
|
await session.refresh(export_task)
|
|
|
|
asyncio.create_task(
|
|
_process_export(
|
|
export_task.id,
|
|
tenant_id,
|
|
request.format,
|
|
request.session_id,
|
|
request.start_date,
|
|
request.end_date,
|
|
)
|
|
)
|
|
|
|
return {
|
|
"taskId": str(export_task.id),
|
|
"status": export_task.status,
|
|
"format": export_task.format,
|
|
"createdAt": export_task.created_at.isoformat(),
|
|
}
|
|
|
|
|
|
@router.get("/conversations/export/{task_id}")
|
|
async def get_export_status(
|
|
task_id: uuid.UUID,
|
|
tenant_id: str = Depends(get_tenant_id),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> dict[str, Any]:
|
|
"""
|
|
[AC-AISVC-110] Get export task status.
|
|
"""
|
|
stmt = select(ExportTask).where(
|
|
ExportTask.id == task_id,
|
|
ExportTask.tenant_id == tenant_id,
|
|
)
|
|
result = await session.execute(stmt)
|
|
task = result.scalar_one_or_none()
|
|
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Export task not found")
|
|
|
|
response = {
|
|
"taskId": str(task.id),
|
|
"status": task.status,
|
|
"format": task.format,
|
|
"createdAt": task.created_at.isoformat(),
|
|
}
|
|
|
|
if task.status == ExportTaskStatus.COMPLETED.value:
|
|
response["fileName"] = task.file_name
|
|
response["fileSize"] = task.file_size
|
|
response["totalRows"] = task.total_rows
|
|
response["completedAt"] = task.completed_at.isoformat() if task.completed_at else None
|
|
elif task.status == ExportTaskStatus.FAILED.value:
|
|
response["errorMessage"] = task.error_message
|
|
|
|
return response
|
|
|
|
|
|
@router.get("/conversations/export/{task_id}/download")
|
|
async def download_export(
|
|
task_id: uuid.UUID,
|
|
tenant_id: str = Depends(get_tenant_id),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> StreamingResponse:
|
|
"""
|
|
[AC-AISVC-110] Download exported file.
|
|
"""
|
|
stmt = select(ExportTask).where(
|
|
ExportTask.id == task_id,
|
|
ExportTask.tenant_id == tenant_id,
|
|
)
|
|
result = await session.execute(stmt)
|
|
task = result.scalar_one_or_none()
|
|
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Export task not found")
|
|
|
|
if task.status != ExportTaskStatus.COMPLETED.value:
|
|
raise HTTPException(status_code=400, detail="Export not completed")
|
|
|
|
if not task.file_path:
|
|
raise HTTPException(status_code=404, detail="Export file not found")
|
|
|
|
try:
|
|
with open(task.file_path, "rb") as f:
|
|
content = f.read()
|
|
|
|
media_type = "application/json" if task.format == "json" else "text/csv"
|
|
|
|
return StreamingResponse(
|
|
iter([content]),
|
|
media_type=media_type,
|
|
headers={
|
|
"Content-Disposition": f'attachment; filename="{task.file_name}"',
|
|
},
|
|
)
|
|
except FileNotFoundError:
|
|
raise HTTPException(status_code=404, detail="Export file expired or not found")
|
|
|
|
|
|
async def _process_export(
|
|
task_id: uuid.UUID,
|
|
tenant_id: str,
|
|
format: str,
|
|
session_id: str | None,
|
|
start_date: datetime | None,
|
|
end_date: datetime | None,
|
|
) -> None:
|
|
"""Background task to process export."""
|
|
from app.core.database import async_session_maker
|
|
|
|
async with async_session_maker() as session:
|
|
try:
|
|
stmt = select(ExportTask).where(ExportTask.id == task_id)
|
|
result = await session.execute(stmt)
|
|
task = result.scalar_one_or_none()
|
|
|
|
if not task:
|
|
return
|
|
|
|
msg_stmt = (
|
|
select(ChatMessage)
|
|
.where(ChatMessage.tenant_id == tenant_id)
|
|
.order_by(ChatMessage.created_at)
|
|
)
|
|
|
|
if session_id:
|
|
msg_stmt = msg_stmt.where(ChatMessage.session_id == session_id)
|
|
if start_date:
|
|
msg_stmt = msg_stmt.where(ChatMessage.created_at >= start_date)
|
|
if end_date:
|
|
msg_stmt = msg_stmt.where(ChatMessage.created_at <= end_date)
|
|
|
|
result = await session.execute(msg_stmt)
|
|
messages = result.scalars().all()
|
|
|
|
conversations = []
|
|
current_conv = None
|
|
|
|
for msg in messages:
|
|
if msg.role == "user":
|
|
if current_conv:
|
|
conversations.append(current_conv)
|
|
current_conv = {
|
|
"session_id": msg.session_id,
|
|
"user_message": msg.content,
|
|
"ai_reply": None,
|
|
"created_at": msg.created_at.isoformat(),
|
|
"intent_rule_id": str(msg.intent_rule_id) if msg.intent_rule_id else None,
|
|
"flow_instance_id": str(msg.flow_instance_id) if msg.flow_instance_id else None,
|
|
"guardrail_triggered": msg.guardrail_triggered,
|
|
}
|
|
elif msg.role == "assistant" and current_conv:
|
|
current_conv["ai_reply"] = msg.content
|
|
current_conv["latency_ms"] = msg.latency_ms
|
|
current_conv["prompt_template_id"] = str(msg.prompt_template_id) if msg.prompt_template_id else None
|
|
|
|
if current_conv:
|
|
conversations.append(current_conv)
|
|
|
|
import os
|
|
export_dir = "exports"
|
|
os.makedirs(export_dir, exist_ok=True)
|
|
|
|
file_name = f"conversations_{tenant_id}_{task_id}.{format}"
|
|
file_path = os.path.join(export_dir, file_name)
|
|
|
|
if format == "json":
|
|
content = json.dumps(conversations, indent=2, ensure_ascii=False)
|
|
with open(file_path, "w", encoding="utf-8") as f:
|
|
f.write(content)
|
|
else:
|
|
with open(file_path, "w", encoding="utf-8", newline="") as f:
|
|
writer = csv.writer(f)
|
|
writer.writerow([
|
|
"session_id", "user_message", "ai_reply", "created_at",
|
|
"intent_rule_id", "flow_instance_id", "guardrail_triggered",
|
|
"latency_ms", "prompt_template_id"
|
|
])
|
|
for conv in conversations:
|
|
writer.writerow([
|
|
conv.get("session_id"),
|
|
conv.get("user_message"),
|
|
conv.get("ai_reply"),
|
|
conv.get("created_at"),
|
|
conv.get("intent_rule_id"),
|
|
conv.get("flow_instance_id"),
|
|
conv.get("guardrail_triggered"),
|
|
conv.get("latency_ms"),
|
|
conv.get("prompt_template_id"),
|
|
])
|
|
|
|
file_size = os.path.getsize(file_path)
|
|
|
|
task.status = ExportTaskStatus.COMPLETED.value
|
|
task.file_path = file_path
|
|
task.file_name = file_name
|
|
task.file_size = file_size
|
|
task.total_rows = len(conversations)
|
|
task.completed_at = datetime.utcnow()
|
|
|
|
await session.commit()
|
|
|
|
logger.info(
|
|
f"[AC-AISVC-110] Export completed: task_id={task_id}, "
|
|
f"rows={len(conversations)}, size={file_size}"
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"[AC-AISVC-110] Export failed: task_id={task_id}, error={e}")
|
|
|
|
task = await session.get(ExportTask, task_id)
|
|
if task:
|
|
task.status = ExportTaskStatus.FAILED.value
|
|
task.error_message = str(e)
|
|
await session.commit()
|