"""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))