Compare commits

..

10 Commits

Author SHA1 Message Date
ondal
59e02fd0cd release 2025-06-17 13:14:02 +09:00
ondal
66f3df24bf release 2025-06-17 13:06:44 +09:00
ondal
8b9663a5a9 release 2025-06-17 12:54:35 +09:00
ondal
708ca29562 release 2025-06-17 12:52:02 +09:00
ondal
3d3f1e5383 release 2025-06-17 12:30:32 +09:00
ondal
ceb05de7f3 release 2025-06-16 13:47:11 +09:00
ondal
66e80d31d7 release 2025-06-16 13:42:04 +09:00
ondal
a2a7daf6f6 release 2025-06-16 13:36:14 +09:00
hiondal
59b01d9630 release 2025-06-16 03:38:45 +00:00
hiondal
ea36d7b221 release 2025-06-16 02:39:55 +00:00
11 changed files with 1269 additions and 416 deletions

225
README.md Normal file
View File

@ -0,0 +1,225 @@
# 🍽️ AI Review - 소상공인을 위한 AI 기반 경쟁업체 분석 및 액션 추천 시스템
소상공인들이 카카오맵 리뷰 데이터를 기반으로 경쟁업체를 분석하고, AI가 제안하는 맞춤형 비즈니스 개선 방안을 받을 수 있는 마이크로서비스 기반 플랫폼입니다.
## 🎯 시스템 개요
### 핵심 가치 제안
- **🔍 스마트 경쟁업체 발견**: 지역과 업종 기반으로 유사한 경쟁업체 자동 탐지
- **📊 대량 리뷰 데이터 분석**: 카카오맵 리뷰를 수집하여 고품질 인사이트 도출
- **🤖 AI 기반 맞춤 컨설팅**: Claude AI가 제공하는 실행 가능한 비즈니스 액션 플랜
- **⚡ 완전 자동화**: API 기반으로 모든 과정이 자동화된 솔루션
### 비즈니스 임팩트
- **소상공인**: 데이터 기반 경영 의사결정으로 매출 향상 지원
- **경쟁 우위**: AI 기반 개인화된 비즈니스 전략으로 차별화
- **운영 효율**: 수작업 시장조사 대비 시간과 비용 90% 절감
## 🏗️ 서비스 아키텍처
본 시스템은 3개의 독립적인 마이크로서비스로 구성되어 있습니다:
### 📍 Restaurant Service
**음식점 검색 및 정보 수집 서비스**
- **핵심 기능**: 카카오 로컬 API 기반 음식점 검색 및 상세 정보 수집
- **주요 API**:
- 음식점 검색 (`/restaurants/search`)
- 음식점 상세 정보 (`/restaurants/{restaurant_id}`)
- **기술 스택**: FastAPI, Python 3.11, Kakao Local API
- **포트**: 18000
### 📝 Review Service
**카카오맵 리뷰 수집 및 분석 서비스**
- **핵심 기능**: 카카오맵 리뷰 대량 수집, 감정 분석, 키워드 추출
- **주요 API**:
- 리뷰 수집 (`/reviews/collect`)
- 리뷰 분석 (`/reviews/analyze`)
- **기술 스택**: FastAPI, Python 3.11, BeautifulSoup, Sentiment Analysis
- **포트**: 19000
### 🧠 Vector Service
**Vector DB 구축 및 AI 비즈니스 컨설팅 서비스**
- **핵심 기능**: ChromaDB 기반 Vector 검색, Claude AI 연동 액션 추천
- **주요 API**:
- Vector DB 구축 (`/find-reviews`)
- AI 액션 추천 (`/action-recommendation`)
- **기술 스택**: FastAPI, ChromaDB, Sentence Transformers, Claude AI
- **포트**: 8000
## 🚀 AKS 배포 가이드
### 1. 사전 준비
#### Local Ubuntu 접속 후 소스 다운로드
```bash
cd ~/workflow
git clone https://github.com/cna-bootcamp/ai-review.git
cd ai-review
```
#### Azure Cloud 로그인
```bash
az login --use-device-code
```
#### AKS Credential 취득
```bash
az aks get-credentials aks-digitalgarage-03 -f ~/.kube/config
```
#### Namespace 생성 및 이동
```bash
k create ns ai-review-ns
kubens ai-review-ns
```
#### Image Pull Secret 생성
```bash
./restaurant/create-imagepullsecret.sh acrdigitalgarage03 rg-digitalgarage-03
```
### 2. Restaurant Service 배포
#### 디렉토리 이동
```bash
cd ~/workspace/ai-review/restaurant
```
#### 베이스 이미지 빌드
```bash
./build-base.sh latest acrdigitalgarage03 rg-digitalgarage-03
```
#### 서비스 이미지 빌드
```bash
./build.sh latest acrdigitalgarage03 rg-digitalgarage-03
```
#### Manifest 수정
- **Deployment yaml** (`deployment/manifest/deployment.yaml`)의 image명을 서비스 이미지명으로 변경
- **Ingress yaml** (`deployment/manifest/ingress.yaml`)의 host 수정
```bash
# Ingress IP 확인
k get svc -n ingress-nginx
```
#### 배포 실행
```bash
k apply -f deployment/manifest
```
#### 배포 확인
```bash
# Pod 상태 확인
k get po
# Ingress 확인
k get ing
# Swagger 페이지 접속
# http://{ingress 주소}/docs
```
### 3. Review Service 배포
#### 디렉토리 이동
```bash
cd ~/workspace/ai-review/review
```
#### 베이스 이미지 빌드
```bash
./build-base.sh latest acrdigitalgarage03 rg-digitalgarage-03
```
#### 서비스 이미지 빌드
```bash
./build.sh latest acrdigitalgarage03 rg-digitalgarage-03
```
#### Manifest 수정
- **Deployment yaml** (`deployment/manifest/deployment.yaml`)의 image명을 서비스 이미지명으로 변경
- **Ingress yaml** (`deployment/manifest/ingress.yaml`)의 host 수정
```bash
# Ingress IP 확인
k get svc -n ingress-nginx
```
#### 배포 실행
```bash
k apply -f deployment/manifest
```
#### 배포 확인
```bash
# Pod 상태 확인
k get po
# Ingress 확인
k get ing
# Swagger 페이지 접속
# http://{ingress 주소}/docs
```
### 4. Vector Service 배포
#### 디렉토리 이동
```bash
cd ~/workspace/ai-review/vector
```
#### 베이스 이미지 빌드
```bash
./build-base.sh latest acrdigitalgarage03 rg-digitalgarage-03
```
#### 서비스 이미지 빌드
```bash
./build.sh latest acrdigitalgarage03 rg-digitalgarage-03
```
#### Manifest 수정
- **Deployment yaml** (`deployment/manifest/deployment.yaml`)의 image명을 서비스 이미지명으로 변경
- **Ingress yaml** (`deployment/manifest/ingress.yaml`)의 host 수정
```bash
# Ingress IP 확인
k get svc -n ingress-nginx
```
#### 배포 실행
```bash
k apply -f deployment/manifest
```
#### 배포 확인
```bash
# Pod 상태 확인
k get po
# Ingress 확인
k get ing
# Swagger 페이지 접속
# http://{ingress 주소}/docs
```
## 🔧 서비스 상태 확인
### 전체 서비스 상태 점검
```bash
# 모든 Pod 상태 확인
kubectl get pods -n ai-review-ns
# 모든 Service 확인
kubectl get svc -n ai-review-ns
# 모든 Ingress 확인
kubectl get ing -n ai-review-ns
# 로그 확인 (예: restaurant 서비스)
kubectl logs -l app=restaurant-api -n ai-review-ns --tail=100
```

