|
| 1 | +from fastapi import APIRouter, Depends, HTTPException, Query |
| 2 | +from sqlalchemy.orm import Session |
| 3 | +from sentence_transformers import SentenceTransformer |
| 4 | +from sqlalchemy import text |
| 5 | +from app.database.connection import get_db |
| 6 | +from app.models import Store |
| 7 | +from app.services.recommend_service import HybridRecommender |
| 8 | +from geoalchemy2.functions import ST_DWithin, ST_SetSRID, ST_MakePoint, ST_Distance |
| 9 | +import logging |
| 10 | +from app.services.collect_user_data import collect_user_data |
| 11 | +from collections import defaultdict |
| 12 | +from app.database.redis_client import r |
| 13 | +import json |
| 14 | + |
| 15 | +router = APIRouter() |
| 16 | +model = SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2") |
| 17 | +recommender = HybridRecommender() |
| 18 | +logger = logging.getLogger(__name__) |
| 19 | + |
| 20 | +def get_min_rank(benefits: list) -> str: |
| 21 | + for b in benefits: |
| 22 | + if b.rank != "NONE": |
| 23 | + return b.rank |
| 24 | + return "NONE" |
| 25 | + |
| 26 | +@router.get("/recommend") |
| 27 | +def recommend( |
| 28 | + user_id:int, |
| 29 | + lat: float = Query(...), |
| 30 | + lng: float = Query(...), |
| 31 | + radius_km: float = Query(2.0), |
| 32 | + db:Session = Depends(get_db) |
| 33 | + ): |
| 34 | + |
| 35 | + # 1. 사용자 정보 수집 |
| 36 | + categories, histories, bookmarks, clicks, searches = collect_user_data(user_id, db) |
| 37 | + |
| 38 | + if not (categories or histories or bookmarks or clicks or searches): |
| 39 | + raise HTTPException(status_code=404, detail="사용자 정보가 부족합니다.") |
| 40 | + |
| 41 | + # 2. 텍스트 통합 후 임베딩 |
| 42 | + user_profile_text = "; ".join(categories + histories + bookmarks + clicks + searches) |
| 43 | + user_vec = model.encode(user_profile_text).tolist() |
| 44 | + |
| 45 | + # 3. pgvector를 활용한 유사도 계산 |
| 46 | + sql = text(""" |
| 47 | + WITH click_counts AS ( |
| 48 | + SELECT store_id, COUNT(*) AS click_count |
| 49 | + FROM store_click_log |
| 50 | + GROUP BY store_id |
| 51 | + ), |
| 52 | + click_stats AS ( |
| 53 | + SELECT MAX(click_count) AS max_clicks, |
| 54 | + MIN(click_count) AS min_clicks |
| 55 | + FROM click_counts |
| 56 | + ) |
| 57 | + SELECT s.id, s.name, s.address, |
| 58 | + 1 - (embedding <-> CAST(:user_vec AS vector)) AS similarity, |
| 59 | + COALESCE(cc.click_count, 0) AS click_score, |
| 60 | + (SELECT COUNT(*) FROM usage_history WHERE store_id = s.id) AS visit_score, |
| 61 | + CASE |
| 62 | + WHEN cs.max_clicks > cs.min_clicks THEN |
| 63 | + (COALESCE(cc.click_count, 0) - cs.min_clicks)::float / NULLIF(cs.max_clicks - cs.min_clicks, 0) |
| 64 | + ELSE 0 |
| 65 | + END AS normalized_click_score |
| 66 | + FROM store_embedding se |
| 67 | + JOIN store s ON se.store_id = s.id |
| 68 | + LEFT JOIN click_counts cc ON cc.store_id = s.id |
| 69 | + CROSS JOIN click_stats cs |
| 70 | + WHERE ST_DWithin(s.location, ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography, :radius) |
| 71 | + ORDER BY 0.7 * (1 - (embedding <-> CAST(:user_vec AS vector))) + 0.3 * ( |
| 72 | + CASE |
| 73 | + WHEN cs.max_clicks > cs.min_clicks THEN |
| 74 | + (COALESCE(cc.click_count, 0) - cs.min_clicks)::float / NULLIF(cs.max_clicks - cs.min_clicks, 0) |
| 75 | + ELSE 0 |
| 76 | + END |
| 77 | + ) DESC |
| 78 | + LIMIT 10 |
| 79 | + """) |
| 80 | + |
| 81 | + results = db.execute(sql, { |
| 82 | + "user_vec": user_vec, |
| 83 | + "lat": lat, |
| 84 | + "lng": lng, |
| 85 | + "radius": radius_km * 1000 |
| 86 | + }).mappings().all() |
| 87 | + |
| 88 | + return {"top10": results} |
| 89 | + |
| 90 | +@router.on_event("startup") |
| 91 | +def startup_event(): |
| 92 | + with next(get_db()) as db: |
| 93 | + recommender.train_model(db) |
| 94 | + |
| 95 | +@router.get("/recommend/hybrid") |
| 96 | +def hybrid_recommend( |
| 97 | + user_id: int, |
| 98 | + lat: float = Query(37.5), |
| 99 | + lng: float = Query(127.04), |
| 100 | + radius_km: float = Query(2.0), |
| 101 | + db: Session = Depends(get_db) |
| 102 | +): |
| 103 | + |
| 104 | + # 0. Redis 캐시 확인 |
| 105 | + cache_key = f"recommendation:user:{user_id}" |
| 106 | + try: |
| 107 | + cached = r.get(cache_key) |
| 108 | + if cached: |
| 109 | + return json.loads(cached) |
| 110 | + except Exception as e: |
| 111 | + logger.error(f"Redis 캐시 확인 중 오류: {e}") |
| 112 | + |
| 113 | + # 1. 사용자 텍스트 정보 수집 |
| 114 | + categories, histories, bookmarks, clicks, searches = collect_user_data(user_id, db) |
| 115 | + |
| 116 | + if not (categories or histories or bookmarks or clicks or searches): |
| 117 | + raise HTTPException(status_code=404, detail="사용자 정보가 부족합니다.") |
| 118 | + |
| 119 | + # 2. 벡터 생성 |
| 120 | + user_profile_text = "; ".join(categories + histories + bookmarks + clicks + searches) |
| 121 | + user_vec = model.encode(user_profile_text).tolist() |
| 122 | + |
| 123 | + # 3. 추천 결과 계산 |
| 124 | + results = recommender.get_hybrid_scores(db, user_id, user_vec) |
| 125 | + recommended_brand_ids = [brand_id for brand_id, _ in results] |
| 126 | + logger.debug(f"Recommendation results for user {user_id}: {results}") |
| 127 | + |
| 128 | + # 4. 위치 기반 필터링: 추천 브랜드 매장 중 반경 km 이내 |
| 129 | + nearby_stores = db.query(Store).filter( |
| 130 | + Store.brand_id.in_(recommended_brand_ids), |
| 131 | + ST_DWithin(Store.location, ST_SetSRID(ST_MakePoint(lng, lat), 4326), radius_km * 1000) |
| 132 | + ).order_by( |
| 133 | + ST_Distance(Store.location, ST_SetSRID(ST_MakePoint(lng, lat), 4326)) |
| 134 | + ).all() |
| 135 | + |
| 136 | + # 매장 중 하나씩 결과 연결 |
| 137 | + store_map = {} |
| 138 | + brand_store_map = defaultdict(list) |
| 139 | + for store in nearby_stores: |
| 140 | + brand_store_map[store.brand_id].append(store) |
| 141 | + store_map = {bid: stores[0] for bid, stores in brand_store_map.items()} |
| 142 | + |
| 143 | + # 5. 결과 구성 |
| 144 | + recommendation_items = [] |
| 145 | + for brand_id, score in results: |
| 146 | + if brand_id not in store_map: |
| 147 | + continue |
| 148 | + store = store_map[brand_id] |
| 149 | + brand = store.brand |
| 150 | + |
| 151 | + item = { |
| 152 | + "storeId": store.id, |
| 153 | + "storeName": store.name, |
| 154 | + "category": brand.category.name if brand.category else None, |
| 155 | + "description": brand.description, |
| 156 | + "isVIPcock": brand.rank_type in ("VIP", "VIP_NORMAL"), |
| 157 | + "minRank": get_min_rank(brand.benefits), |
| 158 | + "imgUrl": brand.image_url |
| 159 | + } |
| 160 | + recommendation_items.append(item) |
| 161 | + final_results = {"recommendationsList": recommendation_items} |
| 162 | + |
| 163 | + # 6. 캐시 저장 (1시간) |
| 164 | + try: |
| 165 | + r.setex(cache_key, 3600, json.dumps(final_results)) |
| 166 | + except Exception as e: |
| 167 | + logger.error(f"Redis 캐싱 실패: {e}") |
| 168 | + |
| 169 | + # 7. 결과 반환 |
| 170 | + return final_results |
0 commit comments