""" UX 분석 — 사용자 행동 이벤트 수집·분석·AI 개선 제안. 엔드포인트: POST /api/ux/event — 이벤트 수집 GET /api/ux/dashboard — UX 현황 대시보드 GET /api/ux/heatmap — 클릭 히트맵 데이터 GET /api/ux/funnel — 사용자 흐름 (페이지 전환 퍼널) GET /api/ux/suggestions — AI 개선 제안 (Ollama) GET /api/ux/errors — UI 에러 패턴 분석 """ from __future__ import annotations import json import logging from collections import defaultdict from datetime import datetime, timedelta from typing import Any, Dict, List, Optional import httpx from fastapi import APIRouter, Depends, Query from pydantic import BaseModel from sqlalchemy import desc, func as sa_func, select, and_ from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user, get_optional_user from database import get_db from models import UXEvent, User logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/ux", tags=["ux-analytics"]) # ── Pydantic 스키마 ─────────────────────────────────────────────────────────── class UXEventIn(BaseModel): event_type: str # click | pageview | error | scroll page: str element: Optional[str] = None duration_ms: Optional[int] = None session_id: str extra: Optional[Dict[str, Any]] = None # 추가 메타데이터 class UXEventOut(BaseModel): model_config = {"from_attributes": True} id: int event_type: str page: str element: Optional[str] duration_ms: Optional[int] session_id: str created_at: Optional[datetime] # ── Ollama 개선 제안 헬퍼 ───────────────────────────────────────────────────── _OLLAMA_URL = "http://localhost:11434/api/generate" _SUGGEST_PROMPT_TMPL = """당신은 UX 분석 전문가입니다. 다음 UX 지표를 보고 개선 제안을 3가지 JSON 배열로 출력하세요. 지표: {metrics} 출력 형식 (JSON 배열만, 설명 없음): [ {{"priority": "HIGH|MEDIUM|LOW", "area": "페이지/기능명", "issue": "문제 설명", "suggestion": "개선 방안"}}, ... ] """ async def _get_ai_suggestions(metrics: Dict[str, Any]) -> List[Dict[str, Any]]: """Ollama로 UX 개선 제안 생성. 실패 시 빈 목록 반환.""" prompt = _SUGGEST_PROMPT_TMPL.format( metrics=json.dumps(metrics, ensure_ascii=False, indent=2) ) try: async with httpx.AsyncClient(timeout=30.0) as client: resp = await client.post( _OLLAMA_URL, json={"model": "llama3", "prompt": prompt, "stream": False}, ) if resp.status_code == 200: raw = resp.json().get("response", "") start = raw.find("[") end = raw.rfind("]") + 1 if start >= 0 and end > start: return json.loads(raw[start:end]) except Exception as exc: logger.warning("Ollama UX 제안 실패: %s", exc) return [] # ── 엔드포인트 ──────────────────────────────────────────────────────────────── @router.post("/event", summary="UX 이벤트 수집") async def collect_event( req: UXEventIn, current_user: Optional[User] = Depends(get_optional_user), db: AsyncSession = Depends(get_db), ): """클라이언트에서 발생한 UX 이벤트를 수집한다. 비로그인 상태에서도 수집 가능.""" event = UXEvent( event_type=req.event_type, page=req.page, element=req.element, duration_ms=req.duration_ms, user_id=current_user.id if current_user else None, session_id=req.session_id, extra=json.dumps(req.extra, ensure_ascii=False) if req.extra else None, ) db.add(event) await db.commit() await db.refresh(event) return {"ok": True, "event_id": event.id} @router.get("/dashboard", summary="UX 현황 대시보드") async def get_dashboard( days: int = Query(7, ge=1, le=90, description="최근 N일"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """최근 N일 기준 UX 이벤트 통계를 반환한다.""" since = datetime.utcnow() - timedelta(days=days) # 전체 이벤트 수 total_stmt = select(sa_func.count(UXEvent.id)).where(UXEvent.created_at >= since) total = (await db.execute(total_stmt)).scalar() or 0 # 이벤트 유형별 집계 type_rows = (await db.execute( select(UXEvent.event_type, sa_func.count(UXEvent.id).label("cnt")) .where(UXEvent.created_at >= since) .group_by(UXEvent.event_type) .order_by(desc("cnt")) )).all() by_type = [{"event_type": r[0], "count": r[1]} for r in type_rows] # 페이지별 집계 (상위 10) page_rows = (await db.execute( select(UXEvent.page, sa_func.count(UXEvent.id).label("cnt")) .where(UXEvent.created_at >= since) .group_by(UXEvent.page) .order_by(desc("cnt")) .limit(10) )).all() by_page = [{"page": r[0], "count": r[1]} for r in page_rows] # 고유 세션 수 session_stmt = select(sa_func.count(sa_func.distinct(UXEvent.session_id))).where( UXEvent.created_at >= since ) unique_sessions = (await db.execute(session_stmt)).scalar() or 0 # 에러 수 error_stmt = select(sa_func.count(UXEvent.id)).where( and_(UXEvent.event_type == "error", UXEvent.created_at >= since) ) error_count = (await db.execute(error_stmt)).scalar() or 0 # 평균 체류 시간 (pageview duration_ms) avg_stmt = select(sa_func.avg(UXEvent.duration_ms)).where( and_( UXEvent.event_type == "pageview", UXEvent.duration_ms.isnot(None), UXEvent.created_at >= since, ) ) avg_duration = (await db.execute(avg_stmt)).scalar() return { "period_days": days, "total_events": total, "unique_sessions": unique_sessions, "error_count": error_count, "avg_pageview_ms": round(avg_duration, 1) if avg_duration else None, "by_type": by_type, "top_pages": by_page, } @router.get("/heatmap", summary="클릭 히트맵 데이터") async def get_heatmap( page: Optional[str] = Query(None, description="특정 페이지 필터"), days: int = Query(7, ge=1, le=90), limit: int = Query(200, ge=1, le=1000), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """특정 페이지(또는 전체)의 클릭 이벤트 원시 데이터를 반환한다.""" since = datetime.utcnow() - timedelta(days=days) conditions = [ UXEvent.event_type == "click", UXEvent.created_at >= since, ] if page: conditions.append(UXEvent.page == page) rows = (await db.execute( select(UXEvent) .where(and_(*conditions)) .order_by(desc(UXEvent.created_at)) .limit(limit) )).scalars().all() # element별 클릭 수 집계 (히트맵 빌드용) agg: Dict[str, Dict[str, Any]] = defaultdict(lambda: {"count": 0, "pages": set()}) raw_points = [] for row in rows: elem = row.element or "(unknown)" agg[elem]["count"] += 1 agg[elem]["pages"].add(row.page) extra_data = {} if row.extra: try: extra_data = json.loads(row.extra) except Exception: extra_data = {} raw_points.append({ "element": elem, "page": row.page, "created_at": row.created_at.isoformat() if row.created_at else None, "extra": extra_data, }) hotspots = sorted( [ {"element": k, "click_count": v["count"], "pages": list(v["pages"])} for k, v in agg.items() ], key=lambda x: x["click_count"], reverse=True, ) return { "page_filter": page, "period_days": days, "hotspots": hotspots[:50], "raw_points": raw_points, } @router.get("/funnel", summary="사용자 흐름 퍼널") async def get_funnel( days: int = Query(7, ge=1, le=90), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """세션별 페이지 전환 순서를 분석하여 주요 흐름과 이탈 지점을 반환한다.""" since = datetime.utcnow() - timedelta(days=days) rows = (await db.execute( select(UXEvent.session_id, UXEvent.page, UXEvent.created_at) .where( and_( UXEvent.event_type == "pageview", UXEvent.created_at >= since, ) ) .order_by(UXEvent.session_id, UXEvent.created_at) )).all() # 세션별 페이지 시퀀스 구성 sessions: Dict[str, List[str]] = defaultdict(list) for sid, page, _ in rows: if not sessions[sid] or sessions[sid][-1] != page: sessions[sid].append(page) # 전환 패턴 집계 (A → B 형태) transitions: Dict[str, int] = defaultdict(int) entry_pages: Dict[str, int] = defaultdict(int) exit_pages: Dict[str, int] = defaultdict(int) for path in sessions.values(): if path: entry_pages[path[0]] += 1 exit_pages[path[-1]] += 1 for i in range(len(path) - 1): key = f"{path[i]} → {path[i + 1]}" transitions[key] += 1 top_transitions = sorted( [{"flow": k, "count": v} for k, v in transitions.items()], key=lambda x: x["count"], reverse=True, )[:20] top_entry = sorted( [{"page": k, "count": v} for k, v in entry_pages.items()], key=lambda x: x["count"], reverse=True, )[:10] top_exit = sorted( [{"page": k, "count": v} for k, v in exit_pages.items()], key=lambda x: x["count"], reverse=True, )[:10] return { "period_days": days, "total_sessions": len(sessions), "top_transitions": top_transitions, "entry_pages": top_entry, "exit_pages": top_exit, } @router.get("/suggestions", summary="AI UX 개선 제안") async def get_suggestions( days: int = Query(7, ge=1, le=90), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """최근 UX 지표를 Ollama에 전달하여 개선 제안 3가지를 반환한다.""" since = datetime.utcnow() - timedelta(days=days) # 지표 수집 total = (await db.execute( select(sa_func.count(UXEvent.id)).where(UXEvent.created_at >= since) )).scalar() or 0 error_count = (await db.execute( select(sa_func.count(UXEvent.id)).where( and_(UXEvent.event_type == "error", UXEvent.created_at >= since) ) )).scalar() or 0 # 에러가 많은 페이지 상위 5 error_pages = (await db.execute( select(UXEvent.page, sa_func.count(UXEvent.id).label("cnt")) .where(and_(UXEvent.event_type == "error", UXEvent.created_at >= since)) .group_by(UXEvent.page) .order_by(desc("cnt")) .limit(5) )).all() # 체류 시간 낮은 페이지 (avg < 5000ms) low_dwell = (await db.execute( select(UXEvent.page, sa_func.avg(UXEvent.duration_ms).label("avg_ms")) .where( and_( UXEvent.event_type == "pageview", UXEvent.duration_ms.isnot(None), UXEvent.created_at >= since, ) ) .group_by(UXEvent.page) .having(sa_func.avg(UXEvent.duration_ms) < 5000) .order_by("avg_ms") .limit(5) )).all() metrics = { "period_days": days, "total_events": total, "error_count": error_count, "error_rate_pct": round(error_count / total * 100, 1) if total else 0, "top_error_pages": [{"page": r[0], "count": r[1]} for r in error_pages], "low_dwell_pages": [{"page": r[0], "avg_ms": round(r[1], 0)} for r in low_dwell], } suggestions = await _get_ai_suggestions(metrics) return { "metrics": metrics, "suggestions": suggestions, "generated_at": datetime.utcnow().isoformat(), } @router.get("/errors", summary="UI 에러 패턴 분석") async def get_error_patterns( days: int = Query(7, ge=1, le=90), limit: int = Query(50, ge=1, le=200), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """최근 UI 에러 이벤트를 페이지·요소별로 분석하여 반환한다.""" since = datetime.utcnow() - timedelta(days=days) rows = (await db.execute( select(UXEvent) .where( and_( UXEvent.event_type == "error", UXEvent.created_at >= since, ) ) .order_by(desc(UXEvent.created_at)) .limit(limit) )).scalars().all() # 페이지별 에러 집계 by_page: Dict[str, int] = defaultdict(int) by_element: Dict[str, int] = defaultdict(int) recent: List[Dict[str, Any]] = [] for row in rows: by_page[row.page] += 1 if row.element: by_element[row.element] += 1 extra_data = {} if row.extra: try: extra_data = json.loads(row.extra) except Exception: extra_data = {} recent.append({ "id": row.id, "page": row.page, "element": row.element, "session_id": row.session_id, "extra": extra_data, "created_at": row.created_at.isoformat() if row.created_at else None, }) top_pages = sorted( [{"page": k, "count": v} for k, v in by_page.items()], key=lambda x: x["count"], reverse=True, ) top_elements = sorted( [{"element": k, "count": v} for k, v in by_element.items()], key=lambda x: x["count"], reverse=True, ) return { "period_days": days, "total_errors": len(rows), "top_error_pages": top_pages[:10], "top_error_elements": top_elements[:10], "recent": recent[:20], }