Skip to content

Commit c51f6b8

Browse files
authored
Merge pull request #5 from MapChillE/develop
develop -> main
2 parents b29a156 + bf37a85 commit c51f6b8

File tree

17 files changed

+953
-29
lines changed

17 files changed

+953
-29
lines changed
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
name: 'uble_reco 이슈 생성'
2+
description: 'uble_reco Repo에 이슈를 생성하며, 생성된 이슈는 Jira와 연동됩니다.'
3+
labels: [order]
4+
title: '이슈 이름을 작성해주세요'
5+
body:
6+
- type: input
7+
id: parentKey
8+
attributes:
9+
label: '🎫Epic Ticket Number'
10+
description: 'Epic의 Ticket Number를 기입해주세요'
11+
placeholder: 'UBLE-00'
12+
validations:
13+
required: true
14+
15+
- type: textarea
16+
id: description
17+
attributes:
18+
label: '📋이슈 내용(Description)'
19+
description: '이슈에 대해서 자세히 설명해주세요'
20+
validations:
21+
required: true
22+
23+
- type: textarea
24+
id: tasks
25+
attributes:
26+
label: '☑️체크리스트(Tasks)'
27+
description: '해당 이슈에 대해 필요한 작업목록을 작성해주세요'
28+
value: |
29+
- [ ] Task1
30+
- [ ] Task2
31+
validations:
32+
required: true
33+
34+
- type: input
35+
id: taskType
36+
attributes:
37+
label: '🗃️업무 유형'
38+
description: 'Docs/Feature/Fix/Hotfix 중 하나를 작성해주세요'
39+
placeholder: 'Docs/Feature/Fix/Hotfix'
40+
validations:
41+
required: true
42+
43+
- type: input
44+
id: branch
45+
attributes:
46+
label: '🌳브랜치 이름(Branch Name)'
47+
description: '해당 issue로 생성될 branch 이름을 기입해주세요'
48+
placeholder: '[type]/[branch-name]'
49+
validations:
50+
required: true
51+
52+
- type: textarea
53+
id: references
54+
attributes:
55+
label: '📁참조(References)'
56+
description: '해당 이슈과 관련된 레퍼런스를 참조해주세요'
57+
value: |
58+
- Reference1
59+
validations:
60+
required: false

.github/PULL_REQUEST_TEMPLATE.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
## #️⃣연관된 이슈
2+
3+
> #이슈번호 (깃허브 이슈 번호를 작성해주세요)
4+
5+
## 📝작업 내용
6+
7+
> 이번 PR에서 작업한 내용을 간략히 설명해주세요(이미지 첨부 가능)
8+
9+
## 📷스크린샷 (선택)
10+
11+
> 변경사항을 첨부해주세요
12+
13+
## 💬리뷰 요구사항(선택)
14+
15+
> 리뷰어가 특별히 봐주었으면 하는 부분이 있다면 작성해주세요
16+
>
17+
> ex) 메서드 XXX의 이름을 더 잘 짓고 싶은데 혹시 좋은 명칭이 있을까요?

.github/pull_request_template.md

Lines changed: 0 additions & 21 deletions
This file was deleted.
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
name: Create Jira issue
2+
on:
3+
issues:
4+
types:
5+
- opened
6+
permissions:
7+
contents: write
8+
issues: write
9+
jobs:
10+
create-issue:
11+
name: Create Jira issue
12+
runs-on: ubuntu-latest
13+
steps:
14+
- name: Login
15+
uses: atlassian/gajira-login@v3
16+
env:
17+
JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }}
18+
JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
19+
JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }}
20+
21+
- name: Checkout main code
22+
uses: actions/checkout@v4
23+
with:
24+
ref: develop
25+
26+
- name: Issue Parser
27+
uses: stefanbuck/github-issue-praser@v3
28+
id: issue-parser
29+
with:
30+
template-path: .github/ISSUE_TEMPLATE/issue-form.yml
31+
32+
- name: Log Issue Parser
33+
run: |
34+
echo '${{ steps.issue-parser.outputs.jsonString }}'
35+
36+
- name: Convert markdown to Jira Syntax
37+
uses: peter-evans/jira2md@v1
38+
id: md2jira
39+
with:
40+
input-text: |
41+
### Github Issue Link
42+
- ${{ github.event.issue.html_url }}
43+
44+
${{ github.event.issue.body }}
45+
mode: md2jira
46+
47+
- name: Create Issue
48+
id: create
49+
uses: atlassian/gajira-create@v3
50+
with:
51+
project: UBLE
52+
issuetype: "${{ steps.issue-parser.outputs.issueparser_taskType }}"
53+
summary: "${{ github.event.issue.title }}"
54+
description: "${{ steps.md2jira.outputs.output-text }}"
55+
fields: |
56+
{
57+
"parent": { "key": "${{ steps.issue-parser.outputs.issueparser_parentKey }}" },
58+
"labels": ${{ toJson(github.event.issue.labels.*.name) }}
59+
}
60+
61+
- name: Log created issue
62+
run: echo "Jira Issue ${{ steps.issue-parser.outputs.parentKey }}/${{ steps.create.outputs.issue }} was created"
63+
64+
- name: Checkout develop code
65+
uses: actions/checkout@v4
66+
with:
67+
ref: develop
68+
69+
- name: Make final branch name
70+
id: make-branch
71+
run: |
72+
INPUT="${{ steps.issue-parser.outputs.issueparser_branch }}"
73+
TICKET="${{ steps.create.outputs.issue }}"
74+
FINAL_BRANCH=$(echo "$INPUT" | sed "s/\//\/$TICKET-/")
75+
echo "final_branch=$FINAL_BRANCH" >> $GITHUB_OUTPUT
76+
77+
- name: Create branch with Ticket number
78+
run: |
79+
git checkout -b ${{ steps.make-branch.outputs.final_branch }}
80+
git push origin ${{ steps.make-branch.outputs.final_branch }}
81+
82+
- name: Update issue title
83+
uses: actions-cool/issues-helper@v3
84+
with:
85+
actions: "update-issue"
86+
token: ${{ secrets.GITHUB_TOKEN }}

.python-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.13.5
1+
3.13

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# 가상환경 생성
22

3-
python -m venv venv
3+
python@3.13 -m venv venv
44

55
# 가상환경 활성화(윈도우)
66

File renamed without changes.

app/api/recommend.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
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

Comments
 (0)