Compare commits
10 Commits
e54def5780
...
59e02fd0cd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59e02fd0cd | ||
|
|
66f3df24bf | ||
|
|
8b9663a5a9 | ||
|
|
708ca29562 | ||
|
|
3d3f1e5383 | ||
|
|
ceb05de7f3 | ||
|
|
66e80d31d7 | ||
|
|
a2a7daf6f6 | ||
|
|
59b01d9630 | ||
|
|
ea36d7b221 |
225
README.md
Normal file
225
README.md
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
# 🍽️ AI Review - 소상공인을 위한 AI 기반 경쟁업체 분석 및 액션 추천 시스템
|
||||||
|
|
||||||
|
소상공인들이 카카오맵 리뷰 데이터를 기반으로 경쟁업체를 분석하고, AI가 제안하는 맞춤형 비즈니스 개선 방안을 받을 수 있는 마이크로서비스 기반 플랫폼입니다.
|
||||||
|
|
||||||
|
## 🎯 시스템 개요
|
||||||
|
|
||||||
|
### 핵심 가치 제안
|
||||||
|
- **🔍 스마트 경쟁업체 발견**: 지역과 업종 기반으로 유사한 경쟁업체 자동 탐지
|
||||||
|
- **📊 대량 리뷰 데이터 분석**: 카카오맵 리뷰를 수집하여 고품질 인사이트 도출
|
||||||
|
- **🤖 AI 기반 맞춤 컨설팅**: Claude AI가 제공하는 실행 가능한 비즈니스 액션 플랜
|
||||||
|
- **⚡ 완전 자동화**: API 기반으로 모든 과정이 자동화된 솔루션
|
||||||
|
|
||||||
|
### 비즈니스 임팩트
|
||||||
|
- **소상공인**: 데이터 기반 경영 의사결정으로 매출 향상 지원
|
||||||
|
- **경쟁 우위**: AI 기반 개인화된 비즈니스 전략으로 차별화
|
||||||
|
- **운영 효율**: 수작업 시장조사 대비 시간과 비용 90% 절감
|
||||||
|
|
||||||
|
## 🏗️ 서비스 아키텍처
|
||||||
|
|
||||||
|
본 시스템은 3개의 독립적인 마이크로서비스로 구성되어 있습니다:
|
||||||
|
|
||||||
|
### 📍 Restaurant Service
|
||||||
|
**음식점 검색 및 정보 수집 서비스**
|
||||||
|
|
||||||
|
- **핵심 기능**: 카카오 로컬 API 기반 음식점 검색 및 상세 정보 수집
|
||||||
|
- **주요 API**:
|
||||||
|
- 음식점 검색 (`/restaurants/search`)
|
||||||
|
- 음식점 상세 정보 (`/restaurants/{restaurant_id}`)
|
||||||
|
- **기술 스택**: FastAPI, Python 3.11, Kakao Local API
|
||||||
|
- **포트**: 18000
|
||||||
|
|
||||||
|
### 📝 Review Service
|
||||||
|
**카카오맵 리뷰 수집 및 분석 서비스**
|
||||||
|
|
||||||
|
- **핵심 기능**: 카카오맵 리뷰 대량 수집, 감정 분석, 키워드 추출
|
||||||
|
- **주요 API**:
|
||||||
|
- 리뷰 수집 (`/reviews/collect`)
|
||||||
|
- 리뷰 분석 (`/reviews/analyze`)
|
||||||
|
- **기술 스택**: FastAPI, Python 3.11, BeautifulSoup, Sentiment Analysis
|
||||||
|
- **포트**: 19000
|
||||||
|
|
||||||
|
### 🧠 Vector Service
|
||||||
|
**Vector DB 구축 및 AI 비즈니스 컨설팅 서비스**
|
||||||
|
|
||||||
|
- **핵심 기능**: ChromaDB 기반 Vector 검색, Claude AI 연동 액션 추천
|
||||||
|
- **주요 API**:
|
||||||
|
- Vector DB 구축 (`/find-reviews`)
|
||||||
|
- AI 액션 추천 (`/action-recommendation`)
|
||||||
|
- **기술 스택**: FastAPI, ChromaDB, Sentence Transformers, Claude AI
|
||||||
|
- **포트**: 8000
|
||||||
|
|
||||||
|
## 🚀 AKS 배포 가이드
|
||||||
|
|
||||||
|
### 1. 사전 준비
|
||||||
|
|
||||||
|
#### Local Ubuntu 접속 후 소스 다운로드
|
||||||
|
```bash
|
||||||
|
cd ~/workflow
|
||||||
|
git clone https://github.com/cna-bootcamp/ai-review.git
|
||||||
|
cd ai-review
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Azure Cloud 로그인
|
||||||
|
```bash
|
||||||
|
az login --use-device-code
|
||||||
|
```
|
||||||
|
|
||||||
|
#### AKS Credential 취득
|
||||||
|
```bash
|
||||||
|
az aks get-credentials aks-digitalgarage-03 -f ~/.kube/config
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Namespace 생성 및 이동
|
||||||
|
```bash
|
||||||
|
k create ns ai-review-ns
|
||||||
|
kubens ai-review-ns
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Image Pull Secret 생성
|
||||||
|
```bash
|
||||||
|
./restaurant/create-imagepullsecret.sh acrdigitalgarage03 rg-digitalgarage-03
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Restaurant Service 배포
|
||||||
|
|
||||||
|
#### 디렉토리 이동
|
||||||
|
```bash
|
||||||
|
cd ~/workspace/ai-review/restaurant
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 베이스 이미지 빌드
|
||||||
|
```bash
|
||||||
|
./build-base.sh latest acrdigitalgarage03 rg-digitalgarage-03
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 서비스 이미지 빌드
|
||||||
|
```bash
|
||||||
|
./build.sh latest acrdigitalgarage03 rg-digitalgarage-03
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Manifest 수정
|
||||||
|
- **Deployment yaml** (`deployment/manifest/deployment.yaml`)의 image명을 서비스 이미지명으로 변경
|
||||||
|
- **Ingress yaml** (`deployment/manifest/ingress.yaml`)의 host 수정
|
||||||
|
```bash
|
||||||
|
# Ingress IP 확인
|
||||||
|
k get svc -n ingress-nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 배포 실행
|
||||||
|
```bash
|
||||||
|
k apply -f deployment/manifest
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 배포 확인
|
||||||
|
```bash
|
||||||
|
# Pod 상태 확인
|
||||||
|
k get po
|
||||||
|
|
||||||
|
# Ingress 확인
|
||||||
|
k get ing
|
||||||
|
|
||||||
|
# Swagger 페이지 접속
|
||||||
|
# http://{ingress 주소}/docs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Review Service 배포
|
||||||
|
|
||||||
|
#### 디렉토리 이동
|
||||||
|
```bash
|
||||||
|
cd ~/workspace/ai-review/review
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 베이스 이미지 빌드
|
||||||
|
```bash
|
||||||
|
./build-base.sh latest acrdigitalgarage03 rg-digitalgarage-03
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 서비스 이미지 빌드
|
||||||
|
```bash
|
||||||
|
./build.sh latest acrdigitalgarage03 rg-digitalgarage-03
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Manifest 수정
|
||||||
|
- **Deployment yaml** (`deployment/manifest/deployment.yaml`)의 image명을 서비스 이미지명으로 변경
|
||||||
|
- **Ingress yaml** (`deployment/manifest/ingress.yaml`)의 host 수정
|
||||||
|
```bash
|
||||||
|
# Ingress IP 확인
|
||||||
|
k get svc -n ingress-nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 배포 실행
|
||||||
|
```bash
|
||||||
|
k apply -f deployment/manifest
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 배포 확인
|
||||||
|
```bash
|
||||||
|
# Pod 상태 확인
|
||||||
|
k get po
|
||||||
|
|
||||||
|
# Ingress 확인
|
||||||
|
k get ing
|
||||||
|
|
||||||
|
# Swagger 페이지 접속
|
||||||
|
# http://{ingress 주소}/docs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Vector Service 배포
|
||||||
|
|
||||||
|
#### 디렉토리 이동
|
||||||
|
```bash
|
||||||
|
cd ~/workspace/ai-review/vector
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 베이스 이미지 빌드
|
||||||
|
```bash
|
||||||
|
./build-base.sh latest acrdigitalgarage03 rg-digitalgarage-03
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 서비스 이미지 빌드
|
||||||
|
```bash
|
||||||
|
./build.sh latest acrdigitalgarage03 rg-digitalgarage-03
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Manifest 수정
|
||||||
|
- **Deployment yaml** (`deployment/manifest/deployment.yaml`)의 image명을 서비스 이미지명으로 변경
|
||||||
|
- **Ingress yaml** (`deployment/manifest/ingress.yaml`)의 host 수정
|
||||||
|
```bash
|
||||||
|
# Ingress IP 확인
|
||||||
|
k get svc -n ingress-nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 배포 실행
|
||||||
|
```bash
|
||||||
|
k apply -f deployment/manifest
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 배포 확인
|
||||||
|
```bash
|
||||||
|
# Pod 상태 확인
|
||||||
|
k get po
|
||||||
|
|
||||||
|
# Ingress 확인
|
||||||
|
k get ing
|
||||||
|
|
||||||
|
# Swagger 페이지 접속
|
||||||
|
# http://{ingress 주소}/docs
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 서비스 상태 확인
|
||||||
|
|
||||||
|
### 전체 서비스 상태 점검
|
||||||
|
```bash
|
||||||
|
# 모든 Pod 상태 확인
|
||||||
|
kubectl get pods -n ai-review-ns
|
||||||
|
|
||||||
|
# 모든 Service 확인
|
||||||
|
kubectl get svc -n ai-review-ns
|
||||||
|
|
||||||
|
# 모든 Ingress 확인
|
||||||
|
kubectl get ing -n ai-review-ns
|
||||||
|
|
||||||
|
# 로그 확인 (예: restaurant 서비스)
|
||||||
|
kubectl logs -l app=restaurant-api -n ai-review-ns --tail=100
|
||||||
|
```
|
||||||
@ -1,78 +0,0 @@
|
|||||||
# deployment/manifests/configmap.yaml
|
|
||||||
apiVersion: v1
|
|
||||||
kind: ConfigMap
|
|
||||||
metadata:
|
|
||||||
name: kakao-review-api-config
|
|
||||||
data:
|
|
||||||
# 애플리케이션 설정
|
|
||||||
APP_TITLE: "카카오맵 리뷰 분석 API"
|
|
||||||
APP_VERSION: "1.0.1"
|
|
||||||
APP_DESCRIPTION: "교육 목적 전용 - 실제 서비스 사용 금지"
|
|
||||||
|
|
||||||
# 서버 설정
|
|
||||||
HOST: "0.0.0.0"
|
|
||||||
PORT: "8000"
|
|
||||||
WORKERS: "1"
|
|
||||||
LOG_LEVEL: "info"
|
|
||||||
|
|
||||||
# API 기본값 설정 (AKS 환경에 최적화)
|
|
||||||
DEFAULT_MAX_TIME: "300"
|
|
||||||
DEFAULT_DAYS_LIMIT: "60"
|
|
||||||
MAX_DAYS_LIMIT: "365"
|
|
||||||
MIN_MAX_TIME: "60"
|
|
||||||
MAX_MAX_TIME: "600"
|
|
||||||
|
|
||||||
# 🔧 Chrome 브라우저 설정 (AKS 환경 최적화)
|
|
||||||
CHROME_OPTIONS: |
|
|
||||||
--headless=new
|
|
||||||
--no-sandbox
|
|
||||||
--disable-dev-shm-usage
|
|
||||||
--disable-gpu
|
|
||||||
--disable-software-rasterizer
|
|
||||||
--window-size=1920,1080
|
|
||||||
--disable-extensions
|
|
||||||
--disable-plugins
|
|
||||||
--disable-usb-keyboard-detect
|
|
||||||
--no-first-run
|
|
||||||
--no-default-browser-check
|
|
||||||
--disable-logging
|
|
||||||
--log-level=3
|
|
||||||
--disable-background-timer-throttling
|
|
||||||
--disable-backgrounding-occluded-windows
|
|
||||||
--disable-renderer-backgrounding
|
|
||||||
--disable-features=TranslateUI,VizDisplayCompositor
|
|
||||||
--disable-ipc-flooding-protection
|
|
||||||
--memory-pressure-off
|
|
||||||
--max_old_space_size=4096
|
|
||||||
--no-zygote
|
|
||||||
--disable-setuid-sandbox
|
|
||||||
--disable-background-networking
|
|
||||||
--disable-default-apps
|
|
||||||
--disable-sync
|
|
||||||
--metrics-recording-only
|
|
||||||
--safebrowsing-disable-auto-update
|
|
||||||
--disable-prompt-on-repost
|
|
||||||
--disable-hang-monitor
|
|
||||||
--disable-client-side-phishing-detection
|
|
||||||
--disable-component-update
|
|
||||||
--disable-domain-reliability
|
|
||||||
--user-data-dir=/tmp/chrome-user-data
|
|
||||||
--data-path=/tmp/chrome-user-data
|
|
||||||
--disk-cache-dir=/tmp/chrome-cache
|
|
||||||
--aggressive-cache-discard
|
|
||||||
--disable-web-security
|
|
||||||
--allow-running-insecure-content
|
|
||||||
--disable-blink-features=AutomationControlled
|
|
||||||
|
|
||||||
# 스크롤링 설정 (AKS 환경에 맞게 조정)
|
|
||||||
SCROLL_CHECK_INTERVAL: "5"
|
|
||||||
SCROLL_NO_CHANGE_LIMIT: "6"
|
|
||||||
SCROLL_WAIT_TIME_SHORT: "2.0"
|
|
||||||
SCROLL_WAIT_TIME_LONG: "3.0"
|
|
||||||
|
|
||||||
# 법적 경고 메시지
|
|
||||||
LEGAL_WARNING_ENABLED: "true"
|
|
||||||
CONTACT_EMAIL: "admin@example.com"
|
|
||||||
|
|
||||||
# 건강 체크 설정
|
|
||||||
HEALTH_CHECK_TIMEOUT: "10"
|
|
||||||
@ -1,130 +0,0 @@
|
|||||||
# deployment/manifests/deployment.yaml
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: kakao-review-api
|
|
||||||
labels:
|
|
||||||
app: kakao-review-api
|
|
||||||
version: v1
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: kakao-review-api
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: kakao-review-api
|
|
||||||
version: v1
|
|
||||||
spec:
|
|
||||||
imagePullSecrets:
|
|
||||||
- name: acr-secret
|
|
||||||
containers:
|
|
||||||
- name: api
|
|
||||||
image: acrdigitalgarage03.azurecr.io/kakao-review-api:latest
|
|
||||||
imagePullPolicy: Always
|
|
||||||
ports:
|
|
||||||
- containerPort: 19000
|
|
||||||
name: http
|
|
||||||
|
|
||||||
# 🔧 ConfigMap 환경 변수
|
|
||||||
envFrom:
|
|
||||||
- configMapRef:
|
|
||||||
name: kakao-review-api-config
|
|
||||||
|
|
||||||
# 🔧 Secret 환경 변수
|
|
||||||
env:
|
|
||||||
- name: EXTERNAL_API_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: kakao-review-api-secret
|
|
||||||
key: EXTERNAL_API_KEY
|
|
||||||
- name: DB_USERNAME
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: kakao-review-api-secret
|
|
||||||
key: DB_USERNAME
|
|
||||||
- name: DB_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: kakao-review-api-secret
|
|
||||||
key: DB_PASSWORD
|
|
||||||
- name: JWT_SECRET
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: kakao-review-api-secret
|
|
||||||
key: JWT_SECRET
|
|
||||||
# 🔧 Chrome/ChromeDriver 환경 변수 (VM과 동일)
|
|
||||||
- name: WDM_LOCAL
|
|
||||||
value: "/tmp/.wdm"
|
|
||||||
- name: WDM_LOG_LEVEL
|
|
||||||
value: "0"
|
|
||||||
- name: CHROME_BIN
|
|
||||||
value: "/usr/bin/google-chrome"
|
|
||||||
- name: CHROMEDRIVER_BIN
|
|
||||||
value: "/usr/local/bin/chromedriver"
|
|
||||||
- name: DISPLAY
|
|
||||||
value: ":99"
|
|
||||||
- name: DBUS_SESSION_BUS_ADDRESS
|
|
||||||
value: "/dev/null"
|
|
||||||
|
|
||||||
# 🔧 리소스 제한 (Chrome 실행에 충분한 리소스)
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: "2Gi"
|
|
||||||
cpu: "1000m"
|
|
||||||
limits:
|
|
||||||
memory: "4Gi"
|
|
||||||
cpu: "2000m"
|
|
||||||
|
|
||||||
# 🔧 헬스 체크 (타임아웃 증가)
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: 19000
|
|
||||||
initialDelaySeconds: 60
|
|
||||||
periodSeconds: 30
|
|
||||||
timeoutSeconds: 15
|
|
||||||
failureThreshold: 5
|
|
||||||
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: 19000
|
|
||||||
initialDelaySeconds: 30
|
|
||||||
periodSeconds: 10
|
|
||||||
timeoutSeconds: 10
|
|
||||||
failureThreshold: 5
|
|
||||||
|
|
||||||
# 🔧 간소화된 보안 컨텍스트 (AKS 호환)
|
|
||||||
securityContext:
|
|
||||||
runAsNonRoot: false
|
|
||||||
runAsUser: 0
|
|
||||||
allowPrivilegeEscalation: true
|
|
||||||
readOnlyRootFilesystem: false
|
|
||||||
capabilities:
|
|
||||||
add:
|
|
||||||
- SYS_ADMIN
|
|
||||||
drop: []
|
|
||||||
|
|
||||||
# 🔧 볼륨 마운트 (Chrome 실행 최적화)
|
|
||||||
volumeMounts:
|
|
||||||
- name: tmp-volume
|
|
||||||
mountPath: /tmp
|
|
||||||
- name: dev-shm
|
|
||||||
mountPath: /dev/shm
|
|
||||||
|
|
||||||
# 🔧 볼륨 정의 (간소화)
|
|
||||||
volumes:
|
|
||||||
- name: tmp-volume
|
|
||||||
emptyDir: {}
|
|
||||||
- name: dev-shm
|
|
||||||
emptyDir:
|
|
||||||
medium: Memory
|
|
||||||
sizeLimit: 2Gi
|
|
||||||
|
|
||||||
restartPolicy: Always
|
|
||||||
|
|
||||||
# 🔧 Pod 레벨 보안 설정 제거 (AKS 호환을 위해)
|
|
||||||
# securityContext: 제거
|
|
||||||
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
# deployment/manifests/ingress.yaml
|
|
||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: kakao-review-api-ingress
|
|
||||||
annotations:
|
|
||||||
nginx.ingress.kubernetes.io/rewrite-target: /
|
|
||||||
nginx.ingress.kubernetes.io/ssl-redirect: "false"
|
|
||||||
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
|
|
||||||
# 🔧 타임아웃 설정 (Chrome 분석 시간 고려)
|
|
||||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "1800"
|
|
||||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "1800"
|
|
||||||
nginx.ingress.kubernetes.io/client-body-timeout: "1800"
|
|
||||||
nginx.ingress.kubernetes.io/proxy-connect-timeout: "60"
|
|
||||||
# 🔧 CORS 설정 (필요시)
|
|
||||||
nginx.ingress.kubernetes.io/enable-cors: "true"
|
|
||||||
nginx.ingress.kubernetes.io/cors-allow-origin: "*"
|
|
||||||
nginx.ingress.kubernetes.io/cors-allow-methods: "GET, POST, OPTIONS"
|
|
||||||
nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"
|
|
||||||
spec:
|
|
||||||
ingressClassName: nginx
|
|
||||||
rules:
|
|
||||||
# 🔧 환경에 맞게 호스트명 수정 필요
|
|
||||||
- host: kakao-review-api.20.249.191.180.nip.io
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: kakao-review-api-service
|
|
||||||
port:
|
|
||||||
number: 80
|
|
||||||
# 🔧 TLS 설정 (HTTPS 필요시 주석 해제)
|
|
||||||
# tls:
|
|
||||||
# - hosts:
|
|
||||||
# - kakao-review-api.example.com
|
|
||||||
# secretName: kakao-review-api-tls
|
|
||||||
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
# deployment/manifests/secret.yaml
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Secret
|
|
||||||
metadata:
|
|
||||||
name: kakao-review-api-secret
|
|
||||||
type: Opaque
|
|
||||||
data:
|
|
||||||
# 🔧 현재 사용하지 않지만 향후 확장을 위한 플레이스홀더
|
|
||||||
# 실제 값 설정 시: echo -n "your-value" | base64
|
|
||||||
|
|
||||||
# API 키 (향후 카카오 공식 API 연동용)
|
|
||||||
EXTERNAL_API_KEY: ""
|
|
||||||
|
|
||||||
# 데이터베이스 연결 정보 (향후 DB 연동용)
|
|
||||||
DB_USERNAME: ""
|
|
||||||
DB_PASSWORD: ""
|
|
||||||
DB_HOST: ""
|
|
||||||
|
|
||||||
# JWT 시크릿 (향후 인증 기능용)
|
|
||||||
JWT_SECRET: ""
|
|
||||||
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
# deployment/manifests/service.yaml
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: kakao-review-api-service
|
|
||||||
labels:
|
|
||||||
app: kakao-review-api
|
|
||||||
spec:
|
|
||||||
type: ClusterIP
|
|
||||||
ports:
|
|
||||||
- port: 80
|
|
||||||
targetPort: 19000
|
|
||||||
protocol: TCP
|
|
||||||
name: http
|
|
||||||
selector:
|
|
||||||
app: kakao-review-api
|
|
||||||
|
|
||||||
547
vector/README.md
547
vector/README.md
@ -94,7 +94,8 @@ vector/
|
|||||||
| Method | Endpoint | 설명 | 요청 모델 | 응답 모델 |
|
| Method | Endpoint | 설명 | 요청 모델 | 응답 모델 |
|
||||||
|--------|----------|------|-----------|-----------|
|
|--------|----------|------|-----------|-----------|
|
||||||
| `POST` | `/find-reviews` | 리뷰 수집 및 Vector DB 저장 | VectorBuildRequest | FindReviewsResponse |
|
| `POST` | `/find-reviews` | 리뷰 수집 및 Vector DB 저장 | VectorBuildRequest | FindReviewsResponse |
|
||||||
| `POST` | `/action-recommendation` | AI 기반 비즈니스 추천 | ActionRecommendationRequest | ActionRecommendationSimpleResponse |
|
| `POST` | `/action-recommendation` | AI 기반 비즈니스 추천 | ActionRecommendationRequest |
|
||||||
|
| `GET` | `/store/{store_id}` | store_id로 매장 정보 조회 | Path Parameter | StoreInfoResponse ActionRecommendationSimpleResponse |
|
||||||
|
|
||||||
## 🚀 빠른 시작
|
## 🚀 빠른 시작
|
||||||
|
|
||||||
@ -276,8 +277,9 @@ curl -X POST "http://vector-api.20.249.191.180.nip.io/find-reviews" \
|
|||||||
"category_name": "음식점 > 치킨",
|
"category_name": "음식점 > 치킨",
|
||||||
"address_name": "서울특별시 강남구 역삼동 123-45",
|
"address_name": "서울특별시 강남구 역삼동 123-45",
|
||||||
"phone": "02-123-4567",
|
"phone": "02-123-4567",
|
||||||
"x": "127.0276",
|
"place_url": "http://place.map.kakao.com/501745730",
|
||||||
"y": "37.4979"
|
"x": "127.0276543",
|
||||||
|
"y": "37.4979234"
|
||||||
},
|
},
|
||||||
"food_category": "치킨",
|
"food_category": "치킨",
|
||||||
"total_reviews": 127,
|
"total_reviews": 127,
|
||||||
@ -285,14 +287,263 @@ curl -X POST "http://vector-api.20.249.191.180.nip.io/find-reviews" \
|
|||||||
"execution_time": 45.2,
|
"execution_time": 45.2,
|
||||||
"stored_data": {
|
"stored_data": {
|
||||||
"501745730": {
|
"501745730": {
|
||||||
"store_info": { /* 가게 정보 */ },
|
"store_info": {
|
||||||
"reviews": [ /* 리뷰 목록 */ ],
|
"id": "501745730",
|
||||||
|
"name": "맛있는 치킨집",
|
||||||
|
"category": "음식점 > 치킨",
|
||||||
|
"rating": "4.2",
|
||||||
|
"review_count": "23",
|
||||||
|
"status": "영업중",
|
||||||
|
"address": "서울특별시 강남구 역삼동 123-45",
|
||||||
|
"phone": "02-123-4567",
|
||||||
|
"place_url": "http://place.map.kakao.com/501745730",
|
||||||
|
"x": "127.0276543",
|
||||||
|
"y": "37.4979234"
|
||||||
|
},
|
||||||
|
"reviews": [
|
||||||
|
{
|
||||||
|
"reviewer_name": "김○○",
|
||||||
|
"reviewer_level": "골드리뷰어",
|
||||||
|
"reviewer_stats": {
|
||||||
|
"reviews": 156,
|
||||||
|
"average_rating": 4.3,
|
||||||
|
"followers": 23
|
||||||
|
},
|
||||||
|
"rating": 5,
|
||||||
|
"date": "2024-06-10",
|
||||||
|
"content": "치킨이 정말 맛있어요! 바삭하고 양념이 잘 배어있어서 자꾸 손이 갑니다. 배달도 빨라서 좋았어요.",
|
||||||
|
"badges": ["맛집", "친절", "빠른배달"],
|
||||||
|
"likes": 12,
|
||||||
|
"photo_count": 3,
|
||||||
|
"has_photos": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reviewer_name": "이○○",
|
||||||
|
"reviewer_level": "실버리뷰어",
|
||||||
|
"reviewer_stats": {
|
||||||
|
"reviews": 89,
|
||||||
|
"average_rating": 4.1,
|
||||||
|
"followers": 8
|
||||||
|
},
|
||||||
|
"rating": 4,
|
||||||
|
"date": "2024-06-08",
|
||||||
|
"content": "치킨은 맛있는데 가격이 조금 비싼 것 같아요. 그래도 재주문 의향 있습니다.",
|
||||||
|
"badges": ["맛있음", "가격"],
|
||||||
|
"likes": 7,
|
||||||
|
"photo_count": 1,
|
||||||
|
"has_photos": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reviewer_name": "박○○",
|
||||||
|
"reviewer_level": "브론즈리뷰어",
|
||||||
|
"reviewer_stats": {
|
||||||
|
"reviews": 34,
|
||||||
|
"average_rating": 3.9,
|
||||||
|
"followers": 2
|
||||||
|
},
|
||||||
|
"rating": 3,
|
||||||
|
"date": "2024-06-05",
|
||||||
|
"content": "치킨은 괜찮은데 배달이 좀 늦었어요. 포장도 조금 아쉬웠습니다.",
|
||||||
|
"badges": ["배달지연", "포장"],
|
||||||
|
"likes": 3,
|
||||||
|
"photo_count": 0,
|
||||||
|
"has_photos": false
|
||||||
|
}
|
||||||
|
],
|
||||||
"review_summary": {
|
"review_summary": {
|
||||||
"total_reviews": 23,
|
"total_reviews": 23,
|
||||||
"average_rating": 4.2,
|
"average_rating": 4.2,
|
||||||
"positive_keywords": ["맛있음", "친절", "깨끗"]
|
"rating_distribution": {
|
||||||
|
"5": 8,
|
||||||
|
"4": 9,
|
||||||
|
"3": 4,
|
||||||
|
"2": 1,
|
||||||
|
"1": 1
|
||||||
|
},
|
||||||
|
"positive_keywords": [
|
||||||
|
"맛있음",
|
||||||
|
"바삭함",
|
||||||
|
"친절",
|
||||||
|
"빠른배달"
|
||||||
|
],
|
||||||
|
"negative_keywords": [
|
||||||
|
"가격",
|
||||||
|
"배달지연",
|
||||||
|
"포장"
|
||||||
|
],
|
||||||
|
"common_keywords": [
|
||||||
|
"맛있음",
|
||||||
|
"치킨",
|
||||||
|
"배달",
|
||||||
|
"친절",
|
||||||
|
"가격",
|
||||||
|
"바삭함"
|
||||||
|
],
|
||||||
|
"sentiment_analysis": {
|
||||||
|
"positive": 65.2,
|
||||||
|
"neutral": 26.1,
|
||||||
|
"negative": 8.7
|
||||||
|
},
|
||||||
|
"photo_reviews": 15,
|
||||||
|
"recent_trend": "상승",
|
||||||
|
"peak_hours": ["19:00-21:00", "12:00-13:00"]
|
||||||
},
|
},
|
||||||
"combined_at": "2024-06-16T10:30:00"
|
"combined_at": "2024-06-16T10:30:15"
|
||||||
|
},
|
||||||
|
"502156891": {
|
||||||
|
"store_info": {
|
||||||
|
"id": "502156891",
|
||||||
|
"name": "황금치킨",
|
||||||
|
"category": "음식점 > 치킨",
|
||||||
|
"rating": "4.4",
|
||||||
|
"review_count": "67",
|
||||||
|
"status": "영업중",
|
||||||
|
"address": "서울특별시 강남구 역삼동 234-56",
|
||||||
|
"phone": "02-234-5678",
|
||||||
|
"place_url": "http://place.map.kakao.com/502156891",
|
||||||
|
"x": "127.0298765",
|
||||||
|
"y": "37.4965432"
|
||||||
|
},
|
||||||
|
"reviews": [
|
||||||
|
{
|
||||||
|
"reviewer_name": "최○○",
|
||||||
|
"reviewer_level": "플래티넘리뷰어",
|
||||||
|
"reviewer_stats": {
|
||||||
|
"reviews": 234,
|
||||||
|
"average_rating": 4.5,
|
||||||
|
"followers": 45
|
||||||
|
},
|
||||||
|
"rating": 5,
|
||||||
|
"date": "2024-06-12",
|
||||||
|
"content": "여기 치킨은 정말 최고예요! 특히 양념치킨이 달콤하면서도 매콤해서 중독성이 있어요. 사장님도 너무 친절하시고 서비스도 좋습니다.",
|
||||||
|
"badges": ["최고", "양념치킨", "친절", "서비스"],
|
||||||
|
"likes": 18,
|
||||||
|
"photo_count": 4,
|
||||||
|
"has_photos": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reviewer_name": "정○○",
|
||||||
|
"reviewer_level": "골드리뷰어",
|
||||||
|
"reviewer_stats": {
|
||||||
|
"reviews": 145,
|
||||||
|
"average_rating": 4.2,
|
||||||
|
"followers": 19
|
||||||
|
},
|
||||||
|
"rating": 4,
|
||||||
|
"date": "2024-06-09",
|
||||||
|
"content": "맛은 좋은데 매장이 좀 좁아요. 포장해서 먹는 걸 추천합니다.",
|
||||||
|
"badges": ["맛좋음", "매장좁음", "포장추천"],
|
||||||
|
"likes": 9,
|
||||||
|
"photo_count": 2,
|
||||||
|
"has_photos": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"review_summary": {
|
||||||
|
"total_reviews": 67,
|
||||||
|
"average_rating": 4.4,
|
||||||
|
"rating_distribution": {
|
||||||
|
"5": 32,
|
||||||
|
"4": 25,
|
||||||
|
"3": 7,
|
||||||
|
"2": 2,
|
||||||
|
"1": 1
|
||||||
|
},
|
||||||
|
"positive_keywords": [
|
||||||
|
"최고",
|
||||||
|
"양념치킨",
|
||||||
|
"친절",
|
||||||
|
"맛좋음",
|
||||||
|
"서비스"
|
||||||
|
],
|
||||||
|
"negative_keywords": [
|
||||||
|
"매장좁음",
|
||||||
|
"대기시간"
|
||||||
|
],
|
||||||
|
"common_keywords": [
|
||||||
|
"양념치킨",
|
||||||
|
"맛좋음",
|
||||||
|
"친절",
|
||||||
|
"서비스",
|
||||||
|
"최고",
|
||||||
|
"포장"
|
||||||
|
],
|
||||||
|
"sentiment_analysis": {
|
||||||
|
"positive": 73.1,
|
||||||
|
"neutral": 20.9,
|
||||||
|
"negative": 6.0
|
||||||
|
},
|
||||||
|
"photo_reviews": 45,
|
||||||
|
"recent_trend": "상승",
|
||||||
|
"peak_hours": ["18:00-20:00", "12:00-14:00"]
|
||||||
|
},
|
||||||
|
"combined_at": "2024-06-16T10:31:22"
|
||||||
|
},
|
||||||
|
"503789456": {
|
||||||
|
"store_info": {
|
||||||
|
"id": "503789456",
|
||||||
|
"name": "크리스피치킨",
|
||||||
|
"category": "음식점 > 치킨",
|
||||||
|
"rating": "3.9",
|
||||||
|
"review_count": "34",
|
||||||
|
"status": "영업중",
|
||||||
|
"address": "서울특별시 강남구 역삼동 345-67",
|
||||||
|
"phone": "02-345-6789",
|
||||||
|
"place_url": "http://place.map.kakao.com/503789456",
|
||||||
|
"x": "127.0312345",
|
||||||
|
"y": "37.4951234"
|
||||||
|
},
|
||||||
|
"reviews": [
|
||||||
|
{
|
||||||
|
"reviewer_name": "한○○",
|
||||||
|
"reviewer_level": "실버리뷰어",
|
||||||
|
"reviewer_stats": {
|
||||||
|
"reviews": 76,
|
||||||
|
"average_rating": 3.8,
|
||||||
|
"followers": 5
|
||||||
|
},
|
||||||
|
"rating": 4,
|
||||||
|
"date": "2024-06-11",
|
||||||
|
"content": "바삭한 치킨을 좋아한다면 추천! 다만 양념이 좀 짜요.",
|
||||||
|
"badges": ["바삭함", "양념짠맛"],
|
||||||
|
"likes": 6,
|
||||||
|
"photo_count": 1,
|
||||||
|
"has_photos": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"review_summary": {
|
||||||
|
"total_reviews": 34,
|
||||||
|
"average_rating": 3.9,
|
||||||
|
"rating_distribution": {
|
||||||
|
"5": 8,
|
||||||
|
"4": 15,
|
||||||
|
"3": 7,
|
||||||
|
"2": 3,
|
||||||
|
"1": 1
|
||||||
|
},
|
||||||
|
"positive_keywords": [
|
||||||
|
"바삭함",
|
||||||
|
"맛있음"
|
||||||
|
],
|
||||||
|
"negative_keywords": [
|
||||||
|
"양념짠맛",
|
||||||
|
"서비스"
|
||||||
|
],
|
||||||
|
"common_keywords": [
|
||||||
|
"바삭함",
|
||||||
|
"치킨",
|
||||||
|
"양념",
|
||||||
|
"맛있음"
|
||||||
|
],
|
||||||
|
"sentiment_analysis": {
|
||||||
|
"positive": 58.8,
|
||||||
|
"neutral": 29.4,
|
||||||
|
"negative": 11.8
|
||||||
|
},
|
||||||
|
"photo_reviews": 18,
|
||||||
|
"recent_trend": "보통",
|
||||||
|
"peak_hours": ["19:00-21:00"]
|
||||||
|
},
|
||||||
|
"combined_at": "2024-06-16T10:32:05"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -316,101 +567,293 @@ curl -X POST "http://vector-api.20.249.191.180.nip.io/action-recommendation" \
|
|||||||
"success": true,
|
"success": true,
|
||||||
"recommendation": {
|
"recommendation": {
|
||||||
"summary": {
|
"summary": {
|
||||||
"current_situation": "치킨 전문점으로 강남구 역삼동에 위치, 평균 별점 3.8점으로 동종업체 대비 개선 여지 존재",
|
"current_situation": "일식 업종으로 매출 감소를 겪고 있으며, 경쟁이 치열한 고품질 시장에서 운영 중입니다. 업계 평균 평점은 4.23점이고, 경쟁업체들은 3.9-4.7점 범위의 평점을 보이고 있습니다.",
|
||||||
"key_insights": [
|
"key_insights": [
|
||||||
"배달 서비스 만족도가 경쟁업체 대비 낮음",
|
"업계 공통 이슈로 고객 만족도 양극화 문제 존재",
|
||||||
"신메뉴 출시 주기가 3개월 이상으로 긴 편",
|
"경쟁업체들은 가성비, 맛, 친절함에서 강점을 보임",
|
||||||
"온라인 리뷰 관리 시스템 부재"
|
"고객 참여도와 일관성 있는 서비스 품질이 성공의 핵심 요소"
|
||||||
],
|
],
|
||||||
"priority_areas": ["배달 품질 개선", "메뉴 혁신"]
|
"priority_areas": [
|
||||||
|
"서비스 일관성 개선",
|
||||||
|
"고객 만족도 안정화"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"action_plans": {
|
"action_plans": {
|
||||||
"short_term": [
|
"short_term": [
|
||||||
{
|
{
|
||||||
"title": "배달 포장재 개선",
|
"title": "서비스 표준화 매뉴얼 작성 및 적용",
|
||||||
"description": "보온성이 우수한 친환경 포장재로 교체하여 배달 만족도 향상",
|
"description": "조리법, 서빙 방식, 고객 응대 등 모든 서비스 과정을 표준화하여 일관성 있는 품질 제공. 직원 교육 실시 및 체크리스트 활용",
|
||||||
"expected_impact": "배달 리뷰 평점 0.5점 상승 예상",
|
"expected_impact": "고객 만족도 양극화 해소, 평점 0.3-0.5점 상승 예상",
|
||||||
"timeline": "2주",
|
"timeline": "2-4주",
|
||||||
"cost": "월 50만원"
|
"cost": "50-100만원 (교육비, 매뉴얼 제작비)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "고객 피드백 수집 시스템 구축",
|
||||||
|
"description": "테이블 QR코드를 통한 실시간 피드백 수집, 불만사항 즉시 대응 체계 마련. 주간 피드백 분석 및 개선사항 도출",
|
||||||
|
"expected_impact": "고객 불만 30% 감소, 재방문율 15% 증가",
|
||||||
|
"timeline": "1-2주",
|
||||||
|
"cost": "30-50만원 (QR코드 제작, 시스템 구축)"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"mid_term": [
|
"mid_term": [
|
||||||
{
|
{
|
||||||
"title": "시즌 한정 메뉴 런칭",
|
"title": "메뉴 최적화 및 시그니처 메뉴 개발",
|
||||||
"description": "여름 시즌 매운맛 신메뉴 2종 개발 및 SNS 마케팅",
|
"description": "경쟁업체 대비 차별화된 시그니처 메뉴 3-5개 개발. 기존 메뉴 중 인기도 낮은 메뉴 정리하고 가성비 우수 메뉴 강화",
|
||||||
"expected_impact": "신규 고객 유입 20% 증가",
|
"expected_impact": "평균 주문 금액 20% 증가, 고객 재방문율 25% 향상",
|
||||||
"timeline": "6주",
|
"timeline": "2-3개월",
|
||||||
"cost": "초기 투자 300만원"
|
"cost": "200-300만원 (메뉴 개발, 재료비, 마케팅)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "디지털 마케팅 강화",
|
||||||
|
"description": "네이버 플레이스, 구글 마이비즈니스 최적화. SNS 활용한 메뉴 홍보 및 고객 후기 관리. 온라인 주문 시스템 도입",
|
||||||
|
"expected_impact": "온라인 노출 50% 증가, 신규 고객 유입 30% 증가",
|
||||||
|
"timeline": "3-4개월",
|
||||||
|
"cost": "150-250만원 (마케팅비, 시스템 구축비)"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"long_term": [
|
"long_term": [
|
||||||
{
|
{
|
||||||
"title": "브랜드 리뉴얼",
|
"title": "고객 충성도 프로그램 구축",
|
||||||
"description": "매장 인테리어 개선 및 브랜드 아이덴티티 강화",
|
"description": "멤버십 시스템 도입, 단골 고객 특별 혜택 제공, 생일/기념일 이벤트 운영. 고객 데이터베이스 구축 및 맞춤형 서비스 제공",
|
||||||
"expected_impact": "브랜드 인지도 향상, 객단가 15% 상승",
|
"expected_impact": "고객 유지율 40% 향상, 월 매출 25-30% 증가",
|
||||||
"timeline": "4개월",
|
"timeline": "6-8개월",
|
||||||
"cost": "1,500만원"
|
"cost": "300-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": "2024-06-16T10:30:00"
|
"analysis_timestamp": "2025-06-16T03:34:08.622763"
|
||||||
},
|
},
|
||||||
"market_intelligence": {
|
"market_intelligence": {
|
||||||
"total_competitors": 7,
|
"total_competitors": 3,
|
||||||
"industry_insights": [
|
"industry_insights": [
|
||||||
"업계 전반적으로 고객 만족도 개선 필요",
|
"경쟁이 치열한 고품질 시장",
|
||||||
"고성과 업체 3개 벤치마킹 가능"
|
"업계 공통 이슈: 고객 만족도 양극화 (일관성 부족)"
|
||||||
],
|
],
|
||||||
"performance_benchmarks": {
|
"performance_benchmarks": {
|
||||||
"average_rating": 4.1,
|
"average_rating": 4.23,
|
||||||
"review_volume_trend": "증가"
|
"rating_range": {
|
||||||
|
"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": "",
|
||||||
"similarity_score": 0.892,
|
"category": "일식",
|
||||||
|
"similarity_score": 0.389,
|
||||||
"performance_analysis": {
|
"performance_analysis": {
|
||||||
"performance": {
|
"performance": {
|
||||||
"rating": "4.3",
|
"rating": "4.1",
|
||||||
"review_count": "156"
|
"review_count": "156",
|
||||||
|
"status": "영업 중"
|
||||||
},
|
},
|
||||||
"feedback": {
|
"feedback": {
|
||||||
"positive_aspects": ["맛", "친절", "빠른배달"],
|
"positive_aspects": [
|
||||||
"negative_aspects": ["가격"]
|
"가성비",
|
||||||
|
"맛",
|
||||||
|
"분위기"
|
||||||
|
],
|
||||||
|
"negative_aspects": [],
|
||||||
|
"recent_trends": [
|
||||||
|
"최근 만족도 상승"
|
||||||
|
],
|
||||||
|
"rating_pattern": "안정적 고만족"
|
||||||
},
|
},
|
||||||
"business_insights": {
|
"insights": {
|
||||||
"key_finding": "신메뉴 런칭으로 리뷰 급증"
|
"competitive_advantage": [
|
||||||
|
"높은 고객 만족도",
|
||||||
|
"활발한 고객 참여",
|
||||||
|
"맛 우수"
|
||||||
|
],
|
||||||
|
"critical_issues": [],
|
||||||
|
"improvement_opportunities": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rank": 2,
|
||||||
|
"store_name": "",
|
||||||
|
"category": "일식",
|
||||||
|
"similarity_score": 0.347,
|
||||||
|
"performance_analysis": {
|
||||||
|
"performance": {
|
||||||
|
"rating": "4.7",
|
||||||
|
"review_count": "412",
|
||||||
|
"status": "영업 중"
|
||||||
|
},
|
||||||
|
"feedback": {
|
||||||
|
"positive_aspects": [
|
||||||
|
"가성비",
|
||||||
|
"맛",
|
||||||
|
"친절",
|
||||||
|
"분위기"
|
||||||
|
],
|
||||||
|
"negative_aspects": [
|
||||||
|
"고객 만족도 양극화 (일관성 부족)"
|
||||||
|
],
|
||||||
|
"recent_trends": [
|
||||||
|
"최근 만족도 상승"
|
||||||
|
],
|
||||||
|
"rating_pattern": "안정적 고만족"
|
||||||
|
},
|
||||||
|
"insights": {
|
||||||
|
"competitive_advantage": [
|
||||||
|
"높은 고객 만족도",
|
||||||
|
"활발한 고객 참여",
|
||||||
|
"맛 우수",
|
||||||
|
"친절 우수"
|
||||||
|
],
|
||||||
|
"critical_issues": [],
|
||||||
|
"improvement_opportunities": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rank": 3,
|
||||||
|
"store_name": "",
|
||||||
|
"category": "일식",
|
||||||
|
"similarity_score": 0.314,
|
||||||
|
"performance_analysis": {
|
||||||
|
"performance": {
|
||||||
|
"rating": "3.9",
|
||||||
|
"review_count": "103",
|
||||||
|
"status": "영업 중"
|
||||||
|
},
|
||||||
|
"feedback": {
|
||||||
|
"positive_aspects": [
|
||||||
|
"가성비",
|
||||||
|
"맛",
|
||||||
|
"친절"
|
||||||
|
],
|
||||||
|
"negative_aspects": [],
|
||||||
|
"recent_trends": [],
|
||||||
|
"rating_pattern": "양극화 패턴"
|
||||||
|
},
|
||||||
|
"insights": {
|
||||||
|
"competitive_advantage": [
|
||||||
|
"활발한 고객 참여",
|
||||||
|
"맛 우수",
|
||||||
|
"친절 우수"
|
||||||
|
],
|
||||||
|
"critical_issues": [],
|
||||||
|
"improvement_opportunities": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"actionable_recommendations": {
|
"actionable_recommendations": {
|
||||||
"immediate_actions": [
|
"immediate_actions": [
|
||||||
"배달 서비스 개선 (업계 5개 업체 공통 문제)"
|
"고객 만족도 양극화 (일관성 부족) 개선 (업계 1개 업체 공통 문제)"
|
||||||
],
|
],
|
||||||
"strategic_improvements": [
|
"strategic_improvements": [],
|
||||||
"메뉴 다양성 확대"
|
"benchmarking_targets": []
|
||||||
],
|
|
||||||
"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 datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import asyncio
|
import asyncio
|
||||||
from fastapi import FastAPI, HTTPException, Depends
|
from fastapi import FastAPI, HTTPException, Depends, Path, Query
|
||||||
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,7 +43,8 @@ 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
|
||||||
@ -52,6 +53,8 @@ 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,
|
||||||
@ -434,7 +437,7 @@ async def find_reviews(
|
|||||||
|
|
||||||
# 동종 업체 리뷰 수집 (본인 가게 제외)
|
# 동종 업체 리뷰 수집 (본인 가게 제외)
|
||||||
similar_store_names = []
|
similar_store_names = []
|
||||||
max_similar_reviews = min(settings.MAX_REVIEWS_PER_RESTAURANT // 2, 20) # 절반 또는 최대 20개
|
max_similar_reviews = settings.MAX_REVIEWS_PER_RESTAURANT
|
||||||
for store in similar_stores[:settings.MAX_RESTAURANTS_PER_CATEGORY]: # 환경변수 활용
|
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,
|
||||||
@ -620,25 +623,36 @@ async def action_recommendation_simple(
|
|||||||
"/vector-status",
|
"/vector-status",
|
||||||
response_model=VectorDBStatusResponse,
|
response_model=VectorDBStatusResponse,
|
||||||
summary="Vector DB 상태 조회",
|
summary="Vector DB 상태 조회",
|
||||||
description="Vector DB의 현재 상태를 조회합니다."
|
description="Vector DB의 현재 상태와 수집된 매장 ID 목록을 조회합니다."
|
||||||
)
|
)
|
||||||
async def get_vector_status(vector_service: VectorService = Depends(get_vector_service)):
|
async def get_vector_status(
|
||||||
"""Vector DB 상태를 조회합니다."""
|
include_store_ids: bool = Query(True, description="매장 ID 목록 포함 여부"),
|
||||||
|
store_limit: int = Query(200, description="매장 ID 목록 최대 개수", ge=1, le=1000),
|
||||||
|
vector_service: VectorService = Depends(get_vector_service)
|
||||||
|
):
|
||||||
|
"""Vector DB 상태와 수집된 매장 ID 목록을 조회합니다."""
|
||||||
try:
|
try:
|
||||||
db_status = vector_service.get_db_status()
|
# store_id 목록 포함 여부와 제한 개수 전달
|
||||||
|
db_status = vector_service.get_db_status(
|
||||||
|
include_store_ids=include_store_ids,
|
||||||
|
store_limit=store_limit
|
||||||
|
)
|
||||||
|
|
||||||
status = VectorDBStatus(
|
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="Vector DB 상태 조회 성공"
|
message=f"Vector DB 상태 조회 성공 - {store_count}개 매장 ID 포함"
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -649,7 +663,8 @@ async def get_vector_status(vector_service: VectorService = Depends(get_vector_s
|
|||||||
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)}"
|
||||||
)
|
)
|
||||||
@ -669,6 +684,65 @@ 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,6 +96,7 @@ 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="컬렉션명")
|
||||||
@ -103,9 +104,32 @@ 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,74 +147,67 @@ 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()
|
||||||
|
|
||||||
processed_count = 0
|
# 1단계: 가게 분류 (새 가게 vs 업데이트 vs 중복)
|
||||||
documents = []
|
logger.info("🔍 가게 분류 중...")
|
||||||
embeddings = []
|
categorization = self.categorize_stores(review_results, region, food_category)
|
||||||
metadatas = []
|
|
||||||
ids = []
|
|
||||||
|
|
||||||
for store_id, store_info, reviews in review_results:
|
# 2단계: 기존 가게 업데이트
|
||||||
try:
|
update_result = {'updated_count': 0, 'skipped_count': 0}
|
||||||
# 텍스트 추출 및 임베딩 생성
|
if categorization['update_stores']:
|
||||||
text_for_embedding = extract_text_for_embedding(store_info, reviews)
|
logger.info(f"🔄 {len(categorization['update_stores'])}개 기존 가게 업데이트 중...")
|
||||||
embedding = self.embedding_model.encode(text_for_embedding)
|
update_result = self.update_existing_stores(
|
||||||
|
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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"✅ Vector Store 구축 완료: {processed_count}개 문서 저장")
|
# 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
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
# 4단계: 결과 정리
|
||||||
'success': True,
|
total_processed = update_result['updated_count'] + add_result['added_count']
|
||||||
'processed_count': processed_count,
|
execution_time = (datetime.now() - start_time).total_seconds()
|
||||||
'message': f"{processed_count}개 문서가 Vector DB에 저장되었습니다"
|
|
||||||
}
|
result = {
|
||||||
else:
|
'success': True,
|
||||||
return {
|
'processed_count': total_processed, # ← 기존 API 호환성 유지
|
||||||
'success': False,
|
'execution_time': execution_time,
|
||||||
'error': '저장할 문서가 없습니다',
|
'summary': categorization['summary'],
|
||||||
'processed_count': 0
|
'operations': {
|
||||||
}
|
'new_stores_added': add_result['added_count'],
|
||||||
|
'existing_stores_updated': update_result['updated_count'],
|
||||||
|
'update_skipped': update_result.get('skipped_count', 0),
|
||||||
|
'duplicates_removed': categorization['summary']['duplicate_count']
|
||||||
|
},
|
||||||
|
'message': f"✅ Vector DB 처리 완료: 새 가게 {add_result['added_count']}개 추가, "
|
||||||
|
f"기존 가게 {update_result['updated_count']}개 업데이트"
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"📊 Vector Store 구축 완료:")
|
||||||
|
logger.info(f" - 총 처리: {total_processed}개")
|
||||||
|
logger.info(f" - 새 가게: {add_result['added_count']}개")
|
||||||
|
logger.info(f" - 업데이트: {update_result['updated_count']}개")
|
||||||
|
logger.info(f" - 실행 시간: {execution_time:.2f}초")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
except Exception as e:
|
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
|
'processed_count': 0 # ← 기존 API 호환성 유지
|
||||||
}
|
}
|
||||||
|
|
||||||
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]]:
|
||||||
@ -899,8 +892,14 @@ class VectorService:
|
|||||||
|
|
||||||
return recommendations
|
return recommendations
|
||||||
|
|
||||||
def get_db_status(self) -> Dict[str, Any]:
|
def get_db_status(self, include_store_ids: bool = True, store_limit: int = 200) -> 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 {
|
||||||
@ -909,19 +908,27 @@ 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': count, # 각 문서가 하나의 가게를 나타냄
|
'total_stores': len(store_ids) if include_store_ids else 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:
|
||||||
@ -932,5 +939,370 @@ 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: "50"
|
MAX_RESTAURANTS_PER_CATEGORY: "10"
|
||||||
MAX_REVIEWS_PER_RESTAURANT: "100"
|
MAX_REVIEWS_PER_RESTAURANT: "50"
|
||||||
REQUEST_DELAY: "0.1"
|
REQUEST_DELAY: "0.1"
|
||||||
REQUEST_TIMEOUT: "600"
|
REQUEST_TIMEOUT: "200"
|
||||||
|
|
||||||
# 🗄️ ChromaDB 설정 (1.0.12 호환)
|
# 🗄️ ChromaDB 설정 (1.0.12 호환)
|
||||||
# ❌ 제거된 deprecated 설정들:
|
# ❌ 제거된 deprecated 설정들:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user