Compare commits
No commits in common. "59e02fd0cd85047af0a5f9ac649fc6bdcedcdf80" and "e54def57803070d4c910a1c1f7199d42ad607bb4" have entirely different histories.
59e02fd0cd
...
e54def5780
225
README.md
225
README.md
@ -1,225 +0,0 @@
|
||||
# 🍽️ 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
|
||||
```
|
||||
78
review/deployment/manifests/configmap.yaml
Normal file
78
review/deployment/manifests/configmap.yaml
Normal file
@ -0,0 +1,78 @@
|
||||
# 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"
|
||||
130
review/deployment/manifests/deployment.yaml
Normal file
130
review/deployment/manifests/deployment.yaml
Normal file
@ -0,0 +1,130 @@
|
||||
# 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: 제거
|
||||
|
||||
39
review/deployment/manifests/ingress.yaml
Normal file
39
review/deployment/manifests/ingress.yaml
Normal file
@ -0,0 +1,39 @@
|
||||
# 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
|
||||
|
||||
21
review/deployment/manifests/secret.yaml
Normal file
21
review/deployment/manifests/secret.yaml
Normal file
@ -0,0 +1,21 @@
|
||||
# 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: ""
|
||||
|
||||
17
review/deployment/manifests/service.yaml
Normal file
17
review/deployment/manifests/service.yaml
Normal file
@ -0,0 +1,17 @@
|
||||
# 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
|
||||
|
||||
547
vector/README.md
547
vector/README.md
@ -94,8 +94,7 @@ vector/
|
||||
| Method | Endpoint | 설명 | 요청 모델 | 응답 모델 |
|
||||
|--------|----------|------|-----------|-----------|
|
||||
| `POST` | `/find-reviews` | 리뷰 수집 및 Vector DB 저장 | VectorBuildRequest | FindReviewsResponse |
|
||||
| `POST` | `/action-recommendation` | AI 기반 비즈니스 추천 | ActionRecommendationRequest |
|
||||
| `GET` | `/store/{store_id}` | store_id로 매장 정보 조회 | Path Parameter | StoreInfoResponse ActionRecommendationSimpleResponse |
|
||||
| `POST` | `/action-recommendation` | AI 기반 비즈니스 추천 | ActionRecommendationRequest | ActionRecommendationSimpleResponse |
|
||||
|
||||
## 🚀 빠른 시작
|
||||
|
||||
@ -277,9 +276,8 @@ curl -X POST "http://vector-api.20.249.191.180.nip.io/find-reviews" \
|
||||
"category_name": "음식점 > 치킨",
|
||||
"address_name": "서울특별시 강남구 역삼동 123-45",
|
||||
"phone": "02-123-4567",
|
||||
"place_url": "http://place.map.kakao.com/501745730",
|
||||
"x": "127.0276543",
|
||||
"y": "37.4979234"
|
||||
"x": "127.0276",
|
||||
"y": "37.4979"
|
||||
},
|
||||
"food_category": "치킨",
|
||||
"total_reviews": 127,
|
||||
@ -287,263 +285,14 @@ curl -X POST "http://vector-api.20.249.191.180.nip.io/find-reviews" \
|
||||
"execution_time": 45.2,
|
||||
"stored_data": {
|
||||
"501745730": {
|
||||
"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
|
||||
}
|
||||
],
|
||||
"store_info": { /* 가게 정보 */ },
|
||||
"reviews": [ /* 리뷰 목록 */ ],
|
||||
"review_summary": {
|
||||
"total_reviews": 23,
|
||||
"average_rating": 4.2,
|
||||
"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"]
|
||||
"positive_keywords": ["맛있음", "친절", "깨끗"]
|
||||
},
|
||||
"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"
|
||||
"combined_at": "2024-06-16T10:30:00"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -567,293 +316,101 @@ curl -X POST "http://vector-api.20.249.191.180.nip.io/action-recommendation" \
|
||||
"success": true,
|
||||
"recommendation": {
|
||||
"summary": {
|
||||
"current_situation": "일식 업종으로 매출 감소를 겪고 있으며, 경쟁이 치열한 고품질 시장에서 운영 중입니다. 업계 평균 평점은 4.23점이고, 경쟁업체들은 3.9-4.7점 범위의 평점을 보이고 있습니다.",
|
||||
"current_situation": "치킨 전문점으로 강남구 역삼동에 위치, 평균 별점 3.8점으로 동종업체 대비 개선 여지 존재",
|
||||
"key_insights": [
|
||||
"업계 공통 이슈로 고객 만족도 양극화 문제 존재",
|
||||
"경쟁업체들은 가성비, 맛, 친절함에서 강점을 보임",
|
||||
"고객 참여도와 일관성 있는 서비스 품질이 성공의 핵심 요소"
|
||||
"배달 서비스 만족도가 경쟁업체 대비 낮음",
|
||||
"신메뉴 출시 주기가 3개월 이상으로 긴 편",
|
||||
"온라인 리뷰 관리 시스템 부재"
|
||||
],
|
||||
"priority_areas": [
|
||||
"서비스 일관성 개선",
|
||||
"고객 만족도 안정화"
|
||||
]
|
||||
"priority_areas": ["배달 품질 개선", "메뉴 혁신"]
|
||||
},
|
||||
"action_plans": {
|
||||
"short_term": [
|
||||
{
|
||||
"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코드 제작, 시스템 구축)"
|
||||
"title": "배달 포장재 개선",
|
||||
"description": "보온성이 우수한 친환경 포장재로 교체하여 배달 만족도 향상",
|
||||
"expected_impact": "배달 리뷰 평점 0.5점 상승 예상",
|
||||
"timeline": "2주",
|
||||
"cost": "월 50만원"
|
||||
}
|
||||
],
|
||||
"mid_term": [
|
||||
{
|
||||
"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만원 (마케팅비, 시스템 구축비)"
|
||||
"title": "시즌 한정 메뉴 런칭",
|
||||
"description": "여름 시즌 매운맛 신메뉴 2종 개발 및 SNS 마케팅",
|
||||
"expected_impact": "신규 고객 유입 20% 증가",
|
||||
"timeline": "6주",
|
||||
"cost": "초기 투자 300만원"
|
||||
}
|
||||
],
|
||||
"long_term": [
|
||||
{
|
||||
"title": "고객 충성도 프로그램 구축",
|
||||
"description": "멤버십 시스템 도입, 단골 고객 특별 혜택 제공, 생일/기념일 이벤트 운영. 고객 데이터베이스 구축 및 맞춤형 서비스 제공",
|
||||
"expected_impact": "고객 유지율 40% 향상, 월 매출 25-30% 증가",
|
||||
"timeline": "6-8개월",
|
||||
"cost": "300-500만원 (시스템 개발, 운영비)"
|
||||
},
|
||||
{
|
||||
"title": "브랜드 아이덴티티 강화 및 확장 전략",
|
||||
"description": "독특한 브랜드 스토리 개발, 인테리어 리뉴얼, 패키징 디자인 개선. 향후 2호점 진출 또는 프랜차이즈 검토",
|
||||
"expected_impact": "브랜드 인지도 향상, 프리미엄 가격 정책 가능, 사업 확장 기반 마련",
|
||||
"timeline": "8-12개월",
|
||||
"cost": "500-1000만원 (리뉴얼, 브랜딩 비용)"
|
||||
"title": "브랜드 리뉴얼",
|
||||
"description": "매장 인테리어 개선 및 브랜드 아이덴티티 강화",
|
||||
"expected_impact": "브랜드 인지도 향상, 객단가 15% 상승",
|
||||
"timeline": "4개월",
|
||||
"cost": "1,500만원"
|
||||
}
|
||||
]
|
||||
},
|
||||
"implementation_tips": [
|
||||
"단기 계획부터 차근차근 실행하되, 서비스 일관성 개선을 최우선으로 진행하세요",
|
||||
"고객 피드백을 적극 수집하고 빠르게 개선사항에 반영하여 고객과의 소통을 강화하세요",
|
||||
"경쟁업체의 강점(가성비, 맛, 친절)을 벤치마킹하되, 차별화 포인트를 명확히 설정하세요"
|
||||
"배달앱 리뷰 모니터링 시스템 도입",
|
||||
"경쟁업체 메뉴 트렌드 주기적 분석",
|
||||
"고객 피드백 기반 개선사항 우선순위 설정"
|
||||
]
|
||||
},
|
||||
"error_message": null,
|
||||
"input_data": {
|
||||
"request_context": {
|
||||
"store_id": "501745730",
|
||||
"owner_request": "매출이 감소하고 있어서 개선 방안이 필요합니다.",
|
||||
"analysis_timestamp": "2025-06-16T03:34:08.622763"
|
||||
"owner_request": "매출이 감소하고 있어서 메뉴 개선이 필요합니다.",
|
||||
"analysis_timestamp": "2024-06-16T10:30:00"
|
||||
},
|
||||
"market_intelligence": {
|
||||
"total_competitors": 3,
|
||||
"total_competitors": 7,
|
||||
"industry_insights": [
|
||||
"경쟁이 치열한 고품질 시장",
|
||||
"업계 공통 이슈: 고객 만족도 양극화 (일관성 부족)"
|
||||
"업계 전반적으로 고객 만족도 개선 필요",
|
||||
"고성과 업체 3개 벤치마킹 가능"
|
||||
],
|
||||
"performance_benchmarks": {
|
||||
"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
|
||||
}
|
||||
}
|
||||
"average_rating": 4.1,
|
||||
"review_volume_trend": "증가"
|
||||
}
|
||||
},
|
||||
"competitive_insights": [
|
||||
{
|
||||
"rank": 1,
|
||||
"store_name": "",
|
||||
"category": "일식",
|
||||
"similarity_score": 0.389,
|
||||
"store_name": "○○치킨",
|
||||
"similarity_score": 0.892,
|
||||
"performance_analysis": {
|
||||
"performance": {
|
||||
"rating": "4.1",
|
||||
"review_count": "156",
|
||||
"status": "영업 중"
|
||||
"rating": "4.3",
|
||||
"review_count": "156"
|
||||
},
|
||||
"feedback": {
|
||||
"positive_aspects": [
|
||||
"가성비",
|
||||
"맛",
|
||||
"분위기"
|
||||
],
|
||||
"negative_aspects": [],
|
||||
"recent_trends": [
|
||||
"최근 만족도 상승"
|
||||
],
|
||||
"rating_pattern": "안정적 고만족"
|
||||
"positive_aspects": ["맛", "친절", "빠른배달"],
|
||||
"negative_aspects": ["가격"]
|
||||
},
|
||||
"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": []
|
||||
"business_insights": {
|
||||
"key_finding": "신메뉴 런칭으로 리뷰 급증"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"actionable_recommendations": {
|
||||
"immediate_actions": [
|
||||
"고객 만족도 양극화 (일관성 부족) 개선 (업계 1개 업체 공통 문제)"
|
||||
"배달 서비스 개선 (업계 5개 업체 공통 문제)"
|
||||
],
|
||||
"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
|
||||
}
|
||||
```
|
||||
|
||||
## ⚙️ 환경 설정
|
||||
|
||||
### 필수 환경변수
|
||||
|
||||
@ -30,7 +30,7 @@ from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import asyncio
|
||||
from fastapi import FastAPI, HTTPException, Depends, Path, Query
|
||||
from fastapi import FastAPI, HTTPException, Depends
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel, Field
|
||||
@ -43,8 +43,7 @@ from app.models.vector_models import (
|
||||
ActionRecommendationRequest, ActionRecommendationResponse,
|
||||
ActionRecommendationSimpleResponse,
|
||||
VectorDBStatusResponse, VectorDBStatus,
|
||||
FindReviewsResponse, StoredDataInfo,
|
||||
StoreInfoRequest, StoreInfoResponse
|
||||
FindReviewsResponse, StoredDataInfo
|
||||
)
|
||||
|
||||
from app.services.restaurant_service import RestaurantService
|
||||
@ -53,8 +52,6 @@ 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,
|
||||
@ -437,7 +434,7 @@ async def find_reviews(
|
||||
|
||||
# 동종 업체 리뷰 수집 (본인 가게 제외)
|
||||
similar_store_names = []
|
||||
max_similar_reviews = settings.MAX_REVIEWS_PER_RESTAURANT
|
||||
max_similar_reviews = min(settings.MAX_REVIEWS_PER_RESTAURANT // 2, 20) # 절반 또는 최대 20개
|
||||
for store in similar_stores[:settings.MAX_RESTAURANTS_PER_CATEGORY]: # 환경변수 활용
|
||||
store_info, reviews = await review_service.collect_store_reviews(
|
||||
store.id,
|
||||
@ -623,36 +620,25 @@ async def action_recommendation_simple(
|
||||
"/vector-status",
|
||||
response_model=VectorDBStatusResponse,
|
||||
summary="Vector DB 상태 조회",
|
||||
description="Vector DB의 현재 상태와 수집된 매장 ID 목록을 조회합니다."
|
||||
description="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 목록을 조회합니다."""
|
||||
async def get_vector_status(vector_service: VectorService = Depends(get_vector_service)):
|
||||
"""Vector DB 상태를 조회합니다."""
|
||||
try:
|
||||
# store_id 목록 포함 여부와 제한 개수 전달
|
||||
db_status = vector_service.get_db_status(
|
||||
include_store_ids=include_store_ids,
|
||||
store_limit=store_limit
|
||||
)
|
||||
db_status = vector_service.get_db_status()
|
||||
|
||||
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'),
|
||||
# 새로 추가되는 정보
|
||||
store_ids=db_status.get('store_ids', [])
|
||||
last_updated=db_status.get('last_updated')
|
||||
)
|
||||
|
||||
store_count = len(db_status.get('store_ids', []))
|
||||
return VectorDBStatusResponse(
|
||||
success=True,
|
||||
status=status,
|
||||
message=f"Vector DB 상태 조회 성공 - {store_count}개 매장 ID 포함"
|
||||
message="Vector DB 상태 조회 성공"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@ -663,8 +649,7 @@ async def get_vector_status(
|
||||
collection_name="unknown",
|
||||
total_documents=0,
|
||||
total_stores=0,
|
||||
db_path="unknown",
|
||||
store_ids=[]
|
||||
db_path="unknown"
|
||||
),
|
||||
message=f"상태 조회 실패: {str(e)}"
|
||||
)
|
||||
@ -684,65 +669,6 @@ 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)
|
||||
@ -96,7 +96,6 @@ 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="컬렉션명")
|
||||
@ -104,32 +103,9 @@ 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="응답 메시지")
|
||||
|
||||
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="실행 시간(초)")
|
||||
|
||||
@ -147,67 +147,74 @@ 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 구축 시작 (기존 데이터 업데이트 포함)...")
|
||||
start_time = datetime.now()
|
||||
logger.info("🚀 Vector Store 구축 시작")
|
||||
|
||||
# 1단계: 가게 분류 (새 가게 vs 업데이트 vs 중복)
|
||||
logger.info("🔍 가게 분류 중...")
|
||||
categorization = self.categorize_stores(review_results, region, food_category)
|
||||
processed_count = 0
|
||||
documents = []
|
||||
embeddings = []
|
||||
metadatas = []
|
||||
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
|
||||
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
|
||||
)
|
||||
|
||||
# 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
|
||||
)
|
||||
logger.info(f"✅ Vector Store 구축 완료: {processed_count}개 문서 저장")
|
||||
|
||||
# 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
|
||||
return {
|
||||
'success': True,
|
||||
'processed_count': processed_count,
|
||||
'message': f"{processed_count}개 문서가 Vector DB에 저장되었습니다"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'success': False,
|
||||
'error': '저장할 문서가 없습니다',
|
||||
'processed_count': 0
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Vector Store 구축 실패: {e}")
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'processed_count': 0 # ← 기존 API 호환성 유지
|
||||
'processed_count': 0
|
||||
}
|
||||
|
||||
def search_similar_cases_improved(self, store_id: str, context: str, limit: int = 5) -> Optional[Dict[str, Any]]:
|
||||
@ -892,14 +899,8 @@ class VectorService:
|
||||
|
||||
return recommendations
|
||||
|
||||
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 목록 최대 개수
|
||||
"""
|
||||
def get_db_status(self) -> Dict[str, Any]:
|
||||
"""DB 상태 정보 반환"""
|
||||
try:
|
||||
if not self.is_ready():
|
||||
return {
|
||||
@ -908,27 +909,19 @@ class VectorService:
|
||||
'total_stores': 0,
|
||||
'db_path': self.db_path,
|
||||
'status': 'not_ready',
|
||||
'error': self.initialization_error,
|
||||
'store_ids': []
|
||||
'error': self.initialization_error
|
||||
}
|
||||
|
||||
# 기본 컬렉션 정보 조회
|
||||
# 컬렉션 정보 조회
|
||||
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': len(store_ids) if include_store_ids else count,
|
||||
'total_stores': count, # 각 문서가 하나의 가게를 나타냄
|
||||
'db_path': self.db_path,
|
||||
'status': 'ready',
|
||||
'last_updated': datetime.now().isoformat(),
|
||||
# 새로 추가되는 정보
|
||||
'store_ids': store_ids
|
||||
'last_updated': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
@ -939,370 +932,5 @@ class VectorService:
|
||||
'total_stores': 0,
|
||||
'db_path': self.db_path,
|
||||
'status': 'error',
|
||||
'error': str(e),
|
||||
'store_ids': []
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
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 []
|
||||
@ -34,10 +34,10 @@ data:
|
||||
EMBEDDING_MODEL: "sentence-transformers/all-MiniLM-L6-v2"
|
||||
|
||||
# 🔧 데이터 수집 제한
|
||||
MAX_RESTAURANTS_PER_CATEGORY: "10"
|
||||
MAX_REVIEWS_PER_RESTAURANT: "50"
|
||||
MAX_RESTAURANTS_PER_CATEGORY: "50"
|
||||
MAX_REVIEWS_PER_RESTAURANT: "100"
|
||||
REQUEST_DELAY: "0.1"
|
||||
REQUEST_TIMEOUT: "200"
|
||||
REQUEST_TIMEOUT: "600"
|
||||
|
||||
# 🗄️ ChromaDB 설정 (1.0.12 호환)
|
||||
# ❌ 제거된 deprecated 설정들:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user