440 lines
14 KiB
Python
440 lines
14 KiB
Python
"""
|
|
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],
|
|
}
|