342 lines
12 KiB
Python
342 lines
12 KiB
Python
"""AI 处理可视化日志 API。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import date, datetime
|
|
from typing import Any, Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy import and_, case, func, or_, select, union_all
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from ..core.response import success_response
|
|
from ..database import get_db
|
|
from ..dependencies.auth import get_current_user
|
|
from ..models import (
|
|
AIProcessingLog,
|
|
FeishuItem,
|
|
ProjectSignalLink,
|
|
ProjectSuggestion,
|
|
Signal,
|
|
User,
|
|
WorkItem,
|
|
)
|
|
|
|
|
|
router = APIRouter(prefix="/ai-logs", tags=["AI Logs"])
|
|
|
|
|
|
def _normalize_bullets(value: Any) -> list[str]:
|
|
if isinstance(value, list):
|
|
return [str(x).strip() for x in value if str(x).strip()][:3]
|
|
if isinstance(value, str):
|
|
raw = value.strip()
|
|
if not raw:
|
|
return []
|
|
try:
|
|
parsed = json.loads(raw)
|
|
if isinstance(parsed, list):
|
|
return [str(x).strip() for x in parsed if str(x).strip()][:3]
|
|
except Exception:
|
|
pass
|
|
return [line.strip(" -•\t") for line in raw.splitlines() if line.strip()][:3]
|
|
return []
|
|
|
|
|
|
def _safe_json(value: Optional[str]) -> Any:
|
|
text = (value or "").strip()
|
|
if not text:
|
|
return None
|
|
try:
|
|
return json.loads(text)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _serialize_log_row(row: AIProcessingLog) -> dict[str, Any]:
|
|
return {
|
|
"id": str(row.id),
|
|
"feishu_message_id": row.feishu_message_id,
|
|
"feishu_item_id": str(row.feishu_item_id) if row.feishu_item_id is not None else None,
|
|
"raw_message": row.raw_message,
|
|
"project_score": row.project_score,
|
|
"signal_confidence": row.signal_confidence,
|
|
"level": row.level,
|
|
"routing_decision": row.routing_decision,
|
|
"routing_reason": row.routing_reason,
|
|
"reasoning_bullets": _normalize_bullets(row.reasoning_bullets),
|
|
"processing_status": row.processing_status,
|
|
"error_message": row.error_message,
|
|
"duration_ms": row.duration_ms,
|
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
|
}
|
|
|
|
|
|
def _serialize_log_detail(row: AIProcessingLog) -> dict[str, Any]:
|
|
payload = _serialize_log_row(row)
|
|
payload["ai_raw_output"] = row.ai_raw_output
|
|
payload["ai_raw_output_json"] = _safe_json(row.ai_raw_output)
|
|
return payload
|
|
|
|
|
|
def _apply_project_filter(stmt, *, tenant_id: int, project_id: Optional[int]):
|
|
if project_id is None:
|
|
return stmt
|
|
|
|
work_item_message_ids = select(func.distinct(WorkItem.source_message_id)).where(
|
|
and_(
|
|
WorkItem.tenant_id == tenant_id,
|
|
WorkItem.project_id == int(project_id),
|
|
WorkItem.source_system == "feishu",
|
|
WorkItem.source_message_id.is_not(None),
|
|
)
|
|
)
|
|
direct_items = select(FeishuItem.id.label("item_id")).where(
|
|
and_(
|
|
FeishuItem.tenant_id == tenant_id,
|
|
FeishuItem.matched_project_id == int(project_id),
|
|
)
|
|
)
|
|
signal_matched_items = select(Signal.feishu_item_id.label("item_id")).where(
|
|
and_(
|
|
Signal.tenant_id == tenant_id,
|
|
Signal.matched_project_id == int(project_id),
|
|
Signal.feishu_item_id.is_not(None),
|
|
)
|
|
)
|
|
linked_items = (
|
|
select(Signal.feishu_item_id.label("item_id"))
|
|
.select_from(Signal)
|
|
.join(
|
|
ProjectSignalLink,
|
|
and_(
|
|
ProjectSignalLink.tenant_id == tenant_id,
|
|
ProjectSignalLink.project_id == int(project_id),
|
|
ProjectSignalLink.signal_id == Signal.id,
|
|
),
|
|
)
|
|
.where(
|
|
and_(
|
|
Signal.tenant_id == tenant_id,
|
|
Signal.feishu_item_id.is_not(None),
|
|
)
|
|
)
|
|
)
|
|
auto_created_items = select(ProjectSuggestion.feishu_item_id.label("item_id")).where(
|
|
and_(
|
|
ProjectSuggestion.tenant_id == tenant_id,
|
|
ProjectSuggestion.created_project_id == int(project_id),
|
|
ProjectSuggestion.feishu_item_id.is_not(None),
|
|
)
|
|
)
|
|
|
|
all_item_ids = union_all(
|
|
direct_items,
|
|
signal_matched_items,
|
|
linked_items,
|
|
auto_created_items,
|
|
).subquery("project_item_ids")
|
|
item_ids_stmt = select(func.distinct(all_item_ids.c.item_id)).where(all_item_ids.c.item_id.is_not(None))
|
|
message_ids_stmt = select(func.distinct(FeishuItem.feishu_message_id)).where(
|
|
and_(
|
|
FeishuItem.tenant_id == tenant_id,
|
|
FeishuItem.id.in_(item_ids_stmt),
|
|
FeishuItem.feishu_message_id.is_not(None),
|
|
)
|
|
)
|
|
return stmt.where(
|
|
or_(
|
|
AIProcessingLog.feishu_item_id.in_(item_ids_stmt),
|
|
AIProcessingLog.feishu_message_id.in_(message_ids_stmt),
|
|
AIProcessingLog.feishu_message_id.in_(work_item_message_ids),
|
|
)
|
|
)
|
|
|
|
|
|
@router.get("")
|
|
async def list_ai_logs(
|
|
project_id: Optional[int] = Query(default=None, ge=1),
|
|
limit: int = Query(default=20, ge=1, le=100),
|
|
offset: int = Query(default=0, ge=0),
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
tenant_id = int(current_user.tenant_id)
|
|
total_stmt = select(func.count(AIProcessingLog.id)).where(AIProcessingLog.tenant_id == tenant_id)
|
|
data_stmt = (
|
|
select(AIProcessingLog)
|
|
.where(AIProcessingLog.tenant_id == tenant_id)
|
|
.order_by(AIProcessingLog.created_at.desc(), AIProcessingLog.id.desc())
|
|
.offset(offset)
|
|
.limit(limit)
|
|
)
|
|
total_stmt = _apply_project_filter(total_stmt, tenant_id=tenant_id, project_id=project_id)
|
|
data_stmt = _apply_project_filter(data_stmt, tenant_id=tenant_id, project_id=project_id)
|
|
|
|
total = int((await db.execute(total_stmt)).scalar() or 0)
|
|
rows = (await db.execute(data_stmt)).scalars().all()
|
|
items = [_serialize_log_row(row) for row in rows]
|
|
return success_response(
|
|
{
|
|
"items": items,
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
"has_more": (offset + len(items)) < total,
|
|
}
|
|
)
|
|
|
|
|
|
@router.get("/stats")
|
|
async def get_ai_log_stats(
|
|
project_id: Optional[int] = Query(default=None, ge=1),
|
|
date_value: str = Query(default="today", alias="date"),
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
tenant_id = int(current_user.tenant_id)
|
|
if date_value == "today":
|
|
target_date = datetime.now().date()
|
|
else:
|
|
try:
|
|
target_date = date.fromisoformat(date_value)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=f"invalid_date:{date_value}") from exc
|
|
|
|
stats_stmt = (
|
|
select(
|
|
func.count(AIProcessingLog.id).label("received_count"),
|
|
func.sum(case((AIProcessingLog.processing_status == "success", 1), else_=0)).label("success_count"),
|
|
func.sum(case((AIProcessingLog.processing_status == "failed", 1), else_=0)).label("failed_count"),
|
|
func.sum(
|
|
case((AIProcessingLog.routing_decision == "duplicate_filtered", 1), else_=0)
|
|
).label("duplicate_filtered_count"),
|
|
func.sum(
|
|
case((AIProcessingLog.routing_decision == "execute_reply_guard_triggered", 1), else_=0)
|
|
).label("execute_reply_guard_count"),
|
|
func.sum(
|
|
case(
|
|
(
|
|
AIProcessingLog.routing_decision.in_(
|
|
["auto_create_project", "auto_enter_signal"]
|
|
),
|
|
1,
|
|
),
|
|
else_=0,
|
|
)
|
|
).label("auto_entered_count"),
|
|
func.sum(
|
|
case(
|
|
(
|
|
AIProcessingLog.routing_decision.in_(
|
|
[
|
|
"pending_low_confidence",
|
|
"pending_cold_start",
|
|
"pending_uncertain_project",
|
|
]
|
|
),
|
|
1,
|
|
),
|
|
else_=0,
|
|
)
|
|
).label("pending_count"),
|
|
func.sum(case((AIProcessingLog.processing_status == "processing", 1), else_=0)).label(
|
|
"processing_count"
|
|
),
|
|
func.avg(AIProcessingLog.signal_confidence).label("avg_signal_confidence"),
|
|
func.avg(AIProcessingLog.project_score).label("avg_project_score"),
|
|
)
|
|
.where(
|
|
and_(
|
|
AIProcessingLog.tenant_id == tenant_id,
|
|
func.date(AIProcessingLog.created_at) == target_date.isoformat(),
|
|
)
|
|
)
|
|
)
|
|
stats_stmt = _apply_project_filter(stats_stmt, tenant_id=tenant_id, project_id=project_id)
|
|
row = (await db.execute(stats_stmt)).one()
|
|
return success_response(
|
|
{
|
|
"date": target_date.isoformat(),
|
|
"received_count": int(row.received_count or 0),
|
|
"success_count": int(row.success_count or 0),
|
|
"failed_count": int(row.failed_count or 0),
|
|
"duplicate_filtered_count": int(row.duplicate_filtered_count or 0),
|
|
"execute_reply_guard_count": int(row.execute_reply_guard_count or 0),
|
|
"auto_entered_count": int(row.auto_entered_count or 0),
|
|
"pending_count": int(row.pending_count or 0),
|
|
"processing_count": int(row.processing_count or 0),
|
|
"avg_signal_confidence": round(float(row.avg_signal_confidence or 0.0), 4),
|
|
"avg_project_score": round(float(row.avg_project_score or 0.0), 4),
|
|
}
|
|
)
|
|
|
|
|
|
@router.get("/alerts/execute-reply-guard")
|
|
async def list_execute_reply_guard_alerts(
|
|
limit: int = Query(default=20, ge=1, le=100),
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
tenant_id = int(current_user.tenant_id)
|
|
rows = (
|
|
await db.execute(
|
|
select(AIProcessingLog)
|
|
.where(
|
|
and_(
|
|
AIProcessingLog.tenant_id == tenant_id,
|
|
AIProcessingLog.routing_decision == "execute_reply_guard_triggered",
|
|
)
|
|
)
|
|
.order_by(AIProcessingLog.created_at.desc(), AIProcessingLog.id.desc())
|
|
.limit(limit)
|
|
)
|
|
).scalars().all()
|
|
return success_response(
|
|
{
|
|
"count": len(rows),
|
|
"items": [_serialize_log_row(row) for row in rows],
|
|
}
|
|
)
|
|
|
|
|
|
@router.get("/latest")
|
|
async def get_latest_ai_log(
|
|
project_id: Optional[int] = Query(default=None, ge=1),
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
tenant_id = int(current_user.tenant_id)
|
|
stmt = (
|
|
select(AIProcessingLog)
|
|
.where(AIProcessingLog.tenant_id == tenant_id)
|
|
.order_by(AIProcessingLog.created_at.desc(), AIProcessingLog.id.desc())
|
|
.limit(1)
|
|
)
|
|
stmt = _apply_project_filter(stmt, tenant_id=tenant_id, project_id=project_id)
|
|
row = (await db.execute(stmt)).scalar_one_or_none()
|
|
return success_response(_serialize_log_detail(row) if row else None)
|
|
|
|
|
|
@router.get("/{log_id}")
|
|
async def get_ai_log_detail(
|
|
log_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
tenant_id = int(current_user.tenant_id)
|
|
row = (
|
|
await db.execute(
|
|
select(AIProcessingLog).where(
|
|
and_(
|
|
AIProcessingLog.tenant_id == tenant_id,
|
|
AIProcessingLog.id == int(log_id),
|
|
)
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
if row is None:
|
|
raise HTTPException(status_code=404, detail="ai_log_not_found")
|
|
return success_response(_serialize_log_detail(row))
|