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 | 설명 | 요청 모델 | 응답 모델 |
|
| Method | Endpoint | 설명 | 요청 모델 | 응답 모델 |
|
||||||
|--------|----------|------|-----------|-----------|
|
|--------|----------|------|-----------|-----------|
|
||||||
| `POST` | `/find-reviews` | 리뷰 수집 및 Vector DB 저장 | VectorBuildRequest | FindReviewsResponse |
|
| `POST` | `/find-reviews` | 리뷰 수집 및 Vector DB 저장 | VectorBuildRequest | FindReviewsResponse |
|
||||||
| `POST` | `/action-recommendation` | AI 기반 비즈니스 추천 | ActionRecommendationRequest |
|
| `POST` | `/action-recommendation` | AI 기반 비즈니스 추천 | ActionRecommendationRequest | ActionRecommendationSimpleResponse |
|
||||||
| `GET` | `/store/{store_id}` | store_id로 매장 정보 조회 | Path Parameter | StoreInfoResponse ActionRecommendationSimpleResponse |
|
|
||||||
|
|
||||||
## 🚀 빠른 시작
|
## 🚀 빠른 시작
|
||||||
|
|
||||||
@ -277,9 +276,8 @@ curl -X POST "http://vector-api.20.249.191.180.nip.io/find-reviews" \
|
|||||||
"category_name": "음식점 > 치킨",
|
"category_name": "음식점 > 치킨",
|
||||||
"address_name": "서울특별시 강남구 역삼동 123-45",
|
"address_name": "서울특별시 강남구 역삼동 123-45",
|
||||||
"phone": "02-123-4567",
|
"phone": "02-123-4567",
|
||||||
"place_url": "http://place.map.kakao.com/501745730",
|
"x": "127.0276",
|
||||||
"x": "127.0276543",
|
"y": "37.4979"
|
||||||
"y": "37.4979234"
|
|
||||||
},
|
},
|
||||||
"food_category": "치킨",
|
"food_category": "치킨",
|
||||||
"total_reviews": 127,
|
"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,
|
"execution_time": 45.2,
|
||||||
"stored_data": {
|
"stored_data": {
|
||||||
"501745730": {
|
"501745730": {
|
||||||
"store_info": {
|
"store_info": { /* 가게 정보 */ },
|
||||||
"id": "501745730",
|
"reviews": [ /* 리뷰 목록 */ ],
|
||||||
"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": {
|
"review_summary": {
|
||||||
"total_reviews": 23,
|
"total_reviews": 23,
|
||||||
"average_rating": 4.2,
|
"average_rating": 4.2,
|
||||||
"rating_distribution": {
|
"positive_keywords": ["맛있음", "친절", "깨끗"]
|
||||||
"5": 8,
|
|
||||||
"4": 9,
|
|
||||||
"3": 4,
|
|
||||||
"2": 1,
|
|
||||||
"1": 1
|
|
||||||
},
|
},
|
||||||
"positive_keywords": [
|
"combined_at": "2024-06-16T10:30:00"
|
||||||
"맛있음",
|
|
||||||
"바삭함",
|
|
||||||
"친절",
|
|
||||||
"빠른배달"
|
|
||||||
],
|
|
||||||
"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: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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -567,290 +316,98 @@ curl -X POST "http://vector-api.20.249.191.180.nip.io/action-recommendation" \
|
|||||||
"success": true,
|
"success": true,
|
||||||
"recommendation": {
|
"recommendation": {
|
||||||
"summary": {
|
"summary": {
|
||||||
"current_situation": "일식 업종으로 매출 감소를 겪고 있으며, 경쟁이 치열한 고품질 시장에서 운영 중입니다. 업계 평균 평점은 4.23점이고, 경쟁업체들은 3.9-4.7점 범위의 평점을 보이고 있습니다.",
|
"current_situation": "치킨 전문점으로 강남구 역삼동에 위치, 평균 별점 3.8점으로 동종업체 대비 개선 여지 존재",
|
||||||
"key_insights": [
|
"key_insights": [
|
||||||
"업계 공통 이슈로 고객 만족도 양극화 문제 존재",
|
"배달 서비스 만족도가 경쟁업체 대비 낮음",
|
||||||
"경쟁업체들은 가성비, 맛, 친절함에서 강점을 보임",
|
"신메뉴 출시 주기가 3개월 이상으로 긴 편",
|
||||||
"고객 참여도와 일관성 있는 서비스 품질이 성공의 핵심 요소"
|
"온라인 리뷰 관리 시스템 부재"
|
||||||
],
|
],
|
||||||
"priority_areas": [
|
"priority_areas": ["배달 품질 개선", "메뉴 혁신"]
|
||||||
"서비스 일관성 개선",
|
|
||||||
"고객 만족도 안정화"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"action_plans": {
|
"action_plans": {
|
||||||
"short_term": [
|
"short_term": [
|
||||||
{
|
{
|
||||||
"title": "서비스 표준화 매뉴얼 작성 및 적용",
|
"title": "배달 포장재 개선",
|
||||||
"description": "조리법, 서빙 방식, 고객 응대 등 모든 서비스 과정을 표준화하여 일관성 있는 품질 제공. 직원 교육 실시 및 체크리스트 활용",
|
"description": "보온성이 우수한 친환경 포장재로 교체하여 배달 만족도 향상",
|
||||||
"expected_impact": "고객 만족도 양극화 해소, 평점 0.3-0.5점 상승 예상",
|
"expected_impact": "배달 리뷰 평점 0.5점 상승 예상",
|
||||||
"timeline": "2-4주",
|
"timeline": "2주",
|
||||||
"cost": "50-100만원 (교육비, 매뉴얼 제작비)"
|
"cost": "월 50만원"
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "고객 피드백 수집 시스템 구축",
|
|
||||||
"description": "테이블 QR코드를 통한 실시간 피드백 수집, 불만사항 즉시 대응 체계 마련. 주간 피드백 분석 및 개선사항 도출",
|
|
||||||
"expected_impact": "고객 불만 30% 감소, 재방문율 15% 증가",
|
|
||||||
"timeline": "1-2주",
|
|
||||||
"cost": "30-50만원 (QR코드 제작, 시스템 구축)"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"mid_term": [
|
"mid_term": [
|
||||||
{
|
{
|
||||||
"title": "메뉴 최적화 및 시그니처 메뉴 개발",
|
"title": "시즌 한정 메뉴 런칭",
|
||||||
"description": "경쟁업체 대비 차별화된 시그니처 메뉴 3-5개 개발. 기존 메뉴 중 인기도 낮은 메뉴 정리하고 가성비 우수 메뉴 강화",
|
"description": "여름 시즌 매운맛 신메뉴 2종 개발 및 SNS 마케팅",
|
||||||
"expected_impact": "평균 주문 금액 20% 증가, 고객 재방문율 25% 향상",
|
"expected_impact": "신규 고객 유입 20% 증가",
|
||||||
"timeline": "2-3개월",
|
"timeline": "6주",
|
||||||
"cost": "200-300만원 (메뉴 개발, 재료비, 마케팅)"
|
"cost": "초기 투자 300만원"
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "디지털 마케팅 강화",
|
|
||||||
"description": "네이버 플레이스, 구글 마이비즈니스 최적화. SNS 활용한 메뉴 홍보 및 고객 후기 관리. 온라인 주문 시스템 도입",
|
|
||||||
"expected_impact": "온라인 노출 50% 증가, 신규 고객 유입 30% 증가",
|
|
||||||
"timeline": "3-4개월",
|
|
||||||
"cost": "150-250만원 (마케팅비, 시스템 구축비)"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"long_term": [
|
"long_term": [
|
||||||
{
|
{
|
||||||
"title": "고객 충성도 프로그램 구축",
|
"title": "브랜드 리뉴얼",
|
||||||
"description": "멤버십 시스템 도입, 단골 고객 특별 혜택 제공, 생일/기념일 이벤트 운영. 고객 데이터베이스 구축 및 맞춤형 서비스 제공",
|
"description": "매장 인테리어 개선 및 브랜드 아이덴티티 강화",
|
||||||
"expected_impact": "고객 유지율 40% 향상, 월 매출 25-30% 증가",
|
"expected_impact": "브랜드 인지도 향상, 객단가 15% 상승",
|
||||||
"timeline": "6-8개월",
|
"timeline": "4개월",
|
||||||
"cost": "300-500만원 (시스템 개발, 운영비)"
|
"cost": "1,500만원"
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "브랜드 아이덴티티 강화 및 확장 전략",
|
|
||||||
"description": "독특한 브랜드 스토리 개발, 인테리어 리뉴얼, 패키징 디자인 개선. 향후 2호점 진출 또는 프랜차이즈 검토",
|
|
||||||
"expected_impact": "브랜드 인지도 향상, 프리미엄 가격 정책 가능, 사업 확장 기반 마련",
|
|
||||||
"timeline": "8-12개월",
|
|
||||||
"cost": "500-1000만원 (리뉴얼, 브랜딩 비용)"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"implementation_tips": [
|
"implementation_tips": [
|
||||||
"단기 계획부터 차근차근 실행하되, 서비스 일관성 개선을 최우선으로 진행하세요",
|
"배달앱 리뷰 모니터링 시스템 도입",
|
||||||
"고객 피드백을 적극 수집하고 빠르게 개선사항에 반영하여 고객과의 소통을 강화하세요",
|
"경쟁업체 메뉴 트렌드 주기적 분석",
|
||||||
"경쟁업체의 강점(가성비, 맛, 친절)을 벤치마킹하되, 차별화 포인트를 명확히 설정하세요"
|
"고객 피드백 기반 개선사항 우선순위 설정"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"error_message": null,
|
|
||||||
"input_data": {
|
"input_data": {
|
||||||
"request_context": {
|
"request_context": {
|
||||||
"store_id": "501745730",
|
"store_id": "501745730",
|
||||||
"owner_request": "매출이 감소하고 있어서 개선 방안이 필요합니다.",
|
"owner_request": "매출이 감소하고 있어서 메뉴 개선이 필요합니다.",
|
||||||
"analysis_timestamp": "2025-06-16T03:34:08.622763"
|
"analysis_timestamp": "2024-06-16T10:30:00"
|
||||||
},
|
},
|
||||||
"market_intelligence": {
|
"market_intelligence": {
|
||||||
"total_competitors": 3,
|
"total_competitors": 7,
|
||||||
"industry_insights": [
|
"industry_insights": [
|
||||||
"경쟁이 치열한 고품질 시장",
|
"업계 전반적으로 고객 만족도 개선 필요",
|
||||||
"업계 공통 이슈: 고객 만족도 양극화 (일관성 부족)"
|
"고성과 업체 3개 벤치마킹 가능"
|
||||||
],
|
],
|
||||||
"performance_benchmarks": {
|
"performance_benchmarks": {
|
||||||
"average_rating": 4.23,
|
"average_rating": 4.1,
|
||||||
"rating_range": {
|
"review_volume_trend": "증가"
|
||||||
"min": 3.9,
|
|
||||||
"max": 4.7
|
|
||||||
},
|
|
||||||
"industry_standard": "Above Average",
|
|
||||||
"review_activity": {
|
|
||||||
"average_reviews": 224,
|
|
||||||
"range": {
|
|
||||||
"min": 103,
|
|
||||||
"max": 412
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"competitive_insights": [
|
"competitive_insights": [
|
||||||
{
|
{
|
||||||
"rank": 1,
|
"rank": 1,
|
||||||
"store_name": "",
|
"store_name": "○○치킨",
|
||||||
"category": "일식",
|
"similarity_score": 0.892,
|
||||||
"similarity_score": 0.389,
|
|
||||||
"performance_analysis": {
|
"performance_analysis": {
|
||||||
"performance": {
|
"performance": {
|
||||||
"rating": "4.1",
|
"rating": "4.3",
|
||||||
"review_count": "156",
|
"review_count": "156"
|
||||||
"status": "영업 중"
|
|
||||||
},
|
},
|
||||||
"feedback": {
|
"feedback": {
|
||||||
"positive_aspects": [
|
"positive_aspects": ["맛", "친절", "빠른배달"],
|
||||||
"가성비",
|
"negative_aspects": ["가격"]
|
||||||
"맛",
|
|
||||||
"분위기"
|
|
||||||
],
|
|
||||||
"negative_aspects": [],
|
|
||||||
"recent_trends": [
|
|
||||||
"최근 만족도 상승"
|
|
||||||
],
|
|
||||||
"rating_pattern": "안정적 고만족"
|
|
||||||
},
|
},
|
||||||
"insights": {
|
"business_insights": {
|
||||||
"competitive_advantage": [
|
"key_finding": "신메뉴 런칭으로 리뷰 급증"
|
||||||
"높은 고객 만족도",
|
|
||||||
"활발한 고객 참여",
|
|
||||||
"맛 우수"
|
|
||||||
],
|
|
||||||
"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": {
|
"actionable_recommendations": {
|
||||||
"immediate_actions": [
|
"immediate_actions": [
|
||||||
"고객 만족도 양극화 (일관성 부족) 개선 (업계 1개 업체 공통 문제)"
|
"배달 서비스 개선 (업계 5개 업체 공통 문제)"
|
||||||
],
|
],
|
||||||
"strategic_improvements": [],
|
"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": {
|
"benchmarking_targets": [
|
||||||
"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 datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import asyncio
|
import asyncio
|
||||||
from fastapi import FastAPI, HTTPException, Depends, Path, Query
|
from fastapi import FastAPI, HTTPException, Depends
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@ -43,8 +43,7 @@ from app.models.vector_models import (
|
|||||||
ActionRecommendationRequest, ActionRecommendationResponse,
|
ActionRecommendationRequest, ActionRecommendationResponse,
|
||||||
ActionRecommendationSimpleResponse,
|
ActionRecommendationSimpleResponse,
|
||||||
VectorDBStatusResponse, VectorDBStatus,
|
VectorDBStatusResponse, VectorDBStatus,
|
||||||
FindReviewsResponse, StoredDataInfo,
|
FindReviewsResponse, StoredDataInfo
|
||||||
StoreInfoRequest, StoreInfoResponse
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.services.restaurant_service import RestaurantService
|
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.services.claude_service import ClaudeService
|
||||||
from app.utils.category_utils import extract_food_category
|
from app.utils.category_utils import extract_food_category
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
# 로깅 설정
|
# 로깅 설정
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@ -437,7 +434,7 @@ async def find_reviews(
|
|||||||
|
|
||||||
# 동종 업체 리뷰 수집 (본인 가게 제외)
|
# 동종 업체 리뷰 수집 (본인 가게 제외)
|
||||||
similar_store_names = []
|
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]: # 환경변수 활용
|
for store in similar_stores[:settings.MAX_RESTAURANTS_PER_CATEGORY]: # 환경변수 활용
|
||||||
store_info, reviews = await review_service.collect_store_reviews(
|
store_info, reviews = await review_service.collect_store_reviews(
|
||||||
store.id,
|
store.id,
|
||||||
@ -623,36 +620,25 @@ async def action_recommendation_simple(
|
|||||||
"/vector-status",
|
"/vector-status",
|
||||||
response_model=VectorDBStatusResponse,
|
response_model=VectorDBStatusResponse,
|
||||||
summary="Vector DB 상태 조회",
|
summary="Vector DB 상태 조회",
|
||||||
description="Vector DB의 현재 상태와 수집된 매장 ID 목록을 조회합니다."
|
description="Vector DB의 현재 상태를 조회합니다."
|
||||||
)
|
)
|
||||||
async def get_vector_status(
|
async def get_vector_status(vector_service: VectorService = Depends(get_vector_service)):
|
||||||
include_store_ids: bool = Query(True, description="매장 ID 목록 포함 여부"),
|
"""Vector DB 상태를 조회합니다."""
|
||||||
store_limit: int = Query(200, description="매장 ID 목록 최대 개수", ge=1, le=1000),
|
|
||||||
vector_service: VectorService = Depends(get_vector_service)
|
|
||||||
):
|
|
||||||
"""Vector DB 상태와 수집된 매장 ID 목록을 조회합니다."""
|
|
||||||
try:
|
try:
|
||||||
# store_id 목록 포함 여부와 제한 개수 전달
|
db_status = vector_service.get_db_status()
|
||||||
db_status = vector_service.get_db_status(
|
|
||||||
include_store_ids=include_store_ids,
|
|
||||||
store_limit=store_limit
|
|
||||||
)
|
|
||||||
|
|
||||||
status = VectorDBStatus(
|
status = VectorDBStatus(
|
||||||
collection_name=db_status['collection_name'],
|
collection_name=db_status['collection_name'],
|
||||||
total_documents=db_status['total_documents'],
|
total_documents=db_status['total_documents'],
|
||||||
total_stores=db_status['total_stores'],
|
total_stores=db_status['total_stores'],
|
||||||
db_path=db_status['db_path'],
|
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(
|
return VectorDBStatusResponse(
|
||||||
success=True,
|
success=True,
|
||||||
status=status,
|
status=status,
|
||||||
message=f"Vector DB 상태 조회 성공 - {store_count}개 매장 ID 포함"
|
message="Vector DB 상태 조회 성공"
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -663,8 +649,7 @@ async def get_vector_status(
|
|||||||
collection_name="unknown",
|
collection_name="unknown",
|
||||||
total_documents=0,
|
total_documents=0,
|
||||||
total_stores=0,
|
total_stores=0,
|
||||||
db_path="unknown",
|
db_path="unknown"
|
||||||
store_ids=[]
|
|
||||||
),
|
),
|
||||||
message=f"상태 조회 실패: {str(e)}"
|
message=f"상태 조회 실패: {str(e)}"
|
||||||
)
|
)
|
||||||
@ -684,65 +669,6 @@ async def health_check():
|
|||||||
"initialization_errors": app_state["initialization_errors"]
|
"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__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run(app, host=settings.HOST, port=settings.PORT)
|
uvicorn.run(app, host=settings.HOST, port=settings.PORT)
|
||||||
@ -96,7 +96,6 @@ class VectorStoreDocument(BaseModel):
|
|||||||
review_summary: Dict[str, Any] = Field(description="리뷰 요약 정보")
|
review_summary: Dict[str, Any] = Field(description="리뷰 요약 정보")
|
||||||
last_updated: str = Field(description="마지막 업데이트 시간")
|
last_updated: str = Field(description="마지막 업데이트 시간")
|
||||||
|
|
||||||
|
|
||||||
class VectorDBStatus(BaseModel):
|
class VectorDBStatus(BaseModel):
|
||||||
"""Vector DB 상태 정보"""
|
"""Vector DB 상태 정보"""
|
||||||
collection_name: str = Field(description="컬렉션명")
|
collection_name: str = Field(description="컬렉션명")
|
||||||
@ -104,32 +103,9 @@ class VectorDBStatus(BaseModel):
|
|||||||
total_stores: int = Field(description="총 가게 수")
|
total_stores: int = Field(description="총 가게 수")
|
||||||
db_path: str = Field(description="DB 경로")
|
db_path: str = Field(description="DB 경로")
|
||||||
last_updated: Optional[str] = Field(None, description="마지막 업데이트 시간")
|
last_updated: Optional[str] = Field(None, description="마지막 업데이트 시간")
|
||||||
store_ids: List[str] = Field(default=[], description="수집된 매장 ID 목록")
|
|
||||||
|
|
||||||
class VectorDBStatusResponse(BaseModel):
|
class VectorDBStatusResponse(BaseModel):
|
||||||
"""Vector DB 상태 조회 응답"""
|
"""Vector DB 상태 조회 응답"""
|
||||||
success: bool = Field(description="조회 성공 여부")
|
success: bool = Field(description="조회 성공 여부")
|
||||||
status: VectorDBStatus = Field(description="DB 상태 정보")
|
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="실행 시간(초)")
|
|
||||||
|
|
||||||
@ -147,67 +147,74 @@ class VectorService:
|
|||||||
food_category: str,
|
food_category: str,
|
||||||
region: str
|
region: str
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Vector Store를 구축합니다 (기존 데이터 업데이트 포함)"""
|
"""Vector Store를 구축합니다"""
|
||||||
if not self.is_ready():
|
if not self.is_ready():
|
||||||
raise Exception(f"VectorService가 준비되지 않음: {self.initialization_error}")
|
raise Exception(f"VectorService가 준비되지 않음: {self.initialization_error}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info("🔧 Vector Store 구축 시작 (기존 데이터 업데이트 포함)...")
|
logger.info("🚀 Vector Store 구축 시작")
|
||||||
start_time = datetime.now()
|
|
||||||
|
|
||||||
# 1단계: 가게 분류 (새 가게 vs 업데이트 vs 중복)
|
processed_count = 0
|
||||||
logger.info("🔍 가게 분류 중...")
|
documents = []
|
||||||
categorization = self.categorize_stores(review_results, region, food_category)
|
embeddings = []
|
||||||
|
metadatas = []
|
||||||
|
ids = []
|
||||||
|
|
||||||
# 2단계: 기존 가게 업데이트
|
for store_id, store_info, reviews in review_results:
|
||||||
update_result = {'updated_count': 0, 'skipped_count': 0}
|
try:
|
||||||
if categorization['update_stores']:
|
# 텍스트 추출 및 임베딩 생성
|
||||||
logger.info(f"🔄 {len(categorization['update_stores'])}개 기존 가게 업데이트 중...")
|
text_for_embedding = extract_text_for_embedding(store_info, reviews)
|
||||||
update_result = self.update_existing_stores(
|
embedding = self.embedding_model.encode(text_for_embedding)
|
||||||
categorization['update_stores'], food_category, region
|
|
||||||
|
# 메타데이터 생성
|
||||||
|
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단계: 새 가게 추가
|
logger.info(f"✅ Vector Store 구축 완료: {processed_count}개 문서 저장")
|
||||||
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단계: 결과 정리
|
return {
|
||||||
total_processed = update_result['updated_count'] + add_result['added_count']
|
|
||||||
execution_time = (datetime.now() - start_time).total_seconds()
|
|
||||||
|
|
||||||
result = {
|
|
||||||
'success': True,
|
'success': True,
|
||||||
'processed_count': total_processed, # ← 기존 API 호환성 유지
|
'processed_count': processed_count,
|
||||||
'execution_time': execution_time,
|
'message': f"{processed_count}개 문서가 Vector DB에 저장되었습니다"
|
||||||
'summary': categorization['summary'],
|
}
|
||||||
'operations': {
|
else:
|
||||||
'new_stores_added': add_result['added_count'],
|
return {
|
||||||
'existing_stores_updated': update_result['updated_count'],
|
'success': False,
|
||||||
'update_skipped': update_result.get('skipped_count', 0),
|
'error': '저장할 문서가 없습니다',
|
||||||
'duplicates_removed': categorization['summary']['duplicate_count']
|
'processed_count': 0
|
||||||
},
|
|
||||||
'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:
|
except Exception as e:
|
||||||
logger.error(f"❌ Vector Store 구축 실패: {e}")
|
logger.error(f"❌ Vector Store 구축 실패: {e}")
|
||||||
return {
|
return {
|
||||||
'success': False,
|
'success': False,
|
||||||
'error': str(e),
|
'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]]:
|
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
|
return recommendations
|
||||||
|
|
||||||
def get_db_status(self, include_store_ids: bool = True, store_limit: int = 200) -> Dict[str, Any]:
|
def get_db_status(self) -> Dict[str, Any]:
|
||||||
"""
|
"""DB 상태 정보 반환"""
|
||||||
DB 상태 정보 반환 - store_id 목록 포함
|
|
||||||
|
|
||||||
Args:
|
|
||||||
include_store_ids: store_id 목록 포함 여부
|
|
||||||
store_limit: store_id 목록 최대 개수
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
if not self.is_ready():
|
if not self.is_ready():
|
||||||
return {
|
return {
|
||||||
@ -908,27 +909,19 @@ class VectorService:
|
|||||||
'total_stores': 0,
|
'total_stores': 0,
|
||||||
'db_path': self.db_path,
|
'db_path': self.db_path,
|
||||||
'status': 'not_ready',
|
'status': 'not_ready',
|
||||||
'error': self.initialization_error,
|
'error': self.initialization_error
|
||||||
'store_ids': []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# 기본 컬렉션 정보 조회
|
# 컬렉션 정보 조회
|
||||||
count = self.collection.count()
|
count = self.collection.count()
|
||||||
|
|
||||||
# store_id 목록 조회 (옵션)
|
|
||||||
store_ids = []
|
|
||||||
if include_store_ids:
|
|
||||||
store_ids = self.get_all_store_ids(limit=store_limit)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'collection_name': self.collection_name,
|
'collection_name': self.collection_name,
|
||||||
'total_documents': count,
|
'total_documents': count,
|
||||||
'total_stores': len(store_ids) if include_store_ids else count,
|
'total_stores': count, # 각 문서가 하나의 가게를 나타냄
|
||||||
'db_path': self.db_path,
|
'db_path': self.db_path,
|
||||||
'status': 'ready',
|
'status': 'ready',
|
||||||
'last_updated': datetime.now().isoformat(),
|
'last_updated': datetime.now().isoformat()
|
||||||
# 새로 추가되는 정보
|
|
||||||
'store_ids': store_ids
|
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -939,370 +932,5 @@ class VectorService:
|
|||||||
'total_stores': 0,
|
'total_stores': 0,
|
||||||
'db_path': self.db_path,
|
'db_path': self.db_path,
|
||||||
'status': 'error',
|
'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 []
|
|
||||||
@ -34,10 +34,10 @@ data:
|
|||||||
EMBEDDING_MODEL: "sentence-transformers/all-MiniLM-L6-v2"
|
EMBEDDING_MODEL: "sentence-transformers/all-MiniLM-L6-v2"
|
||||||
|
|
||||||
# 🔧 데이터 수집 제한
|
# 🔧 데이터 수집 제한
|
||||||
MAX_RESTAURANTS_PER_CATEGORY: "10"
|
MAX_RESTAURANTS_PER_CATEGORY: "50"
|
||||||
MAX_REVIEWS_PER_RESTAURANT: "50"
|
MAX_REVIEWS_PER_RESTAURANT: "100"
|
||||||
REQUEST_DELAY: "0.1"
|
REQUEST_DELAY: "0.1"
|
||||||
REQUEST_TIMEOUT: "200"
|
REQUEST_TIMEOUT: "600"
|
||||||
|
|
||||||
# 🗄️ ChromaDB 설정 (1.0.12 호환)
|
# 🗄️ ChromaDB 설정 (1.0.12 호환)
|
||||||
# ❌ 제거된 deprecated 설정들:
|
# ❌ 제거된 deprecated 설정들:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user