View File

@ -1,78 +0,0 @@
# deployment/manifests/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: kakao-review-api-config
data:
# 애플리케이션 설정
APP_TITLE: "카카오맵 리뷰 분석 API"
APP_VERSION: "1.0.1"
APP_DESCRIPTION: "교육 목적 전용 - 실제 서비스 사용 금지"
# 서버 설정
HOST: "0.0.0.0"
PORT: "8000"
WORKERS: "1"
LOG_LEVEL: "info"
# API 기본값 설정 (AKS 환경에 최적화)
DEFAULT_MAX_TIME: "300"
DEFAULT_DAYS_LIMIT: "60"
MAX_DAYS_LIMIT: "365"
MIN_MAX_TIME: "60"
MAX_MAX_TIME: "600"
# 🔧 Chrome 브라우저 설정 (AKS 환경 최적화)
CHROME_OPTIONS: |
--headless=new
--no-sandbox
--disable-dev-shm-usage
--disable-gpu
--disable-software-rasterizer
--window-size=1920,1080
--disable-extensions
--disable-plugins
--disable-usb-keyboard-detect
--no-first-run
--no-default-browser-check
--disable-logging
--log-level=3
--disable-background-timer-throttling
--disable-backgrounding-occluded-windows
--disable-renderer-backgrounding
--disable-features=TranslateUI,VizDisplayCompositor
--disable-ipc-flooding-protection
--memory-pressure-off
--max_old_space_size=4096
--no-zygote
--disable-setuid-sandbox
--disable-background-networking
--disable-default-apps
--disable-sync
--metrics-recording-only
--safebrowsing-disable-auto-update
--disable-prompt-on-repost
--disable-hang-monitor
--disable-client-side-phishing-detection
--disable-component-update
--disable-domain-reliability
--user-data-dir=/tmp/chrome-user-data
--data-path=/tmp/chrome-user-data
--disk-cache-dir=/tmp/chrome-cache
--aggressive-cache-discard
--disable-web-security
--allow-running-insecure-content
--disable-blink-features=AutomationControlled
# 스크롤링 설정 (AKS 환경에 맞게 조정)
SCROLL_CHECK_INTERVAL: "5"
SCROLL_NO_CHANGE_LIMIT: "6"
SCROLL_WAIT_TIME_SHORT: "2.0"
SCROLL_WAIT_TIME_LONG: "3.0"
# 법적 경고 메시지
LEGAL_WARNING_ENABLED: "true"
CONTACT_EMAIL: "admin@example.com"
# 건강 체크 설정
HEALTH_CHECK_TIMEOUT: "10"

View File

@ -1,130 +0,0 @@
# deployment/manifests/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: kakao-review-api
labels:
app: kakao-review-api
version: v1
spec:
replicas: 1
selector:
matchLabels:
app: kakao-review-api
template:
metadata:
labels:
app: kakao-review-api
version: v1
spec:
imagePullSecrets:
- name: acr-secret
containers:
- name: api
image: acrdigitalgarage03.azurecr.io/kakao-review-api:latest
imagePullPolicy: Always
ports:
- containerPort: 19000
name: http
# 🔧 ConfigMap 환경 변수
envFrom:
- configMapRef:
name: kakao-review-api-config
# 🔧 Secret 환경 변수
env:
- name: EXTERNAL_API_KEY
valueFrom:
secretKeyRef:
name: kakao-review-api-secret
key: EXTERNAL_API_KEY
- name: DB_USERNAME
valueFrom:
secretKeyRef:
name: kakao-review-api-secret
key: DB_USERNAME
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: kakao-review-api-secret
key: DB_PASSWORD
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: kakao-review-api-secret
key: JWT_SECRET
# 🔧 Chrome/ChromeDriver 환경 변수 (VM과 동일)
- name: WDM_LOCAL
value: "/tmp/.wdm"
- name: WDM_LOG_LEVEL
value: "0"
- name: CHROME_BIN
value: "/usr/bin/google-chrome"
- name: CHROMEDRIVER_BIN
value: "/usr/local/bin/chromedriver"
- name: DISPLAY
value: ":99"
- name: DBUS_SESSION_BUS_ADDRESS
value: "/dev/null"
# 🔧 리소스 제한 (Chrome 실행에 충분한 리소스)
resources:
requests:
memory: "2Gi"
cpu: "1000m"
limits:
memory: "4Gi"
cpu: "2000m"
# 🔧 헬스 체크 (타임아웃 증가)
livenessProbe:
httpGet:
path: /health
port: 19000
initialDelaySeconds: 60
periodSeconds: 30
timeoutSeconds: 15
failureThreshold: 5
readinessProbe:
httpGet:
path: /health
port: 19000
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 10
failureThreshold: 5
# 🔧 간소화된 보안 컨텍스트 (AKS 호환)
securityContext:
runAsNonRoot: false
runAsUser: 0
allowPrivilegeEscalation: true
readOnlyRootFilesystem: false
capabilities:
add:
- SYS_ADMIN
drop: []
# 🔧 볼륨 마운트 (Chrome 실행 최적화)
volumeMounts:
- name: tmp-volume
mountPath: /tmp
- name: dev-shm
mountPath: /dev/shm
# 🔧 볼륨 정의 (간소화)
volumes:
- name: tmp-volume
emptyDir: {}
- name: dev-shm
emptyDir:
medium: Memory
sizeLimit: 2Gi
restartPolicy: Always
# 🔧 Pod 레벨 보안 설정 제거 (AKS 호환을 위해)
# securityContext: 제거

View File

@ -1,39 +0,0 @@
# deployment/manifests/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: kakao-review-api-ingress
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
nginx.ingress.kubernetes.io/ssl-redirect: "false"
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
# 🔧 타임아웃 설정 (Chrome 분석 시간 고려)
nginx.ingress.kubernetes.io/proxy-read-timeout: "1800"
nginx.ingress.kubernetes.io/proxy-send-timeout: "1800"
nginx.ingress.kubernetes.io/client-body-timeout: "1800"
nginx.ingress.kubernetes.io/proxy-connect-timeout: "60"
# 🔧 CORS 설정 (필요시)
nginx.ingress.kubernetes.io/enable-cors: "true"
nginx.ingress.kubernetes.io/cors-allow-origin: "*"
nginx.ingress.kubernetes.io/cors-allow-methods: "GET, POST, OPTIONS"
nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"
spec:
ingressClassName: nginx
rules:
# 🔧 환경에 맞게 호스트명 수정 필요
- host: kakao-review-api.20.249.191.180.nip.io
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: kakao-review-api-service
port:
number: 80
# 🔧 TLS 설정 (HTTPS 필요시 주석 해제)
# tls:
# - hosts:
# - kakao-review-api.example.com
# secretName: kakao-review-api-tls

View File

@ -1,21 +0,0 @@
# deployment/manifests/secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: kakao-review-api-secret
type: Opaque
data:
# 🔧 현재 사용하지 않지만 향후 확장을 위한 플레이스홀더
# 실제 값 설정 시: echo -n "your-value" | base64
# API 키 (향후 카카오 공식 API 연동용)
EXTERNAL_API_KEY: ""
# 데이터베이스 연결 정보 (향후 DB 연동용)
DB_USERNAME: ""
DB_PASSWORD: ""
DB_HOST: ""
# JWT 시크릿 (향후 인증 기능용)
JWT_SECRET: ""

View File

@ -1,17 +0,0 @@
# deployment/manifests/service.yaml
apiVersion: v1
kind: Service
metadata:
name: kakao-review-api-service
labels:
app: kakao-review-api
spec:
type: ClusterIP
ports:
- port: 80
targetPort: 19000
protocol: TCP
name: http
selector:
app: kakao-review-api

View File

@ -94,7 +94,8 @@ vector/
| Method | Endpoint | 설명 | 요청 모델 | 응답 모델 |
|--------|----------|------|-----------|-----------|
| `POST` | `/find-reviews` | 리뷰 수집 및 Vector DB 저장 | VectorBuildRequest | FindReviewsResponse |
| `POST` | `/action-recommendation` | AI 기반 비즈니스 추천 | ActionRecommendationRequest | ActionRecommendationSimpleResponse |
| `POST` | `/action-recommendation` | AI 기반 비즈니스 추천 | ActionRecommendationRequest |
| `GET` | `/store/{store_id}` | store_id로 매장 정보 조회 | Path Parameter | StoreInfoResponse ActionRecommendationSimpleResponse |
## 🚀 빠른 시작
@ -276,8 +277,9 @@ curl -X POST "http://vector-api.20.249.191.180.nip.io/find-reviews" \
"category_name": "음식점 > 치킨",
"address_name": "서울특별시 강남구 역삼동 123-45",
"phone": "02-123-4567",
"x": "127.0276",
"y": "37.4979"
"place_url": "http://place.map.kakao.com/501745730",
"x": "127.0276543",
"y": "37.4979234"
},
"food_category": "치킨",
"total_reviews": 127,
@ -285,14 +287,263 @@ curl -X POST "http://vector-api.20.249.191.180.nip.io/find-reviews" \
"execution_time": 45.2,
"stored_data": {
"501745730": {
"store_info": { /* 가게 정보 */ },
"reviews": [ /* 리뷰 목록 */ ],
"store_info": {
"id": "501745730",
"name": "맛있는 치킨집",
"category": "음식점 > 치킨",
"rating": "4.2",
"review_count": "23",
"status": "영업중",
"address": "서울특별시 강남구 역삼동 123-45",
"phone": "02-123-4567",
"place_url": "http://place.map.kakao.com/501745730",
"x": "127.0276543",
"y": "37.4979234"
},
"reviews": [
{
"reviewer_name": "김○○",
"reviewer_level": "골드리뷰어",
"reviewer_stats": {
"reviews": 156,
"average_rating": 4.3,
"followers": 23
},
"rating": 5,
"date": "2024-06-10",
"content": "치킨이 정말 맛있어요! 바삭하고 양념이 잘 배어있어서 자꾸 손이 갑니다. 배달도 빨라서 좋았어요.",
"badges": ["맛집", "친절", "빠른배달"],
"likes": 12,
"photo_count": 3,
"has_photos": true
},
{
"reviewer_name": "이○○",
"reviewer_level": "실버리뷰어",
"reviewer_stats": {
"reviews": 89,
"average_rating": 4.1,
"followers": 8
},
"rating": 4,
"date": "2024-06-08",
"content": "치킨은 맛있는데 가격이 조금 비싼 것 같아요. 그래도 재주문 의향 있습니다.",
"badges": ["맛있음", "가격"],
"likes": 7,
"photo_count": 1,
"has_photos": true
},
{
"reviewer_name": "박○○",
"reviewer_level": "브론즈리뷰어",
"reviewer_stats": {
"reviews": 34,
"average_rating": 3.9,
"followers": 2
},
"rating": 3,
"date": "2024-06-05",
"content": "치킨은 괜찮은데 배달이 좀 늦었어요. 포장도 조금 아쉬웠습니다.",
"badges": ["배달지연", "포장"],
"likes": 3,
"photo_count": 0,
"has_photos": false
}
],
"review_summary": {
"total_reviews": 23,
"average_rating": 4.2,
"positive_keywords": ["맛있음", "친절", "깨끗"]
"rating_distribution": {
"5": 8,
"4": 9,
"3": 4,
"2": 1,
"1": 1
},
"positive_keywords": [
"맛있음",
"바삭함",
"친절",
"빠른배달"
],
"negative_keywords": [
"가격",
"배달지연",
"포장"
],
"common_keywords": [
"맛있음",
"치킨",
"배달",
"친절",
"가격",
"바삭함"
],
"sentiment_analysis": {
"positive": 65.2,
"neutral": 26.1,
"negative": 8.7
},
"photo_reviews": 15,
"recent_trend": "상승",
"peak_hours": ["19:00-21:00", "12:00-13:00"]
},
"combined_at": "2024-06-16T10:30:00"
"combined_at": "2024-06-16T10:30:15"
},
"502156891": {
"store_info": {
"id": "502156891",
"name": "황금치킨",
"category": "음식점 > 치킨",
"rating": "4.4",
"review_count": "67",
"status": "영업중",
"address": "서울특별시 강남구 역삼동 234-56",
"phone": "02-234-5678",
"place_url": "http://place.map.kakao.com/502156891",
"x": "127.0298765",
"y": "37.4965432"
},
"reviews": [
{
"reviewer_name": "최○○",
"reviewer_level": "플래티넘리뷰어",
"reviewer_stats": {
"reviews": 234,
"average_rating": 4.5,
"followers": 45
},
"rating": 5,
"date": "2024-06-12",
"content": "여기 치킨은 정말 최고예요! 특히 양념치킨이 달콤하면서도 매콤해서 중독성이 있어요. 사장님도 너무 친절하시고 서비스도 좋습니다.",
"badges": ["최고", "양념치킨", "친절", "서비스"],
"likes": 18,
"photo_count": 4,
"has_photos": true
},
{
"reviewer_name": "정○○",
"reviewer_level": "골드리뷰어",
"reviewer_stats": {
"reviews": 145,
"average_rating": 4.2,
"followers": 19
},
"rating": 4,
"date": "2024-06-09",
"content": "맛은 좋은데 매장이 좀 좁아요. 포장해서 먹는 걸 추천합니다.",
"badges": ["맛좋음", "매장좁음", "포장추천"],
"likes": 9,
"photo_count": 2,
"has_photos": true
}
],
"review_summary": {
"total_reviews": 67,
"average_rating": 4.4,
"rating_distribution": {
"5": 32,
"4": 25,
"3": 7,
"2": 2,
"1": 1
},
"positive_keywords": [
"최고",
"양념치킨",
"친절",
"맛좋음",
"서비스"
],
"negative_keywords": [
"매장좁음",
"대기시간"
],
"common_keywords": [
"양념치킨",
"맛좋음",
"친절",
"서비스",
"최고",
"포장"
],
"sentiment_analysis": {
"positive": 73.1,
"neutral": 20.9,
"negative": 6.0
},
"photo_reviews": 45,
"recent_trend": "상승",
"peak_hours": ["18:00-20:00", "12:00-14:00"]
},
"combined_at": "2024-06-16T10:31:22"
},
"503789456": {
"store_info": {
"id": "503789456",
"name": "크리스피치킨",
"category": "음식점 > 치킨",
"rating": "3.9",
"review_count": "34",
"status": "영업중",
"address": "서울특별시 강남구 역삼동 345-67",
"phone": "02-345-6789",
"place_url": "http://place.map.kakao.com/503789456",
"x": "127.0312345",
"y": "37.4951234"
},
"reviews": [
{
"reviewer_name": "한○○",
"reviewer_level": "실버리뷰어",
"reviewer_stats": {
"reviews": 76,
"average_rating": 3.8,
"followers": 5
},
"rating": 4,
"date": "2024-06-11",
"content": "바삭한 치킨을 좋아한다면 추천! 다만 양념이 좀 짜요.",
"badges": ["바삭함", "양념짠맛"],
"likes": 6,
"photo_count": 1,
"has_photos": true
}
],
"review_summary": {
"total_reviews": 34,
"average_rating": 3.9,
"rating_distribution": {
"5": 8,
"4": 15,
"3": 7,
"2": 3,
"1": 1
},
"positive_keywords": [
"바삭함",
"맛있음"
],
"negative_keywords": [
"양념짠맛",
"서비스"
],
"common_keywords": [
"바삭함",
"치킨",
"양념",
"맛있음"
],
"sentiment_analysis": {
"positive": 58.8,
"neutral": 29.4,
"negative": 11.8
},
"photo_reviews": 18,
"recent_trend": "보통",
"peak_hours": ["19:00-21:00"]
},
"combined_at": "2024-06-16T10:32:05"
}
}
}
@ -316,101 +567,293 @@ curl -X POST "http://vector-api.20.249.191.180.nip.io/action-recommendation" \
"success": true,
"recommendation": {
"summary": {
"current_situation": "치킨 전문점으로 강남구 역삼동에 위치, 평균 별점 3.8점으로 동종업체 대비 개선 여지 존재",
"current_situation": "일식 업종으로 매출 감소를 겪고 있으며, 경쟁이 치열한 고품질 시장에서 운영 중입니다. 업계 평균 평점은 4.23점이고, 경쟁업체들은 3.9-4.7점 범위의 평점을 보이고 있습니다.",
"key_insights": [
"배달 서비스 만족도가 경쟁업체 대비 낮음",
"신메뉴 출시 주기가 3개월 이상으로 긴 편",
"온라인 리뷰 관리 시스템 부재"
"업계 공통 이슈로 고객 만족도 양극화 문제 존재",
"경쟁업체들은 가성비, 맛, 친절함에서 강점을 보임",
"고객 참여도와 일관성 있는 서비스 품질이 성공의 핵심 요소"
],
"priority_areas": ["배달 품질 개선", "메뉴 혁신"]
"priority_areas": [
"서비스 일관성 개선",
"고객 만족도 안정화"
]
},
"action_plans": {
"short_term": [
{
"title": "배달 포장재 개선",
"description": "보온성이 우수한 친환경 포장재로 교체하여 배달 만족도 향상",
"expected_impact": "배달 리뷰 평점 0.5점 상승 예상",
"timeline": "2주",
"cost": "월 50만원"
"title": "서비스 표준화 매뉴얼 작성 및 적용",
"description": "조리법, 서빙 방식, 고객 응대 등 모든 서비스 과정을 표준화하여 일관성 있는 품질 제공. 직원 교육 실시 및 체크리스트 활용",
"expected_impact": "고객 만족도 양극화 해소, 평점 0.3-0.5점 상승 예상",
"timeline": "2-4주",
"cost": "50-100만원 (교육비, 매뉴얼 제작비)"
},
{
"title": "고객 피드백 수집 시스템 구축",
"description": "테이블 QR코드를 통한 실시간 피드백 수집, 불만사항 즉시 대응 체계 마련. 주간 피드백 분석 및 개선사항 도출",
"expected_impact": "고객 불만 30% 감소, 재방문율 15% 증가",
"timeline": "1-2주",
"cost": "30-50만원 (QR코드 제작, 시스템 구축)"
}
],
"mid_term": [
{
"title": "시즌 한정 메뉴 런칭",
"description": "여름 시즌 매운맛 신메뉴 2종 개발 및 SNS 마케팅",
"expected_impact": "신규 고객 유입 20% 증가",
"timeline": "6주",
"cost": "초기 투자 300만원"
"title": "메뉴 최적화 및 시그니처 메뉴 개발",
"description": "경쟁업체 대비 차별화된 시그니처 메뉴 3-5개 개발. 기존 메뉴 중 인기도 낮은 메뉴 정리하고 가성비 우수 메뉴 강화",
"expected_impact": "평균 주문 금액 20% 증가, 고객 재방문율 25% 향상",
"timeline": "2-3개월",
"cost": "200-300만원 (메뉴 개발, 재료비, 마케팅)"
},
{
"title": "디지털 마케팅 강화",
"description": "네이버 플레이스, 구글 마이비즈니스 최적화. SNS 활용한 메뉴 홍보 및 고객 후기 관리. 온라인 주문 시스템 도입",
"expected_impact": "온라인 노출 50% 증가, 신규 고객 유입 30% 증가",
"timeline": "3-4개월",
"cost": "150-250만원 (마케팅비, 시스템 구축비)"
}
],
"long_term": [
{
"title": "브랜드 리뉴얼",
"description": "매장 인테리어 개선 및 브랜드 아이덴티티 강화",
"expected_impact": "브랜드 인지도 향상, 객단가 15% 상승",
"timeline": "4개월",
"cost": "1,500만원"
"title": "고객 충성도 프로그램 구축",
"description": "멤버십 시스템 도입, 단골 고객 특별 혜택 제공, 생일/기념일 이벤트 운영. 고객 데이터베이스 구축 및 맞춤형 서비스 제공",
"expected_impact": "고객 유지율 40% 향상, 월 매출 25-30% 증가",
"timeline": "6-8개월",
"cost": "300-500만원 (시스템 개발, 운영비)"
},
{
"title": "브랜드 아이덴티티 강화 및 확장 전략",
"description": "독특한 브랜드 스토리 개발, 인테리어 리뉴얼, 패키징 디자인 개선. 향후 2호점 진출 또는 프랜차이즈 검토",
"expected_impact": "브랜드 인지도 향상, 프리미엄 가격 정책 가능, 사업 확장 기반 마련",
"timeline": "8-12개월",
"cost": "500-1000만원 (리뉴얼, 브랜딩 비용)"
}
]
},
"implementation_tips": [
"배달앱 리뷰 모니터링 시스템 도입",
"경쟁업체 메뉴 트렌드 주기적 분석",
"고객 피드백 기반 개선사항 우선순위 설정"
"단기 계획부터 차근차근 실행하되, 서비스 일관성 개선을 최우선으로 진행하세요",
"고객 피드백을 적극 수집하고 빠르게 개선사항에 반영하여 고객과의 소통을 강화하세요",
"경쟁업체의 강점(가성비, 맛, 친절)을 벤치마킹하되, 차별화 포인트를 명확히 설정하세요"
]
},
"error_message": null,
"input_data": {
"request_context": {
"store_id": "501745730",
"owner_request": "매출이 감소하고 있어서 메뉴 개선이 필요합니다.",
"analysis_timestamp": "2024-06-16T10:30:00"
"owner_request": "매출이 감소하고 있어서 개선 방안이 필요합니다.",
"analysis_timestamp": "2025-06-16T03:34:08.622763"
},
"market_intelligence": {
"total_competitors": 7,
"total_competitors": 3,
"industry_insights": [
"업계 전반적으로 고객 만족도 개선 필요",
"고성과 업체 3개 벤치마킹 가능"
"경쟁이 치열한 고품질 시장",
"업계 공통 이슈: 고객 만족도 양극화 (일관성 부족)"
],
"performance_benchmarks": {
"average_rating": 4.1,
"review_volume_trend": "증가"
"average_rating": 4.23,
"rating_range": {
"min": 3.9,
"max": 4.7
},
"industry_standard": "Above Average",
"review_activity": {
"average_reviews": 224,
"range": {
"min": 103,
"max": 412
}
}
}
},
"competitive_insights": [
{
"rank": 1,
"store_name": "○○치킨",
"similarity_score": 0.892,
"store_name": "",
"category": "일식",
"similarity_score": 0.389,
"performance_analysis": {
"performance": {
"rating": "4.3",
"review_count": "156"
"rating": "4.1",
"review_count": "156",
"status": "영업 중"
},
"feedback": {
"positive_aspects": ["맛", "친절", "빠른배달"],
"negative_aspects": ["가격"]
"positive_aspects": [
"가성비",
"맛",
"분위기"
],
"negative_aspects": [],
"recent_trends": [
"최근 만족도 상승"
],
"rating_pattern": "안정적 고만족"
},
"business_insights": {
"key_finding": "신메뉴 런칭으로 리뷰 급증"
"insights": {
"competitive_advantage": [
"높은 고객 만족도",
"활발한 고객 참여",
"맛 우수"
],
"critical_issues": [],
"improvement_opportunities": []
}
}
},
{
"rank": 2,
"store_name": "",
"category": "일식",
"similarity_score": 0.347,
"performance_analysis": {
"performance": {
"rating": "4.7",
"review_count": "412",
"status": "영업 중"
},
"feedback": {
"positive_aspects": [
"가성비",
"맛",
"친절",
"분위기"
],
"negative_aspects": [
"고객 만족도 양극화 (일관성 부족)"
],
"recent_trends": [
"최근 만족도 상승"
],
"rating_pattern": "안정적 고만족"
},
"insights": {
"competitive_advantage": [
"높은 고객 만족도",
"활발한 고객 참여",
"맛 우수",
"친절 우수"
],
"critical_issues": [],
"improvement_opportunities": []
}
}
},
{
"rank": 3,
"store_name": "",
"category": "일식",
"similarity_score": 0.314,
"performance_analysis": {
"performance": {
"rating": "3.9",
"review_count": "103",
"status": "영업 중"
},
"feedback": {
"positive_aspects": [
"가성비",
"맛",
"친절"
],
"negative_aspects": [],
"recent_trends": [],
"rating_pattern": "양극화 패턴"
},
"insights": {
"competitive_advantage": [
"활발한 고객 참여",
"맛 우수",
"친절 우수"
],
"critical_issues": [],
"improvement_opportunities": []
}
}
}
],
"actionable_recommendations": {
"immediate_actions": [
"배달 서비스 개선 (업계 5개 업체 공통 문제)"
"고객 만족도 양극화 (일관성 부족) 개선 (업계 1개 업체 공통 문제)"
],
"strategic_improvements": [
"메뉴 다양성 확대"
],
"benchmarking_targets": [
"○○치킨: 신메뉴 마케팅 전략"
]
"strategic_improvements": [],
"benchmarking_targets": []
}
}
}
```
### 3. 매장 정보 조회 API
#### 요청 예시
```bash
curl -X GET "http://vector-api.20.249.191.180.nip.io/store/501745730"
```
#### 응답 예시 (성공)
```json
{
"success": true,
"message": "매장 정보 조회 성공: 맛있는 치킨집",
"store_id": "501745730",
"store_info": {
"id": "501745730",
"place_name": "맛있는 치킨집",
"category_name": "음식점 > 치킨",
"address_name": "서울특별시 강남구 역삼동 123-45",
"phone": "02-123-4567",
"place_url": "http://place.map.kakao.com/501745730",
"x": "127.0276543",
"y": "37.4979234"
},
"reviews": [
{
"reviewer_name": "김○○",
"rating": 5,
"date": "2024-06-10",
"content": "치킨이 정말 맛있어요!",
"badges": ["맛집", "친절", "빠른배달"],
"likes": 12
}
],
"review_summary": {
"total_reviews": 23,
"average_rating": 4.2,
"rating_distribution": {
"5": 12,
"4": 8,
"3": 2,
"2": 1,
"1": 0
},
"total_likes": 145,
"common_keywords": ["맛집", "친절", "빠른배달", "바삭함"]
},
"metadata": {
"store_id": "501745730",
"store_name": "맛있는 치킨집",
"food_category": "치킨",
"region": "서울특별시 강남구 역삼동",
"last_updated": "2024-06-17T10:30:00"
},
"last_updated": "2024-06-17T10:30:00",
"total_reviews": 23,
"execution_time": 0.15
}
```
#### 응답 예시 (실패)
```json
{
"success": false,
"message": "매장 정보를 찾을 수 없습니다: store_id=999999",
"store_id": "999999",
"store_info": null,
"reviews": null,
"review_summary": null,
"metadata": null,
"last_updated": null,
"total_reviews": 0,
"execution_time": 0.05
}
```
## ⚙️ 환경 설정
### 필수 환경변수

View File

@ -30,7 +30,7 @@ from contextlib import asynccontextmanager
from datetime import datetime
from typing import Optional
import asyncio
from fastapi import FastAPI, HTTPException, Depends
from fastapi import FastAPI, HTTPException, Depends, Path, Query
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
@ -43,7 +43,8 @@ from app.models.vector_models import (
ActionRecommendationRequest, ActionRecommendationResponse,
ActionRecommendationSimpleResponse,
VectorDBStatusResponse, VectorDBStatus,
FindReviewsResponse, StoredDataInfo
FindReviewsResponse, StoredDataInfo,
StoreInfoRequest, StoreInfoResponse
)
from app.services.restaurant_service import RestaurantService
@ -52,6 +53,8 @@ from app.services.vector_service import VectorService
from app.services.claude_service import ClaudeService
from app.utils.category_utils import extract_food_category
import json
# 로깅 설정
logging.basicConfig(
level=logging.INFO,
@ -434,7 +437,7 @@ async def find_reviews(
# 동종 업체 리뷰 수집 (본인 가게 제외)
similar_store_names = []
max_similar_reviews = min(settings.MAX_REVIEWS_PER_RESTAURANT // 2, 20) # 절반 또는 최대 20개
max_similar_reviews = settings.MAX_REVIEWS_PER_RESTAURANT
for store in similar_stores[:settings.MAX_RESTAURANTS_PER_CATEGORY]: # 환경변수 활용
store_info, reviews = await review_service.collect_store_reviews(
store.id,
@ -620,25 +623,36 @@ async def action_recommendation_simple(
"/vector-status",
response_model=VectorDBStatusResponse,
summary="Vector DB 상태 조회",
description="Vector DB의 현재 상태 조회합니다."
description="Vector DB의 현재 상태와 수집된 매장 ID 목록을 조회합니다."
)
async def get_vector_status(vector_service: VectorService = Depends(get_vector_service)):
"""Vector DB 상태를 조회합니다."""
async def get_vector_status(
include_store_ids: bool = Query(True, description="매장 ID 목록 포함 여부"),
store_limit: int = Query(200, description="매장 ID 목록 최대 개수", ge=1, le=1000),
vector_service: VectorService = Depends(get_vector_service)
):
"""Vector DB 상태와 수집된 매장 ID 목록을 조회합니다."""
try:
db_status = vector_service.get_db_status()
# store_id 목록 포함 여부와 제한 개수 전달
db_status = vector_service.get_db_status(
include_store_ids=include_store_ids,
store_limit=store_limit
)
status = VectorDBStatus(
collection_name=db_status['collection_name'],
total_documents=db_status['total_documents'],
total_stores=db_status['total_stores'],
db_path=db_status['db_path'],
last_updated=db_status.get('last_updated')
last_updated=db_status.get('last_updated'),
# 새로 추가되는 정보
store_ids=db_status.get('store_ids', [])
)
store_count = len(db_status.get('store_ids', []))
return VectorDBStatusResponse(
success=True,
status=status,
message="Vector DB 상태 조회 성공"
message=f"Vector DB 상태 조회 성공 - {store_count}개 매장 ID 포함"
)
except Exception as e:
@ -649,7 +663,8 @@ async def get_vector_status(vector_service: VectorService = Depends(get_vector_s
collection_name="unknown",
total_documents=0,
total_stores=0,
db_path="unknown"
db_path="unknown",
store_ids=[]
),
message=f"상태 조회 실패: {str(e)}"
)
@ -669,6 +684,65 @@ async def health_check():
"initialization_errors": app_state["initialization_errors"]
}
@app.get(
"/store/{store_id}",
response_model=StoreInfoResponse,
summary="매장 정보 조회",
description="store_id를 이용하여 Vector DB에서 매장 기본정보와 리뷰 정보를 조회합니다"
)
async def get_store_info(
store_id: str = Path(..., description="조회할 매장 ID", example="501745730"),
vector_service: VectorService = Depends(get_vector_service)
):
"""🏪 store_id로 매장 정보 조회 API"""
start_time = datetime.now()
try:
logger.info(f"🔍 매장 정보 조회 요청: store_id={store_id}")
# Vector DB에서 매장 정보 조회
store_data = vector_service.get_store_by_id(store_id)
execution_time = (datetime.now() - start_time).total_seconds()
if not store_data:
return StoreInfoResponse(
success=False,
message=f"매장 정보를 찾을 수 없습니다: store_id={store_id}",
store_id=store_id,
execution_time=execution_time
)
# 성공 응답
store_info = store_data.get('store_info', {})
reviews = store_data.get('reviews', [])
review_summary = store_data.get('review_summary', {})
metadata = store_data.get('metadata', {})
return StoreInfoResponse(
success=True,
message=f"매장 정보 조회 성공: {store_info.get('place_name', store_id)}",
store_id=store_id,
store_info=store_info,
reviews=reviews,
review_summary=review_summary,
metadata=metadata,
last_updated=store_data.get('combined_at'),
total_reviews=len(reviews),
execution_time=execution_time
)
except Exception as e:
execution_time = (datetime.now() - start_time).total_seconds()
logger.error(f"❌ 매장 정보 조회 실패: store_id={store_id}, error={e}")
return StoreInfoResponse(
success=False,
message=f"매장 정보 조회 중 오류 발생: {str(e)}",
store_id=store_id,
execution_time=execution_time
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host=settings.HOST, port=settings.PORT)
uvicorn.run(app, host=settings.HOST, port=settings.PORT)

View File

@ -96,6 +96,7 @@ class VectorStoreDocument(BaseModel):
review_summary: Dict[str, Any] = Field(description="리뷰 요약 정보")
last_updated: str = Field(description="마지막 업데이트 시간")
class VectorDBStatus(BaseModel):
"""Vector DB 상태 정보"""
collection_name: str = Field(description="컬렉션명")
@ -103,9 +104,32 @@ class VectorDBStatus(BaseModel):
total_stores: int = Field(description="총 가게 수")
db_path: str = Field(description="DB 경로")
last_updated: Optional[str] = Field(None, description="마지막 업데이트 시간")
store_ids: List[str] = Field(default=[], description="수집된 매장 ID 목록")
class VectorDBStatusResponse(BaseModel):
"""Vector DB 상태 조회 응답"""
success: bool = Field(description="조회 성공 여부")
status: VectorDBStatus = Field(description="DB 상태 정보")
message: str = Field(description="응답 메시지")
message: str = Field(description="응답 메시지")
class StoreInfoRequest(BaseModel):
"""매장 정보 조회 요청 모델"""
store_id: str = Field(
...,
description="조회할 매장 ID",
example="501745730"
)
class StoreInfoResponse(BaseModel):
"""매장 정보 조회 응답 모델"""
success: bool = Field(description="조회 성공 여부")
message: str = Field(description="응답 메시지")
store_id: str = Field(description="매장 ID")
store_info: Optional[Dict[str, Any]] = Field(None, description="매장 기본 정보")
reviews: Optional[List[Dict[str, Any]]] = Field(None, description="리뷰 목록")
review_summary: Optional[Dict[str, Any]] = Field(None, description="리뷰 요약 정보")
metadata: Optional[Dict[str, Any]] = Field(None, description="Vector DB 메타데이터")
last_updated: Optional[str] = Field(None, description="마지막 업데이트 시간")
total_reviews: int = Field(default=0, description="총 리뷰 수")
execution_time: Optional[float] = Field(None, description="실행 시간(초)")

View File

@ -147,74 +147,67 @@ class VectorService:
food_category: str,
region: str
) -> Dict[str, Any]:
"""Vector Store를 구축합니다"""
"""Vector Store를 구축합니다 (기존 데이터 업데이트 포함)"""
if not self.is_ready():
raise Exception(f"VectorService가 준비되지 않음: {self.initialization_error}")
try:
logger.info("🚀 Vector Store 구축 시작")
logger.info("🔧 Vector Store 구축 시작 (기존 데이터 업데이트 포함)...")
start_time = datetime.now()
processed_count = 0
documents = []
embeddings = []
metadatas = []
ids = []
# 1단계: 가게 분류 (새 가게 vs 업데이트 vs 중복)
logger.info("🔍 가게 분류 중...")
categorization = self.categorize_stores(review_results, region, food_category)
for store_id, store_info, reviews in review_results:
try:
# 텍스트 추출 및 임베딩 생성
text_for_embedding = extract_text_for_embedding(store_info, reviews)
embedding = self.embedding_model.encode(text_for_embedding)
# 메타데이터 생성
metadata = create_metadata(store_info, food_category, region)
# 문서 ID 생성
document_id = create_store_hash(store_id, store_info.get('name', ''), region)
# 문서 데이터 생성
document_text = combine_store_and_reviews(store_info, reviews)
documents.append(document_text)
embeddings.append(embedding.tolist())
metadatas.append(metadata)
ids.append(document_id)
processed_count += 1
except Exception as e:
logger.warning(f"⚠️ 가게 {store_id} 처리 실패: {e}")
continue
# Vector DB에 저장
if documents:
self.collection.add(
documents=documents,
embeddings=embeddings,
metadatas=metadatas,
ids=ids
# 2단계: 기존 가게 업데이트
update_result = {'updated_count': 0, 'skipped_count': 0}
if categorization['update_stores']:
logger.info(f"🔄 {len(categorization['update_stores'])}개 기존 가게 업데이트 중...")
update_result = self.update_existing_stores(
categorization['update_stores'], food_category, region
)
logger.info(f"✅ Vector Store 구축 완료: {processed_count}개 문서 저장")
return {
'success': True,
'processed_count': processed_count,
'message': f"{processed_count}개 문서가 Vector DB에 저장되었습니다"
}
else:
return {
'success': False,
'error': '저장할 문서가 없습니다',
'processed_count': 0
}
# 3단계: 새 가게 추가
add_result = {'added_count': 0}
if categorization['new_stores']:
logger.info(f"{len(categorization['new_stores'])}개 새 가게 추가 중...")
add_result = self.add_new_stores(
categorization['new_stores'], food_category, region
)
# 4단계: 결과 정리
total_processed = update_result['updated_count'] + add_result['added_count']
execution_time = (datetime.now() - start_time).total_seconds()
result = {
'success': True,
'processed_count': total_processed, # ← 기존 API 호환성 유지
'execution_time': execution_time,
'summary': categorization['summary'],
'operations': {
'new_stores_added': add_result['added_count'],
'existing_stores_updated': update_result['updated_count'],
'update_skipped': update_result.get('skipped_count', 0),
'duplicates_removed': categorization['summary']['duplicate_count']
},
'message': f"✅ Vector DB 처리 완료: 새 가게 {add_result['added_count']}개 추가, "
f"기존 가게 {update_result['updated_count']}개 업데이트"
}
logger.info(f"📊 Vector Store 구축 완료:")
logger.info(f" - 총 처리: {total_processed}")
logger.info(f" - 새 가게: {add_result['added_count']}")
logger.info(f" - 업데이트: {update_result['updated_count']}")
logger.info(f" - 실행 시간: {execution_time:.2f}")
return result
except Exception as e:
logger.error(f"❌ Vector Store 구축 실패: {e}")
return {
'success': False,
'error': str(e),
'processed_count': 0
'processed_count': 0 # ← 기존 API 호환성 유지
}
def search_similar_cases_improved(self, store_id: str, context: str, limit: int = 5) -> Optional[Dict[str, Any]]:
@ -899,8 +892,14 @@ class VectorService:
return recommendations
def get_db_status(self) -> Dict[str, Any]:
"""DB 상태 정보 반환"""
def get_db_status(self, include_store_ids: bool = True, store_limit: int = 200) -> Dict[str, Any]:
"""
DB 상태 정보 반환 - store_id 목록 포함
Args:
include_store_ids: store_id 목록 포함 여부
store_limit: store_id 목록 최대 개수
"""
try:
if not self.is_ready():
return {
@ -909,19 +908,27 @@ class VectorService:
'total_stores': 0,
'db_path': self.db_path,
'status': 'not_ready',
'error': self.initialization_error
'error': self.initialization_error,
'store_ids': []
}
# 컬렉션 정보 조회
# 기본 컬렉션 정보 조회
count = self.collection.count()
# store_id 목록 조회 (옵션)
store_ids = []
if include_store_ids:
store_ids = self.get_all_store_ids(limit=store_limit)
return {
'collection_name': self.collection_name,
'total_documents': count,
'total_stores': count, # 각 문서가 하나의 가게를 나타냄
'total_stores': len(store_ids) if include_store_ids else count,
'db_path': self.db_path,
'status': 'ready',
'last_updated': datetime.now().isoformat()
'last_updated': datetime.now().isoformat(),
# 새로 추가되는 정보
'store_ids': store_ids
}
except Exception as e:
@ -932,5 +939,370 @@ class VectorService:
'total_stores': 0,
'db_path': self.db_path,
'status': 'error',
'error': str(e)
}
'error': str(e),
'store_ids': []
}
def get_existing_store_data(self, region: str = None, food_category: str = None) -> Dict[str, Dict[str, Any]]:
"""
Vector DB에서 기존 store 데이터를 가져옵니다.
Args:
region: 지역 필터 (선택사항)
food_category: 음식 카테고리 필터 (선택사항)
Returns:
{store_id: {metadata, document_id}} 형태의 딕셔너리
"""
try:
if not self.is_ready():
logger.warning("⚠️ VectorService가 준비되지 않음")
return {}
# 필터 조건 설정
where_condition = {}
if region and food_category:
where_condition = {
"$and": [
{"region": region},
{"food_category": food_category}
]
}
elif region:
where_condition = {"region": region}
elif food_category:
where_condition = {"food_category": food_category}
# 기존 데이터 조회
if where_condition:
results = self.collection.get(
where=where_condition,
include=['metadatas', 'documents']
)
else:
results = self.collection.get(
include=['metadatas', 'documents']
)
if not results or not results.get('metadatas'):
logger.info("📊 Vector DB에 기존 데이터 없음")
return {}
# store_id별로 기존 데이터 매핑
existing_data = {}
metadatas = results.get('metadatas', [])
ids = results.get('ids', [])
documents = results.get('documents', [])
for i, metadata in enumerate(metadatas):
if metadata and 'store_id' in metadata:
store_id = metadata['store_id']
existing_data[store_id] = {
'metadata': metadata,
'document_id': ids[i] if i < len(ids) else None,
'document': documents[i] if i < len(documents) else None,
'last_updated': metadata.get('last_updated', 'unknown')
}
logger.info(f"📊 기존 Vector DB에서 {len(existing_data)}개 store 데이터 발견")
return existing_data
except Exception as e:
logger.error(f"❌ 기존 store 데이터 조회 실패: {e}")
return {}
def categorize_stores(self, review_results: List[tuple], region: str, food_category: str) -> Dict[str, Any]:
"""
가게들을 가게/업데이트 대상/현재 배치 중복으로 분류
"""
# 기존 Vector DB 데이터 조회
existing_data = self.get_existing_store_data(region, food_category)
existing_store_ids = set(existing_data.keys())
# 분류 작업
new_stores = [] # 완전히 새로운 가게
update_stores = [] # 기존 가게 업데이트
current_duplicates = [] # 현재 배치 내 중복
seen_in_batch = set()
for store_id, store_info, reviews in review_results:
# 현재 배치 내 중복 체크
if store_id in seen_in_batch:
current_duplicates.append((store_id, store_info, reviews))
logger.info(f"🔄 현재 배치 중복 발견: {store_id}")
continue
seen_in_batch.add(store_id)
# 기존 DB에 있는지 확인
if store_id in existing_store_ids:
# 업데이트 대상
update_stores.append({
'store_id': store_id,
'new_data': (store_id, store_info, reviews),
'existing_data': existing_data[store_id]
})
logger.info(f"🔄 업데이트 대상: {store_id}")
else:
# 새 가게
new_stores.append((store_id, store_info, reviews))
logger.info(f"✨ 새 가게: {store_id}")
result = {
'new_stores': new_stores,
'update_stores': update_stores,
'current_duplicates': current_duplicates,
'existing_data': existing_data,
'summary': {
'total_input': len(review_results),
'new_count': len(new_stores),
'update_count': len(update_stores),
'duplicate_count': len(current_duplicates),
'will_process': len(new_stores) + len(update_stores)
}
}
logger.info(f"📊 가게 분류 완료:")
logger.info(f" - 새 가게: {len(new_stores)}")
logger.info(f" - 업데이트: {len(update_stores)}")
logger.info(f" - 현재 중복: {len(current_duplicates)}")
return result
def update_existing_stores(self, update_stores: List[Dict], food_category: str, region: str) -> Dict[str, Any]:
"""
기존 가게들을 업데이트
"""
updated_count = 0
skipped_count = 0
update_details = []
for update_item in update_stores:
store_id = update_item['store_id']
new_data = update_item['new_data']
existing_data = update_item['existing_data']
try:
_, store_info, reviews = new_data
# 새 임베딩 및 메타데이터 생성
text_for_embedding = extract_text_for_embedding(store_info, reviews)
if not text_for_embedding or len(text_for_embedding.strip()) < 10:
logger.warning(f"⚠️ {store_id}: 임베딩 텍스트 부족, 업데이트 스킵")
skipped_count += 1
continue
embedding = self.embedding_model.encode(text_for_embedding)
new_metadata = create_metadata(store_info, food_category, region)
document_text = combine_store_and_reviews(store_info, reviews)
# 기존 document_id 사용 (ID 일관성 유지)
document_id = existing_data['document_id']
# Vector DB 업데이트 (ChromaDB에서는 upsert 사용)
self.collection.upsert(
documents=[document_text],
embeddings=[embedding.tolist()],
metadatas=[new_metadata],
ids=[document_id]
)
updated_count += 1
update_details.append({
'store_id': store_id,
'document_id': document_id,
'previous_updated': existing_data['metadata'].get('last_updated', 'unknown'),
'new_updated': new_metadata['last_updated']
})
logger.info(f"{store_id} 업데이트 완료")
except Exception as e:
logger.error(f"{store_id} 업데이트 실패: {e}")
skipped_count += 1
continue
return {
'updated_count': updated_count,
'skipped_count': skipped_count,
'update_details': update_details
}
def add_new_stores(self, new_stores: List[tuple], food_category: str, region: str) -> Dict[str, Any]:
"""
가게들을 Vector DB에 추가
"""
if not new_stores:
return {'added_count': 0, 'add_details': []}
documents = []
embeddings = []
metadatas = []
ids = []
added_count = 0
add_details = []
for store_id, store_info, reviews in new_stores:
try:
# 임베딩용 텍스트 생성
text_for_embedding = extract_text_for_embedding(store_info, reviews)
if not text_for_embedding or len(text_for_embedding.strip()) < 10:
logger.warning(f"⚠️ {store_id}: 임베딩 텍스트 부족, 추가 스킵")
continue
# 임베딩 생성
embedding = self.embedding_model.encode(text_for_embedding)
# 메타데이터 생성
metadata = create_metadata(store_info, food_category, region)
# 문서 ID 생성
document_id = create_store_hash(store_id, store_info.get('name', ''), region)
# 문서 데이터 생성
document_text = combine_store_and_reviews(store_info, reviews)
documents.append(document_text)
embeddings.append(embedding.tolist())
metadatas.append(metadata)
ids.append(document_id)
add_details.append({
'store_id': store_id,
'document_id': document_id,
'added_at': metadata['last_updated']
})
added_count += 1
except Exception as e:
logger.warning(f"⚠️ {store_id} 추가 실패: {e}")
continue
# Vector DB에 새 가게들 추가
if documents:
self.collection.add(
documents=documents,
embeddings=embeddings,
metadatas=metadatas,
ids=ids
)
logger.info(f"{added_count}개 새 가게 추가 완료")
return {
'added_count': added_count,
'add_details': add_details
}
def get_store_by_id(self, store_id: str) -> Optional[Dict[str, Any]]:
"""
store_id로 매장 정보를 조회합니다.
Args:
store_id: 조회할 매장 ID
Returns:
매장 정보 딕셔너리 또는 None
"""
try:
if not self.is_ready():
logger.warning("⚠️ VectorService가 준비되지 않음")
return None
logger.info(f"🔍 매장 정보 조회 시작: store_id={store_id}")
# Vector DB에서 해당 store_id로 검색
# ✅ 수정: include에서 'ids' 제거 (ChromaDB는 자동으로 ids를 반환함)
results = self.collection.get(
where={"store_id": store_id},
include=['documents', 'metadatas'] # 'ids' 제거
)
if not results or not results.get('metadatas') or not results['metadatas']:
logger.warning(f"❌ 매장 정보를 찾을 수 없음: store_id={store_id}")
return None
# 첫 번째 결과 사용 (store_id는 유니크해야 함)
metadata = results['metadatas'][0]
document = results['documents'][0] if results.get('documents') else None
# ✅ 수정: ChromaDB는 자동으로 ids를 반환하므로 그대로 사용
document_id = results['ids'][0] if results.get('ids') else None
logger.info(f"✅ 매장 정보 조회 성공: {metadata.get('store_name', 'Unknown')}")
# 문서에서 JSON 파싱 (combine_store_and_reviews 형태로 저장되어 있음)
store_data = None
if document:
try:
import json
store_data = json.loads(document)
except json.JSONDecodeError as e:
logger.error(f"❌ JSON 파싱 실패: {e}")
return None
# 응답 데이터 구성
result = {
"metadata": metadata,
"document_id": document_id,
"store_info": store_data.get('store_info') if store_data else None,
"reviews": store_data.get('reviews', []) if store_data else [],
"review_summary": store_data.get('review_summary', {}) if store_data else {},
"combined_at": store_data.get('combined_at') if store_data else None
}
return result
except Exception as e:
logger.error(f"❌ 매장 정보 조회 실패: store_id={store_id}, error={e}")
return None
def get_all_store_ids(self, limit: int = 200) -> List[str]:
"""
Vector DB에 저장된 모든 store_id 목록을 조회합니다.
Args:
limit: 반환할 최대 store_id (기본값: 200)
Returns:
store_id 문자열 리스트
"""
try:
if not self.is_ready():
logger.warning("⚠️ VectorService가 준비되지 않음")
return []
logger.info(f"🏪 전체 store_id 목록 조회 시작 (최대 {limit}개)")
# Vector DB에서 모든 메타데이터 조회
results = self.collection.get(
include=['metadatas'],
limit=limit # 성능을 위한 제한
)
if not results or not results.get('metadatas'):
logger.info("📊 Vector DB에 저장된 매장이 없습니다")
return []
# store_id만 추출 및 중복 제거
store_ids = []
seen_ids = set()
for metadata in results['metadatas']:
if not metadata:
continue
store_id = metadata.get('store_id', '')
if store_id and store_id not in seen_ids:
store_ids.append(store_id)
seen_ids.add(store_id)
# store_id로 정렬 (숫자 순서)
store_ids.sort()
logger.info(f"✅ store_id 목록 조회 완료: {len(store_ids)}")
return store_ids
except Exception as e:
logger.error(f"❌ store_id 목록 조회 실패: {e}")
return []

View File

@ -34,10 +34,10 @@ data:
EMBEDDING_MODEL: "sentence-transformers/all-MiniLM-L6-v2"
# 🔧 데이터 수집 제한
MAX_RESTAURANTS_PER_CATEGORY: "50"
MAX_REVIEWS_PER_RESTAURANT: "100"
MAX_RESTAURANTS_PER_CATEGORY: "10"
MAX_REVIEWS_PER_RESTAURANT: "50"
REQUEST_DELAY: "0.1"
REQUEST_TIMEOUT: "600"
REQUEST_TIMEOUT: "200"
# 🗄️ ChromaDB 설정 (1.0.12 호환)
# ❌ 제거된 deprecated 설정들: