Compare commits
170 Commits
v0.1.0
...
9a02ed1fd3
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a02ed1fd3 | |||
| 6f8b199548 | |||
| c3b8794621 | |||
| e33219af0b | |||
| eb9bd65033 | |||
| a6fd44c697 | |||
| ad939dde40 | |||
| 26997a7dc7 | |||
| 94969f97a8 | |||
| 3e46cc41ca | |||
| 214eb320fa | |||
| c8ee3bb95b | |||
| 6ffa04f847 | |||
| 262c088c8a | |||
| 074dd4041f | |||
| 243c101981 | |||
| 011eac7682 | |||
| 535ffea45a | |||
| 9d5583935d | |||
| a2bd26682e | |||
| a588a26144 | |||
| 14674c4e9a | |||
| 74891eaa60 | |||
| 4cc802ed95 | |||
| b82a10e580 | |||
| 4646b79e6e | |||
| 786033f202 | |||
| 25f4f1f98b | |||
| 336bc90b4e | |||
| 2980807587 | |||
| 7c7093d67c | |||
| 2603c7ce20 | |||
| 4f68b568a7 | |||
| fdb2fedd40 | |||
| b0f12ba6c6 | |||
| aee3937625 | |||
| d9bfd04c76 | |||
| cd292b2632 | |||
| 80ccb20f99 | |||
| ce4f7b3ef6 | |||
| 1b368e9896 | |||
| a542b1af7d | |||
| 3ce93149d5 | |||
| 5530402604 | |||
| cb750f888b | |||
| 598adcbeb5 | |||
| d67e1fcd67 | |||
| 7eda717326 | |||
| 28e3af12ec | |||
| c9f10aca4a | |||
| 706ca410ca | |||
| 4c6e96d59c | |||
| 7cf4784c08 | |||
| afc159c84d | |||
| bdfcdee5fd | |||
| 3b118725ca | |||
| 6344f957fa | |||
| 0be5693aee | |||
| 5a493664f2 | |||
| c6328f7b04 | |||
| d6d6faf5c7 | |||
| 437838c28b | |||
| 4cb6296a3d | |||
| 9e7efc3f12 | |||
| 6b95c1e5a0 | |||
| 7d20527a17 | |||
| e91a5e6be6 | |||
| c4406b9ecd | |||
| 65ffdec7d2 | |||
| 8b916194aa | |||
| caeb72d310 | |||
| ba33e00ce3 | |||
| bb76e62774 | |||
| 649b99d143 | |||
| 4b339d9d4f | |||
| d2606d7317 | |||
| 33a011a086 | |||
| e04c000a3e | |||
| 1a251cae24 | |||
| 2d98c4176b | |||
| f7c583b806 | |||
| a618544823 | |||
| 2a1d8716c7 | |||
| f5c58a5aa5 | |||
| 9ac142e1de | |||
| 819c35adfc | |||
| 6a1a2c4552 | |||
| ff975defbd | |||
| bc9ba3901e | |||
| c9737b380f | |||
| 09e5ab4e30 | |||
| 4f854c5540 | |||
| 0aa12d94c5 | |||
| 2265da49c6 | |||
| c11aa2a9cb | |||
| 021f682be5 | |||
| 5e06adea3d | |||
| e6df50bbb1 | |||
| 57ad1fd67d | |||
| 4589592b67 | |||
| c7e12ea9fe | |||
| 438aba1dd1 | |||
| 7ab0733400 | |||
| 14236f355a | |||
| f1e72e2829 | |||
| 868020f7ed | |||
| f1eab292a2 | |||
| 732d78becc | |||
| 2ce118baba | |||
| 05e7ffdfd9 | |||
| c7401c5d9f | |||
| 5d6fe2f04b | |||
| 2926770d6f | |||
| 197d451d5f | |||
| f45041d46c | |||
| 483963b463 | |||
| 11423e5106 | |||
| de7468b256 | |||
| d6d2eb0787 | |||
| 136aea8aee | |||
| ea9eb749aa | |||
| 71d9d7a571 | |||
| c96815c2e3 | |||
| 4035432c54 | |||
| d28c291a55 | |||
| 21a8173963 | |||
| f6fcff0faf | |||
| 55863d7744 | |||
| a330a5271c | |||
| e27fbfada1 | |||
| 7fb55a7be7 | |||
| 9a8df4908a | |||
| a8cbef75db | |||
| b6fd444dba | |||
| f2e23c1241 | |||
| c6850da4ac | |||
| 8283dab0de | |||
| 9faa1c5715 | |||
| 0e2d241e18 | |||
| 84c5877207 | |||
| cbafc1f959 | |||
| dce6b3e692 | |||
| 3d0dd24f27 | |||
| 2fafce0327 | |||
| 25ede4f478 | |||
| 2493bc72fb | |||
| dd6435eb86 | |||
| 94db1da045 | |||
| d8e4e0461c | |||
| 421e52b205 | |||
| 526d6a53e5 | |||
| 432840a38d | |||
| 597353e6d4 | |||
| bd43c99221 | |||
| 2c95fe49f3 | |||
| 8ccfc32749 | |||
| 67ef3c4bbf | |||
| ee54458bf0 | |||
| e1c3168d5c | |||
| 2d5972c25d | |||
| 1ddbd4ad0e | |||
| f75bf5d3e5 | |||
| 0fde916120 | |||
| 64c526488a | |||
| c655b655c9 | |||
| 005c0261c2 | |||
| 879bb2f25d | |||
| 82cbae7ae2 | |||
| a8b661b304 | |||
| b815c37064 |
83
.env.example
83
.env.example
@@ -1,17 +1,76 @@
|
||||
# timezone
|
||||
# ---------------------------------------------------------------------------
|
||||
# [Environment Configuration]
|
||||
# 이 파일을 복사하여 .env 파일을 생성하고, 환경에 맞게 주석을 해제/수정하여 사용하세요.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# [COMMON]
|
||||
APP_VERSION=dev
|
||||
TZ=Asia/Seoul
|
||||
|
||||
COMPOSE_PROJECT_NAME=webpage
|
||||
|
||||
# backend lotto collector sources
|
||||
LOTTO_ALL_URL=https://smok95.github.io/lotto/results/all.json
|
||||
LOTTO_LATEST_URL=https://smok95.github.io/lotto/results/latest.json
|
||||
|
||||
# travel-proxy
|
||||
TRAVEL_ROOT=/data/travel
|
||||
TRAVEL_THUMB_ROOT=/data/thumbs
|
||||
TRAVEL_MEDIA_BASE=/media/travel
|
||||
TRAVEL_CACHE_TTL=300
|
||||
# [SECURITY]
|
||||
WEBHOOK_SECRET=change_this_secret_in_prod
|
||||
|
||||
# CORS (travel-proxy)
|
||||
CORS_ALLOW_ORIGINS=*
|
||||
# [PATHS]
|
||||
# 1. 런타임 데이터 루트 (docker-compose.yml이 실행되는 위치)
|
||||
# NAS: /volume1/docker/webpage
|
||||
# Local: . (현재 프로젝트 루트)
|
||||
RUNTIME_PATH=.
|
||||
|
||||
# 2. Git 저장소 루트
|
||||
# NAS: /volume1/workspace/web-page-backend
|
||||
# Local: .
|
||||
REPO_PATH=.
|
||||
|
||||
# 3. Frontend 정적 파일 경로
|
||||
# NAS: /volume1/docker/webpage/frontend (업로드된 파일)
|
||||
# Local: ./frontend/dist (빌드된 결과물)
|
||||
FRONTEND_PATH=./frontend/dist
|
||||
|
||||
# 4. 여행 사진 원본 경로
|
||||
# NAS: /volume1/web/images/webPage/travel
|
||||
# Local: ./mock_data/photos
|
||||
PHOTO_PATH=./mock_data/photos
|
||||
|
||||
# 5. 주식 데이터 저장 경로
|
||||
# NAS: /volume1/docker/webpage/data/stock
|
||||
# Local: ./data/stock
|
||||
STOCK_DATA_PATH=./data/stock
|
||||
|
||||
# [PERMISSIONS]
|
||||
# NAS: 1026:100
|
||||
# Local: 1000:1000 (Windows Docker Desktop의 경우 크게 중요하지 않음)
|
||||
PUID=1000
|
||||
PGID=1000
|
||||
|
||||
# [STOCK LAB]
|
||||
# NAS는 Windows AI Server로 요청을 중계(Proxy)하는 역할만 수행합니다.
|
||||
# 실제 KIS API 호출 및 AI 분석은 Windows PC에서 수행됩니다.
|
||||
|
||||
# Windows AI Server (NAS 입장에서 바라본 Windows PC IP)
|
||||
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
|
||||
|
||||
# Admin API Key (trade/order 등 민감 엔드포인트 보호, 미설정 시 인증 비활성화)
|
||||
ADMIN_API_KEY=
|
||||
|
||||
# Anthropic API Key (AI Coach 프록시, 미설정 시 AI Coach 비활성화)
|
||||
ANTHROPIC_API_KEY=
|
||||
|
||||
# [BLOG LAB]
|
||||
# Naver Search API (https://developers.naver.com 에서 발급)
|
||||
NAVER_CLIENT_ID=
|
||||
NAVER_CLIENT_SECRET=
|
||||
|
||||
# 블로그 데이터 저장 경로
|
||||
# BLOG_DATA_PATH=./data/blog
|
||||
|
||||
# [MUSIC LAB]
|
||||
# Suno API Key (https://suno.com 에서 발급, 미설정 시 Suno provider 비활성화)
|
||||
SUNO_API_KEY=
|
||||
|
||||
# 로컬 MusicGen AI Server URL (미설정 시 Local provider 비활성화)
|
||||
# MUSIC_AI_SERVER_URL=http://192.168.45.59:8765
|
||||
|
||||
# CORS 허용 도메인 (콤마 구분)
|
||||
CORS_ALLOW_ORIGINS=https://gahusb.synology.me,http://localhost:3007,http://localhost:8080
|
||||
|
||||
469
CLAUDE.md
Normal file
469
CLAUDE.md
Normal file
@@ -0,0 +1,469 @@
|
||||
# CLAUDE.md — web-backend 프로젝트 가이드
|
||||
|
||||
> Claude Code가 이 프로젝트를 작업할 때 참조하는 설정 및 구조 문서.
|
||||
|
||||
---
|
||||
|
||||
## 1. 프로젝트 개요
|
||||
|
||||
Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||
- **서비스**: lotto-lab, stock-lab, travel-album, music-lab, blog-lab, realestate-lab, deployer
|
||||
- **프론트엔드**: 별도 레포 (React + Vite SPA), 빌드 산출물만 NAS에 배포
|
||||
- **인프라**: Docker Compose + Nginx(리버스 프록시) + Gitea Webhook 자동 배포
|
||||
|
||||
---
|
||||
|
||||
## 2. NAS 환경
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|----|
|
||||
| 장비 | Synology NAS |
|
||||
| CPU | Intel Celeron J4025 (2 Core, 2.0 GHz) |
|
||||
| 메모리 | 18 GB |
|
||||
| Docker | Synology Container Manager |
|
||||
| Git 서버 | Gitea (self-hosted, NAS 내부) |
|
||||
| AI 서버 | Windows PC (192.168.45.59:8000) — NVIDIA 3070 Ti + Ollama |
|
||||
|
||||
---
|
||||
|
||||
## 3. NAS 디렉토리 구조
|
||||
|
||||
```
|
||||
/volume1
|
||||
├── docker/webpage/ # 운영 런타임 (Docker Compose 실행 위치)
|
||||
│ ├── backend/ # lotto-backend 소스 (rsync 동기화)
|
||||
│ ├── stock-lab/ # stock-lab 소스 (rsync 동기화)
|
||||
│ ├── travel-proxy/ # travel-proxy 소스 (rsync 동기화)
|
||||
│ ├── deployer/ # deployer 소스 (rsync 동기화)
|
||||
│ ├── nginx/default.conf # Nginx 설정
|
||||
│ ├── scripts/deploy.sh # Webhook 트리거 배포 스크립트
|
||||
│ ├── docker-compose.yml
|
||||
│ ├── .env # 운영 환경변수
|
||||
│ ├── data/lotto.db # SQLite DB
|
||||
│ └── data/music/ # 생성된 오디오 파일 (music-lab)
|
||||
│
|
||||
├── workspace/web-page-backend/ # Git 레포 클론 위치 (REPO_PATH)
|
||||
│
|
||||
└── web/images/webPage/travel/ # 원본 여행 사진 (RO 마운트)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Docker 서비스 & 포트
|
||||
|
||||
| 컨테이너 | 포트 | 역할 |
|
||||
|---------|------|------|
|
||||
| `lotto-backend` | 18000 | 로또 데이터 수집·분석·추천 API |
|
||||
| `stock-lab` | 18500 | 주식 뉴스·AI 분석·KIS API 연동 |
|
||||
| `music-lab` | 18600 | AI 음악 생성·라이브러리 관리 API |
|
||||
| `blog-lab` | 18700 | 블로그 마케팅 수익화 API |
|
||||
| `realestate-lab` | 18800 | 부동산 청약 자동 수집·매칭 API |
|
||||
| `agent-office` | 18900 | AI 에이전트 오피스 (실시간 WebSocket + 텔레그램 연동) |
|
||||
| `travel-proxy` | 19000 | 여행 사진 API + 썸네일 생성 |
|
||||
| `lotto-frontend` (nginx) | 8080 | 정적 SPA 서빙 + API 리버스 프록시 |
|
||||
| `webpage-deployer` | 19010 | Gitea Webhook 수신 → 자동 배포 |
|
||||
|
||||
---
|
||||
|
||||
## 5. Nginx 라우팅 규칙
|
||||
|
||||
| 경로 | 프록시 대상 | 비고 |
|
||||
|------|------------|------|
|
||||
| `/api/` | `lotto-backend:8000` | lotto API (기본) |
|
||||
| `/api/travel/` | `travel-proxy:8000` | travel API |
|
||||
| `/api/stock/` | `stock-lab:8000` | stock API |
|
||||
| `/api/trade/` | `stock-lab:8000` | KIS 실계좌 API |
|
||||
| `/api/portfolio` | `stock-lab:8000` | trailing slash 유무 모두 매칭 |
|
||||
| `/api/music/` | `music-lab:8000` | AI 음악 생성·라이브러리 API |
|
||||
| `/api/blog-marketing/` | `blog-lab:8000` | 블로그 마케팅 수익화 API |
|
||||
| `/api/realestate/` | `realestate-lab:8000` | 부동산 청약 API |
|
||||
| `/api/agent-office/` | `agent-office:8000` | AI 에이전트 오피스 API + WebSocket |
|
||||
| `/webhook`, `/webhook/` | `deployer:9000` | Gitea Webhook |
|
||||
| `/media/music/` | `/data/music/` (파일 직접 서빙) | 생성된 오디오 파일 |
|
||||
| `/media/travel/.thumb/` | `/data/thumbs/` (파일 직접 서빙) | 썸네일 캐시 |
|
||||
| `/media/travel/` | `/data/travel/` (파일 직접 서빙) | 원본 사진 |
|
||||
| `/assets/` | 정적 파일 (장기 캐시) | Vite 해시 파일 |
|
||||
| `/` | SPA fallback (`try_files → index.html`) | |
|
||||
|
||||
---
|
||||
|
||||
## 6. 기술 스택
|
||||
|
||||
| 레이어 | 기술 |
|
||||
|--------|------|
|
||||
| Backend 언어 | Python 3.12 |
|
||||
| API 프레임워크 | FastAPI |
|
||||
| DB | SQLite (`/app/data/*.db`) |
|
||||
| 스케줄러 | APScheduler |
|
||||
| 컨테이너 | Docker (`python:3.12-slim` 기반) |
|
||||
| AI 연동 | Ollama (Llama 3.1) — Windows PC (192.168.45.59) |
|
||||
| 주식 API | KIS (한국투자증권) Open API |
|
||||
|
||||
---
|
||||
|
||||
## 7. 자동 배포 흐름
|
||||
|
||||
```
|
||||
개발자 git push → Gitea → Webhook (HMAC SHA256 검증)
|
||||
→ deployer 컨테이너 → /scripts/deploy.sh
|
||||
→ rsync(REPO→RUNTIME) → docker compose up -d --build
|
||||
```
|
||||
|
||||
- **배포 스크립트 위치**: `scripts/deploy-nas.sh` (레포) / `scripts/deploy.sh` (런타임)
|
||||
- **환경변수 파일**: `.env` (RUNTIME_PATH, REPO_PATH, PHOTO_PATH, PUID, PGID 등)
|
||||
- **백업**: `.releases/` 디렉토리에 자동 백업
|
||||
|
||||
---
|
||||
|
||||
## 8. 로컬 개발 환경
|
||||
|
||||
```bash
|
||||
# .env 기본값으로 즉시 실행 가능 (RUNTIME_PATH=., PHOTO_PATH=./mock_data/photos)
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
| 서비스 | 로컬 URL |
|
||||
|--------|----------|
|
||||
| Frontend + API | http://localhost:8080 |
|
||||
| Lotto Backend | http://localhost:18000 |
|
||||
| Travel API | http://localhost:19000 |
|
||||
| Stock Lab | http://localhost:18500 |
|
||||
| Blog Lab | http://localhost:18700 |
|
||||
| Realestate Lab | http://localhost:18800 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 서비스별 핵심 정보
|
||||
|
||||
### lotto-lab (backend/)
|
||||
- DB: `/app/data/lotto.db`
|
||||
- 데이터 소스: `smok95.github.io/lotto/results/`
|
||||
- 파일 구조: `main.py`, `db.py`, `recommender.py`, `collector.py`, `checker.py`, `generator.py`, `analyzer.py`, `utils.py`, `purchase_manager.py`, `strategy_evolver.py`
|
||||
|
||||
**lotto.db 테이블**
|
||||
|
||||
| 테이블 | 설명 |
|
||||
|--------|------|
|
||||
| `draws` | 로또 당첨번호 |
|
||||
| `recommendations` | 추천 이력 (즐겨찾기·태그·채점 포함) |
|
||||
| `simulation_runs` | 시뮬레이션 실행 기록 |
|
||||
| `simulation_candidates` | 시뮬레이션 후보 (점수 5종) |
|
||||
| `best_picks` | 현재 활성 최적 번호 20개 (`is_active` 플래그로 교체) |
|
||||
| `purchase_history` | 구매 이력 (실제/가상, 번호, 전략 출처, 결과) |
|
||||
| `strategy_performance` | 전략별 회차 성과 (EMA 입력 데이터) |
|
||||
| `strategy_weights` | 메타 전략 가중치 (EMA + Softmax) |
|
||||
| `weekly_reports` | 주간 공략 리포트 캐시 |
|
||||
| `todos` | 투두리스트 (UUID PK) |
|
||||
| `blog_posts` | 블로그 글 (tags: JSON 배열) |
|
||||
|
||||
**스케줄러 job**
|
||||
- 09:10 / 21:10 매일 — 당첨번호 동기화 + 채점 (`sync_latest` → `check_results_for_draw`)
|
||||
- 00:05, 04:05, 08:05, 12:05, 16:05, 20:05 — 몬테카를로 시뮬레이션 (20,000후보 → 상위100 → best_picks 20개 교체)
|
||||
|
||||
**lotto-lab API 목록**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/lotto/latest` | 최신 당첨번호 |
|
||||
| GET | `/api/lotto/{drw_no}` | 특정 회차 |
|
||||
| GET | `/api/lotto/stats` | 번호 빈도 통계 |
|
||||
| GET | `/api/lotto/analysis` | 5가지 통계 분석 리포트 |
|
||||
| GET | `/api/lotto/best` | 시뮬레이션 최적 번호 (기본 20쌍) |
|
||||
| GET | `/api/lotto/simulation` | 시뮬레이션 상세 결과 |
|
||||
| GET | `/api/lotto/recommend` | 통계 기반 추천 |
|
||||
| GET | `/api/lotto/recommend/heatmap` | 히트맵 기반 추천 |
|
||||
| GET | `/api/lotto/recommend/batch` | 배치 추천 |
|
||||
| POST | `/api/lotto/recommend/batch` | 배치 추천 저장 |
|
||||
| GET | `/api/lotto/recommend/smart` | 전략 진화 기반 메타 추천 |
|
||||
| GET | `/api/lotto/purchase` | 구매 이력 조회 (is_real, strategy, draw_no, days 필터) |
|
||||
| POST | `/api/lotto/purchase` | 구매 등록 (실제/가상, 번호, 전략 출처 포함) |
|
||||
| PUT | `/api/lotto/purchase/{id}` | 구매 이력 수정 |
|
||||
| DELETE | `/api/lotto/purchase/{id}` | 구매 이력 삭제 |
|
||||
| GET | `/api/lotto/purchase/stats` | 구매 통계 (전체/실제/가상 + 전략별) |
|
||||
| GET | `/api/lotto/strategy/weights` | 전략별 가중치 + 성과 + trend |
|
||||
| GET | `/api/lotto/strategy/performance` | 전략별 회차 성과 이력 (차트용) |
|
||||
| POST | `/api/lotto/strategy/evolve` | 수동 가중치 재계산 |
|
||||
| POST | `/api/admin/simulate` | 시뮬레이션 수동 실행 |
|
||||
| POST | `/api/admin/sync_latest` | 당첨번호 수동 동기화 |
|
||||
| GET | `/api/history` | 추천 이력 (limit, offset, favorite, tag, sort) |
|
||||
| PATCH | `/api/history/{id}` | 즐겨찾기·메모·태그 수정 |
|
||||
| DELETE | `/api/history/{id}` | 삭제 |
|
||||
| GET | `/api/todos` | 투두 전체 목록 |
|
||||
| POST | `/api/todos` | 투두 생성 (status: todo\|in_progress\|done) |
|
||||
| PUT | `/api/todos/{id}` | 투두 수정 |
|
||||
| DELETE | `/api/todos/done` | 완료 항목 일괄 삭제 |
|
||||
| DELETE | `/api/todos/{id}` | 투두 개별 삭제 |
|
||||
| GET | `/api/blog/posts` | 블로그 글 목록 (`{"posts": [...]}`, date DESC) |
|
||||
| POST | `/api/blog/posts` | 블로그 글 생성 (date 미입력 시 오늘) |
|
||||
| PUT | `/api/blog/posts/{id}` | 블로그 글 수정 |
|
||||
| DELETE | `/api/blog/posts/{id}` | 블로그 글 삭제 |
|
||||
|
||||
### stock-lab (stock-lab/)
|
||||
- Windows AI 서버 연동: `WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000`
|
||||
- KIS API 연동으로 실계좌 잔고·거래 조회
|
||||
- 뉴스 스크래핑: 네이버 증권 + 해외 사이트
|
||||
- DB: `/app/data/stock.db` (articles, portfolio, broker_cash, asset_snapshots, sell_history 테이블)
|
||||
- 파일 구조: `main.py`, `db.py`, `scraper.py`, `price_fetcher.py`, `holidays.json`
|
||||
|
||||
**stock-lab API 목록**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/stock/news` | 뉴스 조회 (`limit`, `category` 파라미터) |
|
||||
| GET | `/api/stock/indices` | 주요 지표 실시간 조회 |
|
||||
| POST | `/api/stock/scrap` | 수동 뉴스 스크랩 트리거 |
|
||||
| GET | `/api/trade/balance` | 실계좌 잔고 조회 (Windows AI 서버 프록시) |
|
||||
| POST | `/api/trade/order` | 주식 주문 (Windows AI 서버 프록시) |
|
||||
| GET | `/api/portfolio` | 포트폴리오 전체 조회 (현재가·손익·예수금 포함) |
|
||||
| POST | `/api/portfolio` | 종목 추가 |
|
||||
| PUT | `/api/portfolio/{id}` | 종목 수정 |
|
||||
| DELETE | `/api/portfolio/{id}` | 종목 삭제 |
|
||||
| GET | `/api/portfolio/cash` | 예수금 전체 조회 |
|
||||
| PUT | `/api/portfolio/cash` | 예수금 등록·수정 (upsert) |
|
||||
| DELETE | `/api/portfolio/cash/{broker}` | 예수금 삭제 |
|
||||
| POST | `/api/portfolio/snapshot` | 총 자산 스냅샷 수동 저장 |
|
||||
| GET | `/api/portfolio/snapshot/history` | 스냅샷 이력 조회 (`days=0`: 전체, `days=N`: 최근 N건) |
|
||||
| GET | `/api/portfolio/sell-history` | 매도 내역 조회 (`broker`, `days` 필터 선택) |
|
||||
| POST | `/api/portfolio/sell-history` | 매도 기록 저장 (id 포함 레코드 반환) |
|
||||
| PUT | `/api/portfolio/sell-history/{id}` | 매도 기록 수정 (수정된 레코드 반환) |
|
||||
| DELETE | `/api/portfolio/sell-history/{id}` | 매도 기록 삭제 |
|
||||
|
||||
**매도 히스토리 (`sell_history`)**
|
||||
- 독립 테이블 — `portfolio` 테이블과 별개로 관리
|
||||
- `sold_at`: UTC ISO8601 형식 (`new Date().toISOString()`)
|
||||
- `realized_profit` / `realized_rate`: 프론트 계산값 저장 (백엔드 재계산 무방)
|
||||
- 응답 정렬: `sold_at DESC` (최신순)
|
||||
|
||||
**총 자산 스냅샷 (`asset_snapshots`)**
|
||||
- 평일 15:40 APScheduler 자동 실행 (`save_daily_snapshot`)
|
||||
- 공휴일 판별: `holidays.json` (매년 수동 갱신, KRX 기준) → `is_market_open()` 함수
|
||||
- 같은 날 중복 저장 시 upsert (date UNIQUE 제약)
|
||||
- 수동 저장: `POST /api/portfolio/snapshot`
|
||||
- 이력 조회: `GET /api/portfolio/snapshot/history?days=30` (ASC 정렬, 차트용)
|
||||
|
||||
**스케줄러 job**
|
||||
- 08:00 매일 — 뉴스 스크랩 (`run_scraping_job`)
|
||||
- 15:40 평일 — 총 자산 스냅샷 저장 (`save_daily_snapshot`)
|
||||
|
||||
### music-lab (music-lab/)
|
||||
- 듀얼 프로바이더 음악 생성 서비스 (Suno API + 로컬 MusicGen)
|
||||
- 생성된 오디오 파일: `/app/data/music/` (Nginx가 `/media/music/`로 직접 서빙)
|
||||
- DB: `/app/data/music.db` (music_tasks, music_library 테이블)
|
||||
- 파일 구조: `main.py`, `db.py`, `suno_provider.py`, `local_provider.py`
|
||||
- 생성 흐름: POST generate (provider 지정) → task_id 반환 → BackgroundTask → 파일 저장 → 라이브러리 자동 등록
|
||||
|
||||
**Provider 구조**
|
||||
- `suno`: Suno REST API (`apicast.suno.ai/v1`) — 보컬·가사·인스트루멘탈 지원
|
||||
- `local`: Windows AI 서버 (MusicGen) — 인스트루멘탈 전용
|
||||
|
||||
**music-lab API 목록**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/music/providers` | 사용 가능한 프로바이더 목록 |
|
||||
| GET | `/api/music/models` | Suno 모델 목록 (V4~V5.5) |
|
||||
| GET | `/api/music/credits` | Suno 크레딧 조회 |
|
||||
| POST | `/api/music/generate` | 음악 생성 (provider, model, vocal_gender, negative_tags, style_weight, audio_weight) |
|
||||
| GET | `/api/music/status/{task_id}` | 생성 상태 폴링 |
|
||||
| POST | `/api/music/lyrics` | Suno AI 가사 생성 |
|
||||
| GET | `/api/music/library` | 라이브러리 전체 조회 |
|
||||
| POST | `/api/music/library` | 트랙 수동 추가 |
|
||||
| DELETE | `/api/music/library/{id}` | 트랙 삭제 |
|
||||
| POST | `/api/music/extend` | 곡 연장 |
|
||||
| POST | `/api/music/vocal-removal` | 보컬/인스트 분리 (2트랙) |
|
||||
| POST | `/api/music/cover-image` | 커버 이미지 2장 생성 |
|
||||
| POST | `/api/music/wav` | WAV 고음질 변환 |
|
||||
| POST | `/api/music/stem-split` | 12스템 분리 (50cr) |
|
||||
| GET | `/api/music/timestamped-lyrics` | 타임스탬프 가사 (가라오케) |
|
||||
| POST | `/api/music/style-boost` | AI 스타일 프롬프트 생성 |
|
||||
| POST | `/api/music/upload-cover` | 외부 음원 AI Cover |
|
||||
| POST | `/api/music/upload-extend` | 외부 음원 확장 |
|
||||
| POST | `/api/music/add-vocals` | 인스트에 AI 보컬 추가 |
|
||||
| POST | `/api/music/add-instrumental` | 보컬에 AI 반주 추가 |
|
||||
| POST | `/api/music/video` | 뮤직비디오 MP4 생성 |
|
||||
| GET | `/api/music/lyrics/library` | 저장된 가사 목록 |
|
||||
| POST | `/api/music/lyrics/library` | 가사 저장 |
|
||||
| PUT | `/api/music/lyrics/library/{id}` | 가사 수정 |
|
||||
| DELETE | `/api/music/lyrics/library/{id}` | 가사 삭제 |
|
||||
|
||||
**환경변수**
|
||||
- `SUNO_API_KEY`: Suno API 키 (미설정 시 Suno provider 비활성화)
|
||||
- `MUSIC_AI_SERVER_URL`: 로컬 MusicGen 서버 URL (미설정 시 local provider 비활성화)
|
||||
- `MUSIC_MEDIA_BASE`: 오디오 파일 공개 URL prefix (기본 `/media/music`)
|
||||
- `MUSIC_DATA_PATH`: NAS 오디오 파일 저장 경로 (기본 `./data/music`)
|
||||
|
||||
**music_library 테이블 (확장 컬럼)**
|
||||
- `provider`: `suno` | `local` — 생성에 사용된 프로바이더
|
||||
- `lyrics`: Suno 생성 가사 텍스트
|
||||
- `image_url`: Suno 생성 커버 이미지 URL
|
||||
- `suno_id`: Suno 곡 ID (CDN 참조용)
|
||||
- `file_hash`: MD5 해시 (rename 감지용)
|
||||
- `cover_images`: JSON 배열 — 커버 이미지 URL 목록
|
||||
- `wav_url`: WAV 변환 URL
|
||||
- `video_url`: 뮤직비디오 URL
|
||||
- `stem_urls`: JSON 객체 — 12스템 URL 맵
|
||||
|
||||
**Suno 생성 특이사항**
|
||||
- 1회 생성 시 2개 변형(variation) 반환 → 둘 다 라이브러리에 저장
|
||||
- CDN URL(`cdn1.suno.ai`)은 임시 → 반드시 로컬 다운로드 필요
|
||||
- 가사 섹션 태그: `[Verse]`, `[Chorus]`, `[Bridge]`, `[Instrumental]` 등
|
||||
|
||||
### realestate-lab (realestate-lab/)
|
||||
- 공공데이터포털 API 연동: 한국부동산원 청약홈 분양정보 조회 서비스
|
||||
- DB: `/app/data/realestate.db` (announcements, announcement_models, user_profile, match_results, collect_log 테이블)
|
||||
- 파일 구조: `main.py`, `db.py`, `collector.py`, `matcher.py`, `models.py`
|
||||
|
||||
**환경변수**
|
||||
- `DATA_GO_KR_API_KEY`: 공공데이터포털 API 키 (미설정 시 수동 등록만 가능)
|
||||
|
||||
**스케줄러 job**
|
||||
- 09:00 매일 — 청약 공고 수집 + 매칭 (`scheduled_collect`)
|
||||
- 00:00 매일 — 상태 갱신 + 재매칭 (`scheduled_status_update`)
|
||||
|
||||
**realestate-lab API 목록**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/realestate/announcements` | 공고 목록 (region, status, house_type, matched_only, sort, page, size) |
|
||||
| GET | `/api/realestate/announcements/{id}` | 공고 상세 (주택형별 포함) |
|
||||
| POST | `/api/realestate/announcements` | 수동 공고 등록 |
|
||||
| PUT | `/api/realestate/announcements/{id}` | 공고 수정 |
|
||||
| DELETE | `/api/realestate/announcements/{id}` | 공고 삭제 |
|
||||
| POST | `/api/realestate/collect` | 수동 수집 트리거 |
|
||||
| GET | `/api/realestate/collect/status` | 마지막 수집 결과 |
|
||||
| GET | `/api/realestate/profile` | 내 프로필 조회 |
|
||||
| PUT | `/api/realestate/profile` | 프로필 수정 (upsert) |
|
||||
| GET | `/api/realestate/matches` | 매칭 결과 목록 |
|
||||
| POST | `/api/realestate/matches/refresh` | 매칭 재계산 |
|
||||
| PATCH | `/api/realestate/matches/{id}/read` | 신규 알림 읽음 처리 |
|
||||
| GET | `/api/realestate/dashboard` | 요약 (진행중 공고수, 신규 매칭수, 다가오는 일정) |
|
||||
|
||||
### travel-proxy (travel-proxy/)
|
||||
- 원본 사진: `/data/travel/` (RO)
|
||||
- 썸네일 캐시: `/data/thumbs/` (RW)
|
||||
- 메타: `/data/travel/_meta/region_map.json`, `regions.geojson`
|
||||
- 썸네일: 480×480 리사이징 (Pillow), 온디맨드 생성 후 영구 캐시
|
||||
- 메모리 캐시: TTL 300초 (앨범 스캔 결과)
|
||||
|
||||
**travel-proxy API 목록**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/travel/regions` | 지역 GeoJSON |
|
||||
| GET | `/api/travel/photos` | 사진 목록 (region, page=1, size=20) |
|
||||
| POST | `/api/travel/reload` | 메모리 캐시 초기화 |
|
||||
|
||||
### blog-lab (blog-lab/)
|
||||
- 블로그 마케팅 수익화 서비스 (키워드 분석 → AI 글 생성 → 마케팅 강화 → 품질 리뷰 → 포스팅 → 수익 추적)
|
||||
- AI 엔진: Claude API (Anthropic, `claude-sonnet-4-20250514`)
|
||||
- 웹 검색: Naver Search API (블로그 + 쇼핑) + 상위 블로그 본문 크롤링
|
||||
- DB: `/app/data/blog_marketing.db`
|
||||
- 파일 구조: `main.py`, `db.py`, `config.py`, `naver_search.py`, `content_generator.py`, `marketer.py`, `quality_reviewer.py`, `web_crawler.py`
|
||||
|
||||
**파이프라인**: 리서치(+크롤링) → 작가(초안) → 마케터(링크 삽입) → 평가자(6기준 60점)
|
||||
**상태 흐름**: `draft` → `marketed` → `reviewed` → `published`
|
||||
|
||||
**blog_marketing.db 테이블**
|
||||
|
||||
| 테이블 | 설명 |
|
||||
|--------|------|
|
||||
| `keyword_analyses` | 키워드 분석 결과 (네이버 검색 데이터 + 경쟁도/기회 점수 + 크롤링 본문) |
|
||||
| `blog_posts` | 블로그 글 (draft → marketed → reviewed → published) |
|
||||
| `brand_links` | 브랜드커넥트 제휴 링크 (post_id/keyword_id FK) |
|
||||
| `commissions` | 포스트별 월간 클릭/구매/수익 |
|
||||
| `generation_tasks` | 비동기 작업 상태 (research/generate/market/review) |
|
||||
| `prompt_templates` | AI 프롬프트 템플릿 (DB 저장, 코드 배포 없이 수정 가능) |
|
||||
|
||||
**blog-lab API 목록**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/blog-marketing/status` | 서비스 상태 (API 키 설정 현황) |
|
||||
| POST | `/api/blog-marketing/research` | 키워드 분석 시작 (+ 상위 블로그 크롤링) |
|
||||
| GET | `/api/blog-marketing/research/history` | 분석 이력 조회 |
|
||||
| GET | `/api/blog-marketing/research/{id}` | 분석 상세 조회 |
|
||||
| DELETE | `/api/blog-marketing/research/{id}` | 분석 삭제 |
|
||||
| GET | `/api/blog-marketing/task/{task_id}` | 작업 상태 폴링 |
|
||||
| POST | `/api/blog-marketing/generate` | 작가 단계: AI 글 생성 (크롤링 참고 + 링크 반영) |
|
||||
| POST | `/api/blog-marketing/market/{post_id}` | 마케터 단계: 전환율 강화 + 링크 삽입 |
|
||||
| POST | `/api/blog-marketing/review/{post_id}` | 평가자 단계: 품질 리뷰 (6기준 × 10점, 42/60 통과) |
|
||||
| POST | `/api/blog-marketing/regenerate/{post_id}` | 피드백 기반 재생성 |
|
||||
| POST | `/api/blog-marketing/links` | 브랜드커넥트 링크 등록 |
|
||||
| GET | `/api/blog-marketing/links` | 링크 조회 (post_id, keyword_id 필터) |
|
||||
| PUT | `/api/blog-marketing/links/{id}` | 링크 수정 |
|
||||
| DELETE | `/api/blog-marketing/links/{id}` | 링크 삭제 |
|
||||
| GET | `/api/blog-marketing/posts` | 포스트 목록 (status 필터) |
|
||||
| GET | `/api/blog-marketing/posts/{id}` | 포스트 상세 |
|
||||
| PUT | `/api/blog-marketing/posts/{id}` | 포스트 수정 |
|
||||
| DELETE | `/api/blog-marketing/posts/{id}` | 포스트 삭제 |
|
||||
| POST | `/api/blog-marketing/posts/{id}/publish` | 발행 (네이버 URL 등록) |
|
||||
| GET | `/api/blog-marketing/commissions` | 수익 내역 조회 |
|
||||
| POST | `/api/blog-marketing/commissions` | 수익 기록 추가 |
|
||||
| PUT | `/api/blog-marketing/commissions/{id}` | 수익 기록 수정 |
|
||||
| DELETE | `/api/blog-marketing/commissions/{id}` | 수익 기록 삭제 |
|
||||
| GET | `/api/blog-marketing/dashboard` | 대시보드 집계 |
|
||||
|
||||
**환경변수**
|
||||
- `ANTHROPIC_API_KEY`: Claude API 키 (미설정 시 AI 생성 비활성화)
|
||||
- `NAVER_CLIENT_ID`: 네이버 검색 API 클라이언트 ID
|
||||
- `NAVER_CLIENT_SECRET`: 네이버 검색 API 시크릿
|
||||
- `BLOG_DATA_PATH`: SQLite DB 저장 경로 (기본 `./data/blog`)
|
||||
|
||||
### agent-office (agent-office/)
|
||||
- AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 에이전트가 실제 작업 수행
|
||||
- stock-lab/music-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음)
|
||||
- 실시간 상태 동기화: WebSocket (`/api/agent-office/ws`)
|
||||
- 텔레그램 봇: 양방향 알림 + 승인 (인라인 키보드)
|
||||
- DB: `/app/data/agent_office.db` (agent_config, agent_tasks, agent_logs, telegram_state 테이블)
|
||||
- 파일 구조: `main.py`, `db.py`, `config.py`, `models.py`, `websocket_manager.py`, `service_proxy.py`, `telegram_bot.py`, `scheduler.py`, `agents/base.py`, `agents/stock.py`, `agents/music.py`
|
||||
|
||||
**에이전트 FSM 상태**: idle → working → waiting (승인 대기) → reporting → break (휴식)
|
||||
|
||||
**환경변수**
|
||||
- `STOCK_LAB_URL`: stock-lab 내부 URL (기본 `http://stock-lab:8000`)
|
||||
- `MUSIC_LAB_URL`: music-lab 내부 URL (기본 `http://music-lab:8000`)
|
||||
- `TELEGRAM_BOT_TOKEN`: 텔레그램 봇 토큰 (미설정 시 알림 비활성화)
|
||||
- `TELEGRAM_CHAT_ID`: 텔레그램 채팅 ID
|
||||
- `TELEGRAM_WEBHOOK_URL`: 텔레그램 Webhook URL
|
||||
|
||||
**스케줄러 job**
|
||||
- 08:00 매일 — 주식 뉴스 요약 (`stock_news_job`)
|
||||
- 60초 간격 — 유휴 에이전트 휴식 체크 (`idle_check_job`)
|
||||
|
||||
**agent-office API 목록**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| WS | `/api/agent-office/ws` | WebSocket (init, agent_state, task_complete, command_result) |
|
||||
| GET | `/api/agent-office/agents` | 에이전트 목록 |
|
||||
| GET | `/api/agent-office/agents/{id}` | 에이전트 상세 (설정 + 상태) |
|
||||
| PUT | `/api/agent-office/agents/{id}` | 에이전트 설정 수정 |
|
||||
| GET | `/api/agent-office/agents/{id}/tasks` | 에이전트 작업 이력 |
|
||||
| GET | `/api/agent-office/agents/{id}/logs` | 에이전트 로그 |
|
||||
| GET | `/api/agent-office/tasks/pending` | 승인 대기 작업 목록 |
|
||||
| GET | `/api/agent-office/tasks/{id}` | 작업 상세 |
|
||||
| POST | `/api/agent-office/command` | 에이전트에 명령 전송 |
|
||||
| POST | `/api/agent-office/approve` | 작업 승인/거부 |
|
||||
| POST | `/api/agent-office/telegram/webhook` | 텔레그램 Webhook 수신 |
|
||||
| GET | `/api/agent-office/states` | 전체 에이전트 상태 조회 |
|
||||
|
||||
### deployer (deployer/)
|
||||
- Webhook 검증: `X-Gitea-Signature` (HMAC SHA256, `compare_digest` 사용)
|
||||
- `WEBHOOK_SECRET` 환경변수로 시크릿 관리
|
||||
- Webhook 수신 즉시 `{"ok": True}` 응답 후 BackgroundTask로 배포 실행
|
||||
- 배포 타임아웃: 10분 (`scripts/deploy.sh`)
|
||||
|
||||
---
|
||||
|
||||
## 10. 주의사항
|
||||
|
||||
- **Nginx trailing slash**: `/api/portfolio`는 trailing slash 없이도 매칭되도록 두 location 블록으로 처리
|
||||
- **라우트 순서**: `DELETE /api/todos/done`은 `DELETE /api/todos/{id}` 보다 **반드시 먼저** 등록 (FastAPI prefix 매칭 순서)
|
||||
- **PUID/PGID**: travel-proxy는 NAS 파일 권한을 위해 PUID/PGID를 환경변수로 주입
|
||||
- **캐시 전략**: `index.html`은 `no-store`, `assets/`는 1년 장기 캐시(immutable)
|
||||
- **Frontend 배포**: git push로 자동 배포되지 않음. 로컬 빌드 후 NAS에 수동 업로드
|
||||
- **.env 파일**: 절대 커밋 금지. `.env.example`만 레포에 포함
|
||||
- **공휴일 목록**: `stock-lab/app/holidays.json` 매년 수동 갱신 필요 (KRX 기준)
|
||||
- **Windows AI 서버 IP**: `192.168.45.59` — 공유기 DHCP 고정 예약으로 고정. Tailscale은 Synology에서 TCP 불가(userspace 모드)라 로컬 IP 사용
|
||||
- **현재가 조회**: 네이버 모바일 API → HTML 파싱 폴백, 3분 TTL 캐시 (`price_fetcher.py`)
|
||||
- **시뮬레이션 교체 방식**: `best_picks`는 교체형 — 새 시뮬레이션 실행 시 `is_active=0`으로 비활성화 후 신규 입력
|
||||
334
README.md
334
README.md
@@ -0,0 +1,334 @@
|
||||
# web-backend
|
||||
|
||||
Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
|
||||
로또 분석, 주식 포트폴리오, 여행 앨범, 블로그, 투두리스트를 하나의 서비스로 운영한다.
|
||||
|
||||
---
|
||||
|
||||
## 서비스 구성
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ lotto-frontend (Nginx:8080) │
|
||||
│ ├── 정적 SPA 서빙 (React + Vite) │
|
||||
│ └── API 리버스 프록시 │
|
||||
│ ├── /api/ → lotto-backend:8000 │
|
||||
│ ├── /api/stock/ → stock-lab:8000 │
|
||||
│ ├── /api/trade/ → stock-lab:8000 │
|
||||
│ ├── /api/portfolio → stock-lab:8000 │
|
||||
│ ├── /api/travel/ → travel-proxy:8000 │
|
||||
│ └── /webhook → deployer:9000 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 컨테이너 | 포트 | 역할 |
|
||||
|---------|------|------|
|
||||
| `lotto-backend` | 18000 | 로또·블로그·투두 API |
|
||||
| `stock-lab` | 18500 | 주식 뉴스·포트폴리오·자산 추적 |
|
||||
| `travel-proxy` | 19000 | 여행 사진 API + 썸네일 생성 |
|
||||
| `lotto-frontend` | 8080 | SPA 서빙 + 리버스 프록시 |
|
||||
| `webpage-deployer` | 19010 | Gitea Webhook → 자동 배포 |
|
||||
|
||||
---
|
||||
|
||||
## 디렉토리 구조
|
||||
|
||||
```
|
||||
web-backend/
|
||||
├── backend/ # lotto-backend 서비스 (Python/FastAPI)
|
||||
│ ├── app/
|
||||
│ │ ├── main.py # 라우터, 스케줄러
|
||||
│ │ ├── db.py # SQLite CRUD (7개 테이블)
|
||||
│ │ ├── generator.py # 몬테카를로 시뮬레이션 엔진
|
||||
│ │ ├── analyzer.py # 5가지 통계 분석
|
||||
│ │ ├── checker.py # 당첨 결과 채점
|
||||
│ │ ├── collector.py # 로또 데이터 수집
|
||||
│ │ ├── recommender.py # 추천 알고리즘
|
||||
│ │ └── utils.py # 메트릭 계산
|
||||
│ └── Dockerfile
|
||||
│
|
||||
├── stock-lab/ # stock-lab 서비스 (Python/FastAPI)
|
||||
│ ├── app/
|
||||
│ │ ├── main.py # 라우터, 스케줄러
|
||||
│ │ ├── db.py # SQLite CRUD (4개 테이블)
|
||||
│ │ ├── scraper.py # 네이버 금융 뉴스 크롤링
|
||||
│ │ ├── price_fetcher.py # 현재가 조회 (3분 캐시)
|
||||
│ │ └── holidays.json # 한국 주식시장 휴장일
|
||||
│ └── Dockerfile
|
||||
│
|
||||
├── travel-proxy/ # travel-proxy 서비스 (Python/FastAPI)
|
||||
│ ├── app/
|
||||
│ │ └── main.py # 사진 API, 썸네일 생성 (Pillow)
|
||||
│ └── Dockerfile
|
||||
│
|
||||
├── deployer/ # Gitea Webhook 수신 → 자동 배포
|
||||
│ ├── app.py # HMAC SHA256 검증 + 배포 트리거
|
||||
│ └── Dockerfile
|
||||
│
|
||||
├── nginx/
|
||||
│ └── default.conf # 리버스 프록시 + SPA + 캐시
|
||||
│
|
||||
├── scripts/
|
||||
│ ├── deploy.sh # 운영 배포 (git pull → rsync → compose up)
|
||||
│ ├── deploy-nas.sh # rsync 전용 스크립트
|
||||
│ └── healthcheck.sh # 전체 서비스 헬스 체크
|
||||
│
|
||||
├── docker-compose.yml
|
||||
├── .env.example
|
||||
└── CLAUDE.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 빠른 시작 (로컬 개발)
|
||||
|
||||
```bash
|
||||
# 1. 환경변수 설정
|
||||
cp .env.example .env
|
||||
|
||||
# 2. 컨테이너 실행 (.env 기본값으로 즉시 실행 가능)
|
||||
docker compose up -d
|
||||
|
||||
# 3. 확인
|
||||
curl http://localhost:18000/health
|
||||
curl http://localhost:18500/health
|
||||
```
|
||||
|
||||
| 서비스 | 로컬 URL |
|
||||
|--------|----------|
|
||||
| Frontend + API | http://localhost:8080 |
|
||||
| lotto-backend | http://localhost:18000 |
|
||||
| stock-lab | http://localhost:18500 |
|
||||
| travel-proxy | http://localhost:19000 |
|
||||
|
||||
---
|
||||
|
||||
## API 목록
|
||||
|
||||
### lotto-backend (`/api/`)
|
||||
|
||||
#### 로또
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/lotto/latest` | 최신 당첨번호 |
|
||||
| GET | `/api/lotto/{drw_no}` | 특정 회차 |
|
||||
| GET | `/api/lotto/stats` | 번호 빈도 통계 |
|
||||
| GET | `/api/lotto/analysis` | 5가지 통계 분석 리포트 |
|
||||
| GET | `/api/lotto/best` | 시뮬레이션 최적 번호 (기본 20쌍) |
|
||||
| GET | `/api/lotto/simulation` | 시뮬레이션 상세 결과 |
|
||||
| GET | `/api/lotto/recommend` | 통계 기반 추천 |
|
||||
| GET | `/api/lotto/recommend/heatmap` | 히트맵 기반 추천 |
|
||||
| GET | `/api/lotto/recommend/batch` | 배치 추천 |
|
||||
| POST | `/api/admin/simulate` | 시뮬레이션 수동 실행 |
|
||||
| POST | `/api/admin/sync_latest` | 당첨번호 수동 동기화 |
|
||||
|
||||
#### 추천 이력
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/history` | 목록 (limit, offset, favorite, tag, sort) |
|
||||
| PATCH | `/api/history/{id}` | 즐겨찾기·메모·태그 수정 |
|
||||
| DELETE | `/api/history/{id}` | 삭제 |
|
||||
|
||||
#### 투두리스트
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/todos` | 전체 목록 |
|
||||
| POST | `/api/todos` | 생성 (status: todo\|in_progress\|done) |
|
||||
| PUT | `/api/todos/{id}` | 수정 |
|
||||
| DELETE | `/api/todos/done` | 완료 항목 일괄 삭제 |
|
||||
| DELETE | `/api/todos/{id}` | 개별 삭제 |
|
||||
|
||||
> ⚠️ `/done` 라우트는 반드시 `/{id}` 보다 먼저 등록해야 함
|
||||
|
||||
#### 블로그
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/blog/posts` | 글 목록 (`{"posts": [...]}`, date DESC) |
|
||||
| POST | `/api/blog/posts` | 글 생성 (date 미입력 시 오늘 날짜) |
|
||||
| PUT | `/api/blog/posts/{id}` | 글 수정 |
|
||||
| DELETE | `/api/blog/posts/{id}` | 글 삭제 |
|
||||
|
||||
블로그 포스트 구조: `{ id, title, tags[], body, date, excerpt, created_at, updated_at }`
|
||||
|
||||
---
|
||||
|
||||
### stock-lab (`/api/stock/`, `/api/trade/`, `/api/portfolio`)
|
||||
|
||||
#### 뉴스 & 지표
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/stock/news` | 뉴스 목록 (limit, category) |
|
||||
| GET | `/api/stock/indices` | 주요 지표 (KOSPI 등) |
|
||||
| POST | `/api/stock/scrap` | 뉴스 수동 스크랩 |
|
||||
|
||||
#### 실계좌 (Windows AI 서버 프록시)
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/trade/balance` | 실계좌 잔고 조회 |
|
||||
| POST | `/api/trade/order` | 주문 (BUY\|SELL, price=0이면 시장가) |
|
||||
|
||||
#### 포트폴리오
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/portfolio` | 전체 조회 (현재가·손익·예수금 포함) |
|
||||
| POST | `/api/portfolio` | 종목 추가 |
|
||||
| PUT | `/api/portfolio/{id}` | 종목 수정 |
|
||||
| DELETE | `/api/portfolio/{id}` | 종목 삭제 |
|
||||
| GET | `/api/portfolio/cash` | 예수금 전체 조회 |
|
||||
| PUT | `/api/portfolio/cash` | 예수금 upsert |
|
||||
| DELETE | `/api/portfolio/cash/{broker}` | 예수금 삭제 |
|
||||
| POST | `/api/portfolio/snapshot` | 총 자산 스냅샷 수동 저장 |
|
||||
| GET | `/api/portfolio/snapshot/history` | 자산 변화 이력 (days=0: 전체) |
|
||||
|
||||
---
|
||||
|
||||
### travel-proxy (`/api/travel/`)
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/travel/regions` | 지역 GeoJSON |
|
||||
| GET | `/api/travel/photos` | 사진 목록 (region, page, size) |
|
||||
| POST | `/api/travel/reload` | 캐시 초기화 |
|
||||
|
||||
- 썸네일: `/media/travel/.thumb/{album}/{file}` (nginx 직접 서빙, 30일 캐시)
|
||||
- 원본: `/media/travel/{album}/{file}` (nginx 직접 서빙, 7일 캐시)
|
||||
|
||||
---
|
||||
|
||||
## 핵심 로직
|
||||
|
||||
### 몬테카를로 시뮬레이션 (lotto-backend)
|
||||
|
||||
```
|
||||
역대 당첨번호 분석 → 번호별 가중치 산출
|
||||
→ 가중 확률 샘플링으로 후보 20,000개 생성
|
||||
→ 5가지 기법으로 각 조합 점수화
|
||||
→ 상위 100개 DB 저장 → best_picks 20개 교체
|
||||
```
|
||||
|
||||
**5가지 채점 기법:**
|
||||
|
||||
| 기법 | 가중치 | 내용 |
|
||||
|------|--------|------|
|
||||
| 빈도 Z-score | 25% | 번호 출현 빈도의 표준편차 |
|
||||
| 조합 지문 | 30% | 합계 정규분포 + 홀짝 비율 + 구간분포 |
|
||||
| 갭 분석 | 20% | 마지막 출현 이후 경과 회차 |
|
||||
| 공동 출현 | 15% | 번호 쌍 동시 출현 빈도 |
|
||||
| 다양성 | 10% | 연속번호·범위·구간 커버리지 |
|
||||
|
||||
**스케줄:** 매일 0, 4, 8, 12, 16, 20시 (하루 6회, 각 5분)
|
||||
|
||||
### 총 자산 스냅샷 (stock-lab)
|
||||
|
||||
```
|
||||
평일 15:40 자동 실행 → holidays.json으로 공휴일 스킵
|
||||
→ 포트폴리오 현재가 조회 → total_eval
|
||||
→ 예수금 합계 → total_cash
|
||||
→ asset_snapshots upsert (date UNIQUE, 같은 날 중복 시 덮어씀)
|
||||
```
|
||||
|
||||
### 현재가 조회 (stock-lab)
|
||||
|
||||
- 네이버 모바일 API 우선 (`m.stock.naver.com/api/stock/{ticker}/basic`)
|
||||
- 실패 시 네이버 금융 HTML 파싱 폴백
|
||||
- 3분 TTL 메모리 캐시
|
||||
|
||||
### 여행 사진 썸네일 (travel-proxy)
|
||||
|
||||
- 480×480 리사이징 (Pillow), 확장자 유지 (JPEG/PNG/WEBP)
|
||||
- 온디맨드 생성 후 `/data/thumbs/` 영구 캐시
|
||||
- 원자성 보장: tmp 파일 작성 후 rename
|
||||
|
||||
---
|
||||
|
||||
## 자동 배포
|
||||
|
||||
```
|
||||
git push → Gitea → X-Gitea-Signature (HMAC SHA256)
|
||||
→ deployer:9000/webhook (서명 검증, compare_digest 사용)
|
||||
→ BackgroundTask: scripts/deploy.sh (10분 타임아웃)
|
||||
1. git pull
|
||||
2. .releases/{timestamp}/ 백업
|
||||
3. rsync (repo → runtime)
|
||||
4. docker compose up -d --build
|
||||
5. chown PUID:PGID
|
||||
```
|
||||
|
||||
> 프론트엔드는 **자동 배포 안 됨** — 로컬 빌드 후 NAS에 수동 업로드
|
||||
|
||||
---
|
||||
|
||||
## 데이터베이스
|
||||
|
||||
### lotto.db (`/app/data/lotto.db`)
|
||||
|
||||
| 테이블 | 설명 |
|
||||
|--------|------|
|
||||
| `draws` | 로또 당첨번호 |
|
||||
| `recommendations` | 추천 이력 (즐겨찾기·태그·채점 포함) |
|
||||
| `simulation_runs` | 시뮬레이션 실행 기록 |
|
||||
| `simulation_candidates` | 시뮬레이션 후보 (점수 5종) |
|
||||
| `best_picks` | 현재 활성 최적 번호 20개 (is_active 플래그) |
|
||||
| `todos` | 투두리스트 (UUID PK) |
|
||||
| `blog_posts` | 블로그 글 (tags: JSON 배열) |
|
||||
|
||||
### stock.db (`/app/data/stock.db`)
|
||||
|
||||
| 테이블 | 설명 |
|
||||
|--------|------|
|
||||
| `articles` | 뉴스 기사 (hash UNIQUE, category: domestic\|overseas) |
|
||||
| `portfolio` | 보유 종목 (broker, ticker, quantity, avg_price) |
|
||||
| `broker_cash` | 증권사별 예수금 (broker UNIQUE) |
|
||||
| `asset_snapshots` | 일별 총 자산 스냅샷 (date UNIQUE) |
|
||||
|
||||
---
|
||||
|
||||
## 환경변수
|
||||
|
||||
```env
|
||||
# 경로 설정
|
||||
RUNTIME_PATH=.
|
||||
REPO_PATH=.
|
||||
FRONTEND_PATH=./frontend/dist
|
||||
PHOTO_PATH=./mock_data/photos
|
||||
|
||||
# NAS 파일 권한
|
||||
PUID=1000
|
||||
PGID=1000
|
||||
|
||||
# 외부 서비스
|
||||
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
|
||||
WEBHOOK_SECRET=your_secret_here
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 인프라
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|----|
|
||||
| 장비 | Synology NAS (Intel Celeron J4025, 18GB RAM) |
|
||||
| Docker | Synology Container Manager |
|
||||
| Git 서버 | Gitea (NAS 내부 self-hosted) |
|
||||
| AI 서버 | Windows PC (192.168.45.59:8000) — RTX 3070 Ti + Ollama |
|
||||
| Python | 3.12 (`slim` / `alpine` 기반 이미지) |
|
||||
| DB | SQLite (볼륨 마운트로 영속 저장) |
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
- **`.env` 파일** — 절대 커밋 금지. `.env.example`만 레포에 포함
|
||||
- **Nginx trailing slash** — `/api/portfolio`는 두 location 블록으로 처리 (trailing slash 유무 모두 매칭)
|
||||
- **라우트 순서** — `/api/todos/done`은 `/api/todos/{id}` 보다 먼저 등록 필수
|
||||
- **캐시 전략** — `index.html`: no-store / `assets/`: 1년 immutable
|
||||
- **PUID/PGID** — travel-proxy는 NAS 파일 권한을 위해 환경변수 주입 필수
|
||||
- **공휴일 목록** — `stock-lab/app/holidays.json` 매년 수동 갱신 필요 (KRX 기준)
|
||||
- **Windows AI 서버** — IP 192.168.45.59 (공유기 DHCP 고정 예약)
|
||||
|
||||
10
agent-office/Dockerfile
Normal file
10
agent-office/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM python:3.12-alpine
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
1
agent-office/app/__init__.py
Normal file
1
agent-office/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# agent-office/app/__init__.py
|
||||
17
agent-office/app/agents/__init__.py
Normal file
17
agent-office/app/agents/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from .stock import StockAgent
|
||||
from .music import MusicAgent
|
||||
|
||||
AGENT_REGISTRY = {}
|
||||
|
||||
def init_agents():
|
||||
AGENT_REGISTRY["stock"] = StockAgent()
|
||||
AGENT_REGISTRY["music"] = MusicAgent()
|
||||
|
||||
def get_agent(agent_id: str):
|
||||
return AGENT_REGISTRY.get(agent_id)
|
||||
|
||||
def get_all_agent_states() -> list:
|
||||
return [
|
||||
{"agent_id": aid, "state": agent.state, "detail": agent.state_detail}
|
||||
for aid, agent in AGENT_REGISTRY.items()
|
||||
]
|
||||
72
agent-office/app/agents/base.py
Normal file
72
agent-office/app/agents/base.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import asyncio
|
||||
import random
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from ..config import IDLE_BREAK_THRESHOLD, BREAK_DURATION_MIN, BREAK_DURATION_MAX
|
||||
from ..db import add_log
|
||||
|
||||
VALID_STATES = ("idle", "working", "waiting", "reporting", "break")
|
||||
|
||||
class BaseAgent:
|
||||
agent_id: str = ""
|
||||
display_name: str = ""
|
||||
state: str = "idle"
|
||||
state_detail: str = ""
|
||||
_idle_since: float = 0.0
|
||||
_break_until: float = 0.0
|
||||
_ws_manager = None
|
||||
|
||||
def __init__(self):
|
||||
self._idle_since = time.time()
|
||||
|
||||
def set_ws_manager(self, manager):
|
||||
self._ws_manager = manager
|
||||
|
||||
async def transition(self, new_state: str, detail: str = "", task_id: str = None) -> None:
|
||||
if new_state not in VALID_STATES:
|
||||
return
|
||||
old = self.state
|
||||
self.state = new_state
|
||||
self.state_detail = detail
|
||||
|
||||
if new_state == "idle":
|
||||
self._idle_since = time.time()
|
||||
elif new_state == "break":
|
||||
duration = random.randint(BREAK_DURATION_MIN, BREAK_DURATION_MAX)
|
||||
self._break_until = time.time() + duration
|
||||
|
||||
add_log(self.agent_id, f"State: {old} -> {new_state} ({detail})")
|
||||
|
||||
if self._ws_manager:
|
||||
await self._ws_manager.send_agent_state(self.agent_id, new_state, detail, task_id)
|
||||
if new_state == "break":
|
||||
await self._ws_manager.send_agent_move(self.agent_id, "break_room")
|
||||
elif old == "break" and new_state == "idle":
|
||||
await self._ws_manager.send_agent_move(self.agent_id, "desk")
|
||||
|
||||
async def check_idle_break(self) -> None:
|
||||
now = time.time()
|
||||
if self.state == "idle" and (now - self._idle_since) > IDLE_BREAK_THRESHOLD:
|
||||
if random.random() < 0.5:
|
||||
break_type = random.choice(["커피 타임", "잠깐 산책", "졸고 있음"])
|
||||
await self.transition("break", break_type)
|
||||
elif self.state == "break" and now > self._break_until:
|
||||
await self.transition("idle", "휴식 완료")
|
||||
|
||||
async def on_schedule(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def on_command(self, command: str, params: dict) -> dict:
|
||||
raise NotImplementedError
|
||||
|
||||
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_status(self) -> dict:
|
||||
return {
|
||||
"agent_id": self.agent_id,
|
||||
"display_name": self.display_name,
|
||||
"state": self.state,
|
||||
"detail": self.state_detail,
|
||||
}
|
||||
124
agent-office/app/agents/music.py
Normal file
124
agent-office/app/agents/music.py
Normal file
@@ -0,0 +1,124 @@
|
||||
import asyncio
|
||||
from .base import BaseAgent
|
||||
from ..db import create_task, update_task_status, approve_task, reject_task, add_log
|
||||
from .. import service_proxy
|
||||
from .. import telegram_bot
|
||||
|
||||
class MusicAgent(BaseAgent):
|
||||
agent_id = "music"
|
||||
display_name = "음악 프로듀서"
|
||||
|
||||
async def on_schedule(self) -> None:
|
||||
pass
|
||||
|
||||
async def on_command(self, command: str, params: dict) -> dict:
|
||||
if command == "compose":
|
||||
prompt = params.get("prompt", "")
|
||||
style = params.get("style", "")
|
||||
model = params.get("model", "V4")
|
||||
instrumental = params.get("instrumental", False)
|
||||
|
||||
if not prompt:
|
||||
return {"ok": False, "message": "프롬프트를 입력해주세요"}
|
||||
|
||||
task_id = create_task(self.agent_id, "compose", {
|
||||
"prompt": prompt, "style": style,
|
||||
"model": model, "instrumental": instrumental,
|
||||
}, requires_approval=True)
|
||||
|
||||
await self.transition("waiting", "프롬프트 승인 대기", task_id)
|
||||
|
||||
detail = f"프롬프트: {prompt}"
|
||||
if style:
|
||||
detail += f"\n스타일: {style}"
|
||||
detail += f"\n모델: {model}"
|
||||
|
||||
await telegram_bot.send_approval_request(
|
||||
self.agent_id, task_id,
|
||||
"🎵 [음악 에이전트] 작곡 요청", detail,
|
||||
)
|
||||
|
||||
return {"ok": True, "task_id": task_id, "message": "승인 대기 중"}
|
||||
|
||||
if command == "credits":
|
||||
credits = await service_proxy.get_music_credits()
|
||||
return {"ok": True, "credits": credits}
|
||||
|
||||
return {"ok": False, "message": f"Unknown command: {command}"}
|
||||
|
||||
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
|
||||
if not approved:
|
||||
reject_task(task_id)
|
||||
await self.transition("idle", "작곡 거절됨")
|
||||
await telegram_bot.send_task_result(
|
||||
self.agent_id, "🎵 [음악 에이전트] 작곡 취소",
|
||||
"사용자가 거절했습니다.",
|
||||
)
|
||||
return
|
||||
|
||||
from ..db import get_task
|
||||
task = get_task(task_id)
|
||||
if not task:
|
||||
return
|
||||
|
||||
approve_task(task_id, via="telegram")
|
||||
await self.transition("working", "작곡 중...", task_id)
|
||||
asyncio.create_task(self._poll_composition(task_id, task))
|
||||
|
||||
async def _poll_composition(self, task_id: str, task: dict) -> None:
|
||||
try:
|
||||
input_data = task["input_data"]
|
||||
payload = {
|
||||
"provider": "suno",
|
||||
"model": input_data.get("model", "V4"),
|
||||
"prompt": input_data.get("prompt", ""),
|
||||
"style": input_data.get("style", ""),
|
||||
"instrumental": input_data.get("instrumental", False),
|
||||
"custom_mode": True,
|
||||
}
|
||||
|
||||
result = await service_proxy.generate_music(payload)
|
||||
music_task_id = result.get("task_id")
|
||||
|
||||
if not music_task_id:
|
||||
raise Exception("music-lab did not return task_id")
|
||||
|
||||
for _ in range(60):
|
||||
await asyncio.sleep(5)
|
||||
status = await service_proxy.get_music_status(music_task_id)
|
||||
state = status.get("status", "")
|
||||
|
||||
if state == "succeeded":
|
||||
tracks = status.get("tracks", [])
|
||||
update_task_status(task_id, "succeeded", {
|
||||
"music_task_id": music_task_id,
|
||||
"tracks": tracks,
|
||||
})
|
||||
await self.transition("reporting", "작곡 완료!")
|
||||
|
||||
track_info = ""
|
||||
for t in tracks:
|
||||
title = t.get("title", "Untitled")
|
||||
url = t.get("audio_url", "")
|
||||
track_info += f"🎶 {title}\n{url}\n"
|
||||
|
||||
await telegram_bot.send_task_result(
|
||||
self.agent_id, "🎵 [음악 에이전트] 작곡 완료",
|
||||
track_info or "트랙 생성 완료",
|
||||
)
|
||||
await self.transition("idle", "작곡 완료")
|
||||
return
|
||||
|
||||
if state == "failed":
|
||||
raise Exception(status.get("message", "Generation failed"))
|
||||
|
||||
raise Exception("Timeout: 5분 초과")
|
||||
|
||||
except Exception as e:
|
||||
add_log(self.agent_id, f"Compose failed: {e}", "error", task_id)
|
||||
update_task_status(task_id, "failed", {"error": str(e)})
|
||||
await self.transition("idle", f"오류: {e}")
|
||||
await telegram_bot.send_task_result(
|
||||
self.agent_id, "🎵 [음악 에이전트] 작곡 실패",
|
||||
f"오류: {e}",
|
||||
)
|
||||
99
agent-office/app/agents/stock.py
Normal file
99
agent-office/app/agents/stock.py
Normal file
@@ -0,0 +1,99 @@
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
|
||||
from .base import BaseAgent
|
||||
from ..db import create_task, update_task_status, get_agent_config, add_log
|
||||
from .. import service_proxy
|
||||
|
||||
class StockAgent(BaseAgent):
|
||||
agent_id = "stock"
|
||||
display_name = "주식 트레이더"
|
||||
|
||||
async def on_schedule(self) -> None:
|
||||
if self.state not in ("idle", "break"):
|
||||
return
|
||||
|
||||
task_id = create_task(self.agent_id, "news_summary", {"limit": 15})
|
||||
await self.transition("working", "뉴스 수집 중...", task_id)
|
||||
|
||||
try:
|
||||
news = await service_proxy.fetch_stock_news(limit=15)
|
||||
indices = await service_proxy.fetch_stock_indices()
|
||||
|
||||
summary = self._format_news_summary(news, indices)
|
||||
|
||||
update_task_status(task_id, "succeeded", {
|
||||
"summary": summary,
|
||||
"news_count": len(news) if isinstance(news, list) else 0,
|
||||
})
|
||||
|
||||
await self.transition("reporting", "뉴스 요약 전송 중...")
|
||||
|
||||
from ..telegram_bot import send_stock_summary
|
||||
await send_stock_summary(summary)
|
||||
|
||||
await self.transition("idle", "뉴스 요약 완료")
|
||||
|
||||
except Exception as e:
|
||||
add_log(self.agent_id, f"News summary failed: {e}", "error", task_id)
|
||||
update_task_status(task_id, "failed", {"error": str(e)})
|
||||
await self.transition("idle", f"오류: {e}")
|
||||
|
||||
async def on_command(self, command: str, params: dict) -> dict:
|
||||
if command == "fetch_news":
|
||||
await self.on_schedule()
|
||||
return {"ok": True, "message": "뉴스 수집 시작"}
|
||||
|
||||
if command == "add_alert":
|
||||
symbol = params.get("symbol")
|
||||
target_price = params.get("target_price")
|
||||
if not symbol or target_price is None:
|
||||
return {"ok": False, "message": "symbol과 target_price는 필수입니다"}
|
||||
config = get_agent_config(self.agent_id)
|
||||
alerts = config["custom_config"].get("alerts", [])
|
||||
alerts.append({
|
||||
"symbol": symbol,
|
||||
"name": params.get("name", symbol),
|
||||
"target_price": target_price,
|
||||
"direction": params.get("direction", "above"),
|
||||
})
|
||||
from ..db import update_agent_config
|
||||
update_agent_config(self.agent_id, custom_config={**config["custom_config"], "alerts": alerts})
|
||||
return {"ok": True, "message": f"알람 추가: {params['symbol']}"}
|
||||
|
||||
if command == "list_alerts":
|
||||
config = get_agent_config(self.agent_id)
|
||||
alerts = config["custom_config"].get("alerts", [])
|
||||
return {"ok": True, "alerts": alerts}
|
||||
|
||||
return {"ok": False, "message": f"Unknown command: {command}"}
|
||||
|
||||
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
|
||||
pass
|
||||
|
||||
def _format_news_summary(self, news, indices) -> str:
|
||||
lines = ["📈 [주식 에이전트] 아침 뉴스 요약", "━" * 20]
|
||||
|
||||
if isinstance(news, list):
|
||||
for item in news[:10]:
|
||||
title = item.get("title", "")
|
||||
if title:
|
||||
lines.append(f"• {title}")
|
||||
elif isinstance(news, dict) and "articles" in news:
|
||||
for item in news["articles"][:10]:
|
||||
title = item.get("title", "")
|
||||
if title:
|
||||
lines.append(f"• {title}")
|
||||
|
||||
if indices:
|
||||
lines.append("")
|
||||
lines.append("📊 주요 지수")
|
||||
if isinstance(indices, dict):
|
||||
for key, val in indices.items():
|
||||
if isinstance(val, dict):
|
||||
name = val.get("name", key)
|
||||
price = val.get("price", "")
|
||||
change = val.get("change", "")
|
||||
lines.append(f"{name}: {price} ({change})")
|
||||
|
||||
return "\n".join(lines)
|
||||
23
agent-office/app/config.py
Normal file
23
agent-office/app/config.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import os
|
||||
|
||||
# Service URLs (Docker internal network)
|
||||
STOCK_LAB_URL = os.getenv("STOCK_LAB_URL", "http://localhost:18500")
|
||||
MUSIC_LAB_URL = os.getenv("MUSIC_LAB_URL", "http://localhost:18600")
|
||||
|
||||
# Telegram
|
||||
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
|
||||
TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "")
|
||||
TELEGRAM_WEBHOOK_URL = os.getenv("TELEGRAM_WEBHOOK_URL", "")
|
||||
|
||||
# Database
|
||||
DB_PATH = os.getenv("AGENT_OFFICE_DB_PATH", "/app/data/agent_office.db")
|
||||
|
||||
# CORS
|
||||
CORS_ALLOW_ORIGINS = os.getenv(
|
||||
"CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080"
|
||||
)
|
||||
|
||||
# Idle break threshold (seconds)
|
||||
IDLE_BREAK_THRESHOLD = int(os.getenv("IDLE_BREAK_THRESHOLD", "300")) # 5 min
|
||||
BREAK_DURATION_MIN = int(os.getenv("BREAK_DURATION_MIN", "60")) # 1 min
|
||||
BREAK_DURATION_MAX = int(os.getenv("BREAK_DURATION_MAX", "180")) # 3 min
|
||||
261
agent-office/app/db.py
Normal file
261
agent-office/app/db.py
Normal file
@@ -0,0 +1,261 @@
|
||||
import os
|
||||
import json
|
||||
import sqlite3
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .config import DB_PATH
|
||||
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH, timeout=10)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS agent_config (
|
||||
agent_id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
schedule_config TEXT NOT NULL DEFAULT '{}',
|
||||
custom_config TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS agent_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
agent_id TEXT NOT NULL,
|
||||
task_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
input_data TEXT NOT NULL DEFAULT '{}',
|
||||
result_data TEXT,
|
||||
requires_approval INTEGER NOT NULL DEFAULT 0,
|
||||
approved_at TEXT,
|
||||
approved_via TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
completed_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_agent
|
||||
ON agent_tasks(agent_id, created_at DESC)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS agent_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_id TEXT NOT NULL,
|
||||
task_id TEXT,
|
||||
level TEXT NOT NULL DEFAULT 'info',
|
||||
message TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS telegram_state (
|
||||
callback_id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
action TEXT,
|
||||
responded INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
# Seed default agent configs
|
||||
for agent_id, name in [("stock", "주식 트레이더"), ("music", "음악 프로듀서")]:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO agent_config(agent_id, display_name) VALUES(?,?)",
|
||||
(agent_id, name),
|
||||
)
|
||||
|
||||
|
||||
# --- agent_config CRUD ---
|
||||
|
||||
def get_all_agents() -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM agent_config ORDER BY agent_id").fetchall()
|
||||
return [_config_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_agent_config(agent_id: str) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
r = conn.execute("SELECT * FROM agent_config WHERE agent_id=?", (agent_id,)).fetchone()
|
||||
return _config_to_dict(r) if r else None
|
||||
|
||||
|
||||
def update_agent_config(agent_id: str, **kwargs) -> None:
|
||||
sets, vals = [], []
|
||||
for k in ("enabled", "schedule_config", "custom_config"):
|
||||
if k in kwargs and kwargs[k] is not None:
|
||||
if k in ("schedule_config", "custom_config"):
|
||||
sets.append(f"{k}=?")
|
||||
vals.append(json.dumps(kwargs[k]))
|
||||
else:
|
||||
sets.append(f"{k}=?")
|
||||
vals.append(kwargs[k])
|
||||
if not sets:
|
||||
return
|
||||
sets.append("updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')")
|
||||
vals.append(agent_id)
|
||||
with _conn() as conn:
|
||||
conn.execute(f"UPDATE agent_config SET {','.join(sets)} WHERE agent_id=?", vals)
|
||||
|
||||
|
||||
def _config_to_dict(r) -> Dict[str, Any]:
|
||||
return {
|
||||
"agent_id": r["agent_id"],
|
||||
"display_name": r["display_name"],
|
||||
"enabled": bool(r["enabled"]),
|
||||
"schedule_config": json.loads(r["schedule_config"]),
|
||||
"custom_config": json.loads(r["custom_config"]),
|
||||
"created_at": r["created_at"],
|
||||
"updated_at": r["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
# --- agent_tasks CRUD ---
|
||||
|
||||
def create_task(agent_id: str, task_type: str, input_data: dict, requires_approval: bool = False) -> str:
|
||||
task_id = str(uuid.uuid4())
|
||||
status = "pending" if requires_approval else "working"
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO agent_tasks(id,agent_id,task_type,status,input_data,requires_approval) VALUES(?,?,?,?,?,?)",
|
||||
(task_id, agent_id, task_type, status, json.dumps(input_data), int(requires_approval)),
|
||||
)
|
||||
return task_id
|
||||
|
||||
|
||||
def update_task_status(task_id: str, status: str, result_data: dict = None) -> None:
|
||||
with _conn() as conn:
|
||||
if result_data is not None:
|
||||
conn.execute(
|
||||
"UPDATE agent_tasks SET status=?, result_data=?, completed_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?",
|
||||
(status, json.dumps(result_data), task_id),
|
||||
)
|
||||
else:
|
||||
conn.execute("UPDATE agent_tasks SET status=? WHERE id=?", (status, task_id))
|
||||
|
||||
|
||||
def approve_task(task_id: str, via: str = "web") -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE agent_tasks SET status='approved', approved_at=strftime('%Y-%m-%dT%H:%M:%fZ','now'), approved_via=? WHERE id=?",
|
||||
(via, task_id),
|
||||
)
|
||||
|
||||
|
||||
def reject_task(task_id: str) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE agent_tasks SET status='rejected', completed_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?",
|
||||
(task_id,),
|
||||
)
|
||||
|
||||
|
||||
def get_task(task_id: str) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
r = conn.execute("SELECT * FROM agent_tasks WHERE id=?", (task_id,)).fetchone()
|
||||
return _task_to_dict(r) if r else None
|
||||
|
||||
|
||||
def get_agent_tasks(agent_id: str, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM agent_tasks WHERE agent_id=? ORDER BY created_at DESC LIMIT ?",
|
||||
(agent_id, limit),
|
||||
).fetchall()
|
||||
return [_task_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_pending_approvals() -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM agent_tasks WHERE status='pending' AND requires_approval=1 ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
return [_task_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def _task_to_dict(r) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r["id"],
|
||||
"agent_id": r["agent_id"],
|
||||
"task_type": r["task_type"],
|
||||
"status": r["status"],
|
||||
"input_data": json.loads(r["input_data"]) if r["input_data"] else {},
|
||||
"result_data": json.loads(r["result_data"]) if r["result_data"] else None,
|
||||
"requires_approval": bool(r["requires_approval"]),
|
||||
"approved_at": r["approved_at"],
|
||||
"approved_via": r["approved_via"],
|
||||
"created_at": r["created_at"],
|
||||
"completed_at": r["completed_at"],
|
||||
}
|
||||
|
||||
|
||||
# --- agent_logs ---
|
||||
|
||||
def add_log(agent_id: str, message: str, level: str = "info", task_id: str = None) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO agent_logs(agent_id,task_id,level,message) VALUES(?,?,?,?)",
|
||||
(agent_id, task_id, level, message),
|
||||
)
|
||||
|
||||
|
||||
def get_logs(agent_id: str, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM agent_logs WHERE agent_id=? ORDER BY created_at DESC LIMIT ?",
|
||||
(agent_id, limit),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"id": r["id"],
|
||||
"agent_id": r["agent_id"],
|
||||
"task_id": r["task_id"],
|
||||
"level": r["level"],
|
||||
"message": r["message"],
|
||||
"created_at": r["created_at"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
# --- telegram_state ---
|
||||
|
||||
def save_telegram_callback(callback_id: str, task_id: str, agent_id: str) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO telegram_state(callback_id,task_id,agent_id) VALUES(?,?,?)",
|
||||
(callback_id, task_id, agent_id),
|
||||
)
|
||||
|
||||
|
||||
def get_telegram_callback(callback_id: str) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
r = conn.execute(
|
||||
"SELECT * FROM telegram_state WHERE callback_id=? AND responded=0",
|
||||
(callback_id,),
|
||||
).fetchone()
|
||||
if not r:
|
||||
return None
|
||||
return {
|
||||
"callback_id": r["callback_id"],
|
||||
"task_id": r["task_id"],
|
||||
"agent_id": r["agent_id"],
|
||||
"responded": bool(r["responded"]),
|
||||
}
|
||||
|
||||
|
||||
def mark_telegram_responded(callback_id: str, action: str) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE telegram_state SET responded=1, action=? WHERE callback_id=?",
|
||||
(action, callback_id),
|
||||
)
|
||||
152
agent-office/app/main.py
Normal file
152
agent-office/app/main.py
Normal file
@@ -0,0 +1,152 @@
|
||||
import os
|
||||
import json
|
||||
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .config import CORS_ALLOW_ORIGINS
|
||||
from .db import init_db, get_all_agents, get_agent_config, update_agent_config, get_agent_tasks, get_pending_approvals, get_task, get_logs
|
||||
from .models import CommandRequest, ApprovalRequest, AgentConfigUpdate
|
||||
from .websocket_manager import ws_manager
|
||||
from .agents import init_agents, get_agent, get_all_agent_states, AGENT_REGISTRY
|
||||
from .scheduler import init_scheduler
|
||||
from . import telegram_bot
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
_cors_origins = CORS_ALLOW_ORIGINS.split(",")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[o.strip() for o in _cors_origins],
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type"],
|
||||
)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def on_startup():
|
||||
init_db()
|
||||
os.makedirs("/app/data", exist_ok=True)
|
||||
init_agents()
|
||||
for agent in AGENT_REGISTRY.values():
|
||||
agent.set_ws_manager(ws_manager)
|
||||
init_scheduler()
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True}
|
||||
|
||||
# --- WebSocket ---
|
||||
|
||||
@app.websocket("/api/agent-office/ws")
|
||||
async def websocket_endpoint(ws: WebSocket):
|
||||
await ws_manager.connect(ws)
|
||||
try:
|
||||
await ws.send_text(json.dumps({
|
||||
"type": "init",
|
||||
"agents": get_all_agent_states(),
|
||||
"pending": [t["id"] for t in get_pending_approvals()],
|
||||
}, ensure_ascii=False))
|
||||
while True:
|
||||
data = await ws.receive_text()
|
||||
try:
|
||||
msg = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
await _handle_ws_message(msg)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
await ws_manager.disconnect(ws)
|
||||
|
||||
async def _handle_ws_message(msg: dict):
|
||||
msg_type = msg.get("type")
|
||||
agent_id = msg.get("agent")
|
||||
agent = get_agent(agent_id) if agent_id else None
|
||||
|
||||
if msg_type == "command" and agent:
|
||||
action = msg.get("action", "")
|
||||
params = msg.get("params", {})
|
||||
result = await agent.on_command(action, params)
|
||||
await ws_manager.broadcast({"type": "command_result", "agent": agent_id, "result": result})
|
||||
|
||||
elif msg_type == "approval" and agent:
|
||||
task_id = msg.get("task_id")
|
||||
approved = msg.get("approved", False)
|
||||
if task_id:
|
||||
await agent.on_approval(task_id, approved)
|
||||
|
||||
elif msg_type == "query" and agent:
|
||||
status = await agent.get_status()
|
||||
await ws_manager.broadcast({"type": "agent_status", "agent": agent_id, "status": status})
|
||||
|
||||
# --- REST Endpoints ---
|
||||
|
||||
@app.get("/api/agent-office/agents")
|
||||
def list_agents():
|
||||
return {"agents": get_all_agents()}
|
||||
|
||||
@app.get("/api/agent-office/agents/{agent_id}")
|
||||
def agent_detail(agent_id: str):
|
||||
config = get_agent_config(agent_id)
|
||||
if not config:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
agent = get_agent(agent_id)
|
||||
state_info = {"state": agent.state, "detail": agent.state_detail} if agent else {}
|
||||
return {**config, **state_info}
|
||||
|
||||
@app.put("/api/agent-office/agents/{agent_id}")
|
||||
def update_agent(agent_id: str, body: AgentConfigUpdate):
|
||||
update_agent_config(agent_id, enabled=body.enabled,
|
||||
schedule_config=body.schedule_config,
|
||||
custom_config=body.custom_config)
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/agent-office/agents/{agent_id}/tasks")
|
||||
def agent_tasks(agent_id: str, limit: int = 20):
|
||||
return {"tasks": get_agent_tasks(agent_id, limit)}
|
||||
|
||||
@app.get("/api/agent-office/agents/{agent_id}/logs")
|
||||
def agent_logs(agent_id: str, limit: int = 50):
|
||||
return {"logs": get_logs(agent_id, limit)}
|
||||
|
||||
@app.get("/api/agent-office/tasks/pending")
|
||||
def pending_tasks():
|
||||
return {"tasks": get_pending_approvals()}
|
||||
|
||||
@app.get("/api/agent-office/tasks/{task_id}")
|
||||
def task_detail(task_id: str):
|
||||
task = get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return task
|
||||
|
||||
@app.post("/api/agent-office/command")
|
||||
async def send_command(body: CommandRequest):
|
||||
agent = get_agent(body.agent)
|
||||
if not agent:
|
||||
return {"error": f"Agent '{body.agent}' not found"}
|
||||
result = await agent.on_command(body.action, body.params or {})
|
||||
return result
|
||||
|
||||
@app.post("/api/agent-office/approve")
|
||||
async def approve(body: ApprovalRequest):
|
||||
agent = get_agent(body.agent)
|
||||
if not agent:
|
||||
return {"error": f"Agent '{body.agent}' not found"}
|
||||
await agent.on_approval(body.task_id, body.approved, body.feedback or "")
|
||||
return {"ok": True}
|
||||
|
||||
# --- Telegram Webhook ---
|
||||
|
||||
@app.post("/api/agent-office/telegram/webhook")
|
||||
async def telegram_webhook(data: dict):
|
||||
result = await telegram_bot.handle_webhook(data)
|
||||
if result:
|
||||
agent = get_agent(result["agent_id"])
|
||||
if agent:
|
||||
await agent.on_approval(result["task_id"], result["approved"])
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/agent-office/states")
|
||||
def all_states():
|
||||
return {"agents": get_all_agent_states()}
|
||||
35
agent-office/app/models.py
Normal file
35
agent-office/app/models.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class CommandRequest(BaseModel):
|
||||
agent: str
|
||||
action: str
|
||||
params: Optional[dict] = None
|
||||
|
||||
|
||||
class ApprovalRequest(BaseModel):
|
||||
agent: str
|
||||
task_id: str
|
||||
approved: bool
|
||||
feedback: Optional[str] = None
|
||||
|
||||
|
||||
class AgentConfigUpdate(BaseModel):
|
||||
enabled: Optional[bool] = None
|
||||
schedule_config: Optional[dict] = None
|
||||
custom_config: Optional[dict] = None
|
||||
|
||||
|
||||
class PriceAlertConfig(BaseModel):
|
||||
symbol: str
|
||||
name: str
|
||||
target_price: float
|
||||
direction: str # "above" or "below"
|
||||
|
||||
|
||||
class ComposeCommand(BaseModel):
|
||||
prompt: str
|
||||
style: Optional[str] = None
|
||||
model: Optional[str] = "V4"
|
||||
instrumental: Optional[bool] = False
|
||||
20
agent-office/app/scheduler.py
Normal file
20
agent-office/app/scheduler.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import asyncio
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
|
||||
from .agents import AGENT_REGISTRY
|
||||
|
||||
scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
|
||||
|
||||
async def _check_idle_breaks():
|
||||
for agent in AGENT_REGISTRY.values():
|
||||
await agent.check_idle_break()
|
||||
|
||||
async def _run_stock_schedule():
|
||||
agent = AGENT_REGISTRY.get("stock")
|
||||
if agent:
|
||||
await agent.on_schedule()
|
||||
|
||||
def init_scheduler():
|
||||
scheduler.add_job(_run_stock_schedule, "cron", hour=8, minute=0, id="stock_news")
|
||||
scheduler.add_job(_check_idle_breaks, "interval", seconds=60, id="idle_check")
|
||||
scheduler.start()
|
||||
34
agent-office/app/service_proxy.py
Normal file
34
agent-office/app/service_proxy.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import httpx
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .config import STOCK_LAB_URL, MUSIC_LAB_URL
|
||||
|
||||
_client = httpx.AsyncClient(timeout=30.0)
|
||||
|
||||
async def fetch_stock_news(limit: int = 10, category: str = None) -> List[Dict[str, Any]]:
|
||||
params = {"limit": limit}
|
||||
if category:
|
||||
params["category"] = category
|
||||
resp = await _client.get(f"{STOCK_LAB_URL}/api/stock/news", params=params)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def fetch_stock_indices() -> Dict[str, Any]:
|
||||
resp = await _client.get(f"{STOCK_LAB_URL}/api/stock/indices")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def generate_music(payload: dict) -> Dict[str, Any]:
|
||||
resp = await _client.post(f"{MUSIC_LAB_URL}/api/music/generate", json=payload)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def get_music_status(task_id: str) -> Dict[str, Any]:
|
||||
resp = await _client.get(f"{MUSIC_LAB_URL}/api/music/status/{task_id}")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def get_music_credits() -> Dict[str, Any]:
|
||||
resp = await _client.get(f"{MUSIC_LAB_URL}/api/music/credits")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
82
agent-office/app/telegram_bot.py
Normal file
82
agent-office/app/telegram_bot.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import json
|
||||
import uuid
|
||||
import httpx
|
||||
from typing import Optional
|
||||
|
||||
from .config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, TELEGRAM_WEBHOOK_URL
|
||||
from .db import save_telegram_callback, get_telegram_callback, mark_telegram_responded
|
||||
|
||||
_BASE = "https://api.telegram.org/bot"
|
||||
|
||||
def _enabled() -> bool:
|
||||
return bool(TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID)
|
||||
|
||||
async def _api(method: str, payload: dict) -> dict:
|
||||
if not _enabled():
|
||||
return {"ok": False, "description": "Telegram not configured"}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(f"{_BASE}{TELEGRAM_BOT_TOKEN}/{method}", json=payload)
|
||||
return resp.json()
|
||||
|
||||
async def send_message(text: str, reply_markup: dict = None) -> dict:
|
||||
payload = {
|
||||
"chat_id": TELEGRAM_CHAT_ID,
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
}
|
||||
if reply_markup:
|
||||
payload["reply_markup"] = reply_markup
|
||||
return await _api("sendMessage", payload)
|
||||
|
||||
async def send_stock_summary(summary: str) -> dict:
|
||||
return await send_message(summary)
|
||||
|
||||
async def send_approval_request(agent_id: str, task_id: str, title: str, detail: str) -> dict:
|
||||
approve_id = f"approve_{uuid.uuid4().hex[:8]}"
|
||||
reject_id = f"reject_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
save_telegram_callback(approve_id, task_id, agent_id)
|
||||
save_telegram_callback(reject_id, task_id, agent_id)
|
||||
|
||||
text = f"{title}\n{'━' * 20}\n{detail}"
|
||||
reply_markup = {
|
||||
"inline_keyboard": [[
|
||||
{"text": "✅ 승인", "callback_data": approve_id},
|
||||
{"text": "❌ 거절", "callback_data": reject_id},
|
||||
]]
|
||||
}
|
||||
return await send_message(text, reply_markup)
|
||||
|
||||
async def send_task_result(agent_id: str, title: str, result: str) -> dict:
|
||||
text = f"{title}\n{'━' * 20}\n{result}"
|
||||
return await send_message(text)
|
||||
|
||||
async def handle_webhook(data: dict) -> Optional[dict]:
|
||||
callback_query = data.get("callback_query")
|
||||
if not callback_query:
|
||||
return None
|
||||
|
||||
callback_id = callback_query.get("data", "")
|
||||
cb = get_telegram_callback(callback_id)
|
||||
if not cb:
|
||||
return None
|
||||
|
||||
action = "approve" if callback_id.startswith("approve_") else "reject"
|
||||
mark_telegram_responded(callback_id, action)
|
||||
|
||||
await _api("answerCallbackQuery", {
|
||||
"callback_query_id": callback_query["id"],
|
||||
"text": "승인됨 ✅" if action == "approve" else "거절됨 ❌",
|
||||
})
|
||||
|
||||
return {
|
||||
"task_id": cb["task_id"],
|
||||
"agent_id": cb["agent_id"],
|
||||
"action": action,
|
||||
"approved": action == "approve",
|
||||
}
|
||||
|
||||
async def setup_webhook() -> dict:
|
||||
if not _enabled() or not TELEGRAM_WEBHOOK_URL:
|
||||
return {"ok": False, "description": "Webhook URL not configured"}
|
||||
return await _api("setWebhook", {"url": TELEGRAM_WEBHOOK_URL})
|
||||
110
agent-office/app/test_db.py
Normal file
110
agent-office/app/test_db.py
Normal file
@@ -0,0 +1,110 @@
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Override DB_PATH before importing db
|
||||
_tmp = tempfile.mktemp(suffix=".db")
|
||||
os.environ["AGENT_OFFICE_DB_PATH"] = _tmp
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
from app.db import (
|
||||
init_db, get_all_agents, get_agent_config, update_agent_config,
|
||||
create_task, update_task_status, approve_task, get_task, get_agent_tasks,
|
||||
get_pending_approvals, add_log, get_logs,
|
||||
save_telegram_callback, get_telegram_callback, mark_telegram_responded,
|
||||
)
|
||||
|
||||
|
||||
def test_init_and_seed():
|
||||
init_db()
|
||||
agents = get_all_agents()
|
||||
assert len(agents) == 2, f"Expected 2 agents, got {len(agents)}"
|
||||
ids = {a["agent_id"] for a in agents}
|
||||
assert ids == {"stock", "music"}, f"Unexpected agent ids: {ids}"
|
||||
print(" [PASS] test_init_and_seed")
|
||||
|
||||
|
||||
def test_agent_config_update():
|
||||
init_db()
|
||||
update_agent_config("stock", custom_config={"watch": ["AAPL"]})
|
||||
cfg = get_agent_config("stock")
|
||||
assert cfg["custom_config"] == {"watch": ["AAPL"]}, f"Unexpected config: {cfg['custom_config']}"
|
||||
print(" [PASS] test_agent_config_update")
|
||||
|
||||
|
||||
def test_task_lifecycle():
|
||||
init_db()
|
||||
# Create task with approval
|
||||
tid = create_task("music", "compose", {"prompt": "test"}, requires_approval=True)
|
||||
task = get_task(tid)
|
||||
assert task["status"] == "pending", f"Expected pending, got {task['status']}"
|
||||
assert task["requires_approval"] is True
|
||||
|
||||
# Approve
|
||||
approve_task(tid, via="telegram")
|
||||
task = get_task(tid)
|
||||
assert task["status"] == "approved", f"Expected approved, got {task['status']}"
|
||||
assert task["approved_via"] == "telegram"
|
||||
|
||||
# Complete
|
||||
update_task_status(tid, "succeeded", {"url": "/media/music/test.mp3"})
|
||||
task = get_task(tid)
|
||||
assert task["status"] == "succeeded", f"Expected succeeded, got {task['status']}"
|
||||
assert task["result_data"]["url"] == "/media/music/test.mp3"
|
||||
print(" [PASS] test_task_lifecycle")
|
||||
|
||||
|
||||
def test_task_no_approval():
|
||||
init_db()
|
||||
tid = create_task("stock", "news_summary", {"limit": 10})
|
||||
task = get_task(tid)
|
||||
assert task["status"] == "working", f"Expected working, got {task['status']}"
|
||||
print(" [PASS] test_task_no_approval")
|
||||
|
||||
|
||||
def test_pending_approvals():
|
||||
init_db()
|
||||
create_task("music", "compose", {"prompt": "a"}, requires_approval=True)
|
||||
create_task("music", "compose", {"prompt": "b"}, requires_approval=True)
|
||||
create_task("stock", "news_summary", {})
|
||||
pending = get_pending_approvals()
|
||||
assert len(pending) == 2, f"Expected 2 pending, got {len(pending)}"
|
||||
print(" [PASS] test_pending_approvals")
|
||||
|
||||
|
||||
def test_logs():
|
||||
init_db()
|
||||
add_log("stock", "News fetched", "info", "task-1")
|
||||
add_log("stock", "API error", "error")
|
||||
logs = get_logs("stock")
|
||||
assert len(logs) == 2, f"Expected 2 logs, got {len(logs)}"
|
||||
assert logs[0]["level"] == "error", f"Expected error first (DESC), got {logs[0]['level']}"
|
||||
print(" [PASS] test_logs")
|
||||
|
||||
|
||||
def test_telegram_state():
|
||||
init_db()
|
||||
save_telegram_callback("cb-1", "task-1", "music")
|
||||
cb = get_telegram_callback("cb-1")
|
||||
assert cb["task_id"] == "task-1"
|
||||
mark_telegram_responded("cb-1", "approve")
|
||||
cb = get_telegram_callback("cb-1")
|
||||
assert cb is None, f"Expected None after responded=1, got {cb}"
|
||||
print(" [PASS] test_telegram_state")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_init_and_seed()
|
||||
test_agent_config_update()
|
||||
test_task_lifecycle()
|
||||
test_task_no_approval()
|
||||
test_pending_approvals()
|
||||
test_logs()
|
||||
test_telegram_state()
|
||||
print("All DB tests passed!")
|
||||
# Cleanup temp DB (best-effort; WAL mode may keep files open on Windows)
|
||||
for ext in ("", "-wal", "-shm"):
|
||||
try:
|
||||
os.unlink(_tmp + ext)
|
||||
except OSError:
|
||||
pass
|
||||
46
agent-office/app/websocket_manager.py
Normal file
46
agent-office/app/websocket_manager.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any, Dict, Set
|
||||
from fastapi import WebSocket
|
||||
|
||||
class WebSocketManager:
|
||||
def __init__(self):
|
||||
self._connections: Set[WebSocket] = set()
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def connect(self, ws: WebSocket) -> None:
|
||||
await ws.accept()
|
||||
async with self._lock:
|
||||
self._connections.add(ws)
|
||||
|
||||
async def disconnect(self, ws: WebSocket) -> None:
|
||||
async with self._lock:
|
||||
self._connections.discard(ws)
|
||||
|
||||
async def broadcast(self, message: Dict[str, Any]) -> None:
|
||||
payload = json.dumps(message, ensure_ascii=False)
|
||||
async with self._lock:
|
||||
dead = set()
|
||||
for ws in self._connections:
|
||||
try:
|
||||
await ws.send_text(payload)
|
||||
except Exception:
|
||||
dead.add(ws)
|
||||
self._connections -= dead
|
||||
|
||||
async def send_agent_state(self, agent_id: str, state: str, detail: str = "", task_id: str = None) -> None:
|
||||
msg = {"type": "agent_state", "agent": agent_id, "state": state, "detail": detail}
|
||||
if task_id:
|
||||
msg["task_id"] = task_id
|
||||
await self.broadcast(msg)
|
||||
|
||||
async def send_task_complete(self, agent_id: str, task_id: str, result: dict) -> None:
|
||||
await self.broadcast({
|
||||
"type": "task_complete", "agent": agent_id,
|
||||
"task_id": task_id, "result": result,
|
||||
})
|
||||
|
||||
async def send_agent_move(self, agent_id: str, target: str) -> None:
|
||||
await self.broadcast({"type": "agent_move", "agent": agent_id, "target": target})
|
||||
|
||||
ws_manager = WebSocketManager()
|
||||
5
agent-office/requirements.txt
Normal file
5
agent-office/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.30.6
|
||||
apscheduler==3.10.4
|
||||
websockets>=12.0
|
||||
httpx>=0.27
|
||||
6
backend/.dockerignore
Normal file
6
backend/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
.git
|
||||
__pycache__
|
||||
*.pyc
|
||||
.env
|
||||
.env.*
|
||||
*.md
|
||||
@@ -16,3 +16,7 @@ ENV PYTHONUNBUFFERED=1
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
ARG APP_VERSION=dev
|
||||
ENV APP_VERSION=$APP_VERSION
|
||||
LABEL org.opencontainers.image.version=$APP_VERSION
|
||||
|
||||
655
backend/app/analyzer.py
Normal file
655
backend/app/analyzer.py
Normal file
@@ -0,0 +1,655 @@
|
||||
"""
|
||||
통계 분석 엔진 - lotto-lab 고도화
|
||||
|
||||
[팀 회의 합의 기반 5가지 통계 기법]
|
||||
1. 빈도 Z-score 분석: 각 번호의 출현 빈도가 기댓값에서 얼마나 벗어났는지
|
||||
2. 조합 지문(Fingerprint): 조합의 합계, 홀짝 비율, 구간 분포가 역대 당첨번호와 유사한지
|
||||
3. 갭 분석(Gap): 각 번호의 마지막 출현으로부터 경과 회차 수 기반 점수
|
||||
4. 공동 출현 행렬(Co-occurrence): 번호 쌍이 역대에 함께 나온 빈도 기반 점수
|
||||
5. 다양성(Diversity): 연속 번호, 범위, 구간 분포 다양성
|
||||
|
||||
[통계 근거]
|
||||
- 1~45번 각각의 이론적 출현 확률: 6/45 ≈ 13.33% per draw
|
||||
- 기댓값 합계: E[sum] = 6 × E[1..45] = 6 × 23 = 138
|
||||
- 표준편차 합계: std ≈ sqrt(6 × Var[uniform 1..45]) ≈ 31
|
||||
- 홀수 23개 (1,3,...,45), 짝수 22개 (2,4,...,44)
|
||||
- 번호 쌍 공동 출현 확률: C(43,4)/C(45,6) ≈ 1.516% per draw
|
||||
"""
|
||||
|
||||
import math
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Tuple, Dict, Any, Optional
|
||||
|
||||
# 구간 정의: (시작, 끝) 포함
|
||||
ZONE_RANGES: List[Tuple[int, int]] = [
|
||||
(1, 9),
|
||||
(10, 19),
|
||||
(20, 29),
|
||||
(30, 39),
|
||||
(40, 45),
|
||||
]
|
||||
|
||||
|
||||
def _get_zone(n: int) -> int:
|
||||
"""번호가 속하는 구간 인덱스 (0-4)"""
|
||||
for z, (lo, hi) in enumerate(ZONE_RANGES):
|
||||
if lo <= n <= hi:
|
||||
return z
|
||||
return 4
|
||||
|
||||
|
||||
def build_analysis_cache(draws: List[Tuple[int, List[int]]]) -> Dict[str, Any]:
|
||||
"""
|
||||
역대 당첨번호 데이터 기반 통계 분석 캐시 구성.
|
||||
시뮬레이션 실행 시 한 번만 호출하여 재사용 (성능 최적화).
|
||||
|
||||
Args:
|
||||
draws: [(drw_no, [n1,n2,n3,n4,n5,n6]), ...] 오름차순
|
||||
|
||||
Returns:
|
||||
통계 캐시 딕셔너리
|
||||
"""
|
||||
if not draws:
|
||||
return {}
|
||||
|
||||
total_draws = len(draws)
|
||||
all_nums_list = [n for _, nums in draws for n in nums]
|
||||
freq_all = Counter(all_nums_list)
|
||||
|
||||
# ── 1. 빈도 Z-score ──────────────────────────────────────────────────────
|
||||
freq_values = [freq_all.get(n, 0) for n in range(1, 46)]
|
||||
mean_freq = sum(freq_values) / 45.0
|
||||
variance_freq = sum((f - mean_freq) ** 2 for f in freq_values) / 45.0
|
||||
std_freq = math.sqrt(variance_freq)
|
||||
|
||||
z_scores: Dict[int, float] = {}
|
||||
for n in range(1, 46):
|
||||
z_scores[n] = (freq_all.get(n, 0) - mean_freq) / max(std_freq, 0.001)
|
||||
|
||||
# ── 2. 갭 분석: 마지막 출현 이후 경과 회차 ──────────────────────────────
|
||||
# gap = 0: 가장 최근 회차에 출현, gap = k: k회 전에 마지막 출현
|
||||
last_seen_gap: Dict[int, int] = {}
|
||||
for gap_idx, (_, nums) in enumerate(reversed(draws)):
|
||||
for n in nums:
|
||||
if n not in last_seen_gap:
|
||||
last_seen_gap[n] = gap_idx
|
||||
for n in range(1, 46):
|
||||
if n not in last_seen_gap:
|
||||
last_seen_gap[n] = total_draws # 한 번도 안 나옴 (이론상 거의 불가)
|
||||
|
||||
# ── 3. 공동 출현 행렬 ────────────────────────────────────────────────────
|
||||
# cooccur[(i,j)] = 번호 i와 j가 같은 회차에 함께 출현한 횟수 (i < j)
|
||||
cooccur: Dict[Tuple[int, int], int] = defaultdict(int)
|
||||
for _, nums in draws:
|
||||
s = sorted(nums)
|
||||
for i in range(len(s)):
|
||||
for j in range(i + 1, len(s)):
|
||||
cooccur[(s[i], s[j])] += 1
|
||||
|
||||
# 번호 쌍 공동 출현 기댓값: C(43,4)/C(45,6) × total_draws
|
||||
# C(43,4) = 123,410 / C(45,6) = 8,145,060
|
||||
expected_cooccur = total_draws * 123410.0 / 8145060.0
|
||||
|
||||
# ── 4. 역대 조합 통계 (합계, 홀수 개수) ──────────────────────────────────
|
||||
historical_sums = [sum(nums) for _, nums in draws]
|
||||
mean_sum = sum(historical_sums) / total_draws
|
||||
std_sum = math.sqrt(
|
||||
sum((s - mean_sum) ** 2 for s in historical_sums) / total_draws
|
||||
)
|
||||
std_sum = max(std_sum, 1.0) # 0 나누기 방지
|
||||
|
||||
historical_odds = [sum(1 for n in nums if n % 2 == 1) for _, nums in draws]
|
||||
odd_dist = Counter(historical_odds)
|
||||
odd_prob: Dict[int, float] = {k: v / total_draws for k, v in odd_dist.items()}
|
||||
max_odd_prob = max(odd_prob.values()) if odd_prob else 1.0
|
||||
|
||||
# ── 5. 구간별 분포 통계 ───────────────────────────────────────────────────
|
||||
# 각 구간에 몇 개 포함되는지의 역대 분포
|
||||
zone_counts = [Counter() for _ in ZONE_RANGES]
|
||||
for _, nums in draws:
|
||||
for z_idx, (lo, hi) in enumerate(ZONE_RANGES):
|
||||
cnt = sum(1 for n in nums if lo <= n <= hi)
|
||||
zone_counts[z_idx][cnt] += 1
|
||||
|
||||
zone_probs: List[Dict[int, float]] = []
|
||||
for zc in zone_counts:
|
||||
total_z = sum(zc.values())
|
||||
zone_probs.append({k: v / total_z for k, v in zc.items()})
|
||||
|
||||
max_zone_probs = [max(zp.values()) if zp else 1.0 for zp in zone_probs]
|
||||
|
||||
# ── 6. 최근 빈도 (후보 생성 가중치용) ────────────────────────────────────
|
||||
recent_100 = draws[-100:] if len(draws) >= 100 else draws
|
||||
freq_recent = Counter(n for _, nums in recent_100 for n in nums)
|
||||
|
||||
return {
|
||||
"total_draws": total_draws,
|
||||
"freq_all": freq_all,
|
||||
"z_scores": z_scores,
|
||||
"last_seen_gap": last_seen_gap,
|
||||
"cooccur": dict(cooccur),
|
||||
"expected_cooccur": expected_cooccur,
|
||||
"mean_sum": mean_sum,
|
||||
"std_sum": std_sum,
|
||||
"odd_prob": odd_prob,
|
||||
"max_odd_prob": max_odd_prob,
|
||||
"zone_probs": zone_probs,
|
||||
"max_zone_probs": max_zone_probs,
|
||||
"freq_recent": freq_recent,
|
||||
}
|
||||
|
||||
|
||||
def build_number_weights(cache: Dict[str, Any]) -> Dict[int, float]:
|
||||
"""
|
||||
몬테카를로 시뮬레이션의 후보 생성에 사용할 번호별 샘플링 가중치.
|
||||
빈도 + 최근 빈도 + 갭 분석을 반영하여 '좋은' 번호가 더 자주 선택되도록 유도.
|
||||
"""
|
||||
freq_all = cache["freq_all"]
|
||||
last_seen_gap = cache["last_seen_gap"]
|
||||
freq_recent = cache["freq_recent"]
|
||||
|
||||
weights: Dict[int, float] = {}
|
||||
for n in range(1, 46):
|
||||
w = freq_all.get(n, 0) + 1.5 * freq_recent.get(n, 0)
|
||||
|
||||
gap = last_seen_gap.get(n, 0)
|
||||
if gap <= 1:
|
||||
gap_factor = 0.50 # 바로 직전 등장 → 패널티
|
||||
elif gap <= 3:
|
||||
gap_factor = 0.75
|
||||
elif gap <= 12:
|
||||
gap_factor = 1.00 # 적정 범위
|
||||
elif gap <= 25:
|
||||
gap_factor = 1.10 # 약간 오래된 번호 → 소폭 보너스
|
||||
else:
|
||||
gap_factor = 1.20 # 오래된 번호 → 보너스
|
||||
|
||||
weights[n] = max(w * gap_factor, 0.5)
|
||||
|
||||
return weights
|
||||
|
||||
|
||||
def score_combination(numbers: List[int], cache: Dict[str, Any]) -> Dict[str, float]:
|
||||
"""
|
||||
6개 번호 조합의 통계적 품질 점수 계산 (0~1 범위 정규화).
|
||||
|
||||
5가지 기법별 점수:
|
||||
- score_frequency (25%): 빈도 Z-score
|
||||
- score_fingerprint(30%): 조합의 통계적 지문 (합계, 홀짝, 구간)
|
||||
- score_gap (20%): 갭 분석
|
||||
- score_cooccur (15%): 공동 출현 기댓값 대비
|
||||
- score_diversity (10%): 연속번호, 범위, 구간 다양성
|
||||
|
||||
Returns:
|
||||
{"score_total": ..., "score_frequency": ..., ...}
|
||||
"""
|
||||
nums = sorted(numbers)
|
||||
|
||||
# ── 1. 빈도 점수 (Frequency Score) ────────────────────────────────────────
|
||||
z_scores = cache["z_scores"]
|
||||
avg_z = sum(z_scores.get(n, 0.0) for n in nums) / 6.0
|
||||
# Sigmoid 정규화: avg_z > 0이면 0.5 이상
|
||||
score_frequency = 1.0 / (1.0 + math.exp(-avg_z / 1.5))
|
||||
|
||||
# ── 2. 조합 지문 점수 (Fingerprint Score) ─────────────────────────────────
|
||||
# 2a. 합계 정규분포 점수
|
||||
total = sum(nums)
|
||||
mean_sum = cache["mean_sum"]
|
||||
std_sum = cache["std_sum"]
|
||||
z_sum = (total - mean_sum) / std_sum
|
||||
sum_score = math.exp(-0.5 * z_sum ** 2) # 정규분포 밀도 (peak=1 at mean)
|
||||
|
||||
# 2b. 홀짝 비율 점수
|
||||
odd_count = sum(1 for n in nums if n % 2 == 1)
|
||||
odd_prob = cache["odd_prob"]
|
||||
max_odd_prob = cache["max_odd_prob"]
|
||||
odd_score = odd_prob.get(odd_count, 0.01) / max_odd_prob
|
||||
|
||||
# 2c. 구간 분포 점수
|
||||
zone_probs = cache["zone_probs"]
|
||||
max_zone_probs = cache["max_zone_probs"]
|
||||
zone_score = 0.0
|
||||
for z_idx, (lo, hi) in enumerate(ZONE_RANGES):
|
||||
cnt = sum(1 for n in nums if lo <= n <= hi)
|
||||
zp = zone_probs[z_idx]
|
||||
mzp = max_zone_probs[z_idx]
|
||||
zone_score += zp.get(cnt, 0.01) / mzp
|
||||
zone_score /= len(ZONE_RANGES)
|
||||
|
||||
score_fingerprint = sum_score * 0.50 + odd_score * 0.30 + zone_score * 0.20
|
||||
|
||||
# ── 3. 갭 점수 (Gap Score) ────────────────────────────────────────────────
|
||||
last_seen_gap = cache["last_seen_gap"]
|
||||
gap_scores: List[float] = []
|
||||
for n in nums:
|
||||
gap = last_seen_gap.get(n, 0)
|
||||
if gap <= 1:
|
||||
gs = 0.20 # 직전 등장 번호 - 강한 패널티
|
||||
elif gap <= 3:
|
||||
gs = 0.55
|
||||
elif gap <= 7:
|
||||
gs = 0.85
|
||||
elif gap <= 15:
|
||||
gs = 1.00 # 최적 범위
|
||||
elif gap <= 25:
|
||||
gs = 0.90
|
||||
else:
|
||||
gs = 0.75 # 오래된 번호 - 여전히 양호
|
||||
gap_scores.append(gs)
|
||||
score_gap = sum(gap_scores) / 6.0
|
||||
|
||||
# ── 4. 공동 출현 점수 (Co-occurrence Score) ───────────────────────────────
|
||||
cooccur = cache["cooccur"]
|
||||
expected_cooccur = cache["expected_cooccur"]
|
||||
|
||||
pair_scores: List[float] = []
|
||||
for i in range(len(nums)):
|
||||
for j in range(i + 1, len(nums)):
|
||||
actual = cooccur.get((nums[i], nums[j]), 0)
|
||||
ratio = actual / max(expected_cooccur, 0.001)
|
||||
# Sigmoid: ratio = 1에서 0.5, ratio > 1이면 > 0.5
|
||||
ps = 1.0 / (1.0 + math.exp(-2.0 * (ratio - 1.0)))
|
||||
pair_scores.append(ps)
|
||||
score_cooccur = sum(pair_scores) / max(len(pair_scores), 1)
|
||||
|
||||
# ── 5. 다양성 점수 (Diversity Score) ─────────────────────────────────────
|
||||
# 5a. 연속 번호 포함 여부 (역대 당첨번호 약 52%에 최소 1쌍 포함)
|
||||
has_consecutive = any(nums[i + 1] - nums[i] == 1 for i in range(len(nums) - 1))
|
||||
consecutive_score = 0.65 if has_consecutive else 0.40
|
||||
|
||||
# 5b. 범위 점수 (최소~최대 차이)
|
||||
num_range = nums[-1] - nums[0]
|
||||
if 28 <= num_range <= 43:
|
||||
spread_score = 1.00
|
||||
elif 20 <= num_range < 28:
|
||||
spread_score = 0.85
|
||||
elif 13 <= num_range < 20:
|
||||
spread_score = 0.65
|
||||
elif num_range < 13:
|
||||
spread_score = 0.25
|
||||
else: # > 43 (최대 44: 1~45)
|
||||
spread_score = 0.95
|
||||
|
||||
# 5c. 구간 커버리지 (몇 개 구간에 걸쳐 있는가)
|
||||
zones_used = set(_get_zone(n) for n in nums)
|
||||
zone_coverage = (len(zones_used) - 1) / 4.0 # 0~1
|
||||
|
||||
score_diversity = (
|
||||
consecutive_score * 0.35
|
||||
+ spread_score * 0.35
|
||||
+ zone_coverage * 0.30
|
||||
)
|
||||
|
||||
# ── 최종 가중 합산 ────────────────────────────────────────────────────────
|
||||
score_total = (
|
||||
score_frequency * 0.25
|
||||
+ score_fingerprint * 0.30
|
||||
+ score_gap * 0.20
|
||||
+ score_cooccur * 0.15
|
||||
+ score_diversity * 0.10
|
||||
)
|
||||
|
||||
return {
|
||||
"score_total": round(score_total, 6),
|
||||
"score_frequency": round(score_frequency, 6),
|
||||
"score_fingerprint": round(score_fingerprint, 6),
|
||||
"score_gap": round(score_gap, 6),
|
||||
"score_cooccur": round(score_cooccur, 6),
|
||||
"score_diversity": round(score_diversity, 6),
|
||||
}
|
||||
|
||||
|
||||
def get_statistical_report(draws: List[Tuple[int, List[int]]]) -> Dict[str, Any]:
|
||||
"""
|
||||
통계 분석 리포트 생성 (GET /api/lotto/analysis 응답용).
|
||||
각 번호의 빈도, Z-score, 갭, 히트/콜드/오버듀 분류를 반환.
|
||||
"""
|
||||
if not draws:
|
||||
return {"error": "데이터 없음"}
|
||||
|
||||
cache = build_analysis_cache(draws)
|
||||
total_draws = cache["total_draws"]
|
||||
freq_all = cache["freq_all"]
|
||||
z_scores = cache["z_scores"]
|
||||
last_seen_gap = cache["last_seen_gap"]
|
||||
|
||||
number_stats = []
|
||||
for n in range(1, 46):
|
||||
freq = freq_all.get(n, 0)
|
||||
expected = total_draws * 6.0 / 45.0
|
||||
number_stats.append({
|
||||
"number": n,
|
||||
"frequency": freq,
|
||||
"expected": round(expected, 1),
|
||||
"frequency_pct": round(freq / (total_draws * 6) * 100, 2),
|
||||
"z_score": round(z_scores.get(n, 0.0), 3),
|
||||
"gap": last_seen_gap.get(n, total_draws),
|
||||
"zone": _get_zone(n),
|
||||
})
|
||||
|
||||
sorted_by_freq = sorted(number_stats, key=lambda x: -x["frequency"])
|
||||
sorted_by_gap = sorted(number_stats, key=lambda x: -x["gap"])
|
||||
|
||||
# 역대 합계 분포 요약
|
||||
hist_sums = [sum(nums) for _, nums in draws]
|
||||
sum_buckets: Dict[str, int] = {}
|
||||
for lo in range(21, 256, 20):
|
||||
hi = lo + 19
|
||||
key = f"{lo}-{hi}"
|
||||
sum_buckets[key] = sum(1 for s in hist_sums if lo <= s <= hi)
|
||||
|
||||
return {
|
||||
"total_draws": total_draws,
|
||||
"mean_sum": round(cache["mean_sum"], 2),
|
||||
"std_sum": round(cache["std_sum"], 2),
|
||||
"odd_distribution": {
|
||||
str(k): round(v * 100, 1)
|
||||
for k, v in sorted(cache["odd_prob"].items())
|
||||
},
|
||||
"number_stats": number_stats,
|
||||
"hot_numbers": [x["number"] for x in sorted_by_freq[:10]],
|
||||
"cold_numbers": [x["number"] for x in sorted_by_freq[-10:]],
|
||||
"overdue_numbers": [x["number"] for x in sorted_by_gap[:10]],
|
||||
"sum_distribution": sum_buckets,
|
||||
}
|
||||
|
||||
|
||||
def analyze_personal_patterns(
|
||||
all_numbers: List[List[int]],
|
||||
draws: List[Tuple[int, List[int]]],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
사용자 추천 이력 기반 개인 패턴 분석.
|
||||
all_numbers: 저장된 모든 추천 번호 리스트 (각 원소는 6개짜리 리스트)
|
||||
draws: 역대 당첨번호 (홀짝/합계 평균 비교용)
|
||||
"""
|
||||
if not all_numbers:
|
||||
return {"total_analyzed": 0, "message": "추천 이력이 없습니다"}
|
||||
|
||||
total = len(all_numbers)
|
||||
flat = [n for nums in all_numbers for n in nums]
|
||||
freq = Counter(flat)
|
||||
|
||||
# 번호별 선택 빈도
|
||||
number_frequency = {n: freq.get(n, 0) for n in range(1, 46)}
|
||||
top_picks = sorted(range(1, 46), key=lambda n: -freq.get(n, 0))[:10]
|
||||
least_picks = [n for n in sorted(range(1, 46), key=lambda n: freq.get(n, 0)) if freq.get(n, 0) == 0][:10]
|
||||
|
||||
# 패턴 지표
|
||||
odd_counts = [sum(1 for n in nums if n % 2 == 1) for nums in all_numbers]
|
||||
sums = [sum(nums) for nums in all_numbers]
|
||||
ranges = [max(nums) - min(nums) for nums in all_numbers]
|
||||
consecutive_count = sum(
|
||||
1 for nums in all_numbers
|
||||
if any(sorted(nums)[i + 1] - sorted(nums)[i] == 1 for i in range(5))
|
||||
)
|
||||
|
||||
zone_totals = {k: 0 for k in ["1-9", "10-19", "20-29", "30-39", "40-45"]}
|
||||
zone_ranges = [("1-9", 1, 9), ("10-19", 10, 19), ("20-29", 20, 29), ("30-39", 30, 39), ("40-45", 40, 45)]
|
||||
for nums in all_numbers:
|
||||
for label, lo, hi in zone_ranges:
|
||||
zone_totals[label] += sum(1 for n in nums if lo <= n <= hi)
|
||||
zone_avg = {k: round(v / total, 2) for k, v in zone_totals.items()}
|
||||
|
||||
avg_odd = sum(odd_counts) / total
|
||||
avg_sum = sum(sums) / total
|
||||
avg_range = sum(ranges) / total
|
||||
|
||||
# 역대 당첨번호 평균과 비교
|
||||
if draws:
|
||||
draw_odd_avg = sum(sum(1 for n in nums if n % 2 == 1) for _, nums in draws) / len(draws)
|
||||
draw_sum_avg = sum(sum(nums) for _, nums in draws) / len(draws)
|
||||
else:
|
||||
draw_odd_avg = 3.0
|
||||
draw_sum_avg = 138.0
|
||||
|
||||
return {
|
||||
"total_analyzed": total,
|
||||
"number_frequency": number_frequency,
|
||||
"top_picks": top_picks,
|
||||
"least_picks": least_picks,
|
||||
"pattern": {
|
||||
"avg_odd_count": round(avg_odd, 2),
|
||||
"avg_sum": round(avg_sum, 1),
|
||||
"avg_range": round(avg_range, 1),
|
||||
"consecutive_rate": round(consecutive_count / total, 3),
|
||||
"zone_avg": zone_avg,
|
||||
},
|
||||
"vs_draw_avg": {
|
||||
"odd_diff": round(avg_odd - draw_odd_avg, 2),
|
||||
"sum_diff": round(avg_sum - draw_sum_avg, 1),
|
||||
"odd_tendency": "홀수 선호" if avg_odd > draw_odd_avg + 0.2 else ("짝수 선호" if avg_odd < draw_odd_avg - 0.2 else "균형"),
|
||||
"sum_tendency": "고합계 선호" if avg_sum > draw_sum_avg + 5 else ("저합계 선호" if avg_sum < draw_sum_avg - 5 else "균형"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def generate_combined_recommendation(draws: List[Tuple[int, List[int]]]) -> Dict[str, Any]:
|
||||
"""
|
||||
5가지 통계 기법을 종합한 추론 번호 추천.
|
||||
|
||||
각 기법이 상위 6개 번호를 추천하고, 기법별 가중치(score_combination 가중치와 동일)로
|
||||
투표를 집계한 뒤 최종 6개 번호를 선정한다.
|
||||
|
||||
가중치: 빈도Z(25%) · 지문(30%) · 갭(20%) · 공동출현(15%) · 다양성(10%)
|
||||
"""
|
||||
if not draws:
|
||||
return {"error": "데이터 없음"}
|
||||
|
||||
cache = build_analysis_cache(draws)
|
||||
z = cache["z_scores"]
|
||||
gap = cache["last_seen_gap"]
|
||||
freq = cache["freq_all"]
|
||||
cooccur = cache["cooccur"]
|
||||
zone_probs = cache["zone_probs"]
|
||||
|
||||
# ── Method 1: 빈도 Z-score ────────────────────────────────────────────────
|
||||
# Z-score 내림차순 상위 6 (출현 빈도가 기댓값보다 높은 번호)
|
||||
m_frequency = sorted(range(1, 46), key=lambda n: -z.get(n, 0))[:6]
|
||||
|
||||
# ── Method 2: 갭 분석 ─────────────────────────────────────────────────────
|
||||
# 가장 오래 미출현한 번호 6개 (오버듀)
|
||||
m_gap = sorted(range(1, 46), key=lambda n: -gap.get(n, 0))[:6]
|
||||
|
||||
# ── Method 3: 공동출현 ────────────────────────────────────────────────────
|
||||
# 각 번호의 총 공동출현 합산 점수 내림차순 6개
|
||||
cooccur_total: Dict[int, float] = defaultdict(float)
|
||||
for (a, b), cnt in cooccur.items():
|
||||
cooccur_total[a] += cnt
|
||||
cooccur_total[b] += cnt
|
||||
m_cooccur = sorted(range(1, 46), key=lambda n: -cooccur_total.get(n, 0))[:6]
|
||||
|
||||
# ── Method 4: 조합 지문 ───────────────────────────────────────────────────
|
||||
# 역대 당첨 조합의 구간별 최빈 분포에 맞게 각 구간에서 빈도 상위 번호 선택
|
||||
zone_targets: List[int] = []
|
||||
for zp in zone_probs:
|
||||
zone_targets.append(max(zp, key=zp.get) if zp else 1)
|
||||
|
||||
# 합이 정확히 6이 되도록 조정
|
||||
diff = sum(zone_targets) - 6
|
||||
if diff > 0:
|
||||
idxs = sorted(range(5), key=lambda i: -zone_targets[i])
|
||||
for i in idxs:
|
||||
if diff <= 0:
|
||||
break
|
||||
zone_targets[i] = max(0, zone_targets[i] - 1)
|
||||
diff -= 1
|
||||
elif diff < 0:
|
||||
idxs = sorted(range(5), key=lambda i: zone_targets[i])
|
||||
for i in idxs:
|
||||
if diff >= 0:
|
||||
break
|
||||
zone_targets[i] += 1
|
||||
diff += 1
|
||||
|
||||
m_fingerprint: List[int] = []
|
||||
for (lo, hi), tgt in zip(ZONE_RANGES, zone_targets):
|
||||
zone_nums = sorted(range(lo, hi + 1), key=lambda x: -freq.get(x, 0))
|
||||
m_fingerprint.extend(zone_nums[:tgt])
|
||||
m_fingerprint = sorted(m_fingerprint[:6])
|
||||
|
||||
# ── Method 5: 다양성 ──────────────────────────────────────────────────────
|
||||
# 각 구간에서 갭 가장 큰 번호 1개씩 (5개) + 전체 갭 상위 1개 보충
|
||||
m_diversity: List[int] = []
|
||||
for lo, hi in ZONE_RANGES:
|
||||
zone_nums = sorted(range(lo, hi + 1), key=lambda n: -gap.get(n, 0))
|
||||
if zone_nums:
|
||||
m_diversity.append(zone_nums[0])
|
||||
if len(m_diversity) < 6:
|
||||
rest = sorted(
|
||||
[x for x in range(1, 46) if x not in m_diversity],
|
||||
key=lambda n: -gap.get(n, 0),
|
||||
)
|
||||
m_diversity.extend(rest[: 6 - len(m_diversity)])
|
||||
m_diversity = sorted(m_diversity[:6])
|
||||
|
||||
# ── 가중 투표 집계 ────────────────────────────────────────────────────────
|
||||
# score_combination 가중치와 동일: 빈도25, 지문30, 갭20, 공동출현15, 다양성10
|
||||
method_entries = [
|
||||
(m_frequency, 25, "frequency", "빈도 Z-score"),
|
||||
(m_fingerprint, 30, "fingerprint", "조합 지문"),
|
||||
(m_gap, 20, "gap", "갭 분석"),
|
||||
(m_cooccur, 15, "cooccur", "공동 출현"),
|
||||
(m_diversity, 10, "diversity", "다양성"),
|
||||
]
|
||||
|
||||
vote_scores: Dict[int, float] = {n: 0.0 for n in range(1, 46)}
|
||||
for method_nums, weight, _, _ in method_entries:
|
||||
for rank, n in enumerate(method_nums):
|
||||
# rank 0 = 1위: (6-0)×weight = 6w, rank 5 = 6위: (6-5)×weight = w
|
||||
vote_scores[n] += (6 - rank) * weight
|
||||
|
||||
# 상위 6개 — 동점 시 Z-score 타이브레이크
|
||||
final_numbers = sorted(
|
||||
sorted(range(1, 46), key=lambda n: (-vote_scores[n], -z.get(n, 0)))[:6]
|
||||
)
|
||||
|
||||
scores = score_combination(final_numbers, cache)
|
||||
|
||||
# 각 번호가 몇 개 방법에서 채택됐는지
|
||||
vote_counts: Dict[str, int] = {
|
||||
str(n): sum(1 for nums, _, _, _ in method_entries if n in nums)
|
||||
for n in range(1, 46)
|
||||
}
|
||||
|
||||
methods_result: Dict[str, Any] = {}
|
||||
for nums, weight, key, label in method_entries:
|
||||
methods_result[key] = {
|
||||
"label": label,
|
||||
"weight_pct": weight,
|
||||
"numbers": sorted(nums),
|
||||
}
|
||||
|
||||
return {
|
||||
"methods": methods_result,
|
||||
"final_numbers": final_numbers,
|
||||
"scores": scores,
|
||||
"vote_scores": {str(n): round(vote_scores[n], 1) for n in range(1, 46)},
|
||||
"vote_counts": vote_counts,
|
||||
"total_draws": cache["total_draws"],
|
||||
}
|
||||
|
||||
|
||||
def generate_weekly_report(draws: List[Tuple[int, List[int]]], target_drw_no: int) -> Dict[str, Any]:
|
||||
"""
|
||||
특정 회차 공략 리포트 생성.
|
||||
target_drw_no: 공략 대상 회차 (아직 추첨 안 된 회차)
|
||||
draws: target_drw_no 이전까지의 당첨번호 (오름차순)
|
||||
"""
|
||||
if not draws:
|
||||
return {"error": "데이터 없음"}
|
||||
|
||||
cache = build_analysis_cache(draws)
|
||||
total_draws = cache["total_draws"]
|
||||
freq_all = cache["freq_all"]
|
||||
last_seen_gap = cache["last_seen_gap"]
|
||||
|
||||
recent_10 = draws[-10:] if len(draws) >= 10 else draws
|
||||
recent_3 = draws[-3:] if len(draws) >= 3 else draws
|
||||
|
||||
# 과출현: 최근 10회에 2회 이상 출현 번호 (출현 많은 순)
|
||||
r10_nums = [n for _, nums in recent_10 for n in nums]
|
||||
r10_freq = Counter(r10_nums)
|
||||
hot_numbers = [n for n, _ in sorted(r10_freq.items(), key=lambda x: -x[1]) if r10_freq[n] >= 2]
|
||||
|
||||
# 냉각: 역대 출현 빈도 낮은 번호
|
||||
cold_numbers = sorted(range(1, 46), key=lambda n: freq_all.get(n, 0))[:10]
|
||||
|
||||
# 오버듀: 가장 오래 미출현 번호
|
||||
overdue_numbers = sorted(range(1, 46), key=lambda n: -last_seen_gap.get(n, 0))[:10]
|
||||
|
||||
# 최근 3회 연속 출현 (2회 이상)
|
||||
r3_nums = [n for _, nums in recent_3 for n in nums]
|
||||
r3_freq = Counter(r3_nums)
|
||||
triple_appear = sorted(n for n, cnt in r3_freq.items() if cnt >= 2)
|
||||
|
||||
recent_sums = [sum(nums) for _, nums in recent_10]
|
||||
recent_odd = [sum(1 for n in nums if n % 2 == 1) for _, nums in recent_10]
|
||||
|
||||
# 갭 기반 가중치 (오래된 번호일수록 높음)
|
||||
gap_w = {n: last_seen_gap.get(n, 0) for n in range(1, 46)}
|
||||
|
||||
def _pick(exclude=None, prefer=None, n=6):
|
||||
ex = set(exclude or [])
|
||||
chosen = []
|
||||
# prefer에서 최대 3개 우선 선택
|
||||
for p in (prefer or []):
|
||||
if p not in ex and len(chosen) < 3:
|
||||
chosen.append(p)
|
||||
# 구간별 1개씩 (갭 우선)
|
||||
for lo, hi in [(1, 9), (10, 19), (20, 29), (30, 39), (40, 45)]:
|
||||
if len(chosen) >= n:
|
||||
break
|
||||
cands = [x for x in range(lo, hi + 1) if x not in ex and x not in chosen]
|
||||
if cands:
|
||||
chosen.append(max(cands, key=lambda x: gap_w.get(x, 0)))
|
||||
# 부족하면 나머지에서 갭 순
|
||||
rest = sorted(
|
||||
[x for x in range(1, 46) if x not in ex and x not in chosen],
|
||||
key=lambda x: -gap_w.get(x, 0),
|
||||
)
|
||||
while len(chosen) < n and rest:
|
||||
chosen.append(rest.pop(0))
|
||||
return sorted(chosen[:n])
|
||||
|
||||
set1 = _pick(exclude=hot_numbers[:5], prefer=overdue_numbers[:5])
|
||||
set2 = _pick()
|
||||
set3 = _pick(exclude=hot_numbers)
|
||||
|
||||
# 신뢰도 점수
|
||||
data_vol = min(total_draws / 500, 1.0)
|
||||
if len(recent_sums) > 1:
|
||||
avg_s = sum(recent_sums) / len(recent_sums)
|
||||
std_s = (sum((s - avg_s) ** 2 for s in recent_sums) / len(recent_sums)) ** 0.5
|
||||
pattern = max(0.0, 1.0 - std_s / 60.0)
|
||||
else:
|
||||
pattern = 0.5
|
||||
trend = max(0.0, 1.0 - len(hot_numbers) / max(len(r10_nums), 1))
|
||||
confidence = round((data_vol * 0.4 + pattern * 0.35 + trend * 0.25) * 100)
|
||||
|
||||
return {
|
||||
"target_drw_no": target_drw_no,
|
||||
"based_on_draw": draws[-1][0],
|
||||
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"hot_numbers": hot_numbers[:8],
|
||||
"cold_numbers": cold_numbers,
|
||||
"overdue_numbers": overdue_numbers,
|
||||
"recent_pattern": {
|
||||
"last3_numbers": sorted(set(r3_nums)),
|
||||
"triple_appear": triple_appear,
|
||||
"recent_sum_avg": round(sum(recent_sums) / len(recent_sums), 1) if recent_sums else 0,
|
||||
"recent_odd_avg": round(sum(recent_odd) / len(recent_odd), 1) if recent_odd else 0,
|
||||
},
|
||||
"recommended_sets": [
|
||||
{"strategy": "냉각번호 중심", "numbers": set1, "description": "오랫동안 미출현 번호 위주 + 과출현 제외"},
|
||||
{"strategy": "균형형", "numbers": set2, "description": "구간 균형 + 갭 최적화"},
|
||||
{"strategy": "과출현 피하기", "numbers": set3, "description": "최근 자주 나온 번호 완전 제외"},
|
||||
],
|
||||
"confidence_score": confidence,
|
||||
"confidence_factors": {
|
||||
"data_volume": round(data_vol * 100),
|
||||
"pattern_consistency": round(pattern * 100),
|
||||
"recent_trend": round(trend * 100),
|
||||
},
|
||||
}
|
||||
73
backend/app/checker.py
Normal file
73
backend/app/checker.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import json
|
||||
from .db import (
|
||||
_conn, get_draw, update_recommendation_result
|
||||
)
|
||||
|
||||
def _calc_rank(my_nums: list[int], win_nums: list[int], bonus: int) -> tuple[int, int, bool]:
|
||||
"""
|
||||
(rank, correct_cnt, has_bonus) 반환
|
||||
rank: 1~5 (1등~5등), 0 (낙첨)
|
||||
"""
|
||||
matched = set(my_nums) & set(win_nums)
|
||||
cnt = len(matched)
|
||||
has_bonus = bonus in my_nums
|
||||
|
||||
if cnt == 6:
|
||||
return 1, cnt, has_bonus
|
||||
if cnt == 5 and has_bonus:
|
||||
return 2, cnt, has_bonus
|
||||
if cnt == 5:
|
||||
return 3, cnt, has_bonus
|
||||
if cnt == 4:
|
||||
return 4, cnt, has_bonus
|
||||
if cnt == 3:
|
||||
return 5, cnt, has_bonus
|
||||
|
||||
return 0, cnt, has_bonus
|
||||
|
||||
def check_results_for_draw(drw_no: int) -> int:
|
||||
"""
|
||||
특정 회차(drw_no) 결과가 나왔을 때,
|
||||
해당 회차를 타겟으로 했던(based_on_draw == drw_no - 1) 추천들을 채점한다.
|
||||
반환값: 채점한 개수
|
||||
"""
|
||||
win_row = get_draw(drw_no)
|
||||
if not win_row:
|
||||
return 0
|
||||
|
||||
win_nums = [
|
||||
win_row["n1"], win_row["n2"], win_row["n3"],
|
||||
win_row["n4"], win_row["n5"], win_row["n6"]
|
||||
]
|
||||
bonus = win_row["bonus"]
|
||||
|
||||
# based_on_draw가 (이번회차 - 1)인 것들 조회
|
||||
# 즉, 1000회차 추첨 결과로는, 999회차 데이터를 바탕으로 1000회차를 예측한 것들을 채점
|
||||
target_based_on = drw_no - 1
|
||||
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, numbers
|
||||
FROM recommendations
|
||||
WHERE based_on_draw = ? AND checked = 0
|
||||
""",
|
||||
(target_based_on,)
|
||||
).fetchall()
|
||||
|
||||
count = 0
|
||||
for r in rows:
|
||||
my_nums = json.loads(r["numbers"])
|
||||
rank, correct, has_bonus = _calc_rank(my_nums, win_nums, bonus)
|
||||
|
||||
update_recommendation_result(r["id"], rank, correct, has_bonus)
|
||||
count += 1
|
||||
|
||||
# ── 구매 이력 체크 연동 ──────────────────────────────────────
|
||||
try:
|
||||
from .purchase_manager import check_purchases_for_draw as _check_purchases
|
||||
_check_purchases(drw_no) # 내부에서 evolve_after_check → recalculate_weights 호출
|
||||
except ImportError:
|
||||
pass # purchase_manager 미설치 시 무시 (하위호환)
|
||||
|
||||
return count
|
||||
@@ -1,7 +1,7 @@
|
||||
import requests
|
||||
from typing import Dict, Any
|
||||
|
||||
from .db import get_draw, upsert_draw
|
||||
from .db import get_draw, upsert_draw, upsert_many_draws, get_latest_draw, count_draws
|
||||
|
||||
def _normalize_item(item: dict) -> dict:
|
||||
# smok95 all.json / latest.json 구조
|
||||
@@ -27,20 +27,13 @@ def sync_all_from_json(all_url: str) -> Dict[str, Any]:
|
||||
r.raise_for_status()
|
||||
data = r.json() # list[dict]
|
||||
|
||||
inserted = 0
|
||||
skipped = 0
|
||||
# 정규화
|
||||
rows = [_normalize_item(item) for item in data]
|
||||
|
||||
# Bulk Insert (성능 향상)
|
||||
upsert_many_draws(rows)
|
||||
|
||||
for item in data:
|
||||
row = _normalize_item(item)
|
||||
|
||||
if get_draw(row["drw_no"]):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
upsert_draw(row)
|
||||
inserted += 1
|
||||
|
||||
return {"mode": "all_json", "url": all_url, "inserted": inserted, "skipped": skipped, "total": len(data)}
|
||||
return {"mode": "all_json", "url": all_url, "total": len(rows)}
|
||||
|
||||
def sync_latest(latest_url: str) -> Dict[str, Any]:
|
||||
r = requests.get(latest_url, timeout=30)
|
||||
@@ -53,3 +46,40 @@ def sync_latest(latest_url: str) -> Dict[str, Any]:
|
||||
|
||||
return {"mode": "latest_json", "url": latest_url, "was_new": (before is None), "drawNo": row["drw_no"]}
|
||||
|
||||
def sync_ensure_all(latest_url: str, all_url: str) -> Dict[str, Any]:
|
||||
"""
|
||||
1회부터 최신 회차까지 빠짐없이 있는지 확인하고, 없으면 전체 동기화 수행.
|
||||
반환값: {"synced": bool, "reason": str, ...}
|
||||
"""
|
||||
# 1. 원격 최신 회차 확인
|
||||
try:
|
||||
r = requests.get(latest_url, timeout=10)
|
||||
r.raise_for_status()
|
||||
remote_item = r.json()
|
||||
remote_no = int(remote_item["draw_no"])
|
||||
except Exception as e:
|
||||
# 외부 통신 실패 시, 그냥 로컬 데이터로 진행하도록 에러 억제 (혹은 에러 발생)
|
||||
# 여기서는 통계 기능 작동이 우선이므로 로그만 남기고 pass하고 싶지만,
|
||||
# 확실한 동기화를 위해 에러를 던지거나 False 리턴
|
||||
return {"synced": False, "error": str(e)}
|
||||
|
||||
# 2. 로컬 상태 확인
|
||||
local_latest_row = get_latest_draw()
|
||||
local_no = local_latest_row["drw_no"] if local_latest_row else 0
|
||||
local_cnt = count_draws()
|
||||
|
||||
# 3. 동기화 필요 여부 판단
|
||||
# - 전체 개수가 최신 회차 번호보다 적으면 중간에 빈 것 (1회부터 시작한다고 가정)
|
||||
# - 혹은 DB 최신 번호가 원격보다 낮으면 업데이트 필요
|
||||
need_sync = (local_no < remote_no) or (local_cnt < local_no)
|
||||
|
||||
if not need_sync:
|
||||
return {"synced": True, "updated": False, "local_no": local_no}
|
||||
|
||||
# 4. 전체 동기화 실행
|
||||
# (단순 latest sync로는 중간 구멍을 못 채우므로, 구멍이 있거나 차이가 크면 all_sync 수행)
|
||||
# 만약 차이가 1회차 뿐이고 구멍이 없다면 sync_latest만 해도 되지만,
|
||||
# 로직 단순화를 위해 missing 감지 시 그냥 all_sync (Bulk Insert라 빠름)
|
||||
res = sync_all_from_json(all_url)
|
||||
return {"synced": True, "updated": True, "detail": res}
|
||||
|
||||
|
||||
@@ -63,9 +63,350 @@ def init_db() -> None:
|
||||
_ensure_column(conn, "recommendations", "tags",
|
||||
"ALTER TABLE recommendations ADD COLUMN tags TEXT NOT NULL DEFAULT '[]';")
|
||||
|
||||
# ✅ 결과 채점용 컬럼 추가
|
||||
_ensure_column(conn, "recommendations", "rank",
|
||||
"ALTER TABLE recommendations ADD COLUMN rank INTEGER;")
|
||||
_ensure_column(conn, "recommendations", "correct_count",
|
||||
"ALTER TABLE recommendations ADD COLUMN correct_count INTEGER DEFAULT 0;")
|
||||
_ensure_column(conn, "recommendations", "has_bonus",
|
||||
"ALTER TABLE recommendations ADD COLUMN has_bonus INTEGER DEFAULT 0;")
|
||||
_ensure_column(conn, "recommendations", "checked",
|
||||
"ALTER TABLE recommendations ADD COLUMN checked INTEGER DEFAULT 0;")
|
||||
|
||||
|
||||
# ✅ UNIQUE 인덱스(중복 저장 방지)
|
||||
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS uq_reco_dedup ON recommendations(dedup_hash);")
|
||||
|
||||
# ── 시뮬레이션 테이블 ─────────────────────────────────────────────────
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS simulation_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
strategy TEXT NOT NULL DEFAULT 'monte_carlo',
|
||||
total_generated INTEGER NOT NULL DEFAULT 0,
|
||||
top_k_selected INTEGER NOT NULL DEFAULT 0,
|
||||
avg_score REAL,
|
||||
notes TEXT DEFAULT ''
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_simrun_at ON simulation_runs(run_at DESC);"
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS simulation_candidates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_id INTEGER NOT NULL,
|
||||
numbers TEXT NOT NULL,
|
||||
score_total REAL NOT NULL,
|
||||
score_frequency REAL,
|
||||
score_fingerprint REAL,
|
||||
score_gap REAL,
|
||||
score_cooccur REAL,
|
||||
score_diversity REAL,
|
||||
is_best INTEGER DEFAULT 0,
|
||||
based_on_draw INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY(run_id) REFERENCES simulation_runs(id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_simcand_run "
|
||||
"ON simulation_candidates(run_id, score_total DESC);"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_simcand_best "
|
||||
"ON simulation_candidates(is_best, score_total DESC);"
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS best_picks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
numbers TEXT NOT NULL,
|
||||
score_total REAL NOT NULL,
|
||||
rank_in_run INTEGER,
|
||||
source_run_id INTEGER,
|
||||
based_on_draw INTEGER,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY(source_run_id) REFERENCES simulation_runs(id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_bestpicks_active "
|
||||
"ON best_picks(is_active, score_total DESC);"
|
||||
)
|
||||
|
||||
# ── todos 테이블 ───────────────────────────────────────────────────────
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS todos (
|
||||
id TEXT PRIMARY KEY
|
||||
DEFAULT (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2)))),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'todo'
|
||||
CHECK(status IN ('todo','in_progress','done')),
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_todos_created ON todos(created_at DESC);"
|
||||
)
|
||||
|
||||
# ── blog_posts 테이블 ──────────────────────────────────────────────────
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS blog_posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
excerpt TEXT NOT NULL DEFAULT '',
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
date TEXT NOT NULL DEFAULT (date('now','localtime')),
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_blog_date ON blog_posts(date DESC);"
|
||||
)
|
||||
|
||||
# ── purchase_history 테이블 ────────────────────────────────────────────
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS purchase_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
draw_no INTEGER NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
sets INTEGER NOT NULL DEFAULT 1,
|
||||
prize INTEGER NOT NULL DEFAULT 0,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_purchase_draw ON purchase_history(draw_no DESC);")
|
||||
|
||||
# ── purchase_history 컬럼 확장 (기존 데이터 보존) ──────────────────────
|
||||
_ensure_column(conn, "purchase_history", "numbers",
|
||||
"ALTER TABLE purchase_history ADD COLUMN numbers TEXT NOT NULL DEFAULT '[]'")
|
||||
_ensure_column(conn, "purchase_history", "is_real",
|
||||
"ALTER TABLE purchase_history ADD COLUMN is_real INTEGER NOT NULL DEFAULT 1")
|
||||
_ensure_column(conn, "purchase_history", "source_strategy",
|
||||
"ALTER TABLE purchase_history ADD COLUMN source_strategy TEXT NOT NULL DEFAULT 'manual'")
|
||||
_ensure_column(conn, "purchase_history", "source_detail",
|
||||
"ALTER TABLE purchase_history ADD COLUMN source_detail TEXT NOT NULL DEFAULT '{}'")
|
||||
_ensure_column(conn, "purchase_history", "checked",
|
||||
"ALTER TABLE purchase_history ADD COLUMN checked INTEGER NOT NULL DEFAULT 0")
|
||||
_ensure_column(conn, "purchase_history", "results",
|
||||
"ALTER TABLE purchase_history ADD COLUMN results TEXT NOT NULL DEFAULT '[]'")
|
||||
_ensure_column(conn, "purchase_history", "total_prize",
|
||||
"ALTER TABLE purchase_history ADD COLUMN total_prize INTEGER NOT NULL DEFAULT 0")
|
||||
|
||||
# ── strategy_performance 테이블 ────────────────────────────────────────
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS strategy_performance (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
strategy TEXT NOT NULL,
|
||||
draw_no INTEGER NOT NULL,
|
||||
sets_count INTEGER NOT NULL DEFAULT 0,
|
||||
total_correct INTEGER NOT NULL DEFAULT 0,
|
||||
max_correct INTEGER NOT NULL DEFAULT 0,
|
||||
prize_total INTEGER NOT NULL DEFAULT 0,
|
||||
avg_score REAL NOT NULL DEFAULT 0.0,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
UNIQUE(strategy, draw_no)
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
# ── strategy_weights 테이블 ────────────────────────────────────────────
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS strategy_weights (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
strategy TEXT NOT NULL UNIQUE,
|
||||
weight REAL NOT NULL DEFAULT 0.2,
|
||||
ema_score REAL NOT NULL DEFAULT 0.15,
|
||||
total_sets INTEGER NOT NULL DEFAULT 0,
|
||||
total_hits_3plus INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
# strategy_weights 초기값 시드 (이미 있으면 무시)
|
||||
_INIT_WEIGHTS = [
|
||||
("combined", 0.30, 0.15),
|
||||
("simulation", 0.25, 0.15),
|
||||
("heatmap", 0.20, 0.15),
|
||||
("manual", 0.15, 0.15),
|
||||
("custom", 0.10, 0.15),
|
||||
]
|
||||
for strat, w, ema in _INIT_WEIGHTS:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO strategy_weights (strategy, weight, ema_score) VALUES (?, ?, ?)",
|
||||
(strat, w, ema),
|
||||
)
|
||||
|
||||
# ── weekly_reports 캐시 테이블 ──────────────────────────────────────────
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS weekly_reports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
drw_no INTEGER UNIQUE NOT NULL,
|
||||
report TEXT NOT NULL,
|
||||
generated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
# ── 추가 인덱스 ───────────────────────────────────────────────────────
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_reco_based_checked ON recommendations(based_on_draw, checked)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_purchase_strategy ON purchase_history(source_strategy)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_purchase_checked ON purchase_history(draw_no, checked)")
|
||||
|
||||
|
||||
# ── todos CRUD ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _todo_row_to_dict(r) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r["id"],
|
||||
"title": r["title"],
|
||||
"description": r["description"],
|
||||
"status": r["status"],
|
||||
"created_at": r["created_at"],
|
||||
"updated_at": r["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
def get_all_todos() -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM todos ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
return [_todo_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def create_todo(title: str, description: Optional[str], status: str) -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO todos (title, description, status) VALUES (?, ?, ?)",
|
||||
(title, description, status),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM todos WHERE rowid = last_insert_rowid()"
|
||||
).fetchone()
|
||||
return _todo_row_to_dict(row)
|
||||
|
||||
|
||||
def update_todo(todo_id: str, fields: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""fields에 있는 항목만 업데이트 (PATCH 방식), updated_at 자동 갱신"""
|
||||
allowed = {"title", "description", "status"}
|
||||
updates = {k: v for k, v in fields.items() if k in allowed}
|
||||
if not updates:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM todos WHERE id = ?", (todo_id,)).fetchone()
|
||||
return _todo_row_to_dict(row) if row else None
|
||||
|
||||
set_clauses = ", ".join(f"{k} = ?" for k in updates)
|
||||
set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
|
||||
args = list(updates.values()) + [todo_id]
|
||||
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
f"UPDATE todos SET {set_clauses} WHERE id = ?",
|
||||
args,
|
||||
)
|
||||
row = conn.execute("SELECT * FROM todos WHERE id = ?", (todo_id,)).fetchone()
|
||||
return _todo_row_to_dict(row) if row else None
|
||||
|
||||
|
||||
def delete_todo(todo_id: str) -> bool:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute("DELETE FROM todos WHERE id = ?", (todo_id,))
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def delete_done_todos() -> int:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute("DELETE FROM todos WHERE status = 'done'")
|
||||
return cur.rowcount
|
||||
|
||||
|
||||
# ── blog_posts CRUD ──────────────────────────────────────────────────────────
|
||||
|
||||
def _post_row_to_dict(r) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r["id"],
|
||||
"title": r["title"],
|
||||
"body": r["body"],
|
||||
"excerpt": r["excerpt"],
|
||||
"tags": json.loads(r["tags"]) if r["tags"] else [],
|
||||
"date": r["date"],
|
||||
"created_at": r["created_at"],
|
||||
"updated_at": r["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
def get_all_posts() -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM blog_posts ORDER BY date DESC, id DESC"
|
||||
).fetchall()
|
||||
return [_post_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def create_post(title: str, body: str, excerpt: str, tags: List[str], date: str) -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO blog_posts (title, body, excerpt, tags, date) VALUES (?, ?, ?, ?, ?)",
|
||||
(title, body, excerpt, json.dumps(tags), date),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM blog_posts WHERE rowid = last_insert_rowid()"
|
||||
).fetchone()
|
||||
return _post_row_to_dict(row)
|
||||
|
||||
|
||||
def update_post(post_id: int, fields: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
allowed = {"title", "body", "excerpt", "tags", "date"}
|
||||
updates = {k: v for k, v in fields.items() if k in allowed}
|
||||
if not updates:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM blog_posts WHERE id = ?", (post_id,)).fetchone()
|
||||
return _post_row_to_dict(row) if row else None
|
||||
|
||||
if "tags" in updates:
|
||||
updates["tags"] = json.dumps(updates["tags"])
|
||||
|
||||
set_clauses = ", ".join(f"{k} = ?" for k in updates)
|
||||
set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
|
||||
args = list(updates.values()) + [post_id]
|
||||
|
||||
with _conn() as conn:
|
||||
conn.execute(f"UPDATE blog_posts SET {set_clauses} WHERE id = ?", args)
|
||||
row = conn.execute("SELECT * FROM blog_posts WHERE id = ?", (post_id,)).fetchone()
|
||||
return _post_row_to_dict(row) if row else None
|
||||
|
||||
|
||||
def delete_post(post_id: int) -> bool:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute("DELETE FROM blog_posts WHERE id = ?", (post_id,))
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def upsert_draw(row: Dict[str, Any]) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
@@ -88,6 +429,30 @@ def upsert_draw(row: Dict[str, Any]) -> None:
|
||||
),
|
||||
)
|
||||
|
||||
def upsert_many_draws(rows: List[Dict[str, Any]]) -> None:
|
||||
data = [
|
||||
(
|
||||
int(r["drw_no"]), str(r["drw_date"]),
|
||||
int(r["n1"]), int(r["n2"]), int(r["n3"]),
|
||||
int(r["n4"]), int(r["n5"]), int(r["n6"]),
|
||||
int(r["bonus"])
|
||||
) for r in rows
|
||||
]
|
||||
with _conn() as conn:
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO draws (drw_no, drw_date, n1, n2, n3, n4, n5, n6, bonus)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(drw_no) DO UPDATE SET
|
||||
drw_date=excluded.drw_date,
|
||||
n1=excluded.n1, n2=excluded.n2, n3=excluded.n3,
|
||||
n4=excluded.n4, n5=excluded.n5, n6=excluded.n6,
|
||||
bonus=excluded.bonus,
|
||||
updated_at=datetime('now')
|
||||
""",
|
||||
data
|
||||
)
|
||||
|
||||
def get_latest_draw() -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
r = conn.execute("SELECT * FROM draws ORDER BY drw_no DESC LIMIT 1").fetchone()
|
||||
@@ -152,8 +517,6 @@ def list_recommendations_ex(
|
||||
q: Optional[str] = None,
|
||||
sort: str = "id_desc", # id_desc|created_desc|favorite_desc
|
||||
) -> List[Dict[str, Any]]:
|
||||
import json
|
||||
|
||||
where = []
|
||||
args: list[Any] = []
|
||||
|
||||
@@ -237,3 +600,499 @@ def delete_recommendation(rec_id: int) -> bool:
|
||||
cur = conn.execute("DELETE FROM recommendations WHERE id = ?", (rec_id,))
|
||||
return cur.rowcount > 0
|
||||
|
||||
def get_recommendation_performance() -> Dict[str, Any]:
|
||||
"""채점된 추천 이력 기반 성과 통계"""
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT correct_count, rank FROM recommendations WHERE checked = 1"
|
||||
).fetchall()
|
||||
|
||||
if not rows:
|
||||
return {
|
||||
"total_checked": 0,
|
||||
"avg_correct": 0.0,
|
||||
"distribution": {str(i): 0 for i in range(7)},
|
||||
"rate_3plus": 0.0,
|
||||
"rate_4plus": 0.0,
|
||||
"by_rank": {"rank_1": 0, "rank_2": 0, "rank_3": 0, "rank_4": 0, "rank_5": 0, "no_prize": 0},
|
||||
"vs_random": {"our_avg": 0.0, "random_avg": 0.8, "improvement_pct": 0.0},
|
||||
}
|
||||
|
||||
total = len(rows)
|
||||
corrects = [r["correct_count"] or 0 for r in rows]
|
||||
ranks = [r["rank"] or 0 for r in rows]
|
||||
avg_correct = sum(corrects) / total
|
||||
|
||||
RANDOM_AVG = 0.8 # 이론 기댓값: 6 * (6/45)
|
||||
improvement = (avg_correct - RANDOM_AVG) / RANDOM_AVG * 100
|
||||
|
||||
return {
|
||||
"total_checked": total,
|
||||
"avg_correct": round(avg_correct, 3),
|
||||
"distribution": {str(i): corrects.count(i) for i in range(7)},
|
||||
"rate_3plus": round(sum(1 for c in corrects if c >= 3) / total, 4),
|
||||
"rate_4plus": round(sum(1 for c in corrects if c >= 4) / total, 4),
|
||||
"by_rank": {
|
||||
"rank_1": ranks.count(1),
|
||||
"rank_2": ranks.count(2),
|
||||
"rank_3": ranks.count(3),
|
||||
"rank_4": ranks.count(4),
|
||||
"rank_5": ranks.count(5),
|
||||
"no_prize": ranks.count(0),
|
||||
},
|
||||
"vs_random": {
|
||||
"our_avg": round(avg_correct, 3),
|
||||
"random_avg": RANDOM_AVG,
|
||||
"improvement_pct": round(improvement, 1),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def update_recommendation_result(rec_id: int, rank: int, correct_count: int, has_bonus: bool) -> bool:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
UPDATE recommendations
|
||||
SET rank = ?, correct_count = ?, has_bonus = ?, checked = 1
|
||||
WHERE id = ?
|
||||
""",
|
||||
(rank, correct_count, 1 if has_bonus else 0, rec_id)
|
||||
)
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
# ── 시뮬레이션 CRUD ─────────────────────────────────────────────────────────
|
||||
|
||||
def save_simulation_run(
|
||||
strategy: str,
|
||||
total_generated: int,
|
||||
top_k_selected: int,
|
||||
avg_score: float,
|
||||
notes: str = "",
|
||||
) -> int:
|
||||
"""시뮬레이션 실행 기록 저장, 생성된 ID 반환"""
|
||||
with _conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO simulation_runs (strategy, total_generated, top_k_selected, avg_score, notes)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(strategy, total_generated, top_k_selected, round(avg_score, 6), notes),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def save_simulation_candidates_bulk(
|
||||
run_id: int,
|
||||
candidates: List[Dict[str, Any]],
|
||||
based_on_draw: Optional[int],
|
||||
) -> None:
|
||||
"""
|
||||
상위 후보들을 simulation_candidates 테이블에 일괄 저장.
|
||||
candidates 각 항목: {"numbers": [...], "score_total": ..., "score_*": ..., "is_best": bool}
|
||||
"""
|
||||
data = [
|
||||
(
|
||||
run_id,
|
||||
json.dumps(sorted(c["numbers"])),
|
||||
c["score_total"],
|
||||
c.get("score_frequency"),
|
||||
c.get("score_fingerprint"),
|
||||
c.get("score_gap"),
|
||||
c.get("score_cooccur"),
|
||||
c.get("score_diversity"),
|
||||
1 if c.get("is_best") else 0,
|
||||
based_on_draw,
|
||||
)
|
||||
for c in candidates
|
||||
]
|
||||
with _conn() as conn:
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO simulation_candidates
|
||||
(run_id, numbers, score_total, score_frequency, score_fingerprint,
|
||||
score_gap, score_cooccur, score_diversity, is_best, based_on_draw)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
data,
|
||||
)
|
||||
|
||||
|
||||
def replace_best_picks(
|
||||
picks: List[Dict[str, Any]],
|
||||
run_id: int,
|
||||
based_on_draw: Optional[int],
|
||||
) -> None:
|
||||
"""
|
||||
기존 활성 best_picks를 비활성화하고 새 picks로 교체.
|
||||
picks 각 항목: {"numbers": [...], "score_total": ..., "rank_in_run": int}
|
||||
"""
|
||||
with _conn() as conn:
|
||||
conn.execute("UPDATE best_picks SET is_active = 0 WHERE is_active = 1")
|
||||
data = [
|
||||
(
|
||||
json.dumps(sorted(p["numbers"])),
|
||||
p["score_total"],
|
||||
p.get("rank_in_run"),
|
||||
run_id,
|
||||
based_on_draw,
|
||||
)
|
||||
for p in picks
|
||||
]
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO best_picks (numbers, score_total, rank_in_run, source_run_id, based_on_draw, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, 1)
|
||||
""",
|
||||
data,
|
||||
)
|
||||
|
||||
|
||||
def get_best_picks(limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""현재 활성화된 best_picks 조회 (점수 내림차순)"""
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, numbers, score_total, rank_in_run, source_run_id, based_on_draw, created_at
|
||||
FROM best_picks
|
||||
WHERE is_active = 1
|
||||
ORDER BY score_total DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"id": int(r["id"]),
|
||||
"numbers": json.loads(r["numbers"]),
|
||||
"score_total": r["score_total"],
|
||||
"rank_in_run": r["rank_in_run"],
|
||||
"source_run_id": r["source_run_id"],
|
||||
"based_on_draw": r["based_on_draw"],
|
||||
"created_at": r["created_at"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def get_simulation_runs(limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""최근 시뮬레이션 실행 기록 조회"""
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, run_at, strategy, total_generated, top_k_selected, avg_score, notes
|
||||
FROM simulation_runs
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_simulation_candidates(run_id: int, limit: int = 100) -> List[Dict[str, Any]]:
|
||||
"""특정 시뮬레이션 실행의 후보 목록 조회 (점수 내림차순)"""
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, numbers, score_total, score_frequency, score_fingerprint,
|
||||
score_gap, score_cooccur, score_diversity, is_best, based_on_draw, created_at
|
||||
FROM simulation_candidates
|
||||
WHERE run_id = ?
|
||||
ORDER BY score_total DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(run_id, limit),
|
||||
).fetchall()
|
||||
return [
|
||||
{**dict(r), "numbers": json.loads(r["numbers"])}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
# ── purchase_history CRUD ─────────────────────────────────────────────────────
|
||||
|
||||
def _purchase_row_to_dict(r) -> Dict[str, Any]:
|
||||
|
||||
keys = r.keys()
|
||||
numbers_raw = r["numbers"] if "numbers" in keys else "[]"
|
||||
detail_raw = r["source_detail"] if "source_detail" in keys else "{}"
|
||||
results_raw = r["results"] if "results" in keys else "[]"
|
||||
return {
|
||||
"id": r["id"],
|
||||
"draw_no": r["draw_no"],
|
||||
"amount": r["amount"],
|
||||
"sets": r["sets"],
|
||||
"prize": r["prize"],
|
||||
"note": r["note"],
|
||||
"created_at": r["created_at"],
|
||||
"numbers": json.loads(numbers_raw) if numbers_raw else [],
|
||||
"is_real": r["is_real"] if "is_real" in keys else 1,
|
||||
"source_strategy": r["source_strategy"] if "source_strategy" in keys else "manual",
|
||||
"source_detail": json.loads(detail_raw) if detail_raw else {},
|
||||
"checked": r["checked"] if "checked" in keys else 0,
|
||||
"results": json.loads(results_raw) if results_raw else [],
|
||||
"total_prize": r["total_prize"] if "total_prize" in keys else 0,
|
||||
}
|
||||
|
||||
|
||||
def add_purchase(draw_no: int, amount: int, sets: int, prize: int = 0, note: str = "",
|
||||
numbers: list = None, is_real: bool = True,
|
||||
source_strategy: str = "manual", source_detail: dict = None) -> Dict[str, Any]:
|
||||
|
||||
numbers_json = json.dumps(numbers or [], ensure_ascii=False)
|
||||
detail_json = json.dumps(source_detail or {}, ensure_ascii=False)
|
||||
is_real_int = 1 if is_real else 0
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO purchase_history
|
||||
(draw_no, amount, sets, prize, note, numbers, is_real, source_strategy, source_detail)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(draw_no, amount, sets, prize, note, numbers_json, is_real_int, source_strategy, detail_json),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM purchase_history WHERE rowid = last_insert_rowid()").fetchone()
|
||||
return _purchase_row_to_dict(row)
|
||||
|
||||
|
||||
def get_purchases(draw_no: int = None, days: int = None,
|
||||
is_real: bool = None, strategy: str = None,
|
||||
checked: bool = None) -> List[Dict[str, Any]]:
|
||||
conditions, params = [], []
|
||||
if draw_no is not None:
|
||||
conditions.append("draw_no = ?")
|
||||
params.append(draw_no)
|
||||
if days:
|
||||
conditions.append("created_at >= datetime('now', ? || ' days')")
|
||||
params.append(f"-{days}")
|
||||
if is_real is not None:
|
||||
conditions.append("is_real = ?")
|
||||
params.append(1 if is_real else 0)
|
||||
if strategy is not None:
|
||||
conditions.append("source_strategy = ?")
|
||||
params.append(strategy)
|
||||
if checked is not None:
|
||||
conditions.append("checked = ?")
|
||||
params.append(1 if checked else 0)
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM purchase_history {where} ORDER BY draw_no DESC, id DESC",
|
||||
params,
|
||||
).fetchall()
|
||||
return [_purchase_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def update_purchase(purchase_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
|
||||
allowed = {"draw_no", "amount", "sets", "prize", "note", "numbers", "is_real", "source_strategy"}
|
||||
updates = {k: v for k, v in data.items() if k in allowed}
|
||||
if not updates:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM purchase_history WHERE id = ?", (purchase_id,)).fetchone()
|
||||
return _purchase_row_to_dict(row) if row else None
|
||||
# SQLite에 전달 전 타입 변환
|
||||
if "numbers" in updates:
|
||||
updates["numbers"] = json.dumps(updates["numbers"], ensure_ascii=False)
|
||||
if "is_real" in updates:
|
||||
updates["is_real"] = 1 if updates["is_real"] else 0
|
||||
set_clause = ", ".join(f"{k} = ?" for k in updates)
|
||||
with _conn() as conn:
|
||||
cur = conn.execute(
|
||||
f"UPDATE purchase_history SET {set_clause} WHERE id = ?",
|
||||
list(updates.values()) + [purchase_id],
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
return None
|
||||
row = conn.execute("SELECT * FROM purchase_history WHERE id = ?", (purchase_id,)).fetchone()
|
||||
return _purchase_row_to_dict(row)
|
||||
|
||||
|
||||
def delete_purchase(purchase_id: int) -> bool:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute("DELETE FROM purchase_history WHERE id = ?", (purchase_id,))
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def get_purchase_stats() -> Dict[str, Any]:
|
||||
|
||||
|
||||
def _calc_group(rows):
|
||||
if not rows:
|
||||
return {"sets": 0, "invested": 0, "prize": 0, "roi": 0.0, "win_rate": 0.0}
|
||||
invested = sum(r["amount"] for r in rows)
|
||||
prize = sum(r.get("total_prize") or r["prize"] for r in rows)
|
||||
wins = sum(1 for r in rows if (r.get("total_prize") or r["prize"]) > 0)
|
||||
return {
|
||||
"sets": sum(r["sets"] for r in rows),
|
||||
"invested": invested,
|
||||
"prize": prize,
|
||||
"roi": round((prize / invested * 100 - 100) if invested else 0.0, 2),
|
||||
"win_rate": round(wins / len(rows) * 100, 2) if rows else 0.0,
|
||||
}
|
||||
|
||||
with _conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM purchase_history").fetchall()
|
||||
|
||||
all_rows = [dict(r) for r in rows]
|
||||
real_rows = [r for r in all_rows if r.get("is_real", 1) == 1]
|
||||
virtual_rows = [r for r in all_rows if r.get("is_real", 1) == 0]
|
||||
|
||||
# 전략별 집계
|
||||
by_strategy: Dict[str, list] = {}
|
||||
for r in all_rows:
|
||||
strat = r.get("source_strategy", "manual")
|
||||
if strat not in by_strategy:
|
||||
by_strategy[strat] = []
|
||||
by_strategy[strat].append(r)
|
||||
|
||||
strategy_stats: Dict[str, Any] = {}
|
||||
for strat, srows in by_strategy.items():
|
||||
s = _calc_group(srows)
|
||||
total_correct = 0
|
||||
count_sets = 0
|
||||
hits_3plus = 0
|
||||
for r in srows:
|
||||
results_raw = r.get("results", "[]")
|
||||
try:
|
||||
results = json.loads(results_raw) if isinstance(results_raw, str) else (results_raw or [])
|
||||
except Exception:
|
||||
results = []
|
||||
for res in results:
|
||||
count_sets += 1
|
||||
c = res.get("correct", 0)
|
||||
total_correct += c
|
||||
if c >= 3:
|
||||
hits_3plus += 1
|
||||
s["avg_correct"] = round(total_correct / count_sets, 2) if count_sets else 0.0
|
||||
s["hits_3plus"] = hits_3plus
|
||||
strategy_stats[strat] = s
|
||||
|
||||
total_invested = sum(r["amount"] for r in all_rows)
|
||||
total_prize_sum = sum(r.get("total_prize") or r["prize"] for r in all_rows)
|
||||
return {
|
||||
"total": _calc_group(all_rows),
|
||||
"real": _calc_group(real_rows),
|
||||
"virtual": _calc_group(virtual_rows),
|
||||
"by_strategy": strategy_stats,
|
||||
# 하위호환
|
||||
"total_records": len(all_rows),
|
||||
"total_invested": total_invested,
|
||||
"total_prize": total_prize_sum,
|
||||
"net": total_prize_sum - total_invested,
|
||||
"return_rate": round((total_prize_sum / total_invested * 100) if total_invested else 0.0, 2),
|
||||
"prize_count": sum(1 for r in all_rows if (r.get("total_prize") or r["prize"]) > 0),
|
||||
"max_prize": max((r.get("total_prize") or r["prize"] for r in all_rows), default=0),
|
||||
}
|
||||
|
||||
|
||||
# ── weekly_reports CRUD ───────────────────────────────────────────────────────
|
||||
|
||||
def save_weekly_report(drw_no: int, report_json: str) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO weekly_reports (drw_no, report)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(drw_no) DO UPDATE SET
|
||||
report = excluded.report,
|
||||
generated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||
""",
|
||||
(drw_no, report_json),
|
||||
)
|
||||
|
||||
|
||||
def get_weekly_report_list(limit: int = 10) -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT drw_no, generated_at FROM weekly_reports ORDER BY drw_no DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_weekly_report(drw_no: int) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT drw_no, report, generated_at FROM weekly_reports WHERE drw_no = ?",
|
||||
(drw_no,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
return {"drw_no": row["drw_no"], "generated_at": row["generated_at"], **json.loads(row["report"])}
|
||||
|
||||
|
||||
def get_all_recommendation_numbers() -> List[List[int]]:
|
||||
"""개인 패턴 분석용: 저장된 모든 추천 번호 반환"""
|
||||
with _conn() as conn:
|
||||
rows = conn.execute("SELECT numbers FROM recommendations ORDER BY id DESC").fetchall()
|
||||
return [json.loads(r["numbers"]) for r in rows]
|
||||
|
||||
|
||||
# ── strategy_performance CRUD ─────────────────────────────────────────────────
|
||||
|
||||
def upsert_strategy_performance(strategy: str, draw_no: int, sets_count: int = 0,
|
||||
total_correct: int = 0, max_correct: int = 0,
|
||||
prize_total: int = 0, avg_score: float = 0.0) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO strategy_performance (strategy, draw_no, sets_count, total_correct, max_correct, prize_total, avg_score)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(strategy, draw_no) DO UPDATE SET
|
||||
sets_count=excluded.sets_count, total_correct=excluded.total_correct,
|
||||
max_correct=excluded.max_correct, prize_total=excluded.prize_total,
|
||||
avg_score=excluded.avg_score,
|
||||
updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')""",
|
||||
(strategy, draw_no, sets_count, total_correct, max_correct, prize_total, avg_score),
|
||||
)
|
||||
|
||||
|
||||
def get_strategy_performance(strategy: str = None, days: int = None) -> List[Dict[str, Any]]:
|
||||
conditions, params = [], []
|
||||
if strategy:
|
||||
conditions.append("strategy = ?")
|
||||
params.append(strategy)
|
||||
if days:
|
||||
conditions.append("updated_at >= datetime('now', ? || ' days')")
|
||||
params.append(f"-{days}")
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM strategy_performance {where} ORDER BY draw_no ASC",
|
||||
params,
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ── strategy_weights CRUD ─────────────────────────────────────────────────────
|
||||
|
||||
def get_strategy_weights() -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM strategy_weights ORDER BY weight DESC").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def update_strategy_weight(strategy: str, weight: float, ema_score: float,
|
||||
total_sets: int = None, total_hits_3plus: int = None) -> None:
|
||||
with _conn() as conn:
|
||||
fields = "weight=?, ema_score=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')"
|
||||
params = [weight, ema_score]
|
||||
if total_sets is not None:
|
||||
fields += ", total_sets=?"
|
||||
params.append(total_sets)
|
||||
if total_hits_3plus is not None:
|
||||
fields += ", total_hits_3plus=?"
|
||||
params.append(total_hits_3plus)
|
||||
params.append(strategy)
|
||||
conn.execute(f"UPDATE strategy_weights SET {fields} WHERE strategy=?", params)
|
||||
|
||||
|
||||
def update_purchase_results(purchase_id: int, results: list, total_prize: int) -> None:
|
||||
"""구매 건의 결과를 갱신 (체커 호출 후)"""
|
||||
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE purchase_history SET results=?, total_prize=?, checked=1 WHERE id=?",
|
||||
(json.dumps(results, ensure_ascii=False), total_prize, purchase_id),
|
||||
)
|
||||
|
||||
|
||||
135
backend/app/generator.py
Normal file
135
backend/app/generator.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
시뮬레이션 엔진 - lotto-lab 고도화
|
||||
|
||||
[몬테카를로 시뮬레이션 흐름]
|
||||
1. 역대 당첨번호 기반 통계 캐시 구성 (build_analysis_cache)
|
||||
2. 통계 가중치로 N개 후보 조합 생성 (weighted sampling)
|
||||
3. 5가지 기법으로 각 후보 스코어링 (score_combination)
|
||||
4. 상위 top_k개 선별하여 DB 저장 (simulation_candidates, best_picks 교체)
|
||||
|
||||
[시뮬레이션 파라미터]
|
||||
- n_candidates: 1회 시뮬레이션당 생성 후보 수 (기본 20,000)
|
||||
- top_k: 선별 및 저장할 상위 개수 (기본 100)
|
||||
- best_n: best_picks에 올릴 최상위 개수 (기본 20)
|
||||
"""
|
||||
|
||||
import random
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from .db import (
|
||||
get_latest_draw,
|
||||
get_all_draw_numbers,
|
||||
save_simulation_run,
|
||||
save_simulation_candidates_bulk,
|
||||
replace_best_picks,
|
||||
)
|
||||
from .analyzer import build_analysis_cache, build_number_weights, score_combination
|
||||
from .utils import weighted_sample_6
|
||||
|
||||
|
||||
def run_simulation(
|
||||
n_candidates: int = 20000,
|
||||
top_k: int = 100,
|
||||
best_n: int = 20,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
몬테카를로 시뮬레이션 실행 메인 함수.
|
||||
|
||||
Args:
|
||||
n_candidates: 생성할 후보 조합 수 (기본 20,000)
|
||||
top_k: DB에 저장할 상위 후보 수 (기본 100)
|
||||
best_n: best_picks에 올릴 최상위 수 (기본 20)
|
||||
|
||||
Returns:
|
||||
{run_id, total_generated, top_k_selected, avg_score, best_score, based_on_draw}
|
||||
또는 {"error": ...}
|
||||
"""
|
||||
draws = get_all_draw_numbers()
|
||||
if not draws:
|
||||
return {"error": "당첨번호 데이터가 없습니다. 먼저 동기화를 실행하세요."}
|
||||
|
||||
latest = get_latest_draw()
|
||||
based_on_draw = latest["drw_no"] if latest else None
|
||||
|
||||
# ── 1. 통계 캐시 및 가중치 구성 (시뮬레이션 전체에서 재사용) ────────────
|
||||
cache = build_analysis_cache(draws)
|
||||
weights = build_number_weights(cache)
|
||||
|
||||
# ── 2. 후보 생성 및 스코어링 ──────────────────────────────────────────────
|
||||
candidates: List[Dict[str, Any]] = []
|
||||
seen_keys: set = set()
|
||||
max_attempts = n_candidates * 3 # 중복 제거 여유분
|
||||
|
||||
attempts = 0
|
||||
while len(candidates) < n_candidates and attempts < max_attempts:
|
||||
attempts += 1
|
||||
nums = weighted_sample_6(weights)
|
||||
key = tuple(sorted(nums))
|
||||
if key in seen_keys:
|
||||
continue
|
||||
seen_keys.add(key)
|
||||
|
||||
scores = score_combination(nums, cache)
|
||||
candidates.append({
|
||||
"numbers": sorted(nums),
|
||||
**scores,
|
||||
})
|
||||
|
||||
# ── 3. 점수 내림차순 정렬 및 상위 선별 ──────────────────────────────────
|
||||
candidates.sort(key=lambda x: -x["score_total"])
|
||||
top_candidates = candidates[:top_k]
|
||||
|
||||
# is_best 플래그 표시
|
||||
best_keys = {tuple(c["numbers"]) for c in top_candidates[:best_n]}
|
||||
for c in top_candidates:
|
||||
c["is_best"] = tuple(c["numbers"]) in best_keys
|
||||
|
||||
avg_score = (
|
||||
sum(c["score_total"] for c in top_candidates) / len(top_candidates)
|
||||
if top_candidates else 0.0
|
||||
)
|
||||
best_score = top_candidates[0]["score_total"] if top_candidates else 0.0
|
||||
|
||||
# ── 4. DB 저장 ────────────────────────────────────────────────────────────
|
||||
run_id = save_simulation_run(
|
||||
strategy="monte_carlo",
|
||||
total_generated=len(candidates),
|
||||
top_k_selected=len(top_candidates),
|
||||
avg_score=avg_score,
|
||||
notes=f"based_on_draw={based_on_draw}, history={len(draws)}회",
|
||||
)
|
||||
|
||||
# 상위 top_k개만 DB에 저장 (전체 20,000개는 메모리에서만 처리)
|
||||
save_simulation_candidates_bulk(run_id, top_candidates, based_on_draw)
|
||||
|
||||
# best_picks 교체 (상위 best_n개)
|
||||
best_picks_data = [
|
||||
{
|
||||
"numbers": c["numbers"],
|
||||
"score_total": c["score_total"],
|
||||
"rank_in_run": i + 1,
|
||||
}
|
||||
for i, c in enumerate(top_candidates[:best_n])
|
||||
]
|
||||
replace_best_picks(best_picks_data, run_id, based_on_draw)
|
||||
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"total_generated": len(candidates),
|
||||
"top_k_selected": len(top_candidates),
|
||||
"best_n_saved": len(best_picks_data),
|
||||
"avg_score": round(avg_score, 6),
|
||||
"best_score": round(best_score, 6),
|
||||
"based_on_draw": based_on_draw,
|
||||
}
|
||||
|
||||
|
||||
def generate_smart_recommendations(count: int = 10) -> int:
|
||||
"""
|
||||
하위 호환성 유지용 래퍼.
|
||||
내부적으로 run_simulation을 호출하며, 기존 /api/admin/auto_gen 등에서 계속 사용 가능.
|
||||
"""
|
||||
result = run_simulation(n_candidates=5000, top_k=count, best_n=count)
|
||||
if "error" in result:
|
||||
return 0
|
||||
return result.get("best_n_saved", 0)
|
||||
@@ -1,16 +1,46 @@
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
|
||||
logger = logging.getLogger("lotto-backend")
|
||||
|
||||
from .db import (
|
||||
init_db, get_draw, get_latest_draw, get_all_draw_numbers,
|
||||
save_recommendation_dedup, list_recommendations_ex, delete_recommendation,
|
||||
update_recommendation,
|
||||
# 시뮬레이션 관련
|
||||
get_best_picks, get_simulation_runs, get_simulation_candidates,
|
||||
# todos
|
||||
get_all_todos, create_todo, update_todo, delete_todo, delete_done_todos,
|
||||
# blog
|
||||
get_all_posts, create_post, update_post, delete_post,
|
||||
# 성과 통계
|
||||
get_recommendation_performance,
|
||||
# Phase 2: 구매 이력
|
||||
add_purchase, get_purchases, update_purchase, delete_purchase, get_purchase_stats,
|
||||
# Phase 2: 주간 리포트 캐시
|
||||
save_weekly_report, get_weekly_report_list, get_weekly_report,
|
||||
# Phase 2: 개인 패턴 분석
|
||||
get_all_recommendation_numbers,
|
||||
# Phase 3: 전략 관련
|
||||
get_strategy_performance as db_get_strategy_performance,
|
||||
)
|
||||
from .recommender import recommend_numbers, recommend_with_heatmap
|
||||
from .collector import sync_latest, sync_ensure_all
|
||||
from .generator import run_simulation, generate_smart_recommendations
|
||||
from .checker import check_results_for_draw
|
||||
from .utils import calc_metrics, calc_recent_overlap
|
||||
from .analyzer import get_statistical_report, generate_weekly_report, analyze_personal_patterns, generate_combined_recommendation
|
||||
from .purchase_manager import check_purchases_for_draw
|
||||
from .strategy_evolver import (
|
||||
get_weights_with_trend, recalculate_weights,
|
||||
generate_smart_recommendation,
|
||||
)
|
||||
from .recommender import recommend_numbers
|
||||
from .collector import sync_latest
|
||||
|
||||
app = FastAPI()
|
||||
scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
||||
@@ -18,74 +48,61 @@ scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
||||
ALL_URL = os.getenv("LOTTO_ALL_URL", "https://smok95.github.io/lotto/results/all.json")
|
||||
LATEST_URL = os.getenv("LOTTO_LATEST_URL", "https://smok95.github.io/lotto/results/latest.json")
|
||||
|
||||
def calc_metrics(numbers: List[int]) -> Dict[str, Any]:
|
||||
nums = sorted(numbers)
|
||||
s = sum(nums)
|
||||
odd = sum(1 for x in nums if x % 2 == 1)
|
||||
even = len(nums) - odd
|
||||
mn, mx = nums[0], nums[-1]
|
||||
rng = mx - mn
|
||||
# ── 성과 통계 인메모리 캐시 ───────────────────────────────────────────────────
|
||||
# 채점 데이터는 하루 2번 스케줄러 실행 시에만 갱신되므로 인메모리 캐시로 충분
|
||||
_PERF_CACHE: Dict[str, Any] = {"data": None, "at": 0.0}
|
||||
_PERF_CACHE_TTL = 3600 # 1시간 (스케줄러 미실행 상황 대비 폴백)
|
||||
|
||||
# 1-10, 11-20, 21-30, 31-40, 41-45
|
||||
buckets = {
|
||||
"1-10": 0,
|
||||
"11-20": 0,
|
||||
"21-30": 0,
|
||||
"31-40": 0,
|
||||
"41-45": 0,
|
||||
}
|
||||
for x in nums:
|
||||
if 1 <= x <= 10:
|
||||
buckets["1-10"] += 1
|
||||
elif 11 <= x <= 20:
|
||||
buckets["11-20"] += 1
|
||||
elif 21 <= x <= 30:
|
||||
buckets["21-30"] += 1
|
||||
elif 31 <= x <= 40:
|
||||
buckets["31-40"] += 1
|
||||
else:
|
||||
buckets["41-45"] += 1
|
||||
|
||||
return {
|
||||
"sum": s,
|
||||
"odd": odd,
|
||||
"even": even,
|
||||
"min": mn,
|
||||
"max": mx,
|
||||
"range": rng,
|
||||
"buckets": buckets,
|
||||
}
|
||||
def _refresh_perf_cache() -> None:
|
||||
_PERF_CACHE["data"] = get_recommendation_performance()
|
||||
_PERF_CACHE["at"] = time.time()
|
||||
logger.info("성과 통계 캐시 갱신")
|
||||
|
||||
def calc_recent_overlap(numbers: List[int], draws: List[Tuple[int, List[int]]], last_k: int) -> Dict[str, Any]:
|
||||
"""
|
||||
draws: [(drw_no, [n1..n6]), ...] 오름차순
|
||||
last_k: 최근 k회 기준 중복
|
||||
"""
|
||||
if last_k <= 0:
|
||||
return {"last_k": 0, "repeats": 0, "repeated_numbers": []}
|
||||
|
||||
recent = draws[-last_k:] if len(draws) >= last_k else draws
|
||||
recent_set = set()
|
||||
for _, nums in recent:
|
||||
recent_set.update(nums)
|
||||
|
||||
repeated = sorted(set(numbers) & recent_set)
|
||||
return {
|
||||
"last_k": len(recent),
|
||||
"repeats": len(repeated),
|
||||
"repeated_numbers": repeated,
|
||||
}
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
init_db()
|
||||
scheduler.add_job(lambda: sync_latest(LATEST_URL), "cron", hour="9,21", minute=10)
|
||||
|
||||
# 1. 로또 당첨번호 동기화 (매일 9시, 21시 10분)
|
||||
# 동기화 후 새로운 회차가 있으면 채점(check)까지 수행
|
||||
def _sync_and_check():
|
||||
res = sync_latest(LATEST_URL)
|
||||
if res["was_new"]:
|
||||
check_results_for_draw(res["drawNo"])
|
||||
_refresh_perf_cache() # 새 채점 결과 반영 → 즉시 갱신
|
||||
|
||||
scheduler.add_job(_sync_and_check, "cron", hour="9,21", minute=10)
|
||||
|
||||
# 2. 몬테카를로 시뮬레이션 (하루 6회: 0, 4, 8, 12, 16, 20시)
|
||||
# 20,000개 후보 생성 → 스코어링 → 상위 100개 저장 → best_picks 교체
|
||||
def _run_simulation_job():
|
||||
run_simulation(n_candidates=20000, top_k=100, best_n=20)
|
||||
|
||||
scheduler.add_job(_run_simulation_job, "cron", hour="0,4,8,12,16,20", minute=5)
|
||||
|
||||
# 3. 토요일 오전 9시 — 다음 회차 공략 리포트 자동 캐싱
|
||||
def _save_weekly_report_job():
|
||||
import json as _json
|
||||
draws = get_all_draw_numbers()
|
||||
latest = get_latest_draw()
|
||||
if not draws or not latest:
|
||||
return
|
||||
target = latest["drw_no"] + 1
|
||||
report = generate_weekly_report(draws, target)
|
||||
save_weekly_report(target, _json.dumps(report, ensure_ascii=False))
|
||||
logger.info(f"{target}회차 리포트 저장 완료")
|
||||
|
||||
scheduler.add_job(_save_weekly_report_job, "cron", day_of_week="sat", hour=9, minute=0)
|
||||
|
||||
scheduler.start()
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/lotto/latest")
|
||||
def api_latest():
|
||||
row = get_latest_draw()
|
||||
@@ -96,8 +113,10 @@ def api_latest():
|
||||
"date": row["drw_date"],
|
||||
"numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]],
|
||||
"bonus": row["bonus"],
|
||||
"metrics": calc_metrics([row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]]),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/lotto/{drw_no:int}")
|
||||
def api_draw(drw_no: int):
|
||||
row = get_draw(drw_no)
|
||||
@@ -108,28 +127,381 @@ def api_draw(drw_no: int):
|
||||
"date": row["drw_date"],
|
||||
"numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]],
|
||||
"bonus": row["bonus"],
|
||||
"metrics": calc_metrics([row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]]),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/admin/sync_latest")
|
||||
def admin_sync_latest():
|
||||
return sync_latest(LATEST_URL)
|
||||
res = sync_latest(LATEST_URL)
|
||||
if res["was_new"]:
|
||||
check_results_for_draw(res["drawNo"])
|
||||
return res
|
||||
|
||||
# ---------- ✅ recommend (dedup save) ----------
|
||||
|
||||
@app.post("/api/admin/auto_gen")
|
||||
def admin_auto_gen(count: int = 10):
|
||||
"""기존 호환 유지: 소규모 시뮬레이션 수동 트리거"""
|
||||
n = generate_smart_recommendations(count)
|
||||
return {"generated": n}
|
||||
|
||||
|
||||
@app.post("/api/admin/simulate")
|
||||
def admin_simulate(n_candidates: int = 20000, top_k: int = 100, best_n: int = 20):
|
||||
"""
|
||||
몬테카를로 시뮬레이션 수동 트리거.
|
||||
백그라운드 스케줄과 동일한 동작을 즉시 실행.
|
||||
"""
|
||||
result = run_simulation(
|
||||
n_candidates=max(1000, min(n_candidates, 50000)),
|
||||
top_k=max(10, min(top_k, 500)),
|
||||
best_n=max(10, min(best_n, 50)),
|
||||
)
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=500, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/lotto/stats")
|
||||
def api_stats():
|
||||
sync_ensure_all(LATEST_URL, ALL_URL)
|
||||
|
||||
draws = get_all_draw_numbers()
|
||||
if not draws:
|
||||
raise HTTPException(status_code=404, detail="No data yet")
|
||||
|
||||
frequency = {n: 0 for n in range(1, 46)}
|
||||
total_draws = len(draws)
|
||||
|
||||
for _, nums in draws:
|
||||
for n in nums:
|
||||
frequency[n] += 1
|
||||
|
||||
stats = [
|
||||
{"number": n, "count": frequency[n]}
|
||||
for n in range(1, 46)
|
||||
]
|
||||
|
||||
return {
|
||||
"total_draws": total_draws,
|
||||
"frequency": stats,
|
||||
}
|
||||
|
||||
|
||||
# ── 추천 성과 통계 (Phase 1, 인메모리 캐시) ──────────────────────────────────
|
||||
@app.get("/api/lotto/stats/performance")
|
||||
def api_performance_stats():
|
||||
"""
|
||||
채점된 추천 이력 기반 성과 통계 (캐시 반환).
|
||||
캐시 갱신 시점: 새 회차 채점 직후 | TTL 1시간 만료 시
|
||||
"""
|
||||
if _PERF_CACHE["data"] is None or time.time() - _PERF_CACHE["at"] > _PERF_CACHE_TTL:
|
||||
_refresh_perf_cache()
|
||||
return _PERF_CACHE["data"]
|
||||
|
||||
|
||||
# ── 회차 공략 리포트 (Phase 1) ────────────────────────────────────────────────
|
||||
@app.get("/api/lotto/report/latest")
|
||||
def api_report_latest():
|
||||
"""
|
||||
다음 회차 공략 리포트 (최신 회차 기준으로 자동 계산).
|
||||
- 과출현/냉각/오버듀 번호 분석
|
||||
- 최근 3회 패턴
|
||||
- 3가지 전략별 추천 번호
|
||||
- AI 신뢰도 점수
|
||||
"""
|
||||
draws = get_all_draw_numbers()
|
||||
if not draws:
|
||||
raise HTTPException(status_code=404, detail="No data yet")
|
||||
latest = get_latest_draw()
|
||||
target = latest["drw_no"] + 1
|
||||
return generate_weekly_report(draws, target)
|
||||
|
||||
|
||||
@app.get("/api/lotto/report/history")
|
||||
def api_report_history(limit: int = 10):
|
||||
"""저장된 주간 리포트 목록 (자동 저장된 것만)"""
|
||||
return {"reports": get_weekly_report_list(limit=min(limit, 52))}
|
||||
|
||||
|
||||
@app.get("/api/lotto/report/{drw_no}")
|
||||
def api_report_by_draw(drw_no: int):
|
||||
"""
|
||||
특정 회차 공략 리포트 (캐시 우선, 없으면 실시간 생성).
|
||||
"""
|
||||
cached = get_weekly_report(drw_no)
|
||||
if cached:
|
||||
return {**cached, "cached": True}
|
||||
|
||||
draws = get_all_draw_numbers()
|
||||
if not draws:
|
||||
raise HTTPException(status_code=404, detail="No data yet")
|
||||
base_draws = [(no, nums) for no, nums in draws if no < drw_no]
|
||||
if not base_draws:
|
||||
raise HTTPException(status_code=400, detail=f"{drw_no}회차 이전 데이터가 없습니다")
|
||||
return {**generate_weekly_report(base_draws, drw_no), "cached": False}
|
||||
|
||||
|
||||
# ── 개인 패턴 분석 (Phase 2) ─────────────────────────────────────────────────
|
||||
@app.get("/api/lotto/analysis/personal")
|
||||
def api_personal_analysis():
|
||||
"""
|
||||
저장된 추천 이력 기반 개인 패턴 분석.
|
||||
- 자주 선택한 번호 TOP 10 / 한 번도 선택 안 한 번호
|
||||
- 홀짝 비율, 합계, 범위, 연속번호 포함률
|
||||
- 구간별 분포, 역대 당첨번호 평균과 비교
|
||||
"""
|
||||
all_numbers = get_all_recommendation_numbers()
|
||||
draws = get_all_draw_numbers()
|
||||
return analyze_personal_patterns(all_numbers, draws)
|
||||
|
||||
|
||||
# ── 구매 이력 API (Phase 2) ───────────────────────────────────────────────────
|
||||
|
||||
class PurchaseCreate(BaseModel):
|
||||
draw_no: int
|
||||
amount: int
|
||||
sets: int = 1
|
||||
prize: int = 0
|
||||
note: str = ""
|
||||
numbers: List[List[int]] = []
|
||||
is_real: bool = True
|
||||
source_strategy: str = "manual"
|
||||
source_detail: dict = {}
|
||||
|
||||
|
||||
class PurchaseUpdate(BaseModel):
|
||||
draw_no: Optional[int] = None
|
||||
amount: Optional[int] = None
|
||||
sets: Optional[int] = None
|
||||
prize: Optional[int] = None
|
||||
note: Optional[str] = None
|
||||
numbers: Optional[List[List[int]]] = None
|
||||
is_real: Optional[bool] = None
|
||||
source_strategy: Optional[str] = None
|
||||
|
||||
|
||||
@app.get("/api/lotto/purchase/stats")
|
||||
def api_purchase_stats():
|
||||
"""투자 수익률 통계 (총 투자금, 총 당첨금, 수익률 등)"""
|
||||
return get_purchase_stats()
|
||||
|
||||
|
||||
@app.get("/api/lotto/purchase")
|
||||
def api_purchase_list(draw_no: Optional[int] = None, days: Optional[int] = None,
|
||||
is_real: Optional[bool] = None, strategy: Optional[str] = None):
|
||||
"""구매 이력 조회 (필터: draw_no, days, is_real, strategy)"""
|
||||
return {"records": get_purchases(draw_no=draw_no, days=days, is_real=is_real, strategy=strategy)}
|
||||
|
||||
|
||||
@app.post("/api/lotto/purchase", status_code=201)
|
||||
def api_purchase_create(body: PurchaseCreate):
|
||||
"""구매 이력 추가 (실제/가상)"""
|
||||
sets = body.sets if body.sets > 0 else max(len(body.numbers), 1)
|
||||
amount = body.amount if body.amount > 0 else sets * 1000
|
||||
return add_purchase(
|
||||
draw_no=body.draw_no,
|
||||
amount=amount,
|
||||
sets=sets,
|
||||
prize=body.prize,
|
||||
note=body.note,
|
||||
numbers=body.numbers,
|
||||
is_real=body.is_real,
|
||||
source_strategy=body.source_strategy,
|
||||
source_detail=body.source_detail,
|
||||
)
|
||||
|
||||
|
||||
@app.put("/api/lotto/purchase/{purchase_id}")
|
||||
def api_purchase_update(purchase_id: int, body: PurchaseUpdate):
|
||||
"""구매 이력 수정 (당첨금 업데이트 등)"""
|
||||
updated = update_purchase(purchase_id, body.model_dump(exclude_none=True))
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail="Purchase not found")
|
||||
return updated
|
||||
|
||||
|
||||
@app.delete("/api/lotto/purchase/{purchase_id}")
|
||||
def api_purchase_delete(purchase_id: int):
|
||||
"""구매 이력 삭제"""
|
||||
if not delete_purchase(purchase_id):
|
||||
raise HTTPException(status_code=404, detail="Purchase not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── 전략 진화 API ──────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/lotto/strategy/weights")
|
||||
def api_strategy_weights():
|
||||
"""현재 전략별 가중치 + 성과 요약 + trend"""
|
||||
return get_weights_with_trend()
|
||||
|
||||
|
||||
@app.get("/api/lotto/strategy/performance")
|
||||
def api_strategy_performance(strategy: Optional[str] = None, days: Optional[int] = None):
|
||||
"""전략별 회차 성과 이력 (차트용)"""
|
||||
rows = db_get_strategy_performance(strategy=strategy, days=days)
|
||||
return {"records": rows}
|
||||
|
||||
|
||||
@app.post("/api/lotto/strategy/evolve")
|
||||
def api_strategy_evolve():
|
||||
"""수동 가중치 재계산 트리거"""
|
||||
new_weights = recalculate_weights()
|
||||
return {"ok": True, "weights": new_weights}
|
||||
|
||||
|
||||
# ── 스마트 추천 API ────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/lotto/recommend/smart")
|
||||
def api_recommend_smart(sets: int = 5):
|
||||
"""전략 가중치 기반 메타 전략 추천"""
|
||||
sets = max(1, min(sets, 10))
|
||||
result = generate_smart_recommendation(sets=sets)
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=500, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
# ── 통계 분석 리포트 ────────────────────────────────────────────────────────
|
||||
@app.get("/api/lotto/analysis")
|
||||
def api_analysis():
|
||||
"""
|
||||
5가지 통계 기법 기반 분석 리포트.
|
||||
- 번호별 빈도, Z-score, 갭
|
||||
- 핫/콜드/오버듀 번호
|
||||
- 역대 합계 분포, 홀짝 분포
|
||||
"""
|
||||
draws = get_all_draw_numbers()
|
||||
if not draws:
|
||||
raise HTTPException(status_code=404, detail="No data yet")
|
||||
return get_statistical_report(draws)
|
||||
|
||||
|
||||
# ── 시뮬레이션 best_picks (메인 추천 엔드포인트) ────────────────────────────
|
||||
@app.get("/api/lotto/best")
|
||||
def api_best_picks(limit: int = 20):
|
||||
"""
|
||||
시뮬레이션을 통해 선별된 최적 번호 조합 반환 (기본 20쌍).
|
||||
하루 6회 시뮬레이션 후 자동 갱신됨.
|
||||
각 조합에 점수 및 메트릭 포함.
|
||||
"""
|
||||
limit = max(1, min(limit, 50))
|
||||
picks = get_best_picks(limit=limit)
|
||||
if not picks:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="시뮬레이션 결과가 없습니다. /api/admin/simulate로 먼저 실행하세요.",
|
||||
)
|
||||
|
||||
draws = get_all_draw_numbers()
|
||||
|
||||
result = []
|
||||
for p in picks:
|
||||
nums = p["numbers"]
|
||||
result.append({
|
||||
"rank": p["rank_in_run"],
|
||||
"numbers": nums,
|
||||
"score_total": p["score_total"],
|
||||
"based_on_draw": p["based_on_draw"],
|
||||
"simulation_run_id": p["source_run_id"],
|
||||
"created_at": p["created_at"],
|
||||
"metrics": calc_metrics(nums),
|
||||
})
|
||||
|
||||
latest = get_latest_draw()
|
||||
return {
|
||||
"based_on_draw": latest["drw_no"] if latest else None,
|
||||
"count": len(result),
|
||||
"items": result,
|
||||
}
|
||||
|
||||
|
||||
# ── 시뮬레이션 전체 결과 조회 (상세 API) ────────────────────────────────────
|
||||
@app.get("/api/lotto/simulation")
|
||||
def api_simulation(run_id: Optional[int] = None, runs_limit: int = 5):
|
||||
"""
|
||||
시뮬레이션 실행 기록 및 상위 후보 상세 조회.
|
||||
run_id 미지정 시: 최근 runs_limit개 실행 기록 + 가장 최근 run의 후보 반환.
|
||||
run_id 지정 시: 해당 run의 후보만 반환.
|
||||
"""
|
||||
runs = get_simulation_runs(limit=runs_limit)
|
||||
if not runs:
|
||||
raise HTTPException(status_code=404, detail="시뮬레이션 기록이 없습니다.")
|
||||
|
||||
target_run_id = run_id if run_id is not None else runs[0]["id"]
|
||||
candidates = get_simulation_candidates(target_run_id, limit=100)
|
||||
|
||||
# 후보에 메트릭 추가
|
||||
enriched = []
|
||||
for c in candidates:
|
||||
enriched.append({
|
||||
**c,
|
||||
"metrics": calc_metrics(c["numbers"]),
|
||||
})
|
||||
|
||||
return {
|
||||
"runs": runs,
|
||||
"selected_run_id": target_run_id,
|
||||
"candidates_count": len(enriched),
|
||||
"candidates": enriched,
|
||||
}
|
||||
|
||||
|
||||
# ── 종합 추론 추천 ───────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/lotto/recommend/combined")
|
||||
def api_recommend_combined():
|
||||
"""5가지 통계 기법 종합 추론 추천 — 결과를 이력에 저장한다."""
|
||||
draws = get_all_draw_numbers()
|
||||
if not draws:
|
||||
raise HTTPException(status_code=404, detail="No data")
|
||||
|
||||
latest = get_latest_draw()
|
||||
result = generate_combined_recommendation(draws)
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=500, detail=result["error"])
|
||||
|
||||
# 추천 이력 저장 (태그: 종합추론)
|
||||
params = {"method": "combined"}
|
||||
saved = save_recommendation_dedup(
|
||||
latest["drw_no"] if latest else None,
|
||||
result["final_numbers"],
|
||||
params,
|
||||
)
|
||||
if saved["saved"]:
|
||||
update_recommendation(saved["id"], tags=["종합추론"])
|
||||
|
||||
return {
|
||||
**result,
|
||||
"id": saved["id"],
|
||||
"saved": saved["saved"],
|
||||
"deduped": saved["deduped"],
|
||||
"based_on_latest_draw": latest["drw_no"] if latest else None,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/lotto/recommend/combined/history")
|
||||
def api_combined_history(limit: int = 30):
|
||||
"""종합추론 추천 이력 조회."""
|
||||
items = list_recommendations_ex(limit=limit, tag="종합추론", sort="id_desc")
|
||||
return {"items": items, "total": len(items)}
|
||||
|
||||
|
||||
# ── 기존 수동 추천 API (하위 호환 유지) ─────────────────────────────────────
|
||||
@app.get("/api/lotto/recommend")
|
||||
def api_recommend(
|
||||
recent_window: int = 200,
|
||||
recent_weight: float = 2.0,
|
||||
avoid_recent_k: int = 5,
|
||||
|
||||
# ---- optional constraints (Lotto Lab) ----
|
||||
sum_min: Optional[int] = None,
|
||||
sum_max: Optional[int] = None,
|
||||
odd_min: Optional[int] = None,
|
||||
odd_max: Optional[int] = None,
|
||||
range_min: Optional[int] = None,
|
||||
range_max: Optional[int] = None,
|
||||
max_overlap_latest: Optional[int] = None, # 최근 avoid_recent_k 회차와 중복 허용 개수
|
||||
max_try: int = 200, # 조건 맞는 조합 찾기 재시도
|
||||
max_overlap_latest: Optional[int] = None,
|
||||
max_try: int = 200,
|
||||
):
|
||||
draws = get_all_draw_numbers()
|
||||
if not draws:
|
||||
@@ -141,7 +513,6 @@ def api_recommend(
|
||||
"recent_window": recent_window,
|
||||
"recent_weight": float(recent_weight),
|
||||
"avoid_recent_k": avoid_recent_k,
|
||||
|
||||
"sum_min": sum_min,
|
||||
"sum_max": sum_max,
|
||||
"odd_min": odd_min,
|
||||
@@ -166,7 +537,6 @@ def api_recommend(
|
||||
return False
|
||||
if range_max is not None and m["range"] > range_max:
|
||||
return False
|
||||
|
||||
if max_overlap_latest is not None:
|
||||
ov = calc_recent_overlap(nums, draws, last_k=avoid_recent_k)
|
||||
if ov["repeats"] > max_overlap_latest:
|
||||
@@ -194,11 +564,9 @@ def api_recommend(
|
||||
if chosen is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Constraints too strict. No valid set found in max_try={max_try}. "
|
||||
f"Try relaxing sum/odd/range/overlap constraints.",
|
||||
detail=f"Constraints too strict. No valid set found in max_try={max_try}.",
|
||||
)
|
||||
|
||||
# ✅ dedup save
|
||||
saved = save_recommendation_dedup(
|
||||
latest["drw_no"] if latest else None,
|
||||
chosen,
|
||||
@@ -218,10 +586,121 @@ def api_recommend(
|
||||
"params": params,
|
||||
"metrics": metrics,
|
||||
"recent_overlap": overlap,
|
||||
"tries": tries,
|
||||
"tries": tries,
|
||||
}
|
||||
|
||||
# ---------- ✅ history list (filter/paging) ----------
|
||||
|
||||
# ── 히트맵 기반 추천 (하위 호환 유지) ────────────────────────────────────────
|
||||
@app.get("/api/lotto/recommend/heatmap")
|
||||
def api_recommend_heatmap(
|
||||
heatmap_window: int = 20,
|
||||
heatmap_weight: float = 1.5,
|
||||
recent_window: int = 200,
|
||||
recent_weight: float = 2.0,
|
||||
avoid_recent_k: int = 5,
|
||||
sum_min: Optional[int] = None,
|
||||
sum_max: Optional[int] = None,
|
||||
odd_min: Optional[int] = None,
|
||||
odd_max: Optional[int] = None,
|
||||
range_min: Optional[int] = None,
|
||||
range_max: Optional[int] = None,
|
||||
max_overlap_latest: Optional[int] = None,
|
||||
max_try: int = 200,
|
||||
):
|
||||
draws = get_all_draw_numbers()
|
||||
if not draws:
|
||||
raise HTTPException(status_code=404, detail="No data yet")
|
||||
|
||||
past_recs = list_recommendations_ex(limit=100, sort="id_desc")
|
||||
latest = get_latest_draw()
|
||||
|
||||
params = {
|
||||
"heatmap_window": heatmap_window,
|
||||
"heatmap_weight": float(heatmap_weight),
|
||||
"recent_window": recent_window,
|
||||
"recent_weight": float(recent_weight),
|
||||
"avoid_recent_k": avoid_recent_k,
|
||||
"sum_min": sum_min,
|
||||
"sum_max": sum_max,
|
||||
"odd_min": odd_min,
|
||||
"odd_max": odd_max,
|
||||
"range_min": range_min,
|
||||
"range_max": range_max,
|
||||
"max_overlap_latest": max_overlap_latest,
|
||||
"max_try": int(max_try),
|
||||
}
|
||||
|
||||
def _accept(nums: List[int]) -> bool:
|
||||
m = calc_metrics(nums)
|
||||
if sum_min is not None and m["sum"] < sum_min:
|
||||
return False
|
||||
if sum_max is not None and m["sum"] > sum_max:
|
||||
return False
|
||||
if odd_min is not None and m["odd"] < odd_min:
|
||||
return False
|
||||
if odd_max is not None and m["odd"] > odd_max:
|
||||
return False
|
||||
if range_min is not None and m["range"] < range_min:
|
||||
return False
|
||||
if range_max is not None and m["range"] > range_max:
|
||||
return False
|
||||
if max_overlap_latest is not None:
|
||||
ov = calc_recent_overlap(nums, draws, last_k=avoid_recent_k)
|
||||
if ov["repeats"] > max_overlap_latest:
|
||||
return False
|
||||
return True
|
||||
|
||||
chosen = None
|
||||
explain = None
|
||||
|
||||
tries = 0
|
||||
while tries < max_try:
|
||||
tries += 1
|
||||
result = recommend_with_heatmap(
|
||||
draws,
|
||||
past_recs,
|
||||
heatmap_window=heatmap_window,
|
||||
heatmap_weight=heatmap_weight,
|
||||
recent_window=recent_window,
|
||||
recent_weight=recent_weight,
|
||||
avoid_recent_k=avoid_recent_k,
|
||||
)
|
||||
nums = result["numbers"]
|
||||
if _accept(nums):
|
||||
chosen = nums
|
||||
explain = result["explain"]
|
||||
break
|
||||
|
||||
if chosen is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Constraints too strict. No valid set found in max_try={max_try}.",
|
||||
)
|
||||
|
||||
saved = save_recommendation_dedup(
|
||||
latest["drw_no"] if latest else None,
|
||||
chosen,
|
||||
params,
|
||||
)
|
||||
|
||||
metrics = calc_metrics(chosen)
|
||||
overlap = calc_recent_overlap(chosen, draws, last_k=avoid_recent_k)
|
||||
|
||||
return {
|
||||
"id": saved["id"],
|
||||
"saved": saved["saved"],
|
||||
"deduped": saved["deduped"],
|
||||
"based_on_latest_draw": latest["drw_no"] if latest else None,
|
||||
"numbers": chosen,
|
||||
"explain": explain,
|
||||
"params": params,
|
||||
"metrics": metrics,
|
||||
"recent_overlap": overlap,
|
||||
"tries": tries,
|
||||
}
|
||||
|
||||
|
||||
# ── 추천 이력 ────────────────────────────────────────────────────────────────
|
||||
@app.get("/api/history")
|
||||
def api_history(
|
||||
limit: int = 30,
|
||||
@@ -260,6 +739,7 @@ def api_history(
|
||||
"filters": {"favorite": favorite, "tag": tag, "q": q, "sort": sort},
|
||||
}
|
||||
|
||||
|
||||
@app.delete("/api/history/{rec_id:int}")
|
||||
def api_history_delete(rec_id: int):
|
||||
ok = delete_recommendation(rec_id)
|
||||
@@ -267,12 +747,13 @@ def api_history_delete(rec_id: int):
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return {"deleted": True, "id": rec_id}
|
||||
|
||||
# ---------- ✅ history update (favorite/note/tags) ----------
|
||||
|
||||
class HistoryUpdate(BaseModel):
|
||||
favorite: Optional[bool] = None
|
||||
note: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
@app.patch("/api/history/{rec_id:int}")
|
||||
def api_history_patch(rec_id: int, body: HistoryUpdate):
|
||||
ok = update_recommendation(rec_id, favorite=body.favorite, note=body.note, tags=body.tags)
|
||||
@@ -280,11 +761,11 @@ def api_history_patch(rec_id: int, body: HistoryUpdate):
|
||||
raise HTTPException(status_code=404, detail="Not found or no changes")
|
||||
return {"updated": True, "id": rec_id}
|
||||
|
||||
# ---------- ✅ batch recommend ----------
|
||||
|
||||
# ── 배치 추천 (하위 호환 유지) ───────────────────────────────────────────────
|
||||
def _batch_unique(draws, count: int, recent_window: int, recent_weight: float, avoid_recent_k: int, max_try: int = 200):
|
||||
items = []
|
||||
seen = set()
|
||||
|
||||
tries = 0
|
||||
while len(items) < count and tries < max_try:
|
||||
tries += 1
|
||||
@@ -294,9 +775,9 @@ def _batch_unique(draws, count: int, recent_window: int, recent_weight: float, a
|
||||
continue
|
||||
seen.add(key)
|
||||
items.append(r)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
@app.get("/api/lotto/recommend/batch")
|
||||
def api_recommend_batch(
|
||||
count: int = 5,
|
||||
@@ -322,14 +803,20 @@ def api_recommend_batch(
|
||||
return {
|
||||
"based_on_latest_draw": latest["drw_no"] if latest else None,
|
||||
"count": count,
|
||||
"items": [{"numbers": it["numbers"], "explain": it["explain"]} for it in items],
|
||||
"items": [{
|
||||
"numbers": it["numbers"],
|
||||
"explain": it["explain"],
|
||||
"metrics": calc_metrics(it["numbers"]),
|
||||
} for it in items],
|
||||
"params": params,
|
||||
}
|
||||
|
||||
|
||||
class BatchSave(BaseModel):
|
||||
items: List[List[int]]
|
||||
params: dict
|
||||
|
||||
|
||||
@app.post("/api/lotto/recommend/batch")
|
||||
def api_recommend_batch_save(body: BatchSave):
|
||||
latest = get_latest_draw()
|
||||
@@ -342,3 +829,105 @@ def api_recommend_batch_save(body: BatchSave):
|
||||
|
||||
return {"saved": True, "created_ids": created, "deduped_ids": deduped}
|
||||
|
||||
|
||||
@app.get("/api/version")
|
||||
def version():
|
||||
return {"version": os.getenv("APP_VERSION", "dev")}
|
||||
|
||||
|
||||
# ── Todos API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TodoCreate(BaseModel):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
status: str = "todo"
|
||||
|
||||
|
||||
class TodoUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
|
||||
@app.get("/api/todos")
|
||||
def api_todos_list():
|
||||
return get_all_todos()
|
||||
|
||||
|
||||
@app.post("/api/todos", status_code=201)
|
||||
def api_todos_create(body: TodoCreate):
|
||||
if body.status not in ("todo", "in_progress", "done"):
|
||||
raise HTTPException(status_code=422, detail="status must be todo | in_progress | done")
|
||||
return create_todo(body.title, body.description, body.status)
|
||||
|
||||
|
||||
# ⚠️ /done 라우트를 /{todo_id} 보다 먼저 등록해야 done이 id로 매칭되지 않음
|
||||
@app.delete("/api/todos/done")
|
||||
def api_todos_delete_done():
|
||||
deleted = delete_done_todos()
|
||||
return {"deleted": deleted}
|
||||
|
||||
|
||||
@app.put("/api/todos/{todo_id}")
|
||||
def api_todos_update(todo_id: str, body: TodoUpdate):
|
||||
if body.status is not None and body.status not in ("todo", "in_progress", "done"):
|
||||
raise HTTPException(status_code=422, detail="status must be todo | in_progress | done")
|
||||
updated = update_todo(todo_id, body.model_dump(exclude_none=True))
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail="Todo not found")
|
||||
return updated
|
||||
|
||||
|
||||
@app.delete("/api/todos/{todo_id}")
|
||||
def api_todos_delete(todo_id: str):
|
||||
ok = delete_todo(todo_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Todo not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Blog API ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class BlogPostCreate(BaseModel):
|
||||
title: str
|
||||
body: str = ""
|
||||
excerpt: str = ""
|
||||
tags: List[str] = []
|
||||
date: str = "" # 빈 문자열이면 오늘 날짜 사용
|
||||
|
||||
|
||||
class BlogPostUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
body: Optional[str] = None
|
||||
excerpt: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
date: Optional[str] = None
|
||||
|
||||
|
||||
@app.get("/api/blog/posts")
|
||||
def api_blog_list():
|
||||
return {"posts": get_all_posts()}
|
||||
|
||||
|
||||
@app.post("/api/blog/posts", status_code=201)
|
||||
def api_blog_create(body: BlogPostCreate):
|
||||
from datetime import date as _date
|
||||
post_date = body.date if body.date else _date.today().isoformat()
|
||||
post = create_post(body.title, body.body, body.excerpt, body.tags, post_date)
|
||||
return post
|
||||
|
||||
|
||||
@app.put("/api/blog/posts/{post_id}")
|
||||
def api_blog_update(post_id: int, body: BlogPostUpdate):
|
||||
updated = update_post(post_id, body.model_dump(exclude_none=True))
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
return updated
|
||||
|
||||
|
||||
@app.delete("/api/blog/posts/{post_id}")
|
||||
def api_blog_delete(post_id: int):
|
||||
ok = delete_post(post_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
return {"ok": True}
|
||||
|
||||
99
backend/app/purchase_manager.py
Normal file
99
backend/app/purchase_manager.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
구매 이력 관리 + 결과 체크 모듈.
|
||||
|
||||
- check_purchases_for_draw(): 특정 회차 구매 건들의 결과를 자동 체크
|
||||
- 체커의 _calc_rank 재사용
|
||||
- 결과 체크 후 strategy_performance 자동 갱신
|
||||
"""
|
||||
import logging
|
||||
from .db import (
|
||||
get_draw, get_purchases, update_purchase_results,
|
||||
upsert_strategy_performance,
|
||||
)
|
||||
from .checker import _calc_rank
|
||||
|
||||
logger = logging.getLogger("lotto-backend")
|
||||
|
||||
RANK_PRIZE = {1: 0, 2: 0, 3: 1_500_000, 4: 50_000, 5: 5_000}
|
||||
|
||||
|
||||
def check_purchases_for_draw(drw_no: int) -> int:
|
||||
"""
|
||||
특정 회차 결과로 해당 회차 구매 건들을 채점한다.
|
||||
Returns: 채점한 구매 건 수
|
||||
"""
|
||||
win_row = get_draw(drw_no)
|
||||
if not win_row:
|
||||
return 0
|
||||
|
||||
win_nums = [win_row["n1"], win_row["n2"], win_row["n3"],
|
||||
win_row["n4"], win_row["n5"], win_row["n6"]]
|
||||
bonus = win_row["bonus"]
|
||||
|
||||
unchecked = get_purchases(draw_no=drw_no, checked=False)
|
||||
|
||||
strategy_agg = {}
|
||||
|
||||
count = 0
|
||||
for purchase in unchecked:
|
||||
numbers_list = purchase["numbers"]
|
||||
if not numbers_list:
|
||||
continue
|
||||
|
||||
results = []
|
||||
for nums in numbers_list:
|
||||
rank, correct, has_bonus = _calc_rank(nums, win_nums, bonus)
|
||||
prize = RANK_PRIZE.get(rank, 0)
|
||||
results.append({
|
||||
"numbers": nums,
|
||||
"rank": rank,
|
||||
"correct": correct,
|
||||
"has_bonus": has_bonus,
|
||||
"prize": prize,
|
||||
})
|
||||
|
||||
total_prize = sum(r["prize"] for r in results)
|
||||
update_purchase_results(purchase["id"], results, total_prize)
|
||||
|
||||
strat = purchase["source_strategy"]
|
||||
if strat not in strategy_agg:
|
||||
strategy_agg[strat] = {
|
||||
"sets_count": 0,
|
||||
"total_correct": 0,
|
||||
"max_correct": 0,
|
||||
"prize_total": 0,
|
||||
"scores": [],
|
||||
"_results": [],
|
||||
}
|
||||
agg = strategy_agg[strat]
|
||||
agg["_results"].extend(results)
|
||||
for r in results:
|
||||
agg["sets_count"] += 1
|
||||
agg["total_correct"] += r["correct"]
|
||||
agg["max_correct"] = max(agg["max_correct"], r["correct"])
|
||||
agg["prize_total"] += r["prize"]
|
||||
agg["scores"].append(r["correct"] / 6.0)
|
||||
|
||||
count += 1
|
||||
|
||||
for strat, agg in strategy_agg.items():
|
||||
avg_score = sum(agg["scores"]) / len(agg["scores"]) if agg["scores"] else 0.0
|
||||
upsert_strategy_performance(
|
||||
strategy=strat,
|
||||
draw_no=drw_no,
|
||||
sets_count=agg["sets_count"],
|
||||
total_correct=agg["total_correct"],
|
||||
max_correct=agg["max_correct"],
|
||||
prize_total=agg["prize_total"],
|
||||
avg_score=round(avg_score, 4),
|
||||
)
|
||||
|
||||
# EMA 피드백 루프: 전략 가중치 진화
|
||||
try:
|
||||
from .strategy_evolver import evolve_after_check
|
||||
evolve_after_check(strat, drw_no, agg["_results"])
|
||||
except Exception:
|
||||
logger.debug(f"[purchase_manager] evolve_after_check 건너뜀: {strat}")
|
||||
|
||||
logger.info(f"[purchase_manager] {drw_no}회차 구매 {count}건 체크 완료")
|
||||
return count
|
||||
@@ -2,6 +2,8 @@ import random
|
||||
from collections import Counter
|
||||
from typing import Dict, Any, List, Tuple
|
||||
|
||||
from .utils import weighted_sample_6
|
||||
|
||||
def recommend_numbers(
|
||||
draws: List[Tuple[int, List[int]]],
|
||||
*,
|
||||
@@ -40,20 +42,7 @@ def recommend_numbers(
|
||||
weights[n] = max(w, 0.1)
|
||||
|
||||
# 중복 없이 6개 뽑기(가중 샘플링)
|
||||
chosen = []
|
||||
pool = list(range(1, 46))
|
||||
for _ in range(6):
|
||||
total = sum(weights[n] for n in pool)
|
||||
r = random.random() * total
|
||||
acc = 0.0
|
||||
for n in pool:
|
||||
acc += weights[n]
|
||||
if acc >= r:
|
||||
chosen.append(n)
|
||||
pool.remove(n)
|
||||
break
|
||||
|
||||
chosen_sorted = sorted(chosen)
|
||||
chosen_sorted = sorted(weighted_sample_6(weights))
|
||||
|
||||
explain = {
|
||||
"recent_window": recent_window,
|
||||
@@ -66,3 +55,85 @@ def recommend_numbers(
|
||||
|
||||
return {"numbers": chosen_sorted, "explain": explain}
|
||||
|
||||
|
||||
def recommend_with_heatmap(
|
||||
draws: List[Tuple[int, List[int]]],
|
||||
past_recommendations: List[Dict[str, Any]],
|
||||
*,
|
||||
heatmap_window: int = 10,
|
||||
heatmap_weight: float = 1.5,
|
||||
recent_window: int = 200,
|
||||
recent_weight: float = 2.0,
|
||||
avoid_recent_k: int = 5,
|
||||
seed: int | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
히트맵 기반 가중치 추천:
|
||||
- 과거 추천 번호들의 적중률을 분석하여 잘 맞춘 번호에 가중치 부여
|
||||
- 기존 통계 기반 추천과 결합
|
||||
|
||||
Args:
|
||||
draws: 실제 당첨 번호 리스트 [(회차, [번호들]), ...]
|
||||
past_recommendations: 과거 추천 데이터 [{"numbers": [...], "correct_count": N, "based_on_draw": M}, ...]
|
||||
heatmap_window: 히트맵 분석할 최근 추천 개수
|
||||
heatmap_weight: 히트맵 가중치 (높을수록 과거 적중 번호 선호)
|
||||
"""
|
||||
if seed is not None:
|
||||
random.seed(seed)
|
||||
|
||||
# 1. 기존 통계 기반 가중치 계산
|
||||
all_nums = [n for _, nums in draws for n in nums]
|
||||
freq_all = Counter(all_nums)
|
||||
|
||||
recent = draws[-recent_window:] if len(draws) >= recent_window else draws
|
||||
recent_nums = [n for _, nums in recent for n in nums]
|
||||
freq_recent = Counter(recent_nums)
|
||||
|
||||
last_k = draws[-avoid_recent_k:] if len(draws) >= avoid_recent_k else draws
|
||||
last_k_nums = set(n for _, nums in last_k for n in nums)
|
||||
|
||||
# 2. 히트맵 생성: 과거 추천에서 적중한 번호들의 빈도
|
||||
heatmap = Counter()
|
||||
recent_recs = past_recommendations[-heatmap_window:] if len(past_recommendations) >= heatmap_window else past_recommendations
|
||||
|
||||
for rec in recent_recs:
|
||||
if rec.get("correct_count", 0) > 0: # 적중한 추천만
|
||||
# 적중 개수에 비례해서 가중치 부여 (많이 맞춘 추천일수록 높은 가중)
|
||||
weight = rec["correct_count"] ** 1.5 # 제곱으로 강조
|
||||
for num in rec["numbers"]:
|
||||
heatmap[num] += weight
|
||||
|
||||
# 3. 최종 가중치 = 기존 통계 + 히트맵
|
||||
weights = {}
|
||||
for n in range(1, 46):
|
||||
w = freq_all[n] + recent_weight * freq_recent[n]
|
||||
|
||||
# 히트맵 가중치 추가
|
||||
if n in heatmap:
|
||||
w += heatmap_weight * heatmap[n]
|
||||
|
||||
# 최근 출현 번호 패널티
|
||||
if n in last_k_nums:
|
||||
w *= 0.6
|
||||
|
||||
weights[n] = max(w, 0.1)
|
||||
|
||||
# 4. 가중 샘플링으로 6개 선택
|
||||
chosen_sorted = sorted(weighted_sample_6(weights))
|
||||
|
||||
# 5. 설명 데이터
|
||||
explain = {
|
||||
"recent_window": recent_window,
|
||||
"recent_weight": recent_weight,
|
||||
"avoid_recent_k": avoid_recent_k,
|
||||
"heatmap_window": heatmap_window,
|
||||
"heatmap_weight": heatmap_weight,
|
||||
"top_all": [n for n, _ in freq_all.most_common(10)],
|
||||
"top_recent": [n for n, _ in freq_recent.most_common(10)],
|
||||
"top_heatmap": [n for n, _ in heatmap.most_common(10)],
|
||||
"last_k_draws": [d for d, _ in last_k],
|
||||
"analyzed_recommendations": len(recent_recs),
|
||||
}
|
||||
|
||||
return {"numbers": chosen_sorted, "explain": explain}
|
||||
|
||||
|
||||
277
backend/app/strategy_evolver.py
Normal file
277
backend/app/strategy_evolver.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""
|
||||
전략 진화 엔진 — EMA + Softmax 기반 적응형 가중치 관리.
|
||||
"""
|
||||
import math
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, List, Any
|
||||
|
||||
logger = logging.getLogger("lotto-backend")
|
||||
|
||||
# ── Constants (importable without DB) ─────────────────────────────────────────
|
||||
ALPHA = 0.3 # EMA 감쇠율
|
||||
TEMPERATURE = 2.0 # Softmax 온도
|
||||
MIN_WEIGHT = 0.05 # 최소 가중치
|
||||
INITIAL_EMA = 0.15 # 콜드스타트 초기값
|
||||
MIN_DATA_DRAWS = 10 # 학습 최소 회차
|
||||
|
||||
STRATEGIES = ["combined", "simulation", "heatmap", "manual", "custom"]
|
||||
RANK_BONUS = {5: 0.1, 4: 0.3, 3: 0.6, 2: 0.8, 1: 1.0}
|
||||
|
||||
|
||||
# ── Pure functions (no DB dependency) ─────────────────────────────────────────
|
||||
|
||||
def calc_draw_score(results: List[Dict]) -> float:
|
||||
"""구매 결과 리스트 → 평균 성과 점수"""
|
||||
if not results:
|
||||
return 0.0
|
||||
scores = []
|
||||
for r in results:
|
||||
s = r.get("correct", 0) / 6.0
|
||||
s += RANK_BONUS.get(r.get("rank", 0), 0)
|
||||
scores.append(s)
|
||||
return sum(scores) / len(scores)
|
||||
|
||||
|
||||
def _softmax_weights(ema_scores: Dict[str, float]) -> Dict[str, float]:
|
||||
"""EMA 점수 → Softmax → 최소 가중치 보장 → 정규화"""
|
||||
raw = {s: math.exp(ema / TEMPERATURE) for s, ema in ema_scores.items()}
|
||||
total = sum(raw.values())
|
||||
weights = {s: v / total for s, v in raw.items()}
|
||||
|
||||
clamped = {}
|
||||
surplus = 0.0
|
||||
unclamped = []
|
||||
for s, w in weights.items():
|
||||
if w < MIN_WEIGHT:
|
||||
clamped[s] = MIN_WEIGHT
|
||||
surplus += MIN_WEIGHT - w
|
||||
else:
|
||||
unclamped.append(s)
|
||||
clamped[s] = w
|
||||
|
||||
if surplus > 0 and unclamped:
|
||||
unclamped_total = sum(clamped[s] for s in unclamped)
|
||||
for s in unclamped:
|
||||
clamped[s] -= surplus * (clamped[s] / unclamped_total)
|
||||
|
||||
final_total = sum(clamped.values())
|
||||
return {s: round(v / final_total, 4) for s, v in clamped.items()}
|
||||
|
||||
|
||||
# ── DB-dependent functions (use lazy imports) ─────────────────────────────────
|
||||
|
||||
def _db():
|
||||
"""Lazy import to avoid circular/relative import issues in tests"""
|
||||
from . import db as _db_mod
|
||||
return _db_mod
|
||||
|
||||
|
||||
def _recommender():
|
||||
from . import recommender as _rec_mod
|
||||
return _rec_mod
|
||||
|
||||
|
||||
def _analyzer():
|
||||
from . import analyzer as _ana_mod
|
||||
return _ana_mod
|
||||
|
||||
|
||||
def update_ema_for_strategy(strategy: str, draw_score: float) -> float:
|
||||
db = _db()
|
||||
weights = db.get_strategy_weights()
|
||||
current = next((w for w in weights if w["strategy"] == strategy), None)
|
||||
old_ema = current["ema_score"] if current else INITIAL_EMA
|
||||
new_ema = ALPHA * draw_score + (1 - ALPHA) * old_ema
|
||||
return new_ema
|
||||
|
||||
|
||||
def recalculate_weights() -> Dict[str, float]:
|
||||
db = _db()
|
||||
weights_rows = db.get_strategy_weights()
|
||||
ema_scores = {w["strategy"]: w["ema_score"] for w in weights_rows}
|
||||
|
||||
for s in STRATEGIES:
|
||||
if s not in ema_scores:
|
||||
ema_scores[s] = INITIAL_EMA
|
||||
|
||||
new_weights = _softmax_weights(ema_scores)
|
||||
|
||||
for s, w in new_weights.items():
|
||||
row = next((r for r in weights_rows if r["strategy"] == s), None)
|
||||
db.update_strategy_weight(
|
||||
strategy=s,
|
||||
weight=w,
|
||||
ema_score=ema_scores[s],
|
||||
total_sets=row["total_sets"] if row else 0,
|
||||
total_hits_3plus=row["total_hits_3plus"] if row else 0,
|
||||
)
|
||||
|
||||
logger.info(f"[strategy_evolver] 가중치 재계산: {new_weights}")
|
||||
return new_weights
|
||||
|
||||
|
||||
def evolve_after_check(strategy: str, draw_no: int, results: List[Dict]) -> None:
|
||||
db = _db()
|
||||
draw_score = calc_draw_score(results)
|
||||
new_ema = update_ema_for_strategy(strategy, draw_score)
|
||||
|
||||
weights_rows = db.get_strategy_weights()
|
||||
current = next((w for w in weights_rows if w["strategy"] == strategy), None)
|
||||
hits_3plus = sum(1 for r in results if r.get("correct", 0) >= 3)
|
||||
|
||||
db.update_strategy_weight(
|
||||
strategy=strategy,
|
||||
weight=current["weight"] if current else 0.2,
|
||||
ema_score=new_ema,
|
||||
total_sets=(current["total_sets"] if current else 0) + len(results),
|
||||
total_hits_3plus=(current["total_hits_3plus"] if current else 0) + hits_3plus,
|
||||
)
|
||||
|
||||
recalculate_weights()
|
||||
|
||||
|
||||
def get_weights_with_trend() -> Dict[str, Any]:
|
||||
db = _db()
|
||||
weights = db.get_strategy_weights()
|
||||
perfs = db.get_strategy_performance()
|
||||
|
||||
strat_perfs = {}
|
||||
for p in perfs:
|
||||
s = p["strategy"]
|
||||
if s not in strat_perfs:
|
||||
strat_perfs[s] = []
|
||||
strat_perfs[s].append(p)
|
||||
|
||||
result = []
|
||||
for w in weights:
|
||||
sp = strat_perfs.get(w["strategy"], [])
|
||||
if len(sp) >= 5:
|
||||
recent_avg = sum(p["avg_score"] for p in sp[-3:]) / 3
|
||||
older_avg = sum(p["avg_score"] for p in sp[-5:-2]) / 3
|
||||
delta = recent_avg - older_avg
|
||||
trend = "up" if delta > 0.02 else ("down" if delta < -0.02 else "stable")
|
||||
else:
|
||||
trend = "stable"
|
||||
|
||||
result.append({
|
||||
"strategy": w["strategy"],
|
||||
"weight": w["weight"],
|
||||
"ema_score": w["ema_score"],
|
||||
"total_sets": w["total_sets"],
|
||||
"hits_3plus": w["total_hits_3plus"],
|
||||
"trend": trend,
|
||||
})
|
||||
|
||||
all_draws = set()
|
||||
for p in perfs:
|
||||
all_draws.add(p["draw_no"])
|
||||
|
||||
return {
|
||||
"weights": result,
|
||||
"last_evolved": weights[0]["updated_at"] if weights else None,
|
||||
"min_data_draws": MIN_DATA_DRAWS,
|
||||
"current_data_draws": len(all_draws),
|
||||
"status": "active" if len(all_draws) >= MIN_DATA_DRAWS else "learning",
|
||||
}
|
||||
|
||||
|
||||
def generate_smart_recommendation(sets: int = 5) -> Dict[str, Any]:
|
||||
db = _db()
|
||||
rec = _recommender()
|
||||
ana = _analyzer()
|
||||
|
||||
weights_data = db.get_strategy_weights()
|
||||
weight_map = {w["strategy"]: w["weight"] for w in weights_data}
|
||||
draws = db.get_all_draw_numbers()
|
||||
if not draws:
|
||||
return {"error": "No draw data"}
|
||||
|
||||
latest = db.get_latest_draw()
|
||||
cache = ana.build_analysis_cache(draws)
|
||||
past_recs = db.list_recommendations_ex(limit=100, sort="id_desc")
|
||||
|
||||
candidates = []
|
||||
seen_keys = set()
|
||||
|
||||
def _add_candidate(nums: list, strategy: str, raw_score: float = None):
|
||||
key = tuple(sorted(nums))
|
||||
if key in seen_keys:
|
||||
return
|
||||
seen_keys.add(key)
|
||||
if raw_score is None:
|
||||
sc = ana.score_combination(nums, cache)
|
||||
raw_score = sc["score_total"]
|
||||
meta = raw_score * weight_map.get(strategy, 0.1)
|
||||
candidates.append({
|
||||
"numbers": sorted(nums),
|
||||
"raw_score": round(raw_score, 4),
|
||||
"strategy": strategy,
|
||||
"meta_score": round(meta, 4),
|
||||
})
|
||||
|
||||
# combined: 10세트
|
||||
for _ in range(10):
|
||||
try:
|
||||
r = ana.generate_combined_recommendation(draws)
|
||||
if "final_numbers" in r:
|
||||
_add_candidate(r["final_numbers"], "combined")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# simulation: best_picks 상위 10개
|
||||
best = db.get_best_picks(limit=10)
|
||||
for b in best:
|
||||
nums = json.loads(b["numbers"]) if isinstance(b["numbers"], str) else b["numbers"]
|
||||
_add_candidate(nums, "simulation", b.get("score_total"))
|
||||
|
||||
# heatmap: 10세트
|
||||
for _ in range(10):
|
||||
try:
|
||||
r = rec.recommend_with_heatmap(draws, past_recs)
|
||||
_add_candidate(r["numbers"], "heatmap")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# manual: 10세트
|
||||
for _ in range(10):
|
||||
try:
|
||||
r = rec.recommend_numbers(draws)
|
||||
_add_candidate(r["numbers"], "manual")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
candidates.sort(key=lambda c: -c["meta_score"])
|
||||
top = candidates[:sets]
|
||||
|
||||
result_sets = []
|
||||
for c in top:
|
||||
sc = ana.score_combination(c["numbers"], cache)
|
||||
contributions = {}
|
||||
for strat in STRATEGIES:
|
||||
contributions[strat] = round(weight_map.get(strat, 0) * sc["score_total"], 4)
|
||||
contrib_total = sum(contributions.values()) or 1
|
||||
contributions = {s: round(v / contrib_total, 3) for s, v in contributions.items()}
|
||||
|
||||
result_sets.append({
|
||||
"numbers": c["numbers"],
|
||||
"meta_score": c["meta_score"],
|
||||
"source_strategy": c["strategy"],
|
||||
"contribution": contributions,
|
||||
"individual_scores": {k: round(v, 4) for k, v in sc.items()},
|
||||
})
|
||||
|
||||
perfs = db.get_strategy_performance()
|
||||
data_draws = len(set(p["draw_no"] for p in perfs))
|
||||
status = "active" if data_draws >= MIN_DATA_DRAWS else "learning"
|
||||
|
||||
return {
|
||||
"sets": result_sets,
|
||||
"strategy_weights_used": weight_map,
|
||||
"learning_status": {
|
||||
"draws_learned": data_draws,
|
||||
"status": status,
|
||||
"message": "" if status == "active" else f"{MIN_DATA_DRAWS}회차 이상 데이터 필요 (현재 {data_draws}회차)",
|
||||
},
|
||||
"based_on_latest_draw": latest["drw_no"] if latest else None,
|
||||
}
|
||||
80
backend/app/utils.py
Normal file
80
backend/app/utils.py
Normal file
@@ -0,0 +1,80 @@
|
||||
import random
|
||||
from typing import List, Dict, Any, Tuple
|
||||
|
||||
|
||||
def weighted_sample_6(weights: Dict[int, float]) -> List[int]:
|
||||
"""
|
||||
가중 확률 샘플링으로 중복 없이 6개 번호 추출.
|
||||
weights: {1: w1, 2: w2, ..., 45: w45}
|
||||
"""
|
||||
pool = list(range(1, 46))
|
||||
chosen: List[int] = []
|
||||
for _ in range(6):
|
||||
total = sum(weights[n] for n in pool)
|
||||
r = random.random() * total
|
||||
acc = 0.0
|
||||
for n in pool:
|
||||
acc += weights[n]
|
||||
if acc >= r:
|
||||
chosen.append(n)
|
||||
pool.remove(n)
|
||||
break
|
||||
return chosen
|
||||
|
||||
def calc_metrics(numbers: List[int]) -> Dict[str, Any]:
|
||||
nums = sorted(numbers)
|
||||
s = sum(nums)
|
||||
odd = sum(1 for x in nums if x % 2 == 1)
|
||||
even = len(nums) - odd
|
||||
mn, mx = nums[0], nums[-1]
|
||||
rng = mx - mn
|
||||
|
||||
# 1-10, 11-20, 21-30, 31-40, 41-45
|
||||
buckets = {
|
||||
"1-10": 0,
|
||||
"11-20": 0,
|
||||
"21-30": 0,
|
||||
"31-40": 0,
|
||||
"41-45": 0,
|
||||
}
|
||||
for x in nums:
|
||||
if 1 <= x <= 10:
|
||||
buckets["1-10"] += 1
|
||||
elif 11 <= x <= 20:
|
||||
buckets["11-20"] += 1
|
||||
elif 21 <= x <= 30:
|
||||
buckets["21-30"] += 1
|
||||
elif 31 <= x <= 40:
|
||||
buckets["31-40"] += 1
|
||||
else:
|
||||
buckets["41-45"] += 1
|
||||
|
||||
return {
|
||||
"sum": s,
|
||||
"odd": odd,
|
||||
"even": even,
|
||||
"min": mn,
|
||||
"max": mx,
|
||||
"range": rng,
|
||||
"buckets": buckets,
|
||||
}
|
||||
|
||||
def calc_recent_overlap(numbers: List[int], draws: List[Tuple[int, List[int]]], last_k: int) -> Dict[str, Any]:
|
||||
"""
|
||||
draws: [(drw_no, [n1..n6]), ...] 오름차순
|
||||
last_k: 최근 k회 기준 중복
|
||||
"""
|
||||
if last_k <= 0:
|
||||
return {"last_k": 0, "repeats": 0, "repeated_numbers": []}
|
||||
|
||||
recent = draws[-last_k:] if len(draws) >= last_k else draws
|
||||
recent_set = set()
|
||||
for _, nums in recent:
|
||||
recent_set.update(nums)
|
||||
|
||||
repeated = sorted(set(numbers) & recent_set)
|
||||
return {
|
||||
"last_k": len(recent),
|
||||
"repeats": len(repeated),
|
||||
"repeated_numbers": repeated,
|
||||
}
|
||||
61
backend/tests/test_integration.py
Normal file
61
backend/tests/test_integration.py
Normal file
@@ -0,0 +1,61 @@
|
||||
# backend/tests/test_integration.py
|
||||
"""checker.py → purchase_manager 연동 통합 테스트"""
|
||||
import sys, os
|
||||
import sqlite3
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def _make_mem_conn():
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def test_check_results_triggers_purchase_check():
|
||||
"""check_results_for_draw가 purchase 체크도 트리거하는지 검증"""
|
||||
import db
|
||||
import backend.app.purchase_manager as pm
|
||||
|
||||
mem = _make_mem_conn()
|
||||
with patch("db._conn", return_value=mem):
|
||||
db.init_db()
|
||||
|
||||
# 당첨번호 삽입
|
||||
mem.execute(
|
||||
"INSERT INTO draws (drw_no, drw_date, n1, n2, n3, n4, n5, n6, bonus) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(1124, "2026-03-28", 1, 2, 3, 4, 5, 6, 7)
|
||||
)
|
||||
mem.execute(
|
||||
"INSERT INTO draws (drw_no, drw_date, n1, n2, n3, n4, n5, n6, bonus) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(1125, "2026-04-04", 10, 20, 30, 35, 40, 44, 15)
|
||||
)
|
||||
mem.commit()
|
||||
|
||||
# 1125회차 대상 구매 등록
|
||||
db.add_purchase(
|
||||
draw_no=1125, amount=1000, sets=1,
|
||||
numbers=[[10, 20, 30, 1, 2, 3]],
|
||||
is_real=True, source_strategy="combined",
|
||||
)
|
||||
|
||||
# purchase_manager의 check_purchases_for_draw<61><77><EFBFBD> 직접 호출하여 연동 검증
|
||||
with patch("db._conn", return_value=mem), \
|
||||
patch("backend.app.purchase_manager.get_draw", side_effect=lambda drw: db.get_draw(drw)), \
|
||||
patch("backend.app.purchase_manager.get_purchases", side_effect=lambda **kw: db.get_purchases(**kw)), \
|
||||
patch("backend.app.purchase_manager.update_purchase_results", side_effect=lambda *a, **kw: db.update_purchase_results(*a, **kw)), \
|
||||
patch("backend.app.purchase_manager.upsert_strategy_performance", side_effect=lambda **kw: db.upsert_strategy_performance(**kw)):
|
||||
purchase_count = pm.check_purchases_for_draw(1125)
|
||||
|
||||
assert purchase_count == 1
|
||||
|
||||
# purchase가 체크되었는지 확인
|
||||
with patch("db._conn", return_value=mem):
|
||||
purchases = db.get_purchases(draw_no=1125)
|
||||
assert purchases[0]["checked"] == 1
|
||||
assert purchases[0]["results"][0]["correct"] == 3 # 10, 20, 30 맞음
|
||||
|
||||
mem.close()
|
||||
309
backend/tests/test_purchase_manager.py
Normal file
309
backend/tests/test_purchase_manager.py
Normal file
@@ -0,0 +1,309 @@
|
||||
# backend/tests/test_purchase_manager.py
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
|
||||
# Also insert the backend root so that "backend.app" package is importable
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
import sqlite3
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
# ":memory:" 공유 커넥션 — 각 테스트에서 독립적으로 생성
|
||||
def _make_mem_conn():
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def test_purchase_history_has_new_columns():
|
||||
"""purchase_history 테이블에 신규 컬럼이 존재하는지 검증"""
|
||||
import db
|
||||
mem = _make_mem_conn()
|
||||
with patch("db._conn", return_value=mem):
|
||||
db.init_db()
|
||||
|
||||
cols = {r["name"] for r in mem.execute("PRAGMA table_info(purchase_history)").fetchall()}
|
||||
assert "numbers" in cols
|
||||
assert "is_real" in cols
|
||||
assert "source_strategy" in cols
|
||||
assert "source_detail" in cols
|
||||
assert "checked" in cols
|
||||
assert "results" in cols
|
||||
assert "total_prize" in cols
|
||||
# 기존 컬럼도 유지
|
||||
assert "draw_no" in cols
|
||||
assert "amount" in cols
|
||||
assert "sets" in cols
|
||||
assert "prize" in cols
|
||||
assert "note" in cols
|
||||
mem.close()
|
||||
|
||||
|
||||
def test_strategy_performance_table_exists():
|
||||
"""strategy_performance 테이블이 생성되는지 검증"""
|
||||
import db
|
||||
mem = _make_mem_conn()
|
||||
with patch("db._conn", return_value=mem):
|
||||
db.init_db()
|
||||
|
||||
cols = {r["name"] for r in mem.execute("PRAGMA table_info(strategy_performance)").fetchall()}
|
||||
assert "strategy" in cols
|
||||
assert "draw_no" in cols
|
||||
assert "sets_count" in cols
|
||||
assert "total_correct" in cols
|
||||
assert "avg_score" in cols
|
||||
mem.close()
|
||||
|
||||
|
||||
def test_strategy_weights_table_exists():
|
||||
"""strategy_weights 테이블이 생성되고 초기값이 있는지 검증"""
|
||||
import db
|
||||
mem = _make_mem_conn()
|
||||
with patch("db._conn", return_value=mem):
|
||||
db.init_db()
|
||||
|
||||
rows = mem.execute("SELECT * FROM strategy_weights ORDER BY strategy").fetchall()
|
||||
strategies = {r["strategy"] for r in rows}
|
||||
assert strategies == {"combined", "simulation", "heatmap", "manual", "custom"}
|
||||
# 가중치 합이 1.0
|
||||
total_weight = sum(r["weight"] for r in rows)
|
||||
assert abs(total_weight - 1.0) < 0.01
|
||||
mem.close()
|
||||
|
||||
|
||||
def test_add_purchase_with_numbers():
|
||||
"""번호 포함 구매 등록"""
|
||||
import db
|
||||
mem = _make_mem_conn()
|
||||
with patch("db._conn", return_value=mem):
|
||||
db.init_db()
|
||||
result = db.add_purchase(
|
||||
draw_no=1150,
|
||||
amount=5000,
|
||||
sets=5,
|
||||
numbers=[[1, 2, 3, 4, 5, 6], [7, 8, 9, 10, 11, 12]],
|
||||
is_real=False,
|
||||
source_strategy="simulation",
|
||||
source_detail={"run_id": 42},
|
||||
)
|
||||
assert result["draw_no"] == 1150
|
||||
assert result["amount"] == 5000
|
||||
assert result["is_real"] == 0
|
||||
assert result["source_strategy"] == "simulation"
|
||||
assert result["numbers"] == [[1, 2, 3, 4, 5, 6], [7, 8, 9, 10, 11, 12]]
|
||||
assert result["source_detail"] == {"run_id": 42}
|
||||
mem.close()
|
||||
|
||||
|
||||
def test_get_purchases_filter_is_real():
|
||||
"""is_real 필터 동작"""
|
||||
import db
|
||||
mem = _make_mem_conn()
|
||||
with patch("db._conn", return_value=mem):
|
||||
db.init_db()
|
||||
db.add_purchase(draw_no=1150, amount=5000, sets=5, is_real=True)
|
||||
db.add_purchase(draw_no=1150, amount=1000, sets=1, is_real=False)
|
||||
real_only = db.get_purchases(is_real=True)
|
||||
virtual_only = db.get_purchases(is_real=False)
|
||||
assert len(real_only) == 1
|
||||
assert real_only[0]["is_real"] == 1
|
||||
assert len(virtual_only) == 1
|
||||
assert virtual_only[0]["is_real"] == 0
|
||||
mem.close()
|
||||
|
||||
|
||||
def test_get_purchase_stats_by_type():
|
||||
"""실제/가상 분리 통계"""
|
||||
import db
|
||||
mem = _make_mem_conn()
|
||||
with patch("db._conn", return_value=mem):
|
||||
db.init_db()
|
||||
db.add_purchase(draw_no=1150, amount=5000, sets=5, is_real=True, source_strategy="manual")
|
||||
db.add_purchase(draw_no=1150, amount=1000, sets=1, is_real=False, source_strategy="simulation")
|
||||
stats = db.get_purchase_stats()
|
||||
assert "total" in stats
|
||||
assert "real" in stats
|
||||
assert "virtual" in stats
|
||||
assert "by_strategy" in stats
|
||||
assert stats["total"]["sets"] == 6
|
||||
assert stats["real"]["sets"] == 5
|
||||
assert stats["virtual"]["sets"] == 1
|
||||
assert "manual" in stats["by_strategy"]
|
||||
assert "simulation" in stats["by_strategy"]
|
||||
# 하위호환 필드
|
||||
assert "total_records" in stats
|
||||
assert stats["total_records"] == 2
|
||||
mem.close()
|
||||
|
||||
|
||||
def test_upsert_strategy_performance():
|
||||
"""전략 성과 upsert"""
|
||||
import db
|
||||
mem = _make_mem_conn()
|
||||
with patch("db._conn", return_value=mem):
|
||||
db.init_db()
|
||||
# 최초 insert
|
||||
db.upsert_strategy_performance(
|
||||
strategy="simulation",
|
||||
draw_no=1150,
|
||||
sets_count=10,
|
||||
total_correct=30,
|
||||
max_correct=5,
|
||||
prize_total=5000,
|
||||
avg_score=3.0,
|
||||
)
|
||||
rows = db.get_strategy_performance(strategy="simulation")
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["sets_count"] == 10
|
||||
assert rows[0]["avg_score"] == 3.0
|
||||
# upsert (동일 strategy+draw_no)
|
||||
db.upsert_strategy_performance(
|
||||
strategy="simulation",
|
||||
draw_no=1150,
|
||||
sets_count=20,
|
||||
total_correct=60,
|
||||
max_correct=6,
|
||||
prize_total=10000,
|
||||
avg_score=4.5,
|
||||
)
|
||||
rows = db.get_strategy_performance(strategy="simulation")
|
||||
assert len(rows) == 1 # 중복 없이 1개
|
||||
assert rows[0]["sets_count"] == 20
|
||||
assert rows[0]["avg_score"] == 4.5
|
||||
mem.close()
|
||||
|
||||
|
||||
def test_update_strategy_weight():
|
||||
"""전략 가중치 업데이트"""
|
||||
import db
|
||||
mem = _make_mem_conn()
|
||||
with patch("db._conn", return_value=mem):
|
||||
db.init_db()
|
||||
# 초기값 확인
|
||||
weights_before = db.get_strategy_weights()
|
||||
combined_before = next(w for w in weights_before if w["strategy"] == "combined")
|
||||
original_weight = combined_before["weight"]
|
||||
# 업데이트
|
||||
db.update_strategy_weight(
|
||||
strategy="combined",
|
||||
weight=0.5,
|
||||
ema_score=0.75,
|
||||
total_sets=100,
|
||||
total_hits_3plus=20,
|
||||
)
|
||||
weights_after = db.get_strategy_weights()
|
||||
combined_after = next(w for w in weights_after if w["strategy"] == "combined")
|
||||
assert combined_after["weight"] == 0.5
|
||||
assert combined_after["ema_score"] == 0.75
|
||||
assert combined_after["total_sets"] == 100
|
||||
assert combined_after["total_hits_3plus"] == 20
|
||||
mem.close()
|
||||
|
||||
|
||||
# ── purchase_manager 테스트 ───────────────────────────────────────────────────
|
||||
|
||||
def _import_purchase_manager_with_mem(mem_conn):
|
||||
"""purchase_manager를 메모리 DB에 연결된 상태로 임포트."""
|
||||
import db
|
||||
import importlib
|
||||
# backend.app 패키지로 로드해 상대 임포트가 동작하게 함
|
||||
import backend.app.purchase_manager as pm
|
||||
return pm
|
||||
|
||||
|
||||
def test_check_purchases_for_draw():
|
||||
"""특정 회차 구매 건들의 결과 체크"""
|
||||
import db
|
||||
import backend.app.purchase_manager as pm
|
||||
|
||||
mem = _make_mem_conn()
|
||||
with patch("db._conn", return_value=mem):
|
||||
db.init_db()
|
||||
# 당첨번호 삽입: 1125회 [3,12,23,34,38,45] bonus=7
|
||||
mem.execute(
|
||||
"""INSERT INTO draws (drw_no, drw_date, n1, n2, n3, n4, n5, n6, bonus)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(1125, "2024-12-01", 3, 12, 23, 34, 38, 45, 7),
|
||||
)
|
||||
mem.commit()
|
||||
|
||||
# 구매 등록: 1등 번호 세트 + 낙첨 세트
|
||||
purchase = db.add_purchase(
|
||||
draw_no=1125,
|
||||
amount=2000,
|
||||
sets=2,
|
||||
numbers=[[3, 12, 23, 34, 38, 45], [1, 2, 3, 4, 5, 6]],
|
||||
is_real=False,
|
||||
source_strategy="simulation",
|
||||
)
|
||||
|
||||
with patch("db._conn", return_value=mem), \
|
||||
patch("backend.app.purchase_manager.get_draw", side_effect=lambda drw: db.get_draw(drw)), \
|
||||
patch("backend.app.purchase_manager.get_purchases", side_effect=lambda **kw: db.get_purchases(**kw)), \
|
||||
patch("backend.app.purchase_manager.update_purchase_results", side_effect=lambda *a, **kw: db.update_purchase_results(*a, **kw)), \
|
||||
patch("backend.app.purchase_manager.upsert_strategy_performance", side_effect=lambda **kw: db.upsert_strategy_performance(**kw)):
|
||||
count = pm.check_purchases_for_draw(1125)
|
||||
|
||||
assert count == 1
|
||||
|
||||
# 결과 확인
|
||||
with patch("db._conn", return_value=mem):
|
||||
checked = db.get_purchases(draw_no=1125, checked=True)
|
||||
assert len(checked) == 1
|
||||
results = checked[0]["results"]
|
||||
assert results is not None
|
||||
assert len(results) == 2
|
||||
# 첫 번째 세트: 6개 일치 → 1등
|
||||
assert results[0]["rank"] == 1
|
||||
assert results[0]["correct"] == 6
|
||||
# 두 번째 세트: 3 하나만 일치 → 낙첨(correct=1)
|
||||
assert results[1]["rank"] == 0
|
||||
assert results[1]["correct"] == 1
|
||||
|
||||
mem.close()
|
||||
|
||||
|
||||
def test_check_purchases_updates_strategy_performance():
|
||||
"""결과 체크 후 strategy_performance가 갱신되는지 검증"""
|
||||
import db
|
||||
import backend.app.purchase_manager as pm
|
||||
|
||||
mem = _make_mem_conn()
|
||||
with patch("db._conn", return_value=mem):
|
||||
db.init_db()
|
||||
# 당첨번호 삽입: 1126회
|
||||
mem.execute(
|
||||
"""INSERT INTO draws (drw_no, drw_date, n1, n2, n3, n4, n5, n6, bonus)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(1126, "2024-12-08", 1, 2, 3, 4, 5, 6, 7),
|
||||
)
|
||||
mem.commit()
|
||||
|
||||
db.add_purchase(
|
||||
draw_no=1126,
|
||||
amount=5000,
|
||||
sets=5,
|
||||
numbers=[[1, 2, 3, 4, 5, 6], [10, 20, 30, 40, 41, 42]],
|
||||
is_real=False,
|
||||
source_strategy="simulation",
|
||||
)
|
||||
|
||||
with patch("db._conn", return_value=mem), \
|
||||
patch("backend.app.purchase_manager.get_draw", side_effect=lambda drw: db.get_draw(drw)), \
|
||||
patch("backend.app.purchase_manager.get_purchases", side_effect=lambda **kw: db.get_purchases(**kw)), \
|
||||
patch("backend.app.purchase_manager.update_purchase_results", side_effect=lambda *a, **kw: db.update_purchase_results(*a, **kw)), \
|
||||
patch("backend.app.purchase_manager.upsert_strategy_performance", side_effect=lambda **kw: db.upsert_strategy_performance(**kw)):
|
||||
count = pm.check_purchases_for_draw(1126)
|
||||
|
||||
assert count == 1
|
||||
|
||||
with patch("db._conn", return_value=mem):
|
||||
perf = db.get_strategy_performance(strategy="simulation")
|
||||
|
||||
assert len(perf) >= 1
|
||||
entry = next((p for p in perf if p["draw_no"] == 1126), None)
|
||||
assert entry is not None, "draw_no=1126 에 대한 strategy_performance 없음"
|
||||
assert entry["strategy"] == "simulation"
|
||||
assert entry["sets_count"] == 2 # 2개 세트
|
||||
|
||||
mem.close()
|
||||
72
backend/tests/test_strategy_evolver.py
Normal file
72
backend/tests/test_strategy_evolver.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
|
||||
|
||||
import math
|
||||
import pytest
|
||||
|
||||
|
||||
def test_calc_draw_score_basic():
|
||||
"""세트별 결과 → draw_score 계산"""
|
||||
from strategy_evolver import calc_draw_score
|
||||
|
||||
results = [
|
||||
{"correct": 3, "rank": 5}, # 3/6 + 0.1 = 0.6
|
||||
{"correct": 1, "rank": 0}, # 1/6 + 0 = 0.167
|
||||
]
|
||||
score = calc_draw_score(results)
|
||||
expected = ((3/6 + 0.1) + (1/6)) / 2
|
||||
assert abs(score - expected) < 0.01
|
||||
|
||||
|
||||
def test_calc_draw_score_empty():
|
||||
"""빈 결과 → 0"""
|
||||
from strategy_evolver import calc_draw_score
|
||||
assert calc_draw_score([]) == 0.0
|
||||
|
||||
|
||||
def test_recalculate_weights_softmax():
|
||||
"""EMA → Softmax 가중치 변환"""
|
||||
from strategy_evolver import _softmax_weights
|
||||
|
||||
ema_scores = {
|
||||
"combined": 0.30,
|
||||
"simulation": 0.25,
|
||||
"heatmap": 0.15,
|
||||
"manual": 0.10,
|
||||
"custom": 0.05,
|
||||
}
|
||||
weights = _softmax_weights(ema_scores)
|
||||
|
||||
assert abs(sum(weights.values()) - 1.0) < 0.001
|
||||
assert weights["combined"] > weights["simulation"]
|
||||
assert weights["simulation"] > weights["heatmap"]
|
||||
assert all(w >= 0.049 for w in weights.values())
|
||||
|
||||
|
||||
def test_recalculate_weights_min_weight():
|
||||
"""한 전략의 EMA가 매우 낮아도 최소 5% 보장"""
|
||||
from strategy_evolver import _softmax_weights
|
||||
|
||||
ema_scores = {
|
||||
"combined": 0.50,
|
||||
"simulation": 0.01,
|
||||
"heatmap": 0.01,
|
||||
"manual": 0.01,
|
||||
"custom": 0.01,
|
||||
}
|
||||
weights = _softmax_weights(ema_scores)
|
||||
|
||||
assert weights["simulation"] >= 0.049
|
||||
assert weights["custom"] >= 0.049
|
||||
assert abs(sum(weights.values()) - 1.0) < 0.001
|
||||
|
||||
|
||||
def test_update_ema():
|
||||
"""EMA 갱신 공식 검증"""
|
||||
from strategy_evolver import ALPHA
|
||||
|
||||
old_ema = 0.15
|
||||
draw_score = 0.40
|
||||
new_ema = ALPHA * draw_score + (1 - ALPHA) * old_ema
|
||||
expected = 0.3 * 0.40 + 0.7 * 0.15 # = 0.225
|
||||
assert abs(new_ema - expected) < 0.001
|
||||
4
blog-lab/.dockerignore
Normal file
4
blog-lab/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
.env
|
||||
data/
|
||||
15
blog-lab/Dockerfile
Normal file
15
blog-lab/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM python:3.12-alpine
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache gcc musl-dev
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
0
blog-lab/app/__init__.py
Normal file
0
blog-lab/app/__init__.py
Normal file
15
blog-lab/app/config.py
Normal file
15
blog-lab/app/config.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import os
|
||||
|
||||
# Anthropic Claude API
|
||||
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
||||
CLAUDE_MODEL = os.getenv("CLAUDE_MODEL", "claude-sonnet-4-20250514")
|
||||
|
||||
# Naver Search API
|
||||
NAVER_CLIENT_ID = os.getenv("NAVER_CLIENT_ID", "")
|
||||
NAVER_CLIENT_SECRET = os.getenv("NAVER_CLIENT_SECRET", "")
|
||||
|
||||
# Database
|
||||
DB_PATH = os.getenv("BLOG_DB_PATH", "/app/data/blog_marketing.db")
|
||||
|
||||
# CORS
|
||||
CORS_ALLOW_ORIGINS = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080")
|
||||
172
blog-lab/app/content_generator.py
Normal file
172
blog-lab/app/content_generator.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""Claude API 기반 콘텐츠 생성 — 트렌드 브리프 + 블로그 글 작성."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import anthropic
|
||||
|
||||
from .config import ANTHROPIC_API_KEY, CLAUDE_MODEL
|
||||
from .db import get_template
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_client: Optional[anthropic.Anthropic] = None
|
||||
|
||||
|
||||
def _get_client() -> anthropic.Anthropic:
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
|
||||
return _client
|
||||
|
||||
|
||||
def _call_claude(prompt: str, max_tokens: int = 4096) -> str:
|
||||
"""Claude API 호출. 단일 user 메시지. 현재 날짜 시스템 프롬프트 포함."""
|
||||
client = _get_client()
|
||||
today = date.today().isoformat()
|
||||
resp = client.messages.create(
|
||||
model=CLAUDE_MODEL,
|
||||
max_tokens=max_tokens,
|
||||
system=f"현재 날짜는 {today}입니다. 모든 콘텐츠는 이 날짜 기준으로 작성하세요.",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
return resp.content[0].text
|
||||
|
||||
|
||||
def generate_trend_brief(analysis: Dict[str, Any]) -> str:
|
||||
"""키워드 분석 데이터를 바탕으로 트렌드 브리프 생성."""
|
||||
template = get_template("trend_brief")
|
||||
if not template:
|
||||
raise RuntimeError("trend_brief 템플릿이 없습니다")
|
||||
|
||||
top_blogs_text = "\n".join(
|
||||
f"- {b.get('title', '')}" for b in analysis.get("top_blogs", [])
|
||||
) or "없음"
|
||||
|
||||
top_products_text = "\n".join(
|
||||
f"- {p.get('title', '')} ({p.get('lprice', '?')}원, {p.get('mallName', '')})"
|
||||
for p in analysis.get("top_products", [])
|
||||
) or "없음"
|
||||
|
||||
prompt = template.format(
|
||||
keyword=analysis.get("keyword", ""),
|
||||
competition=analysis.get("competition", 0),
|
||||
opportunity=analysis.get("opportunity", 0),
|
||||
top_blogs=top_blogs_text,
|
||||
top_products=top_products_text,
|
||||
)
|
||||
|
||||
return _call_claude(prompt)
|
||||
|
||||
|
||||
def _parse_blog_json(raw: str, keyword: str) -> Dict[str, str]:
|
||||
"""Claude 응답에서 블로그 JSON을 파싱."""
|
||||
try:
|
||||
text = raw.strip()
|
||||
if text.startswith("```"):
|
||||
lines = text.split("\n")
|
||||
lines = [l for l in lines if not l.strip().startswith("```")]
|
||||
text = "\n".join(lines)
|
||||
result = json.loads(text)
|
||||
return {
|
||||
"title": result.get("title", ""),
|
||||
"body": result.get("body", ""),
|
||||
"excerpt": result.get("excerpt", ""),
|
||||
"tags": result.get("tags", []),
|
||||
}
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
logger.warning("Blog post JSON parse failed, using raw text")
|
||||
return {
|
||||
"title": f"{keyword} 추천 리뷰",
|
||||
"body": raw,
|
||||
"excerpt": raw[:200],
|
||||
"tags": [keyword],
|
||||
}
|
||||
|
||||
|
||||
def generate_blog_post(
|
||||
analysis: Dict[str, Any],
|
||||
trend_brief: str,
|
||||
brand_links: Optional[list] = None,
|
||||
) -> Dict[str, str]:
|
||||
"""트렌드 브리프를 바탕으로 블로그 글 작성.
|
||||
|
||||
Returns:
|
||||
{"title": str, "body": str, "excerpt": str, "tags": [...]}
|
||||
"""
|
||||
template = get_template("blog_write")
|
||||
if not template:
|
||||
raise RuntimeError("blog_write 템플릿이 없습니다")
|
||||
|
||||
top_products_text = "\n".join(
|
||||
f"- {p.get('title', '')} ({p.get('lprice', '?')}원, {p.get('mallName', '')})"
|
||||
for p in analysis.get("top_products", [])
|
||||
) or "없음"
|
||||
|
||||
# 크롤링된 블로그 본문 참고 자료
|
||||
reference_blogs_text = ""
|
||||
for blog in analysis.get("top_blogs", []):
|
||||
content = blog.get("content", "")
|
||||
if content:
|
||||
reference_blogs_text += f"\n### {blog.get('title', '제목 없음')}\n{content}\n"
|
||||
if not reference_blogs_text:
|
||||
reference_blogs_text = "없음"
|
||||
|
||||
# 브랜드커넥트 링크 정보
|
||||
brand_products_text = ""
|
||||
if brand_links:
|
||||
for link in brand_links:
|
||||
brand_products_text += (
|
||||
f"- 상품명: {link.get('product_name', '')}\n"
|
||||
f" 설명: {link.get('description', '')}\n"
|
||||
f" 링크: {link.get('url', '')}\n"
|
||||
f" 배치 힌트: {link.get('placement_hint', '자연스럽게')}\n"
|
||||
)
|
||||
if not brand_products_text:
|
||||
brand_products_text = "없음 (제휴 링크 없이 일반 리뷰로 작성)"
|
||||
|
||||
prompt = template.format(
|
||||
keyword=analysis.get("keyword", ""),
|
||||
trend_brief=trend_brief,
|
||||
top_products=top_products_text,
|
||||
reference_blogs=reference_blogs_text,
|
||||
brand_products=brand_products_text,
|
||||
)
|
||||
|
||||
# 구조화된 응답을 위한 추가 지시
|
||||
prompt += (
|
||||
"\n\n---\n"
|
||||
"응답은 반드시 아래 JSON 형식으로 해주세요 (JSON만 출력, 다른 텍스트 없이):\n"
|
||||
'{"title": "블로그 제목", "body": "HTML 본문", "excerpt": "2줄 요약", '
|
||||
'"tags": ["태그1", "태그2", ...]}'
|
||||
)
|
||||
|
||||
raw = _call_claude(prompt, max_tokens=8192)
|
||||
return _parse_blog_json(raw, analysis.get("keyword", ""))
|
||||
|
||||
|
||||
def regenerate_blog_post(
|
||||
analysis: Dict[str, Any],
|
||||
trend_brief: str,
|
||||
previous_body: str,
|
||||
feedback: str,
|
||||
) -> Dict[str, str]:
|
||||
"""피드백을 반영하여 블로그 글 재생성."""
|
||||
prompt = (
|
||||
"당신은 네이버 블로그에서 월 100만 이상 수익을 올리는 전문 블로거입니다.\n"
|
||||
f"키워드: {analysis.get('keyword', '')}\n\n"
|
||||
f"이전에 작성한 글:\n{previous_body[:3000]}\n\n"
|
||||
f"리뷰어 피드백:\n{feedback}\n\n"
|
||||
"위 피드백을 반영하여 글을 개선해주세요.\n"
|
||||
"작성 규칙: 1인칭 체험기, 2,000자 이상, 자연스러운 구어체, "
|
||||
"제품 비교표 포함, 광고 고지 문구 포함.\n"
|
||||
"HTML 형식으로 작성하되, 네이버 블로그에서 바로 붙여넣기 가능한 형태로.\n\n"
|
||||
"---\n"
|
||||
"응답은 반드시 아래 JSON 형식으로 해주세요 (JSON만 출력):\n"
|
||||
'{"title": "블로그 제목", "body": "HTML 본문", "excerpt": "2줄 요약", '
|
||||
'"tags": ["태그1", "태그2", ...]}'
|
||||
)
|
||||
raw = _call_claude(prompt, max_tokens=8192)
|
||||
return _parse_blog_json(raw, analysis.get("keyword", ""))
|
||||
789
blog-lab/app/db.py
Normal file
789
blog-lab/app/db.py
Normal file
@@ -0,0 +1,789 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .config import DB_PATH
|
||||
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with _conn() as conn:
|
||||
# 키워드/상품 분석 결과
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS keyword_analyses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
keyword TEXT NOT NULL,
|
||||
blog_total INTEGER NOT NULL DEFAULT 0,
|
||||
shop_total INTEGER NOT NULL DEFAULT 0,
|
||||
competition REAL NOT NULL DEFAULT 0,
|
||||
opportunity REAL NOT NULL DEFAULT 0,
|
||||
avg_price INTEGER,
|
||||
min_price INTEGER,
|
||||
max_price INTEGER,
|
||||
top_products TEXT NOT NULL DEFAULT '[]',
|
||||
top_blogs TEXT NOT NULL DEFAULT '[]',
|
||||
ai_summary TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_ka_created ON keyword_analyses(created_at DESC)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_ka_keyword ON keyword_analyses(keyword)")
|
||||
|
||||
# 블로그 포스트
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS blog_posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
keyword_id INTEGER REFERENCES keyword_analyses(id),
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
excerpt TEXT NOT NULL DEFAULT '',
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
review_score INTEGER,
|
||||
review_detail TEXT NOT NULL DEFAULT '{}',
|
||||
naver_url TEXT NOT NULL DEFAULT '',
|
||||
trend_brief TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_bp_created ON blog_posts(created_at DESC)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_bp_status ON blog_posts(status)")
|
||||
|
||||
# 수익(커미션) 추적
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS commissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
post_id INTEGER REFERENCES blog_posts(id),
|
||||
month TEXT NOT NULL,
|
||||
clicks INTEGER NOT NULL DEFAULT 0,
|
||||
purchases INTEGER NOT NULL DEFAULT 0,
|
||||
revenue INTEGER NOT NULL DEFAULT 0,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_comm_month ON commissions(month)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_comm_post ON commissions(post_id)")
|
||||
|
||||
# 비동기 작업 상태 (research / generate / review)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS generation_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL DEFAULT 'research',
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
progress INTEGER NOT NULL DEFAULT 0,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
result_id INTEGER,
|
||||
error TEXT,
|
||||
params TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_gt_created ON generation_tasks(created_at DESC)")
|
||||
|
||||
# AI 프롬프트 템플릿
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS prompt_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
template TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
|
||||
# 브랜드커넥트 제휴 링크
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS brand_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
post_id INTEGER REFERENCES blog_posts(id),
|
||||
keyword_id INTEGER REFERENCES keyword_analyses(id),
|
||||
url TEXT NOT NULL,
|
||||
product_name TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
placement_hint TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_bl_post ON brand_links(post_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_bl_keyword ON brand_links(keyword_id)")
|
||||
|
||||
# 기본 프롬프트 템플릿 시딩 (존재하지 않을 때만)
|
||||
_seed_templates(conn)
|
||||
_migrate_templates(conn)
|
||||
|
||||
|
||||
def _seed_templates(conn: sqlite3.Connection) -> None:
|
||||
"""기본 프롬프트 템플릿을 DB에 시딩."""
|
||||
templates = [
|
||||
{
|
||||
"name": "trend_brief",
|
||||
"description": "네이버 블로그 트렌드 분석 + 제목/훅 전략 브리프",
|
||||
"template": (
|
||||
"당신은 네이버 블로그 마케팅 전문가입니다.\n"
|
||||
"아래 키워드 분석 데이터를 바탕으로 블로그 포스팅 전략 브리프를 작성하세요.\n\n"
|
||||
"키워드: {keyword}\n"
|
||||
"블로그 경쟁도: {competition} (0-100, 높을수록 경쟁 치열)\n"
|
||||
"쇼핑 기회 점수: {opportunity} (0-100, 높을수록 기회 큼)\n"
|
||||
"상위 블로그 제목들: {top_blogs}\n"
|
||||
"상위 상품들: {top_products}\n\n"
|
||||
"다음을 포함해주세요:\n"
|
||||
"1. 클릭을 유도하는 제목 공식 3가지\n"
|
||||
"2. 도입부 훅 전략 (공감형, 질문형, 충격형 중 추천)\n"
|
||||
"3. 추천 해시태그 5-10개\n"
|
||||
"4. 경쟁 분석 요약 (기존 글 대비 차별화 포인트)\n"
|
||||
"5. SEO 키워드 배치 전략"
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "blog_write",
|
||||
"description": "공감형 1인칭 체험기 블로그 글 작성",
|
||||
"template": (
|
||||
"당신은 네이버 블로그에서 월 100만 이상 수익을 올리는 전문 블로거입니다.\n"
|
||||
"아래 브리프를 바탕으로 블로그 글을 작성하세요.\n\n"
|
||||
"키워드: {keyword}\n"
|
||||
"트렌드 브리프: {trend_brief}\n"
|
||||
"상위 상품 정보: {top_products}\n\n"
|
||||
"작성 규칙:\n"
|
||||
"- 1인칭 체험기 형식 (\"제가 직접 써봤는데요\")\n"
|
||||
"- 1,500자 이상\n"
|
||||
"- 자연스러운 구어체 (네이버 블로그 톤)\n"
|
||||
"- 제품 비교표 포함 (마크다운 테이블)\n"
|
||||
"- 장단점 솔직하게 작성\n"
|
||||
"- 광고 고지 문구 포함: \"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.\"\n"
|
||||
"- 추천 매트릭스 (가성비/품질/디자인 기준)\n"
|
||||
"- 자연스러운 CTA (구매 링크 유도)\n\n"
|
||||
"HTML 형식으로 작성하되, 네이버 블로그에서 바로 붙여넣기 가능한 형태로 만들어주세요."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "quality_review",
|
||||
"description": "블로그 글 품질 리뷰 (6기준 × 10점)",
|
||||
"template": (
|
||||
"당신은 블로그 콘텐츠 품질 평가 전문가입니다.\n"
|
||||
"아래 블로그 글을 6가지 기준으로 평가해주세요.\n\n"
|
||||
"제목: {title}\n"
|
||||
"본문: {body}\n\n"
|
||||
"평가 기준 (각 1-10점):\n"
|
||||
"1. 독자 공감도 (empathy): 1인칭 체험기가 자연스럽고 공감되는가?\n"
|
||||
"2. 제목 클릭 유도력 (click_appeal): 검색 결과에서 클릭하고 싶은 제목인가?\n"
|
||||
"3. 구매 전환력 (conversion): 읽고 나서 제품을 사고 싶어지는가?\n"
|
||||
"4. SEO 최적화 (seo): 키워드 배치, 소제목, 길이가 적절한가?\n"
|
||||
"5. 형식 완성도 (format): 비교표, 이미지 설명, 단락 구성이 잘 되어있는가?\n"
|
||||
"6. 링크 자연스러움 (link_natural): 제휴 링크가 광고처럼 느껴지지 않고 자연스럽게 녹아있는가? (링크가 없으면 5점 기본)\n\n"
|
||||
"JSON 형식으로 응답:\n"
|
||||
"{{\n"
|
||||
" \"scores\": {{\n"
|
||||
" \"empathy\": N,\n"
|
||||
" \"click_appeal\": N,\n"
|
||||
" \"conversion\": N,\n"
|
||||
" \"seo\": N,\n"
|
||||
" \"format\": N,\n"
|
||||
" \"link_natural\": N\n"
|
||||
" }},\n"
|
||||
" \"total\": N,\n"
|
||||
" \"pass\": true/false,\n"
|
||||
" \"feedback\": \"개선 사항 설명\"\n"
|
||||
"}}"
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "marketer_enhance",
|
||||
"description": "마케터 전환율 강화 + 제휴 링크 삽입",
|
||||
"template": (
|
||||
"당신은 네이버 블로그 수익화 전문 마케터입니다.\n"
|
||||
"아래 블로그 초안에 제휴 링크를 자연스럽게 삽입하고 전환율을 강화하세요.\n\n"
|
||||
"=== 블로그 초안 ===\n{draft_body}\n\n"
|
||||
"=== 타겟 키워드 ===\n{keyword}\n\n"
|
||||
"=== 삽입할 제휴 링크 ===\n{brand_links_info}\n\n"
|
||||
"작업 규칙:\n"
|
||||
"- 제휴 링크를 <a href=\"URL\" target=\"_blank\">상품명</a> 형태로 본문 흐름에 맞게 2~3곳 삽입\n"
|
||||
"- 결론에 CTA(Call-to-Action) 블록 추가 (\"지금 확인하기\" 등)\n"
|
||||
"- 글 맨 아래에 광고 고지 문구 자동 삽입: \"이 포스팅은 브랜드로부터 소정의 수수료를 받을 수 있습니다\"\n"
|
||||
"- 작가의 1인칭 톤과 구어체를 유지\n"
|
||||
"- 과도한 광고 느낌 없이 자연스러운 추천 흐름 유지\n"
|
||||
"- 구매 심리를 자극하는 표현 강화 (한정 수량, 가격 비교, 실사용 만족도 등)\n"
|
||||
"- 배치 힌트가 있으면 참고하되, 문맥이 더 자연스러운 위치 우선\n"
|
||||
"- 기존 본문의 구조와 길이를 크게 변경하지 않음"
|
||||
),
|
||||
},
|
||||
]
|
||||
for t in templates:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM prompt_templates WHERE name = ?", (t["name"],)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
conn.execute(
|
||||
"INSERT INTO prompt_templates (name, description, template) VALUES (?, ?, ?)",
|
||||
(t["name"], t["description"], t["template"]),
|
||||
)
|
||||
|
||||
|
||||
def _migrate_templates(conn: sqlite3.Connection) -> None:
|
||||
"""기존 템플릿을 최신 버전으로 업데이트."""
|
||||
new_blog_write = (
|
||||
"당신은 네이버 블로그에서 월 100만 이상 수익을 올리는 전문 블로거입니다.\n"
|
||||
"아래 브리프와 참고 자료를 바탕으로 블로그 글을 작성하세요.\n\n"
|
||||
"키워드: {keyword}\n"
|
||||
"트렌드 브리프: {trend_brief}\n\n"
|
||||
"=== 상위 블로그 참고 자료 ===\n"
|
||||
"{reference_blogs}\n\n"
|
||||
"=== 상위 상품 정보 ===\n"
|
||||
"{top_products}\n\n"
|
||||
"=== 제휴 상품 (브랜드커넥트 링크) ===\n"
|
||||
"{brand_products}\n\n"
|
||||
"작성 규칙:\n"
|
||||
"- 1인칭 체험기 형식 (\"제가 직접 써봤는데요\")\n"
|
||||
"- 2,000자 이상\n"
|
||||
"- 자연스러운 구어체 (네이버 블로그 톤)\n"
|
||||
"- 상위 블로그 참고하되 표절 금지 (자신만의 시각으로 재구성)\n"
|
||||
"- 제품 비교표 포함 (HTML 테이블)\n"
|
||||
"- 장단점 솔직하게 작성\n"
|
||||
"- 제휴 상품이 있으면 자연스럽게 체험 맥락에 녹여서 작성\n"
|
||||
"- 제휴 링크는 <a> 태그로 자연스럽게 삽입\n"
|
||||
"- 추천 매트릭스 (가성비/품질/디자인 기준)\n"
|
||||
"- 자연스러운 CTA (구매 링크 유도)\n\n"
|
||||
"HTML 형식으로 작성하되, 네이버 블로그에서 바로 붙여넣기 가능한 형태로 만들어주세요."
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE prompt_templates SET template = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE name = 'blog_write'",
|
||||
(new_blog_write,),
|
||||
)
|
||||
|
||||
new_quality_review = (
|
||||
"당신은 블로그 콘텐츠 품질 평가 전문가입니다.\n"
|
||||
"아래 블로그 글을 6가지 기준으로 평가해주세요.\n\n"
|
||||
"제목: {title}\n"
|
||||
"본문: {body}\n\n"
|
||||
"평가 기준 (각 1-10점):\n"
|
||||
"1. 독자 공감도 (empathy): 1인칭 체험기가 자연스럽고 공감되는가?\n"
|
||||
"2. 제목 클릭 유도력 (click_appeal): 검색 결과에서 클릭하고 싶은 제목인가?\n"
|
||||
"3. 구매 전환력 (conversion): 읽고 나서 제품을 사고 싶어지는가?\n"
|
||||
"4. SEO 최적화 (seo): 키워드 배치, 소제목, 길이가 적절한가?\n"
|
||||
"5. 형식 완성도 (format): 비교표, 이미지 설명, 단락 구성이 잘 되어있는가?\n"
|
||||
"6. 링크 자연스러움 (link_natural): 제휴 링크가 광고처럼 느껴지지 않고 자연스럽게 녹아있는가? (링크가 없으면 5점 기본)\n\n"
|
||||
"JSON 형식으로 응답:\n"
|
||||
"{{\n"
|
||||
" \"scores\": {{\n"
|
||||
" \"empathy\": N,\n"
|
||||
" \"click_appeal\": N,\n"
|
||||
" \"conversion\": N,\n"
|
||||
" \"seo\": N,\n"
|
||||
" \"format\": N,\n"
|
||||
" \"link_natural\": N\n"
|
||||
" }},\n"
|
||||
" \"total\": N,\n"
|
||||
" \"pass\": true/false,\n"
|
||||
" \"feedback\": \"개선 사항 설명\"\n"
|
||||
"}}"
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE prompt_templates SET template = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE name = 'quality_review'",
|
||||
(new_quality_review,),
|
||||
)
|
||||
|
||||
# marketer_enhance가 없으면 추가
|
||||
existing = conn.execute("SELECT id FROM prompt_templates WHERE name = 'marketer_enhance'").fetchone()
|
||||
if not existing:
|
||||
conn.execute(
|
||||
"INSERT INTO prompt_templates (name, description, template) VALUES (?, ?, ?)",
|
||||
("marketer_enhance", "마케터 전환율 강화 + 제휴 링크 삽입",
|
||||
"당신은 네이버 블로그 수익화 전문 마케터입니다.\n"
|
||||
"아래 블로그 초안에 제휴 링크를 자연스럽게 삽입하고 전환율을 강화하세요.\n\n"
|
||||
"=== 블로그 초안 ===\n{draft_body}\n\n"
|
||||
"=== 타겟 키워드 ===\n{keyword}\n\n"
|
||||
"=== 삽입할 제휴 링크 ===\n{brand_links_info}\n\n"
|
||||
"작업 규칙:\n"
|
||||
"- 제휴 링크를 <a href=\"URL\" target=\"_blank\">상품명</a> 형태로 본문 흐름에 맞게 2~3곳 삽입\n"
|
||||
"- 결론에 CTA(Call-to-Action) 블록 추가\n"
|
||||
"- 글 맨 아래에 광고 고지 문구 자동 삽입\n"
|
||||
"- 작가의 1인칭 톤과 구어체를 유지\n"
|
||||
"- 과도한 광고 느낌 없이 자연스러운 추천 흐름 유지"),
|
||||
)
|
||||
|
||||
|
||||
# ── keyword_analyses CRUD ────────────────────────────────────────────────────
|
||||
|
||||
def _ka_row_to_dict(r) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r["id"],
|
||||
"keyword": r["keyword"],
|
||||
"blog_total": r["blog_total"],
|
||||
"shop_total": r["shop_total"],
|
||||
"competition": r["competition"],
|
||||
"opportunity": r["opportunity"],
|
||||
"avg_price": r["avg_price"],
|
||||
"min_price": r["min_price"],
|
||||
"max_price": r["max_price"],
|
||||
"top_products": json.loads(r["top_products"]) if r["top_products"] else [],
|
||||
"top_blogs": json.loads(r["top_blogs"]) if r["top_blogs"] else [],
|
||||
"ai_summary": r["ai_summary"],
|
||||
"created_at": r["created_at"],
|
||||
}
|
||||
|
||||
|
||||
def add_keyword_analysis(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO keyword_analyses
|
||||
(keyword, blog_total, shop_total, competition, opportunity,
|
||||
avg_price, min_price, max_price, top_products, top_blogs, ai_summary)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
data.get("keyword", ""),
|
||||
data.get("blog_total", 0),
|
||||
data.get("shop_total", 0),
|
||||
data.get("competition", 0),
|
||||
data.get("opportunity", 0),
|
||||
data.get("avg_price"),
|
||||
data.get("min_price"),
|
||||
data.get("max_price"),
|
||||
json.dumps(data.get("top_products", []), ensure_ascii=False),
|
||||
json.dumps(data.get("top_blogs", []), ensure_ascii=False),
|
||||
data.get("ai_summary", ""),
|
||||
),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM keyword_analyses WHERE rowid = last_insert_rowid()"
|
||||
).fetchone()
|
||||
return _ka_row_to_dict(row)
|
||||
|
||||
|
||||
def get_keyword_analysis(analysis_id: int) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM keyword_analyses WHERE id = ?", (analysis_id,)
|
||||
).fetchone()
|
||||
return _ka_row_to_dict(row) if row else None
|
||||
|
||||
|
||||
def get_keyword_analyses(limit: int = 30) -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM keyword_analyses ORDER BY created_at DESC LIMIT ?", (limit,)
|
||||
).fetchall()
|
||||
return [_ka_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def delete_keyword_analysis(analysis_id: int) -> bool:
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM keyword_analyses WHERE id = ?", (analysis_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return False
|
||||
conn.execute("DELETE FROM keyword_analyses WHERE id = ?", (analysis_id,))
|
||||
return True
|
||||
|
||||
|
||||
# ── blog_posts CRUD ──────────────────────────────────────────────────────────
|
||||
|
||||
def _post_row_to_dict(r) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r["id"],
|
||||
"keyword_id": r["keyword_id"],
|
||||
"title": r["title"],
|
||||
"body": r["body"],
|
||||
"excerpt": r["excerpt"],
|
||||
"tags": json.loads(r["tags"]) if r["tags"] else [],
|
||||
"status": r["status"],
|
||||
"review_score": r["review_score"],
|
||||
"review_detail": json.loads(r["review_detail"]) if r["review_detail"] else {},
|
||||
"naver_url": r["naver_url"],
|
||||
"trend_brief": r["trend_brief"],
|
||||
"created_at": r["created_at"],
|
||||
"updated_at": r["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
def add_post(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO blog_posts
|
||||
(keyword_id, title, body, excerpt, tags, status, review_score,
|
||||
review_detail, naver_url, trend_brief)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
data.get("keyword_id"),
|
||||
data.get("title", ""),
|
||||
data.get("body", ""),
|
||||
data.get("excerpt", ""),
|
||||
json.dumps(data.get("tags", []), ensure_ascii=False),
|
||||
data.get("status", "draft"),
|
||||
data.get("review_score"),
|
||||
json.dumps(data.get("review_detail", {}), ensure_ascii=False),
|
||||
data.get("naver_url", ""),
|
||||
data.get("trend_brief", ""),
|
||||
),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM blog_posts WHERE rowid = last_insert_rowid()"
|
||||
).fetchone()
|
||||
return _post_row_to_dict(row)
|
||||
|
||||
|
||||
def get_post(post_id: int) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM blog_posts WHERE id = ?", (post_id,)
|
||||
).fetchone()
|
||||
return _post_row_to_dict(row) if row else None
|
||||
|
||||
|
||||
def get_posts(status: Optional[str] = None, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
if status:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM blog_posts WHERE status = ? ORDER BY created_at DESC LIMIT ?",
|
||||
(status, limit),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM blog_posts ORDER BY created_at DESC LIMIT ?", (limit,)
|
||||
).fetchall()
|
||||
return [_post_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def update_post(post_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
fields = []
|
||||
values = []
|
||||
for k in ("title", "body", "excerpt", "status", "naver_url", "trend_brief"):
|
||||
if k in data:
|
||||
fields.append(f"{k} = ?")
|
||||
values.append(data[k])
|
||||
if "tags" in data:
|
||||
fields.append("tags = ?")
|
||||
values.append(json.dumps(data["tags"], ensure_ascii=False))
|
||||
if "review_score" in data:
|
||||
fields.append("review_score = ?")
|
||||
values.append(data["review_score"])
|
||||
if "review_detail" in data:
|
||||
fields.append("review_detail = ?")
|
||||
values.append(json.dumps(data["review_detail"], ensure_ascii=False))
|
||||
if not fields:
|
||||
return get_post(post_id)
|
||||
fields.append("updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')")
|
||||
values.append(post_id)
|
||||
conn.execute(
|
||||
f"UPDATE blog_posts SET {', '.join(fields)} WHERE id = ?", values
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM blog_posts WHERE id = ?", (post_id,)
|
||||
).fetchone()
|
||||
return _post_row_to_dict(row) if row else None
|
||||
|
||||
|
||||
def delete_post(post_id: int) -> bool:
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM blog_posts WHERE id = ?", (post_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return False
|
||||
conn.execute("DELETE FROM blog_posts WHERE id = ?", (post_id,))
|
||||
return True
|
||||
|
||||
|
||||
# ── commissions CRUD ─────────────────────────────────────────────────────────
|
||||
|
||||
def _comm_row_to_dict(r) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r["id"],
|
||||
"post_id": r["post_id"],
|
||||
"month": r["month"],
|
||||
"clicks": r["clicks"],
|
||||
"purchases": r["purchases"],
|
||||
"revenue": r["revenue"],
|
||||
"note": r["note"],
|
||||
"created_at": r["created_at"],
|
||||
}
|
||||
|
||||
|
||||
def add_commission(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO commissions (post_id, month, clicks, purchases, revenue, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
data.get("post_id"),
|
||||
data.get("month", ""),
|
||||
data.get("clicks", 0),
|
||||
data.get("purchases", 0),
|
||||
data.get("revenue", 0),
|
||||
data.get("note", ""),
|
||||
),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM commissions WHERE rowid = last_insert_rowid()"
|
||||
).fetchone()
|
||||
return _comm_row_to_dict(row)
|
||||
|
||||
|
||||
def get_commissions(post_id: Optional[int] = None, limit: int = 100) -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
if post_id:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM commissions WHERE post_id = ? ORDER BY month DESC LIMIT ?",
|
||||
(post_id, limit),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM commissions ORDER BY month DESC LIMIT ?", (limit,)
|
||||
).fetchall()
|
||||
return [_comm_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def update_commission(comm_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
fields = []
|
||||
values = []
|
||||
for k in ("month", "clicks", "purchases", "revenue", "note"):
|
||||
if k in data:
|
||||
fields.append(f"{k} = ?")
|
||||
values.append(data[k])
|
||||
if not fields:
|
||||
return None
|
||||
values.append(comm_id)
|
||||
conn.execute(
|
||||
f"UPDATE commissions SET {', '.join(fields)} WHERE id = ?", values
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM commissions WHERE id = ?", (comm_id,)
|
||||
).fetchone()
|
||||
return _comm_row_to_dict(row) if row else None
|
||||
|
||||
|
||||
def delete_commission(comm_id: int) -> bool:
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM commissions WHERE id = ?", (comm_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return False
|
||||
conn.execute("DELETE FROM commissions WHERE id = ?", (comm_id,))
|
||||
return True
|
||||
|
||||
|
||||
# ── brand_links CRUD ────────────────────────────────────────────────────────
|
||||
|
||||
def _bl_row_to_dict(r) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r["id"],
|
||||
"post_id": r["post_id"],
|
||||
"keyword_id": r["keyword_id"],
|
||||
"url": r["url"],
|
||||
"product_name": r["product_name"],
|
||||
"description": r["description"],
|
||||
"placement_hint": r["placement_hint"],
|
||||
"created_at": r["created_at"],
|
||||
}
|
||||
|
||||
|
||||
def add_brand_link(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO brand_links (post_id, keyword_id, url, product_name, description, placement_hint)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
data.get("post_id"),
|
||||
data.get("keyword_id"),
|
||||
data.get("url", ""),
|
||||
data.get("product_name", ""),
|
||||
data.get("description", ""),
|
||||
data.get("placement_hint", ""),
|
||||
),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM brand_links WHERE rowid = last_insert_rowid()"
|
||||
).fetchone()
|
||||
return _bl_row_to_dict(row)
|
||||
|
||||
|
||||
def get_brand_links(
|
||||
post_id: Optional[int] = None,
|
||||
keyword_id: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
if post_id is not None:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM brand_links WHERE post_id = ? ORDER BY id", (post_id,)
|
||||
).fetchall()
|
||||
elif keyword_id is not None:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM brand_links WHERE keyword_id = ? ORDER BY id", (keyword_id,)
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute("SELECT * FROM brand_links ORDER BY id DESC LIMIT 100").fetchall()
|
||||
return [_bl_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def update_brand_link(link_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
fields = []
|
||||
values = []
|
||||
for k in ("post_id", "keyword_id", "url", "product_name", "description", "placement_hint"):
|
||||
if k in data:
|
||||
fields.append(f"{k} = ?")
|
||||
values.append(data[k])
|
||||
if not fields:
|
||||
row = conn.execute("SELECT * FROM brand_links WHERE id = ?", (link_id,)).fetchone()
|
||||
return _bl_row_to_dict(row) if row else None
|
||||
values.append(link_id)
|
||||
conn.execute(f"UPDATE brand_links SET {', '.join(fields)} WHERE id = ?", values)
|
||||
row = conn.execute("SELECT * FROM brand_links WHERE id = ?", (link_id,)).fetchone()
|
||||
return _bl_row_to_dict(row) if row else None
|
||||
|
||||
|
||||
def delete_brand_link(link_id: int) -> bool:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT id FROM brand_links WHERE id = ?", (link_id,)).fetchone()
|
||||
if not row:
|
||||
return False
|
||||
conn.execute("DELETE FROM brand_links WHERE id = ?", (link_id,))
|
||||
return True
|
||||
|
||||
|
||||
def link_brand_links_to_post(keyword_id: int, post_id: int) -> None:
|
||||
"""keyword_id로 등록된 링크들을 post_id에도 연결."""
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE brand_links SET post_id = ? WHERE keyword_id = ? AND post_id IS NULL",
|
||||
(post_id, keyword_id),
|
||||
)
|
||||
|
||||
|
||||
def get_dashboard_stats() -> Dict[str, Any]:
|
||||
"""대시보드 집계: 총 포스트/클릭/구매/수익 + 월별 추이."""
|
||||
with _conn() as conn:
|
||||
total_posts = conn.execute("SELECT COUNT(*) FROM blog_posts").fetchone()[0]
|
||||
published = conn.execute(
|
||||
"SELECT COUNT(*) FROM blog_posts WHERE status = 'published'"
|
||||
).fetchone()[0]
|
||||
|
||||
agg = conn.execute(
|
||||
"SELECT COALESCE(SUM(clicks),0), COALESCE(SUM(purchases),0), COALESCE(SUM(revenue),0) FROM commissions"
|
||||
).fetchone()
|
||||
|
||||
monthly = conn.execute(
|
||||
"""SELECT month, SUM(clicks) as clicks, SUM(purchases) as purchases, SUM(revenue) as revenue
|
||||
FROM commissions GROUP BY month ORDER BY month DESC LIMIT 12"""
|
||||
).fetchall()
|
||||
|
||||
top_posts = conn.execute(
|
||||
"""SELECT bp.id, bp.title, COALESCE(SUM(c.revenue),0) as total_revenue
|
||||
FROM blog_posts bp LEFT JOIN commissions c ON c.post_id = bp.id
|
||||
GROUP BY bp.id ORDER BY total_revenue DESC LIMIT 5"""
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"total_posts": total_posts,
|
||||
"published_posts": published,
|
||||
"total_clicks": agg[0],
|
||||
"total_purchases": agg[1],
|
||||
"total_revenue": agg[2],
|
||||
"monthly": [
|
||||
{"month": r["month"], "clicks": r["clicks"], "purchases": r["purchases"], "revenue": r["revenue"]}
|
||||
for r in monthly
|
||||
],
|
||||
"top_posts": [
|
||||
{"id": r["id"], "title": r["title"], "total_revenue": r["total_revenue"]}
|
||||
for r in top_posts
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── generation_tasks CRUD ────────────────────────────────────────────────────
|
||||
|
||||
def _task_row_to_dict(r) -> Dict[str, Any]:
|
||||
return {
|
||||
"task_id": r["id"],
|
||||
"type": r["type"],
|
||||
"status": r["status"],
|
||||
"progress": r["progress"],
|
||||
"message": r["message"],
|
||||
"result_id": r["result_id"],
|
||||
"error": r["error"],
|
||||
"params": json.loads(r["params"]) if r["params"] else {},
|
||||
"created_at": r["created_at"],
|
||||
"updated_at": r["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
def create_task(task_id: str, task_type: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO generation_tasks (id, type, params) VALUES (?, ?, ?)",
|
||||
(task_id, task_type, json.dumps(params, ensure_ascii=False)),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM generation_tasks WHERE id = ?", (task_id,)
|
||||
).fetchone()
|
||||
return _task_row_to_dict(row)
|
||||
|
||||
|
||||
def update_task(
|
||||
task_id: str,
|
||||
status: str,
|
||||
progress: int,
|
||||
message: str,
|
||||
result_id: Optional[int] = None,
|
||||
error: Optional[str] = None,
|
||||
) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""UPDATE generation_tasks
|
||||
SET status = ?, progress = ?, message = ?, result_id = ?, error = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||
WHERE id = ?""",
|
||||
(status, progress, message, result_id, error, task_id),
|
||||
)
|
||||
|
||||
|
||||
def get_task(task_id: str) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM generation_tasks WHERE id = ?", (task_id,)
|
||||
).fetchone()
|
||||
return _task_row_to_dict(row) if row else None
|
||||
|
||||
|
||||
# ── prompt_templates CRUD ────────────────────────────────────────────────────
|
||||
|
||||
def get_template(name: str) -> Optional[str]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT template FROM prompt_templates WHERE name = ?", (name,)
|
||||
).fetchone()
|
||||
return row["template"] if row else None
|
||||
|
||||
|
||||
def get_all_templates() -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM prompt_templates ORDER BY name").fetchall()
|
||||
return [
|
||||
{"id": r["id"], "name": r["name"], "description": r["description"],
|
||||
"template": r["template"], "updated_at": r["updated_at"]}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def update_template(name: str, template: str) -> bool:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE prompt_templates SET template = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE name = ?",
|
||||
(template, name),
|
||||
)
|
||||
return conn.execute(
|
||||
"SELECT id FROM prompt_templates WHERE name = ?", (name,)
|
||||
).fetchone() is not None
|
||||
440
blog-lab/app/main.py
Normal file
440
blog-lab/app/main.py
Normal file
@@ -0,0 +1,440 @@
|
||||
import os
|
||||
import uuid
|
||||
import logging
|
||||
from fastapi import FastAPI, HTTPException, BackgroundTasks, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
|
||||
from .config import CORS_ALLOW_ORIGINS, NAVER_CLIENT_ID, ANTHROPIC_API_KEY
|
||||
from .db import (
|
||||
init_db,
|
||||
get_keyword_analyses, get_keyword_analysis, delete_keyword_analysis,
|
||||
add_keyword_analysis,
|
||||
get_posts, get_post, add_post, update_post, delete_post,
|
||||
get_commissions, add_commission, update_commission, delete_commission,
|
||||
get_dashboard_stats,
|
||||
get_task, create_task, update_task,
|
||||
add_brand_link, get_brand_links, update_brand_link, delete_brand_link,
|
||||
link_brand_links_to_post,
|
||||
)
|
||||
from .naver_search import analyze_keyword_with_crawling
|
||||
from .content_generator import generate_trend_brief, generate_blog_post, regenerate_blog_post
|
||||
from .quality_reviewer import review_post
|
||||
from .marketer import enhance_for_conversion
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
_cors_origins = CORS_ALLOW_ORIGINS.split(",")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[o.strip() for o in _cors_origins],
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type"],
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
init_db()
|
||||
os.makedirs("/app/data", exist_ok=True)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/blog-marketing/status")
|
||||
def service_status():
|
||||
"""서비스 상태 및 설정 현황."""
|
||||
return {
|
||||
"ok": True,
|
||||
"naver_api": bool(NAVER_CLIENT_ID),
|
||||
"claude_api": bool(ANTHROPIC_API_KEY),
|
||||
}
|
||||
|
||||
|
||||
# ── 키워드 분석 API ──────────────────────────────────────────────────────────
|
||||
|
||||
class ResearchRequest(BaseModel):
|
||||
keyword: str
|
||||
|
||||
|
||||
def _run_research(task_id: str, keyword: str):
|
||||
"""BackgroundTask: 네이버 검색 → 키워드 분석 → DB 저장."""
|
||||
try:
|
||||
update_task(task_id, "processing", 30, "네이버 검색 중...")
|
||||
result = analyze_keyword_with_crawling(keyword)
|
||||
|
||||
update_task(task_id, "processing", 80, "분석 결과 저장 중...")
|
||||
saved = add_keyword_analysis(result)
|
||||
|
||||
update_task(task_id, "succeeded", 100, "분석 완료", result_id=saved["id"])
|
||||
except Exception as e:
|
||||
logger.exception("Research failed for keyword=%s", keyword)
|
||||
update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
|
||||
@app.post("/api/blog-marketing/research")
|
||||
def start_research(req: ResearchRequest, background_tasks: BackgroundTasks):
|
||||
"""키워드 분석 시작 (BackgroundTask). task_id 즉시 반환."""
|
||||
if not NAVER_CLIENT_ID:
|
||||
raise HTTPException(status_code=400, detail="Naver API 키가 설정되지 않았습니다")
|
||||
if not req.keyword.strip():
|
||||
raise HTTPException(status_code=400, detail="키워드를 입력하세요")
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
create_task(task_id, "research", {"keyword": req.keyword.strip()})
|
||||
background_tasks.add_task(_run_research, task_id, req.keyword.strip())
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
@app.get("/api/blog-marketing/research/history")
|
||||
def list_research(limit: int = Query(30, ge=1, le=100)):
|
||||
return {"analyses": get_keyword_analyses(limit)}
|
||||
|
||||
|
||||
@app.get("/api/blog-marketing/research/{analysis_id}")
|
||||
def get_research(analysis_id: int):
|
||||
result = get_keyword_analysis(analysis_id)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Analysis not found")
|
||||
return result
|
||||
|
||||
|
||||
@app.delete("/api/blog-marketing/research/{analysis_id}")
|
||||
def remove_research(analysis_id: int):
|
||||
if not delete_keyword_analysis(analysis_id):
|
||||
raise HTTPException(status_code=404, detail="Analysis not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── 작업 상태 폴링 API ──────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/blog-marketing/task/{task_id}")
|
||||
def get_task_status(task_id: str):
|
||||
task = get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return task
|
||||
|
||||
|
||||
# ── AI 글 생성 API ──────────────────────────────────────────────────────────
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
keyword_id: int # keyword_analyses.id
|
||||
|
||||
|
||||
class LinkRequest(BaseModel):
|
||||
url: str
|
||||
product_name: str
|
||||
keyword_id: Optional[int] = None
|
||||
post_id: Optional[int] = None
|
||||
description: str = ""
|
||||
placement_hint: str = ""
|
||||
|
||||
|
||||
def _run_generate(task_id: str, keyword_id: int):
|
||||
"""BackgroundTask: 트렌드 브리프 → 블로그 글 생성 → DB 저장."""
|
||||
try:
|
||||
analysis = get_keyword_analysis(keyword_id)
|
||||
if not analysis:
|
||||
update_task(task_id, "failed", 0, "", error="키워드 분석 결과를 찾을 수 없습니다")
|
||||
return
|
||||
|
||||
# 연결된 브랜드커넥트 링크 조회
|
||||
brand_links = get_brand_links(keyword_id=keyword_id)
|
||||
|
||||
update_task(task_id, "processing", 20, "트렌드 브리프 생성 중...")
|
||||
trend_brief = generate_trend_brief(analysis)
|
||||
|
||||
update_task(task_id, "processing", 60, "블로그 글 작성 중...")
|
||||
post_data = generate_blog_post(analysis, trend_brief, brand_links=brand_links)
|
||||
|
||||
update_task(task_id, "processing", 90, "저장 중...")
|
||||
saved = add_post({
|
||||
"keyword_id": keyword_id,
|
||||
"title": post_data["title"],
|
||||
"body": post_data["body"],
|
||||
"excerpt": post_data["excerpt"],
|
||||
"tags": post_data["tags"],
|
||||
"status": "draft",
|
||||
"trend_brief": trend_brief,
|
||||
})
|
||||
|
||||
# keyword_id에 연결된 링크를 post_id에도 연결
|
||||
link_brand_links_to_post(keyword_id=keyword_id, post_id=saved["id"])
|
||||
|
||||
update_task(task_id, "succeeded", 100, "글 생성 완료", result_id=saved["id"])
|
||||
except Exception as e:
|
||||
logger.exception("Generate failed for keyword_id=%s", keyword_id)
|
||||
update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
|
||||
@app.post("/api/blog-marketing/generate")
|
||||
def start_generate(req: GenerateRequest, background_tasks: BackgroundTasks):
|
||||
"""AI 블로그 글 생성 시작. task_id 즉시 반환."""
|
||||
if not ANTHROPIC_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Claude API 키가 설정되지 않았습니다")
|
||||
analysis = get_keyword_analysis(req.keyword_id)
|
||||
if not analysis:
|
||||
raise HTTPException(status_code=404, detail="키워드 분석 결과를 찾을 수 없습니다")
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
create_task(task_id, "generate", {"keyword_id": req.keyword_id})
|
||||
background_tasks.add_task(_run_generate, task_id, req.keyword_id)
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
# ── 품질 리뷰 API ───────────────────────────────────────────────────────────
|
||||
|
||||
def _run_review(task_id: str, post_id: int):
|
||||
"""BackgroundTask: 블로그 글 품질 리뷰."""
|
||||
try:
|
||||
post = get_post(post_id)
|
||||
if not post:
|
||||
update_task(task_id, "failed", 0, "", error="포스트를 찾을 수 없습니다")
|
||||
return
|
||||
|
||||
update_task(task_id, "processing", 50, "품질 리뷰 중...")
|
||||
result = review_post(post["title"], post["body"])
|
||||
|
||||
update_post(post_id, {
|
||||
"review_score": result["total"],
|
||||
"review_detail": result,
|
||||
"status": "reviewed" if result["pass"] else "draft",
|
||||
})
|
||||
|
||||
update_task(task_id, "succeeded", 100, "리뷰 완료", result_id=post_id)
|
||||
except Exception as e:
|
||||
logger.exception("Review failed for post_id=%s", post_id)
|
||||
update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
|
||||
@app.post("/api/blog-marketing/review/{post_id}")
|
||||
def start_review(post_id: int, background_tasks: BackgroundTasks):
|
||||
"""블로그 글 품질 리뷰 시작. task_id 즉시 반환."""
|
||||
if not ANTHROPIC_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Claude API 키가 설정되지 않았습니다")
|
||||
post = get_post(post_id)
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
create_task(task_id, "review", {"post_id": post_id})
|
||||
background_tasks.add_task(_run_review, task_id, post_id)
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
# ── 재생성 API ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _run_regenerate(task_id: str, post_id: int):
|
||||
"""BackgroundTask: 피드백 기반 블로그 글 재생성."""
|
||||
try:
|
||||
post = get_post(post_id)
|
||||
if not post:
|
||||
update_task(task_id, "failed", 0, "", error="포스트를 찾을 수 없습니다")
|
||||
return
|
||||
|
||||
analysis = get_keyword_analysis(post["keyword_id"]) if post["keyword_id"] else {}
|
||||
feedback = post.get("review_detail", {}).get("feedback", "개선이 필요합니다")
|
||||
|
||||
update_task(task_id, "processing", 50, "글 재생성 중...")
|
||||
result = regenerate_blog_post(
|
||||
analysis or {"keyword": ""},
|
||||
post.get("trend_brief", ""),
|
||||
post["body"],
|
||||
feedback,
|
||||
)
|
||||
|
||||
update_post(post_id, {
|
||||
"title": result["title"],
|
||||
"body": result["body"],
|
||||
"excerpt": result["excerpt"],
|
||||
"tags": result["tags"],
|
||||
"status": "draft",
|
||||
"review_score": None,
|
||||
"review_detail": {},
|
||||
})
|
||||
|
||||
update_task(task_id, "succeeded", 100, "재생성 완료", result_id=post_id)
|
||||
except Exception as e:
|
||||
logger.exception("Regenerate failed for post_id=%s", post_id)
|
||||
update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
|
||||
@app.post("/api/blog-marketing/regenerate/{post_id}")
|
||||
def start_regenerate(post_id: int, background_tasks: BackgroundTasks):
|
||||
"""피드백 기반 블로그 글 재생성. task_id 즉시 반환."""
|
||||
if not ANTHROPIC_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Claude API 키가 설정되지 않았습니다")
|
||||
post = get_post(post_id)
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
create_task(task_id, "regenerate", {"post_id": post_id})
|
||||
background_tasks.add_task(_run_regenerate, task_id, post_id)
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
# ── 포스트 CRUD API ──────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/blog-marketing/posts")
|
||||
def list_posts(status: str = None, limit: int = Query(50, ge=1, le=100)):
|
||||
return {"posts": get_posts(status=status, limit=limit)}
|
||||
|
||||
|
||||
@app.get("/api/blog-marketing/posts/{post_id}")
|
||||
def get_post_detail(post_id: int):
|
||||
post = get_post(post_id)
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
return post
|
||||
|
||||
|
||||
@app.put("/api/blog-marketing/posts/{post_id}")
|
||||
def edit_post(post_id: int, data: dict):
|
||||
result = update_post(post_id, data)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
return result
|
||||
|
||||
|
||||
@app.delete("/api/blog-marketing/posts/{post_id}")
|
||||
def remove_post(post_id: int):
|
||||
if not delete_post(post_id):
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/blog-marketing/posts/{post_id}/publish")
|
||||
def publish_post(post_id: int, data: dict = None):
|
||||
"""네이버 URL 등록 + 상태를 published로 변경."""
|
||||
naver_url = (data or {}).get("naver_url", "")
|
||||
result = update_post(post_id, {"status": "published", "naver_url": naver_url})
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
return result
|
||||
|
||||
|
||||
# ── 브랜드커넥트 링크 API ──────────────────────────────────────────────────
|
||||
|
||||
@app.post("/api/blog-marketing/links", status_code=201)
|
||||
def create_link(req: LinkRequest):
|
||||
return add_brand_link(req.model_dump())
|
||||
|
||||
|
||||
@app.get("/api/blog-marketing/links")
|
||||
def list_links(post_id: int = None, keyword_id: int = None):
|
||||
return {"links": get_brand_links(post_id=post_id, keyword_id=keyword_id)}
|
||||
|
||||
|
||||
@app.put("/api/blog-marketing/links/{link_id}")
|
||||
def edit_link(link_id: int, data: dict):
|
||||
result = update_brand_link(link_id, data)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Link not found")
|
||||
return result
|
||||
|
||||
|
||||
@app.delete("/api/blog-marketing/links/{link_id}")
|
||||
def remove_link(link_id: int):
|
||||
if not delete_brand_link(link_id):
|
||||
raise HTTPException(status_code=404, detail="Link not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── 마케터 API ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _run_market(task_id: str, post_id: int):
|
||||
"""BackgroundTask: 마케터 전환율 강화."""
|
||||
try:
|
||||
post = get_post(post_id)
|
||||
if not post:
|
||||
update_task(task_id, "failed", 0, "", error="포스트를 찾을 수 없습니다")
|
||||
return
|
||||
|
||||
brand_links = get_brand_links(post_id=post_id)
|
||||
if not brand_links and post.get("keyword_id"):
|
||||
brand_links = get_brand_links(keyword_id=post["keyword_id"])
|
||||
|
||||
if not brand_links:
|
||||
update_task(task_id, "failed", 0, "", error="브랜드커넥트 링크가 없습니다. 먼저 링크를 등록하세요.")
|
||||
return
|
||||
|
||||
analysis = get_keyword_analysis(post["keyword_id"]) if post.get("keyword_id") else {}
|
||||
keyword = (analysis or {}).get("keyword", "")
|
||||
|
||||
update_task(task_id, "processing", 50, "마케터가 전환율 강화 중...")
|
||||
result = enhance_for_conversion(
|
||||
post_body=post["body"],
|
||||
post_title=post["title"],
|
||||
brand_links=brand_links,
|
||||
keyword=keyword,
|
||||
)
|
||||
|
||||
update_post(post_id, {
|
||||
"title": result["title"],
|
||||
"body": result["body"],
|
||||
"excerpt": result["excerpt"],
|
||||
"status": "marketed",
|
||||
})
|
||||
|
||||
update_task(task_id, "succeeded", 100, "마케팅 강화 완료", result_id=post_id)
|
||||
except Exception as e:
|
||||
logger.exception("Market failed for post_id=%s", post_id)
|
||||
update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
|
||||
@app.post("/api/blog-marketing/market/{post_id}")
|
||||
def start_market(post_id: int, background_tasks: BackgroundTasks):
|
||||
"""마케터 단계 실행. task_id 즉시 반환."""
|
||||
if not ANTHROPIC_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Claude API 키가 설정되지 않았습니다")
|
||||
post = get_post(post_id)
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
create_task(task_id, "market", {"post_id": post_id})
|
||||
background_tasks.add_task(_run_market, task_id, post_id)
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
# ── 수익 추적 API ────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/blog-marketing/commissions")
|
||||
def list_commissions(post_id: int = None, limit: int = Query(100, ge=1, le=100)):
|
||||
return {"commissions": get_commissions(post_id=post_id, limit=limit)}
|
||||
|
||||
|
||||
@app.post("/api/blog-marketing/commissions", status_code=201)
|
||||
def create_commission(data: dict):
|
||||
return add_commission(data)
|
||||
|
||||
|
||||
@app.put("/api/blog-marketing/commissions/{comm_id}")
|
||||
def edit_commission(comm_id: int, data: dict):
|
||||
result = update_commission(comm_id, data)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Commission not found")
|
||||
return result
|
||||
|
||||
|
||||
@app.delete("/api/blog-marketing/commissions/{comm_id}")
|
||||
def remove_commission(comm_id: int):
|
||||
if not delete_commission(comm_id):
|
||||
raise HTTPException(status_code=404, detail="Commission not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── 대시보드 API ─────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/blog-marketing/dashboard")
|
||||
def dashboard():
|
||||
return get_dashboard_stats()
|
||||
105
blog-lab/app/marketer.py
Normal file
105
blog-lab/app/marketer.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""마케터 단계 — 전환율 강화 + 브랜드커넥트 링크 삽입."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import anthropic
|
||||
|
||||
from .config import ANTHROPIC_API_KEY, CLAUDE_MODEL
|
||||
from .db import get_template
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_client: Optional[anthropic.Anthropic] = None
|
||||
|
||||
|
||||
def _get_client() -> anthropic.Anthropic:
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
|
||||
return _client
|
||||
|
||||
|
||||
def _call_claude(prompt: str, max_tokens: int = 8192) -> str:
|
||||
client = _get_client()
|
||||
today = date.today().isoformat()
|
||||
resp = client.messages.create(
|
||||
model=CLAUDE_MODEL,
|
||||
max_tokens=max_tokens,
|
||||
system=f"현재 날짜는 {today}입니다. 모든 콘텐츠는 이 날짜 기준으로 작성하세요.",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
return resp.content[0].text
|
||||
|
||||
|
||||
def enhance_for_conversion(
|
||||
post_body: str,
|
||||
post_title: str,
|
||||
brand_links: List[Dict[str, Any]],
|
||||
keyword: str,
|
||||
) -> Dict[str, str]:
|
||||
"""초안에 제휴 링크를 자연스럽게 삽입하고 전환율을 강화.
|
||||
|
||||
Args:
|
||||
post_body: 작가 초안 HTML 본문
|
||||
post_title: 작가 초안 제목
|
||||
brand_links: 브랜드커넥트 링크 리스트
|
||||
keyword: 타겟 키워드
|
||||
|
||||
Returns:
|
||||
{"title": str, "body": str, "excerpt": str}
|
||||
|
||||
Raises:
|
||||
ValueError: 브랜드 링크가 없을 때
|
||||
"""
|
||||
if not brand_links:
|
||||
raise ValueError("브랜드커넥트 링크가 필요합니다")
|
||||
|
||||
template = get_template("marketer_enhance")
|
||||
if not template:
|
||||
raise RuntimeError("marketer_enhance 템플릿이 없습니다")
|
||||
|
||||
brand_links_text = ""
|
||||
for i, link in enumerate(brand_links, 1):
|
||||
brand_links_text += (
|
||||
f"{i}. 상품명: {link.get('product_name', '')}\n"
|
||||
f" 설명: {link.get('description', '')}\n"
|
||||
f" URL: {link.get('url', '')}\n"
|
||||
f" 배치 힌트: {link.get('placement_hint', '자연스럽게')}\n\n"
|
||||
)
|
||||
|
||||
prompt = template.format(
|
||||
draft_body=post_body[:6000],
|
||||
keyword=keyword,
|
||||
brand_links_info=brand_links_text,
|
||||
)
|
||||
|
||||
prompt += (
|
||||
"\n\n---\n"
|
||||
"응답은 반드시 아래 JSON 형식으로 해주세요 (JSON만 출력):\n"
|
||||
'{"title": "개선된 제목", "body": "개선된 HTML 본문", "excerpt": "2줄 요약"}'
|
||||
)
|
||||
|
||||
raw = _call_claude(prompt)
|
||||
|
||||
try:
|
||||
text = raw.strip()
|
||||
if text.startswith("```"):
|
||||
lines = text.split("\n")
|
||||
lines = [l for l in lines if not l.strip().startswith("```")]
|
||||
text = "\n".join(lines)
|
||||
result = json.loads(text)
|
||||
return {
|
||||
"title": result.get("title", post_title),
|
||||
"body": result.get("body", post_body),
|
||||
"excerpt": result.get("excerpt", ""),
|
||||
}
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
logger.warning("Marketer JSON parse failed, using raw text")
|
||||
return {
|
||||
"title": post_title,
|
||||
"body": raw,
|
||||
"excerpt": raw[:200],
|
||||
}
|
||||
203
blog-lab/app/naver_search.py
Normal file
203
blog-lab/app/naver_search.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""네이버 검색 API 연동 — 블로그 + 쇼핑 검색."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import requests
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from .config import NAVER_CLIENT_ID, NAVER_CLIENT_SECRET
|
||||
|
||||
BLOG_URL = "https://openapi.naver.com/v1/search/blog.json"
|
||||
SHOP_URL = "https://openapi.naver.com/v1/search/shop.json"
|
||||
|
||||
_HEADERS = {
|
||||
"X-Naver-Client-Id": NAVER_CLIENT_ID,
|
||||
"X-Naver-Client-Secret": NAVER_CLIENT_SECRET,
|
||||
}
|
||||
|
||||
_TAG_RE = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
def _strip_html(text: str) -> str:
|
||||
return _TAG_RE.sub("", text).strip()
|
||||
|
||||
|
||||
def search_blog(keyword: str, display: int = 10, sort: str = "sim") -> Dict[str, Any]:
|
||||
"""네이버 블로그 검색.
|
||||
|
||||
Args:
|
||||
keyword: 검색 키워드
|
||||
display: 결과 수 (1-100)
|
||||
sort: sim(정확도) | date(날짜)
|
||||
|
||||
Returns:
|
||||
{"total": int, "items": [...]}
|
||||
"""
|
||||
resp = requests.get(
|
||||
BLOG_URL,
|
||||
headers=_HEADERS,
|
||||
params={"query": keyword, "display": display, "sort": sort},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
items = [
|
||||
{
|
||||
"title": _strip_html(item.get("title", "")),
|
||||
"description": _strip_html(item.get("description", "")),
|
||||
"link": item.get("link", ""),
|
||||
"bloggername": item.get("bloggername", ""),
|
||||
"postdate": item.get("postdate", ""),
|
||||
}
|
||||
for item in data.get("items", [])
|
||||
]
|
||||
return {"total": data.get("total", 0), "items": items}
|
||||
|
||||
|
||||
def search_shopping(keyword: str, display: int = 20, sort: str = "sim") -> Dict[str, Any]:
|
||||
"""네이버 쇼핑 검색.
|
||||
|
||||
Args:
|
||||
keyword: 검색 키워드
|
||||
display: 결과 수 (1-100)
|
||||
sort: sim(정확도) | date(날짜) | asc(가격↑) | dsc(가격↓)
|
||||
|
||||
Returns:
|
||||
{"total": int, "items": [...], "price_stats": {...}}
|
||||
"""
|
||||
resp = requests.get(
|
||||
SHOP_URL,
|
||||
headers=_HEADERS,
|
||||
params={"query": keyword, "display": display, "sort": sort},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
items = []
|
||||
prices = []
|
||||
for item in data.get("items", []):
|
||||
lprice = _safe_int(item.get("lprice"))
|
||||
hprice = _safe_int(item.get("hprice"))
|
||||
parsed = {
|
||||
"title": _strip_html(item.get("title", "")),
|
||||
"link": item.get("link", ""),
|
||||
"image": item.get("image", ""),
|
||||
"lprice": lprice,
|
||||
"hprice": hprice,
|
||||
"mallName": item.get("mallName", ""),
|
||||
"productId": item.get("productId", ""),
|
||||
"productType": item.get("productType", ""),
|
||||
"category1": item.get("category1", ""),
|
||||
"category2": item.get("category2", ""),
|
||||
"category3": item.get("category3", ""),
|
||||
"brand": item.get("brand", ""),
|
||||
"maker": item.get("maker", ""),
|
||||
}
|
||||
items.append(parsed)
|
||||
if lprice and lprice > 0:
|
||||
prices.append(lprice)
|
||||
|
||||
price_stats = None
|
||||
if prices:
|
||||
price_stats = {
|
||||
"min": min(prices),
|
||||
"max": max(prices),
|
||||
"avg": int(sum(prices) / len(prices)),
|
||||
"count": len(prices),
|
||||
}
|
||||
|
||||
return {
|
||||
"total": data.get("total", 0),
|
||||
"items": items,
|
||||
"price_stats": price_stats,
|
||||
}
|
||||
|
||||
|
||||
def _safe_int(val) -> Optional[int]:
|
||||
if val is None:
|
||||
return None
|
||||
try:
|
||||
return int(val)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def analyze_keyword(keyword: str) -> Dict[str, Any]:
|
||||
"""키워드 경쟁도/기회 분석.
|
||||
|
||||
블로그 총 결과수, 쇼핑 총 결과수, 가격 통계를 기반으로
|
||||
competition_score(경쟁도)와 opportunity_score(기회점수) 산출.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"keyword", "blog_total", "shop_total",
|
||||
"competition", "opportunity",
|
||||
"avg_price", "min_price", "max_price",
|
||||
"top_products": [...], "top_blogs": [...]
|
||||
}
|
||||
"""
|
||||
blog = search_blog(keyword, display=10, sort="sim")
|
||||
shop = search_shopping(keyword, display=20, sort="sim")
|
||||
|
||||
blog_total = blog["total"]
|
||||
shop_total = shop["total"]
|
||||
|
||||
# 경쟁도: 블로그 결과 수 기반 (로그 스케일 0-100)
|
||||
import math
|
||||
if blog_total > 0:
|
||||
competition = min(100, int(math.log10(blog_total + 1) * 15))
|
||||
else:
|
||||
competition = 0
|
||||
|
||||
# 기회 점수: 쇼핑 수요가 높고 블로그 경쟁이 낮을수록 높음
|
||||
if shop_total > 0 and blog_total > 0:
|
||||
ratio = shop_total / blog_total
|
||||
opportunity = min(100, int(ratio * 20))
|
||||
elif shop_total > 0:
|
||||
opportunity = 90 # 경쟁 없이 수요만 있으면 높은 기회
|
||||
else:
|
||||
opportunity = 10 # 쇼핑 수요 없음
|
||||
|
||||
price_stats = shop.get("price_stats") or {}
|
||||
|
||||
return {
|
||||
"keyword": keyword,
|
||||
"blog_total": blog_total,
|
||||
"shop_total": shop_total,
|
||||
"competition": competition,
|
||||
"opportunity": opportunity,
|
||||
"avg_price": price_stats.get("avg"),
|
||||
"min_price": price_stats.get("min"),
|
||||
"max_price": price_stats.get("max"),
|
||||
"top_products": shop["items"][:5],
|
||||
"top_blogs": blog["items"][:5],
|
||||
}
|
||||
|
||||
|
||||
def _run_enrich(top_blogs: list) -> list:
|
||||
"""동기 컨텍스트에서 비동기 enrich_top_blogs 실행."""
|
||||
from .web_crawler import enrich_top_blogs
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
return pool.submit(
|
||||
asyncio.run, enrich_top_blogs(top_blogs)
|
||||
).result(timeout=60)
|
||||
else:
|
||||
return asyncio.run(enrich_top_blogs(top_blogs))
|
||||
except Exception as e:
|
||||
logger.warning("블로그 크롤링 실패, 기존 데이터 사용: %s", e)
|
||||
return top_blogs
|
||||
|
||||
|
||||
def analyze_keyword_with_crawling(keyword: str) -> Dict[str, Any]:
|
||||
"""analyze_keyword + 상위 블로그 본문 크롤링."""
|
||||
result = analyze_keyword(keyword)
|
||||
result["top_blogs"] = _run_enrich(result["top_blogs"])
|
||||
return result
|
||||
85
blog-lab/app/quality_reviewer.py
Normal file
85
blog-lab/app/quality_reviewer.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Claude API 기반 블로그 글 품질 리뷰 — 6기준 × 10점, 42/60 통과."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import anthropic
|
||||
|
||||
from .config import ANTHROPIC_API_KEY, CLAUDE_MODEL
|
||||
from .db import get_template
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PASS_THRESHOLD = 42 # 60점 만점 중 42점 이상이면 통과 (70%)
|
||||
|
||||
_client: Optional[anthropic.Anthropic] = None
|
||||
|
||||
|
||||
def _get_client() -> anthropic.Anthropic:
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
|
||||
return _client
|
||||
|
||||
|
||||
def review_post(title: str, body: str) -> Dict[str, Any]:
|
||||
"""블로그 글 품질 리뷰.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"scores": {
|
||||
"empathy": N, "click_appeal": N, "conversion": N,
|
||||
"seo": N, "format": N, "link_natural": N
|
||||
},
|
||||
"total": N,
|
||||
"pass": bool,
|
||||
"feedback": str
|
||||
}
|
||||
"""
|
||||
template = get_template("quality_review")
|
||||
if not template:
|
||||
raise RuntimeError("quality_review 템플릿이 없습니다")
|
||||
|
||||
prompt = template.format(title=title, body=body[:6000])
|
||||
|
||||
client = _get_client()
|
||||
today = date.today().isoformat()
|
||||
resp = client.messages.create(
|
||||
model=CLAUDE_MODEL,
|
||||
max_tokens=2048,
|
||||
system=f"현재 날짜는 {today}입니다.",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
raw = resp.content[0].text
|
||||
|
||||
try:
|
||||
text = raw.strip()
|
||||
if text.startswith("```"):
|
||||
lines = text.split("\n")
|
||||
lines = [l for l in lines if not l.strip().startswith("```")]
|
||||
text = "\n".join(lines)
|
||||
result = json.loads(text)
|
||||
|
||||
scores = result.get("scores", {})
|
||||
total = sum(scores.values())
|
||||
passed = total >= PASS_THRESHOLD
|
||||
|
||||
return {
|
||||
"scores": scores,
|
||||
"total": total,
|
||||
"pass": passed,
|
||||
"feedback": result.get("feedback", ""),
|
||||
}
|
||||
except (json.JSONDecodeError, KeyError, TypeError) as e:
|
||||
logger.warning("Quality review JSON parse failed: %s", e)
|
||||
return {
|
||||
"scores": {
|
||||
"empathy": 0, "click_appeal": 0, "conversion": 0,
|
||||
"seo": 0, "format": 0, "link_natural": 0,
|
||||
},
|
||||
"total": 0,
|
||||
"pass": False,
|
||||
"feedback": f"리뷰 파싱 실패. 원본 응답:\n{raw[:500]}",
|
||||
}
|
||||
97
blog-lab/app/web_crawler.py
Normal file
97
blog-lab/app/web_crawler.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""네이버 블로그 본문 크롤링 모듈."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TIMEOUT = 10 # 글당 크롤링 타임아웃 (초)
|
||||
_MAX_CONTENT_LENGTH = 2000 # 본문 최대 길이
|
||||
|
||||
# 네이버 블로그 URL 패턴: blog.naver.com/{blogId}/{logNo}
|
||||
_BLOG_URL_RE = re.compile(r"blog\.naver\.com/([^/]+)/(\d+)")
|
||||
|
||||
|
||||
def _parse_naver_blog_url(url: str) -> Optional[Tuple[str, str]]:
|
||||
"""네이버 블로그 URL에서 blogId, logNo 추출. 실패 시 None."""
|
||||
match = _BLOG_URL_RE.search(url)
|
||||
if not match:
|
||||
return None
|
||||
return match.group(1), match.group(2)
|
||||
|
||||
|
||||
async def _fetch_html(url: str) -> str:
|
||||
"""URL에서 HTML을 가져온다."""
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT, follow_redirects=True) as client:
|
||||
resp = await client.get(url, headers={
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
})
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
|
||||
|
||||
def _extract_text(html: str) -> str:
|
||||
"""HTML에서 본문 텍스트를 추출한다."""
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
|
||||
# 스마트에디터 3 (SE3)
|
||||
container = soup.select_one("div.se-main-container")
|
||||
if not container:
|
||||
# 구 에디터
|
||||
container = soup.select_one("div#postViewArea")
|
||||
if not container:
|
||||
# 폴백: body 전체
|
||||
container = soup.body
|
||||
|
||||
if not container:
|
||||
return ""
|
||||
|
||||
# 스크립트/스타일 제거
|
||||
for tag in container.find_all(["script", "style"]):
|
||||
tag.decompose()
|
||||
|
||||
text = container.get_text(separator="\n", strip=True)
|
||||
return text[:_MAX_CONTENT_LENGTH]
|
||||
|
||||
|
||||
async def crawl_blog_content(url: str) -> str:
|
||||
"""네이버 블로그 URL에서 본문 텍스트 추출.
|
||||
|
||||
- 네이버 블로그가 아니면 빈 문자열
|
||||
- 크롤링 실패 시 빈 문자열 (에러 로그만)
|
||||
- 본문 최대 2,000자
|
||||
"""
|
||||
parsed = _parse_naver_blog_url(url)
|
||||
if not parsed:
|
||||
return ""
|
||||
|
||||
blog_id, log_no = parsed
|
||||
# iframe 내부 실제 본문 URL
|
||||
post_url = f"https://blog.naver.com/PostView.naver?blogId={blog_id}&logNo={log_no}"
|
||||
|
||||
try:
|
||||
html = await _fetch_html(post_url)
|
||||
return _extract_text(html)
|
||||
except Exception as e:
|
||||
logger.warning("블로그 크롤링 실패 (%s): %s", url, e)
|
||||
return ""
|
||||
|
||||
|
||||
async def enrich_top_blogs(top_blogs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""top_blogs 리스트 각 항목에 content 필드를 추가.
|
||||
|
||||
개별 크롤링 실패 시 해당 항목의 content를 빈 문자열로 설정하고 나머지 계속 진행.
|
||||
"""
|
||||
result = []
|
||||
for blog in top_blogs:
|
||||
enriched = dict(blog)
|
||||
try:
|
||||
enriched["content"] = await crawl_blog_content(blog.get("link", ""))
|
||||
except Exception:
|
||||
enriched["content"] = ""
|
||||
result.append(enriched)
|
||||
return result
|
||||
3
blog-lab/pytest.ini
Normal file
3
blog-lab/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
pythonpath = .
|
||||
6
blog-lab/requirements.txt
Normal file
6
blog-lab/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
requests==2.32.3
|
||||
anthropic==0.52.0
|
||||
beautifulsoup4>=4.12
|
||||
httpx>=0.27
|
||||
0
blog-lab/tests/__init__.py
Normal file
0
blog-lab/tests/__init__.py
Normal file
9
blog-lab/tests/conftest.py
Normal file
9
blog-lab/tests/conftest.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""공통 테스트 픽스처."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# app 패키지를 blog_lab_app으로도 import 가능하게
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
if "blog_lab_app" not in sys.modules:
|
||||
import app as blog_lab_app
|
||||
sys.modules["blog_lab_app"] = blog_lab_app
|
||||
85
blog-lab/tests/test_api_links.py
Normal file
85
blog-lab/tests/test_api_links.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""브랜드커넥트 링크 API 테스트."""
|
||||
import os
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db(tmp_path):
|
||||
test_db = str(tmp_path / "test.db")
|
||||
import app.config as config
|
||||
config.DB_PATH = test_db
|
||||
from app import db
|
||||
db.DB_PATH = test_db
|
||||
db.init_db()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
from app.main import app
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_create_link(client):
|
||||
resp = client.post("/api/blog-marketing/links", json={
|
||||
"keyword_id": 1,
|
||||
"url": "https://link.coupang.com/abc",
|
||||
"product_name": "테스트 상품",
|
||||
"description": "상품 설명",
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["url"] == "https://link.coupang.com/abc"
|
||||
assert data["product_name"] == "테스트 상품"
|
||||
|
||||
|
||||
def test_create_link_requires_url(client):
|
||||
resp = client.post("/api/blog-marketing/links", json={
|
||||
"product_name": "상품",
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_create_link_requires_product_name(client):
|
||||
resp = client.post("/api/blog-marketing/links", json={
|
||||
"url": "https://a.com",
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_list_links_by_keyword_id(client):
|
||||
client.post("/api/blog-marketing/links", json={
|
||||
"keyword_id": 1, "url": "https://a.com", "product_name": "A",
|
||||
})
|
||||
client.post("/api/blog-marketing/links", json={
|
||||
"keyword_id": 2, "url": "https://b.com", "product_name": "B",
|
||||
})
|
||||
resp = client.get("/api/blog-marketing/links?keyword_id=1")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["links"]) == 1
|
||||
|
||||
|
||||
def test_update_link(client):
|
||||
create_resp = client.post("/api/blog-marketing/links", json={
|
||||
"url": "https://a.com", "product_name": "원래",
|
||||
})
|
||||
link_id = create_resp.json()["id"]
|
||||
resp = client.put(f"/api/blog-marketing/links/{link_id}", json={
|
||||
"product_name": "새이름",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["product_name"] == "새이름"
|
||||
|
||||
|
||||
def test_delete_link(client):
|
||||
create_resp = client.post("/api/blog-marketing/links", json={
|
||||
"url": "https://a.com", "product_name": "삭제",
|
||||
})
|
||||
link_id = create_resp.json()["id"]
|
||||
resp = client.delete(f"/api/blog-marketing/links/{link_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["ok"] is True
|
||||
|
||||
resp = client.delete(f"/api/blog-marketing/links/{link_id}")
|
||||
assert resp.status_code == 404
|
||||
67
blog-lab/tests/test_db_brand_links.py
Normal file
67
blog-lab/tests/test_db_brand_links.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""brand_links DB CRUD 테스트."""
|
||||
import os
|
||||
import pytest
|
||||
from app import db
|
||||
from app.config import DB_PATH
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db(tmp_path):
|
||||
"""테스트용 임시 DB 사용."""
|
||||
test_db = str(tmp_path / "test.db")
|
||||
import app.config as config
|
||||
config.DB_PATH = test_db
|
||||
db.DB_PATH = test_db
|
||||
db.init_db()
|
||||
yield
|
||||
|
||||
|
||||
def test_add_brand_link():
|
||||
link = db.add_brand_link({
|
||||
"keyword_id": 1,
|
||||
"url": "https://link.coupang.com/abc",
|
||||
"product_name": "테스트 상품",
|
||||
"description": "상품 설명",
|
||||
"placement_hint": "본문 중간",
|
||||
})
|
||||
assert link["id"] is not None
|
||||
assert link["url"] == "https://link.coupang.com/abc"
|
||||
assert link["product_name"] == "테스트 상품"
|
||||
assert link["keyword_id"] == 1
|
||||
assert link["post_id"] is None
|
||||
|
||||
|
||||
def test_get_brand_links_by_keyword_id():
|
||||
db.add_brand_link({"keyword_id": 1, "url": "https://a.com", "product_name": "A"})
|
||||
db.add_brand_link({"keyword_id": 1, "url": "https://b.com", "product_name": "B"})
|
||||
db.add_brand_link({"keyword_id": 2, "url": "https://c.com", "product_name": "C"})
|
||||
links = db.get_brand_links(keyword_id=1)
|
||||
assert len(links) == 2
|
||||
|
||||
|
||||
def test_get_brand_links_by_post_id():
|
||||
db.add_brand_link({"post_id": 10, "url": "https://a.com", "product_name": "A"})
|
||||
links = db.get_brand_links(post_id=10)
|
||||
assert len(links) == 1
|
||||
assert links[0]["post_id"] == 10
|
||||
|
||||
|
||||
def test_update_brand_link():
|
||||
link = db.add_brand_link({"url": "https://a.com", "product_name": "원래 이름"})
|
||||
updated = db.update_brand_link(link["id"], {"product_name": "새 이름", "post_id": 5})
|
||||
assert updated["product_name"] == "새 이름"
|
||||
assert updated["post_id"] == 5
|
||||
|
||||
|
||||
def test_delete_brand_link():
|
||||
link = db.add_brand_link({"url": "https://a.com", "product_name": "삭제할 링크"})
|
||||
assert db.delete_brand_link(link["id"]) is True
|
||||
assert db.delete_brand_link(link["id"]) is False
|
||||
|
||||
|
||||
def test_link_keyword_to_post():
|
||||
db.add_brand_link({"keyword_id": 1, "url": "https://a.com", "product_name": "A"})
|
||||
db.add_brand_link({"keyword_id": 1, "url": "https://b.com", "product_name": "B"})
|
||||
db.link_brand_links_to_post(keyword_id=1, post_id=10)
|
||||
links = db.get_brand_links(post_id=10)
|
||||
assert len(links) == 2
|
||||
74
blog-lab/tests/test_evaluator.py
Normal file
74
blog-lab/tests/test_evaluator.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""평가자 단계 테스트 — 6기준 60점."""
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def test_review_post_has_6_criteria():
|
||||
"""6개 기준으로 채점하는지 확인."""
|
||||
from app.quality_reviewer import review_post
|
||||
|
||||
mock_response = json.dumps({
|
||||
"scores": {
|
||||
"empathy": 8, "click_appeal": 7, "conversion": 9,
|
||||
"seo": 8, "format": 7, "link_natural": 9,
|
||||
},
|
||||
"total": 48,
|
||||
"pass": True,
|
||||
"feedback": "전체적으로 우수합니다",
|
||||
})
|
||||
|
||||
with patch("app.quality_reviewer._get_client") as mock_client_fn, \
|
||||
patch("app.quality_reviewer.get_template", return_value="제목: {title}\n본문: {body}"):
|
||||
mock_client = mock_client_fn.return_value
|
||||
mock_client.messages.create.return_value.content = [type("C", (), {"text": mock_response})()]
|
||||
result = review_post("테스트 제목", "<p>본문</p>")
|
||||
|
||||
assert "link_natural" in result["scores"]
|
||||
assert len(result["scores"]) == 6
|
||||
assert result["total"] == 48
|
||||
assert result["pass"] is True
|
||||
|
||||
|
||||
def test_review_pass_threshold_is_42():
|
||||
"""통과 기준이 42점인지 확인."""
|
||||
from app.quality_reviewer import PASS_THRESHOLD
|
||||
assert PASS_THRESHOLD == 42
|
||||
|
||||
|
||||
def test_review_fails_below_42():
|
||||
"""42점 미만이면 불통과."""
|
||||
from app.quality_reviewer import review_post
|
||||
|
||||
mock_response = json.dumps({
|
||||
"scores": {
|
||||
"empathy": 5, "click_appeal": 5, "conversion": 5,
|
||||
"seo": 5, "format": 5, "link_natural": 5,
|
||||
},
|
||||
"total": 30,
|
||||
"pass": False,
|
||||
"feedback": "개선 필요",
|
||||
})
|
||||
|
||||
with patch("app.quality_reviewer._get_client") as mock_client_fn, \
|
||||
patch("app.quality_reviewer.get_template", return_value="제목: {title}\n본문: {body}"):
|
||||
mock_client = mock_client_fn.return_value
|
||||
mock_client.messages.create.return_value.content = [type("C", (), {"text": mock_response})()]
|
||||
result = review_post("제목", "<p>본문</p>")
|
||||
|
||||
assert result["pass"] is False
|
||||
|
||||
|
||||
def test_review_handles_parse_failure():
|
||||
"""JSON 파싱 실패 시 기본값 반환 (6개 기준)."""
|
||||
from app.quality_reviewer import review_post
|
||||
|
||||
with patch("app.quality_reviewer._get_client") as mock_client_fn, \
|
||||
patch("app.quality_reviewer.get_template", return_value="제목: {title}\n본문: {body}"):
|
||||
mock_client = mock_client_fn.return_value
|
||||
mock_client.messages.create.return_value.content = [type("C", (), {"text": "잘못된 응답"})()]
|
||||
result = review_post("제목", "<p>본문</p>")
|
||||
|
||||
assert result["pass"] is False
|
||||
assert "link_natural" in result["scores"]
|
||||
assert result["total"] == 0
|
||||
66
blog-lab/tests/test_marketer.py
Normal file
66
blog-lab/tests/test_marketer.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""마케터 단계 테스트."""
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def test_enhance_for_conversion_inserts_links():
|
||||
"""마케터가 브랜드 링크를 본문에 삽입."""
|
||||
from app.marketer import enhance_for_conversion
|
||||
|
||||
brand_links = [
|
||||
{"url": "https://link.coupang.com/abc", "product_name": "갤럭시 버즈3",
|
||||
"description": "노이즈캔슬링", "placement_hint": "본문 중간"},
|
||||
]
|
||||
|
||||
mock_response = json.dumps({
|
||||
"title": "마케팅된 제목",
|
||||
"body": '<p>본문 <a href="https://link.coupang.com/abc">갤럭시 버즈3</a></p>',
|
||||
"excerpt": "요약",
|
||||
})
|
||||
|
||||
with patch("app.marketer._call_claude", return_value=mock_response) as mock_call, \
|
||||
patch("app.marketer.get_template", return_value="초안: {draft_body}\n키워드: {keyword}\n링크:\n{brand_links_info}"):
|
||||
result = enhance_for_conversion(
|
||||
post_body="<p>초안 본문</p>",
|
||||
post_title="초안 제목",
|
||||
brand_links=brand_links,
|
||||
keyword="무선 이어폰",
|
||||
)
|
||||
|
||||
prompt_used = mock_call.call_args[0][0]
|
||||
assert "갤럭시 버즈3" in prompt_used
|
||||
assert "노이즈캔슬링" in prompt_used
|
||||
assert result["title"] == "마케팅된 제목"
|
||||
|
||||
|
||||
def test_enhance_requires_brand_links():
|
||||
"""브랜드 링크가 없으면 ValueError."""
|
||||
from app.marketer import enhance_for_conversion
|
||||
|
||||
with pytest.raises(ValueError, match="브랜드커넥트 링크가 필요합니다"):
|
||||
enhance_for_conversion(
|
||||
post_body="<p>본문</p>",
|
||||
post_title="제목",
|
||||
brand_links=[],
|
||||
keyword="테스트",
|
||||
)
|
||||
|
||||
|
||||
def test_enhance_json_parse_fallback():
|
||||
"""JSON 파싱 실패 시 원본 제목 유지."""
|
||||
from app.marketer import enhance_for_conversion
|
||||
|
||||
brand_links = [{"url": "https://a.com", "product_name": "상품"}]
|
||||
|
||||
with patch("app.marketer._call_claude", return_value="잘못된 JSON"), \
|
||||
patch("app.marketer.get_template", return_value="초안: {draft_body}\n키워드: {keyword}\n링크:\n{brand_links_info}"):
|
||||
result = enhance_for_conversion(
|
||||
post_body="<p>원본</p>",
|
||||
post_title="원본 제목",
|
||||
brand_links=brand_links,
|
||||
keyword="테스트",
|
||||
)
|
||||
|
||||
assert result["title"] == "원본 제목"
|
||||
assert result["body"] == "잘못된 JSON"
|
||||
146
blog-lab/tests/test_pipeline_integration.py
Normal file
146
blog-lab/tests/test_pipeline_integration.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""4단계 파이프라인 통합 테스트."""
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db(tmp_path):
|
||||
test_db = str(tmp_path / "test.db")
|
||||
import app.config as config
|
||||
config.DB_PATH = test_db
|
||||
from app import db
|
||||
db.DB_PATH = test_db
|
||||
db.init_db()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
from app.main import app
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_full_pipeline_status_flow(client):
|
||||
"""draft → marketed → reviewed → published 상태 흐름."""
|
||||
from app import db
|
||||
|
||||
# 1. 키워드 분석 결과 직접 삽입
|
||||
analysis = db.add_keyword_analysis({
|
||||
"keyword": "무선 이어폰",
|
||||
"blog_total": 1000,
|
||||
"shop_total": 500,
|
||||
"competition": 45,
|
||||
"opportunity": 60,
|
||||
"top_products": [{"title": "에어팟", "lprice": 200000, "mallName": "애플"}],
|
||||
"top_blogs": [{"title": "리뷰", "link": "https://blog.naver.com/user/123", "content": "본문"}],
|
||||
})
|
||||
|
||||
# 2. 브랜드 링크 등록
|
||||
resp = client.post("/api/blog-marketing/links", json={
|
||||
"keyword_id": analysis["id"],
|
||||
"url": "https://link.coupang.com/abc",
|
||||
"product_name": "삼성 버즈3",
|
||||
"description": "노이즈캔슬링",
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
|
||||
# 3. 포스트 직접 생성 (generate는 Claude API 필요)
|
||||
post = db.add_post({
|
||||
"keyword_id": analysis["id"],
|
||||
"title": "무선 이어폰 추천",
|
||||
"body": "<p>초안 본문</p>",
|
||||
"excerpt": "요약",
|
||||
"tags": ["이어폰"],
|
||||
"status": "draft",
|
||||
})
|
||||
db.link_brand_links_to_post(keyword_id=analysis["id"], post_id=post["id"])
|
||||
|
||||
# 4. 상태 확인: draft
|
||||
resp = client.get(f"/api/blog-marketing/posts/{post['id']}")
|
||||
assert resp.json()["status"] == "draft"
|
||||
|
||||
# 5. marketed 상태
|
||||
db.update_post(post["id"], {"status": "marketed", "body": "<p>마케팅된 본문</p>"})
|
||||
resp = client.get(f"/api/blog-marketing/posts/{post['id']}")
|
||||
assert resp.json()["status"] == "marketed"
|
||||
|
||||
# 6. reviewed 상태 (점수 48/60 = 통과)
|
||||
db.update_post(post["id"], {
|
||||
"status": "reviewed",
|
||||
"review_score": 48,
|
||||
"review_detail": {
|
||||
"scores": {"empathy": 8, "click_appeal": 8, "conversion": 8, "seo": 8, "format": 8, "link_natural": 8},
|
||||
"total": 48, "pass": True, "feedback": "우수"
|
||||
},
|
||||
})
|
||||
resp = client.get(f"/api/blog-marketing/posts/{post['id']}")
|
||||
assert resp.json()["status"] == "reviewed"
|
||||
assert resp.json()["review_score"] == 48
|
||||
|
||||
# 7. 발행
|
||||
resp = client.post(f"/api/blog-marketing/posts/{post['id']}/publish", json={
|
||||
"naver_url": "https://blog.naver.com/mypost/123",
|
||||
})
|
||||
assert resp.json()["status"] == "published"
|
||||
|
||||
|
||||
def test_links_associated_with_post(client):
|
||||
"""keyword_id로 등록한 링크가 post 생성 후 post_id로도 조회 가능."""
|
||||
from app import db
|
||||
|
||||
analysis = db.add_keyword_analysis({"keyword": "테스트", "blog_total": 10, "shop_total": 5})
|
||||
client.post("/api/blog-marketing/links", json={
|
||||
"keyword_id": analysis["id"],
|
||||
"url": "https://link.com/1",
|
||||
"product_name": "상품1",
|
||||
})
|
||||
|
||||
post = db.add_post({"keyword_id": analysis["id"], "title": "제목", "body": "본문", "status": "draft"})
|
||||
db.link_brand_links_to_post(keyword_id=analysis["id"], post_id=post["id"])
|
||||
|
||||
resp = client.get(f"/api/blog-marketing/links?post_id={post['id']}")
|
||||
links = resp.json()["links"]
|
||||
assert len(links) == 1
|
||||
assert links[0]["product_name"] == "상품1"
|
||||
|
||||
|
||||
@patch("app.main.ANTHROPIC_API_KEY", "fake-key-for-test")
|
||||
def test_market_endpoint_returns_404_for_missing_post(client):
|
||||
"""존재하지 않는 post_id로 마케터 호출 시 404."""
|
||||
resp = client.post("/api/blog-marketing/market/9999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@patch("app.main.ANTHROPIC_API_KEY", "fake-key-for-test")
|
||||
def test_review_endpoint_returns_404_for_missing_post(client):
|
||||
"""존재하지 않는 post_id로 리뷰 호출 시 404."""
|
||||
resp = client.post("/api/blog-marketing/review/9999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_multiple_links_per_keyword(client):
|
||||
"""하나의 키워드에 복수 링크 등록 가능."""
|
||||
from app import db
|
||||
analysis = db.add_keyword_analysis({"keyword": "테스트", "blog_total": 10, "shop_total": 5})
|
||||
|
||||
for i in range(3):
|
||||
resp = client.post("/api/blog-marketing/links", json={
|
||||
"keyword_id": analysis["id"],
|
||||
"url": f"https://link.com/{i}",
|
||||
"product_name": f"상품{i}",
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
|
||||
resp = client.get(f"/api/blog-marketing/links?keyword_id={analysis['id']}")
|
||||
assert len(resp.json()["links"]) == 3
|
||||
|
||||
|
||||
def test_dashboard_still_works(client):
|
||||
"""대시보드 API가 여전히 정상 작동."""
|
||||
resp = client.get("/api/blog-marketing/dashboard")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "total_posts" in data
|
||||
assert "published_posts" in data
|
||||
58
blog-lab/tests/test_research_crawling.py
Normal file
58
blog-lab/tests/test_research_crawling.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""리서치 단계 크롤링 통합 테스트."""
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def test_analyze_keyword_with_crawling_enriches_top_blogs():
|
||||
"""analyze_keyword_with_crawling가 top_blogs에 content 필드를 추가."""
|
||||
from app.naver_search import analyze_keyword_with_crawling
|
||||
|
||||
mock_blog_result = {
|
||||
"total": 100,
|
||||
"items": [
|
||||
{"title": "테스트 블로그", "link": "https://blog.naver.com/user1/111",
|
||||
"bloggername": "유저1", "description": "설명", "postdate": "20260401"},
|
||||
],
|
||||
}
|
||||
mock_shop_result = {
|
||||
"total": 50,
|
||||
"items": [{"title": "상품1", "lprice": 10000, "mallName": "쿠팡"}],
|
||||
"price_stats": {"min": 10000, "max": 10000, "avg": 10000, "count": 1},
|
||||
}
|
||||
|
||||
with patch("app.naver_search.search_blog", return_value=mock_blog_result), \
|
||||
patch("app.naver_search.search_shopping", return_value=mock_shop_result), \
|
||||
patch("app.naver_search._run_enrich", return_value=[
|
||||
{"title": "테스트 블로그", "link": "https://blog.naver.com/user1/111",
|
||||
"bloggername": "유저1", "description": "설명", "postdate": "20260401",
|
||||
"content": "크롤링된 본문 내용"}
|
||||
]):
|
||||
result = analyze_keyword_with_crawling("테스트 키워드")
|
||||
|
||||
assert "content" in result["top_blogs"][0]
|
||||
assert result["top_blogs"][0]["content"] == "크롤링된 본문 내용"
|
||||
|
||||
|
||||
def test_analyze_keyword_with_crawling_fallback_on_enrich_failure():
|
||||
"""크롤링 실패 시 기존 데이터 유지."""
|
||||
from app.naver_search import analyze_keyword_with_crawling
|
||||
|
||||
mock_blog_result = {
|
||||
"total": 50,
|
||||
"items": [{"title": "블로그", "link": "https://blog.naver.com/u/1", "bloggername": "유저", "description": "설명"}],
|
||||
}
|
||||
mock_shop_result = {"total": 10, "items": [], "price_stats": None}
|
||||
|
||||
with patch("app.naver_search.search_blog", return_value=mock_blog_result), \
|
||||
patch("app.naver_search.search_shopping", return_value=mock_shop_result), \
|
||||
patch("app.naver_search._run_enrich", side_effect=Exception("크롤링 실패")):
|
||||
# _run_enrich 내부에서 예외를 잡으므로 실제로는 이 테스트에서는
|
||||
# _run_enrich 자체가 예외를 던지는 상황을 시뮬레이션
|
||||
# 하지만 _run_enrich는 내부에서 잡으므로, 직접 fallback 테스트
|
||||
pass
|
||||
|
||||
# _run_enrich 자체 fallback 테스트
|
||||
from app.naver_search import _run_enrich
|
||||
original_blogs = [{"title": "원본", "link": "https://blog.naver.com/u/1"}]
|
||||
with patch("app.web_crawler.enrich_top_blogs", side_effect=Exception("fail")):
|
||||
result = _run_enrich(original_blogs)
|
||||
assert result == original_blogs # fallback으로 원본 반환
|
||||
94
blog-lab/tests/test_web_crawler.py
Normal file
94
blog-lab/tests/test_web_crawler.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""web_crawler 모듈 테스트."""
|
||||
import pytest
|
||||
from unittest.mock import patch, AsyncMock
|
||||
from app.web_crawler import crawl_blog_content, enrich_top_blogs, _parse_naver_blog_url, _extract_text
|
||||
|
||||
|
||||
def test_parse_naver_blog_url_valid():
|
||||
"""blog.naver.com URL에서 blogId와 logNo를 올바르게 파싱."""
|
||||
result = _parse_naver_blog_url("https://blog.naver.com/testuser/123456")
|
||||
assert result == ("testuser", "123456")
|
||||
|
||||
|
||||
def test_parse_returns_none_for_invalid_url():
|
||||
"""잘못된 URL은 None 반환."""
|
||||
result = _parse_naver_blog_url("https://example.com/post")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_extract_text_prefers_se_main_container():
|
||||
"""SE3 에디터 컨테이너를 우선 선택."""
|
||||
html = '<div class="se-main-container"><p>SE3 본문</p></div><div id="postViewArea"><p>구 에디터</p></div>'
|
||||
assert _extract_text(html) == "SE3 본문"
|
||||
|
||||
|
||||
def test_extract_text_falls_back_to_post_view_area():
|
||||
"""SE3 없으면 구 에디터 컨테이너 사용."""
|
||||
html = '<div id="postViewArea"><p>구 에디터 본문</p></div>'
|
||||
assert _extract_text(html) == "구 에디터 본문"
|
||||
|
||||
|
||||
def test_extract_text_removes_script_and_style():
|
||||
"""스크립트/스타일 태그 제거."""
|
||||
html = '<div class="se-main-container"><p>본문</p><script>alert(1)</script><style>.x{}</style></div>'
|
||||
result = _extract_text(html)
|
||||
assert "alert" not in result
|
||||
assert ".x" not in result
|
||||
assert "본문" in result
|
||||
|
||||
|
||||
def test_extract_text_returns_empty_on_no_container():
|
||||
"""컨테이너가 없고 body도 없으면 빈 문자열."""
|
||||
assert _extract_text("") == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crawl_returns_empty_on_non_naver_url():
|
||||
"""네이버 블로그가 아닌 URL은 빈 문자열 반환."""
|
||||
result = await crawl_blog_content("https://example.com/post")
|
||||
assert result == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crawl_truncates_to_2000_chars():
|
||||
"""본문이 2000자를 초과하면 잘라낸다."""
|
||||
long_html = f'<div class="se-main-container"><p>{"가" * 3000}</p></div>'
|
||||
with patch("app.web_crawler._fetch_html", new_callable=AsyncMock, return_value=long_html):
|
||||
result = await crawl_blog_content("https://blog.naver.com/testuser/123")
|
||||
assert len(result) <= 2000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crawl_returns_empty_on_fetch_failure():
|
||||
"""HTTP 요청 실패 시 빈 문자열 반환."""
|
||||
with patch("app.web_crawler._fetch_html", new_callable=AsyncMock, side_effect=Exception("timeout")):
|
||||
result = await crawl_blog_content("https://blog.naver.com/testuser/123")
|
||||
assert result == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enrich_top_blogs_adds_content_field():
|
||||
"""enrich_top_blogs가 각 블로그에 content 필드를 추가."""
|
||||
blogs = [
|
||||
{"title": "테스트", "link": "https://blog.naver.com/user1/111", "bloggername": "유저1", "description": "설명"},
|
||||
{"title": "테스트2", "link": "https://blog.naver.com/user2/222", "bloggername": "유저2", "description": "설명2"},
|
||||
]
|
||||
with patch("app.web_crawler.crawl_blog_content", new_callable=AsyncMock, return_value="크롤링된 본문"):
|
||||
result = await enrich_top_blogs(blogs)
|
||||
assert len(result) == 2
|
||||
assert result[0]["content"] == "크롤링된 본문"
|
||||
assert result[1]["content"] == "크롤링된 본문"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enrich_top_blogs_handles_partial_failure():
|
||||
"""일부 크롤링 실패 시에도 나머지는 정상 처리."""
|
||||
blogs = [
|
||||
{"title": "성공", "link": "https://blog.naver.com/user1/111"},
|
||||
{"title": "실패", "link": "https://blog.naver.com/user2/222"},
|
||||
]
|
||||
side_effects = ["성공 본문", Exception("fail")]
|
||||
with patch("app.web_crawler.crawl_blog_content", new_callable=AsyncMock, side_effect=side_effects):
|
||||
result = await enrich_top_blogs(blogs)
|
||||
assert result[0]["content"] == "성공 본문"
|
||||
assert result[1]["content"] == ""
|
||||
86
blog-lab/tests/test_writer.py
Normal file
86
blog-lab/tests/test_writer.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""작가 단계 테스트 -- 크롤링 본문 + 링크 참조 글 생성."""
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def test_generate_blog_post_includes_crawled_content():
|
||||
"""크롤링 본문이 프롬프트에 포함되는지 확인."""
|
||||
from app.content_generator import generate_blog_post
|
||||
|
||||
analysis = {
|
||||
"keyword": "무선 이어폰",
|
||||
"top_products": [{"title": "에어팟", "lprice": 200000, "mallName": "애플"}],
|
||||
"top_blogs": [
|
||||
{"title": "에어팟 리뷰", "content": "에어팟을 한 달간 써봤는데 음질이 정말 좋았습니다."},
|
||||
],
|
||||
}
|
||||
|
||||
mock_response = json.dumps({
|
||||
"title": "무선 이어폰 추천",
|
||||
"body": "<p>본문</p>",
|
||||
"excerpt": "요약",
|
||||
"tags": ["이어폰"],
|
||||
})
|
||||
|
||||
with patch("app.content_generator._call_claude", return_value=mock_response) as mock_call, \
|
||||
patch("app.content_generator.get_template", return_value=(
|
||||
"키워드: {keyword}\n참고 블로그:\n{reference_blogs}\n상품: {top_products}\n링크 상품: {brand_products}"
|
||||
)):
|
||||
result = generate_blog_post(analysis, "트렌드 브리프", brand_links=[])
|
||||
|
||||
prompt_used = mock_call.call_args[0][0]
|
||||
assert "에어팟을 한 달간 써봤는데" in prompt_used
|
||||
assert result["title"] == "무선 이어폰 추천"
|
||||
|
||||
|
||||
def test_generate_blog_post_includes_brand_links():
|
||||
"""브랜드커넥트 링크 정보가 프롬프트에 포함되는지 확인."""
|
||||
from app.content_generator import generate_blog_post
|
||||
|
||||
analysis = {"keyword": "무선 이어폰", "top_products": [], "top_blogs": []}
|
||||
brand_links = [
|
||||
{"url": "https://link.coupang.com/abc", "product_name": "삼성 버즈3",
|
||||
"description": "노이즈캔슬링 지원", "placement_hint": "본문 중간"},
|
||||
]
|
||||
|
||||
mock_response = json.dumps({
|
||||
"title": "제목", "body": "<p>본문</p>", "excerpt": "요약", "tags": ["태그"],
|
||||
})
|
||||
|
||||
with patch("app.content_generator._call_claude", return_value=mock_response) as mock_call, \
|
||||
patch("app.content_generator.get_template", return_value=(
|
||||
"키워드: {keyword}\n참고 블로그:\n{reference_blogs}\n상품: {top_products}\n링크 상품: {brand_products}"
|
||||
)):
|
||||
result = generate_blog_post(analysis, "트렌드 브리프", brand_links=brand_links)
|
||||
|
||||
prompt_used = mock_call.call_args[0][0]
|
||||
assert "삼성 버즈3" in prompt_used
|
||||
assert "노이즈캔슬링 지원" in prompt_used
|
||||
|
||||
|
||||
def test_generate_blog_post_works_without_links():
|
||||
"""링크 없이도 정상 동작."""
|
||||
from app.content_generator import generate_blog_post
|
||||
|
||||
analysis = {"keyword": "테스트", "top_products": [], "top_blogs": []}
|
||||
mock_response = json.dumps({
|
||||
"title": "제목", "body": "<p>본문</p>", "excerpt": "요약", "tags": ["태그"],
|
||||
})
|
||||
|
||||
with patch("app.content_generator._call_claude", return_value=mock_response), \
|
||||
patch("app.content_generator.get_template", return_value=(
|
||||
"키워드: {keyword}\n참고 블로그:\n{reference_blogs}\n상품: {top_products}\n링크 상품: {brand_products}"
|
||||
)):
|
||||
result = generate_blog_post(analysis, "브리프")
|
||||
|
||||
assert result["title"] == "제목"
|
||||
|
||||
|
||||
def test_parse_blog_json_fallback():
|
||||
"""JSON 파싱 실패 시 원본 텍스트를 body로 사용."""
|
||||
from app.content_generator import _parse_blog_json
|
||||
|
||||
result = _parse_blog_json("잘못된 JSON", "테스트 키워드")
|
||||
assert result["title"] == "테스트 키워드 추천 리뷰"
|
||||
assert result["body"] == "잘못된 JSON"
|
||||
6
deployer/.dockerignore
Normal file
6
deployer/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
.git
|
||||
__pycache__
|
||||
*.pyc
|
||||
.env
|
||||
.env.*
|
||||
*.md
|
||||
24
deployer/Dockerfile
Normal file
24
deployer/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Docker CE CLI + Compose Plugin (공식 저장소에서 설치)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git rsync ca-certificates curl util-linux gnupg \
|
||||
&& install -m 0755 -d /etc/apt/keyrings \
|
||||
&& curl -fsSL https://download.docker.com/linux/debian/gpg \
|
||||
| gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
|
||||
https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" \
|
||||
> /etc/apt/sources.list.d/docker.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app.py /app/app.py
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
EXPOSE 9000
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "9000"]
|
||||
79
deployer/app.py
Normal file
79
deployer/app.py
Normal file
@@ -0,0 +1,79 @@
|
||||
import os, hmac, hashlib, subprocess, threading
|
||||
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
|
||||
from fastapi.responses import JSONResponse
|
||||
import logging
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S %Z",
|
||||
)
|
||||
logger = logging.getLogger("deployer")
|
||||
|
||||
app = FastAPI()
|
||||
SECRET = os.getenv("WEBHOOK_SECRET", "")
|
||||
|
||||
if not SECRET:
|
||||
logger.warning("WEBHOOK_SECRET is not set! All webhooks will be rejected.")
|
||||
|
||||
_deploy_lock = threading.Lock()
|
||||
|
||||
def verify(sig: str, body: bytes) -> bool:
|
||||
if not SECRET or not sig:
|
||||
return False
|
||||
|
||||
mac = hmac.new(SECRET.encode(), msg=body, digestmod=hashlib.sha256).hexdigest()
|
||||
candidates = {mac, f"sha256={mac}"}
|
||||
return any(hmac.compare_digest(sig, c) for c in candidates)
|
||||
|
||||
def run_deploy_script():
|
||||
"""배포 스크립트를 백그라운드에서 실행 (동시 실행 방지)"""
|
||||
if not _deploy_lock.acquire(blocking=False):
|
||||
logger.info("Deploy already in progress, skipping")
|
||||
return
|
||||
|
||||
try:
|
||||
logger.info("Starting deployment script...")
|
||||
p = subprocess.run(["/bin/bash", "/scripts/deploy.sh"], capture_output=True, text=True, timeout=600)
|
||||
|
||||
if p.returncode == 0:
|
||||
logger.info(f"Deployment SUCCESS:\n{p.stdout}")
|
||||
else:
|
||||
logger.error(f"Deployment FAILED ({p.returncode}):\n{p.stdout}\n{p.stderr}")
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("Deployment TIMEOUT (10 min exceeded)")
|
||||
except Exception as e:
|
||||
logger.exception(f"Exception during deployment: {e}")
|
||||
finally:
|
||||
_deploy_lock.release()
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "healthy", "service": "deployer"}
|
||||
|
||||
@app.post("/webhook")
|
||||
async def webhook(req: Request, background_tasks: BackgroundTasks):
|
||||
body = await req.body()
|
||||
|
||||
sig = (
|
||||
req.headers.get("X-Gitea-Signature")
|
||||
or req.headers.get("X-Hub-Signature-256")
|
||||
or ""
|
||||
)
|
||||
|
||||
if not verify(sig, body):
|
||||
raise HTTPException(401, "bad signature")
|
||||
|
||||
# 동시 배포 방지: 이미 진행 중이면 503 반환
|
||||
if _deploy_lock.locked():
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"ok": False, "message": "Deploy already in progress"},
|
||||
)
|
||||
|
||||
# ✅ 비동기 실행: Gitea에게는 즉시 OK 응답을 주고, 배포는 뒤에서 실행
|
||||
background_tasks.add_task(run_deploy_script)
|
||||
|
||||
return {"ok": True, "message": "Deployment started in background"}
|
||||
|
||||
2
deployer/requirements.txt
Normal file
2
deployer/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
@@ -1,8 +1,11 @@
|
||||
version: "3.8"
|
||||
name: webpage
|
||||
|
||||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
build:
|
||||
context: ./backend
|
||||
args:
|
||||
APP_VERSION: ${APP_VERSION:-dev}
|
||||
container_name: lotto-backend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -12,36 +15,183 @@ services:
|
||||
- LOTTO_ALL_URL=${LOTTO_ALL_URL:-https://smok95.github.io/lotto/results/all.json}
|
||||
- LOTTO_LATEST_URL=${LOTTO_LATEST_URL:-https://smok95.github.io/lotto/results/latest.json}
|
||||
volumes:
|
||||
- /volume1/docker/webpage/data:/app/data
|
||||
- ${RUNTIME_PATH}/data:/app/data
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
stock-lab:
|
||||
build:
|
||||
context: ./stock-lab
|
||||
args:
|
||||
APP_VERSION: ${APP_VERSION:-dev}
|
||||
container_name: stock-lab
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18500:8000"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- WINDOWS_AI_SERVER_URL=${WINDOWS_AI_SERVER_URL:-http://192.168.0.5:8000}
|
||||
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
|
||||
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-1.5-flash}
|
||||
- ADMIN_API_KEY=${ADMIN_API_KEY:-}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/data/stock:/app/data
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
music-lab:
|
||||
build:
|
||||
context: ./music-lab
|
||||
container_name: music-lab
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18600:8000"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- MUSIC_AI_SERVER_URL=${MUSIC_AI_SERVER_URL:-}
|
||||
- SUNO_API_KEY=${SUNO_API_KEY:-}
|
||||
- MUSIC_MEDIA_BASE=${MUSIC_MEDIA_BASE:-/media/music}
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/data/music:/app/data
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
blog-lab:
|
||||
build:
|
||||
context: ./blog-lab
|
||||
container_name: blog-lab
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18700:8000"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
- NAVER_CLIENT_ID=${NAVER_CLIENT_ID:-}
|
||||
- NAVER_CLIENT_SECRET=${NAVER_CLIENT_SECRET:-}
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/data/blog:/app/data
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
realestate-lab:
|
||||
build:
|
||||
context: ./realestate-lab
|
||||
container_name: realestate-lab
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18800:8000"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- DATA_GO_KR_API_KEY=${DATA_GO_KR_API_KEY:-}
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/data/realestate:/app/data
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
agent-office:
|
||||
build:
|
||||
context: ./agent-office
|
||||
container_name: agent-office
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18900:8000"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
- STOCK_LAB_URL=http://stock-lab:8000
|
||||
- MUSIC_LAB_URL=http://music-lab:8000
|
||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
|
||||
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
|
||||
- TELEGRAM_WEBHOOK_URL=${TELEGRAM_WEBHOOK_URL:-}
|
||||
volumes:
|
||||
- ${RUNTIME_PATH:-.}/data/agent-office:/app/data
|
||||
depends_on:
|
||||
- stock-lab
|
||||
- music-lab
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
travel-proxy:
|
||||
build: ./travel-proxy
|
||||
container_name: travel-proxy
|
||||
restart: unless-stopped
|
||||
user: "1026:100"
|
||||
user: "${PUID}:${PGID}"
|
||||
ports:
|
||||
- "19000:8000" # 내부 확인용
|
||||
- "19000:8000"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- TRAVEL_ROOT=${TRAVEL_ROOT:-/data/travel}
|
||||
- TRAVEL_THUMB_ROOT=${TRAVEL_THUMB_ROOT:-/data/thumbs}
|
||||
- TRAVEL_MEDIA_BASE=${TRAVEL_MEDIA_BASE:-/media/travel}
|
||||
- TRAVEL_CACHE_TTL=${TRAVEL_CACHE_TTL:-300}
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-*}
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
volumes:
|
||||
- /volume1/web/images/webPage/travel:/data/travel:ro
|
||||
- /volume1/docker/webpage/travel-thumbs:/data/thumbs:rw
|
||||
- ${PHOTO_PATH}:/data/travel:ro
|
||||
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:rw
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
frontend:
|
||||
image: nginx:alpine
|
||||
container_name: lotto-frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- music-lab
|
||||
- blog-lab
|
||||
- realestate-lab
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- /volume1/docker/webpage/frontend:/usr/share/nginx/html:ro
|
||||
- /volume1/docker/webpage/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- /volume1/web/images/webPage/travel:/data/travel:ro
|
||||
- /volume1/docker/webpage/travel-thumbs:/data/thumbs:ro
|
||||
- ${FRONTEND_PATH}:/usr/share/nginx/html:ro
|
||||
- ${RUNTIME_PATH}/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ${PHOTO_PATH}:/data/travel:ro
|
||||
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:ro
|
||||
- ${RUNTIME_PATH}/data/music:/data/music:ro
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:80/"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
deployer:
|
||||
build: ./deployer
|
||||
container_name: webpage-deployer
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:19010:9000"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- WEBHOOK_SECRET=${WEBHOOK_SECRET}
|
||||
volumes:
|
||||
- ${REPO_PATH}:/repo:rw
|
||||
- ${RUNTIME_PATH}:/runtime:rw
|
||||
- ${RUNTIME_PATH}/scripts:/scripts:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1509
docs/superpowers/plans/2026-04-05-realestate-lab.md
Normal file
1509
docs/superpowers/plans/2026-04-05-realestate-lab.md
Normal file
File diff suppressed because it is too large
Load Diff
672
docs/superpowers/plans/2026-04-07-pet-lab.md
Normal file
672
docs/superpowers/plans/2026-04-07-pet-lab.md
Normal file
@@ -0,0 +1,672 @@
|
||||
# Pet Lab Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Windows 데스크톱 펫 애플리케이션 — 화면 하단에 고정된 캐릭터가 마우스 시선을 추적하고 클릭/우클릭 상호작용을 지원한다.
|
||||
|
||||
**Architecture:** PyQt5 투명 프레임리스 윈도우에 캐릭터 이미지를 표시. QTimer 루프로 마우스 좌표를 폴링하여 이미지 기울기/반전으로 시선을 표현. 좌클릭(점프)/더블클릭(흔들기) 애니메이션과 우클릭 컨텍스트 메뉴 제공.
|
||||
|
||||
**Tech Stack:** Python 3.12, PyQt5
|
||||
|
||||
**Project Path:** `C:\Users\jaeoh\Desktop\workspace\pet-lab`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 파일 | 역할 | 생성/수정 |
|
||||
|------|------|-----------|
|
||||
| `app/config.py` | 상수 정의 (크기, 위치, 애니메이션, 경로) | Create |
|
||||
| `app/eye_tracker.py` | 마우스→기울기 각도/반전 계산 (순수 함수) | Create |
|
||||
| `app/pet_widget.py` | 투명 윈도우 + 캐릭터 렌더링 + QTimer 루프 | Create |
|
||||
| `app/interaction.py` | 클릭 애니메이션 + 우클릭 메뉴 | Create |
|
||||
| `app/main.py` | 엔트리포인트 (QApplication 초기화) | Create |
|
||||
| `assets/characters/박뚱냥.png` | 캐릭터 이미지 | Copy |
|
||||
| `requirements.txt` | PyQt5 의존성 | Create |
|
||||
| `tests/test_eye_tracker.py` | eye_tracker 단위 테스트 | Create |
|
||||
| `tests/test_config.py` | config 상수 검증 테스트 | Create |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 프로젝트 초기화 + config.py
|
||||
|
||||
**Files:**
|
||||
- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\requirements.txt`
|
||||
- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\app\config.py`
|
||||
- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\tests\test_config.py`
|
||||
- Copy: `Z:\homes\jaeoh\캐릭터\박뚱냥.jpg` → `C:\Users\jaeoh\Desktop\workspace\pet-lab\assets\characters\박뚱냥.png`
|
||||
|
||||
- [ ] **Step 1: 프로젝트 디렉토리 생성 및 git 초기화**
|
||||
|
||||
```bash
|
||||
mkdir -p "C:\Users\jaeoh\Desktop\workspace\pet-lab"/{app,assets/characters,tests}
|
||||
cd "C:\Users\jaeoh\Desktop\workspace\pet-lab"
|
||||
git init
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 캐릭터 이미지 복사**
|
||||
|
||||
```bash
|
||||
cp "Z:\homes\jaeoh\캐릭터\박뚱냥.jpg" "C:\Users\jaeoh\Desktop\workspace\pet-lab\assets\characters\박뚱냥.png"
|
||||
```
|
||||
|
||||
참고: 원본이 .jpg이지만 투명 배경이 있는 이미지이므로 그대로 사용. 파일명은 .png으로 저장하되, 실제 포맷이 JPG라면 PyQt5의 QPixmap이 자동 감지하므로 문제없음.
|
||||
|
||||
- [ ] **Step 3: requirements.txt 생성**
|
||||
|
||||
```
|
||||
PyQt5>=5.15,<6.0
|
||||
pytest>=7.0
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 가상환경 생성 및 의존성 설치**
|
||||
|
||||
```bash
|
||||
cd "C:\Users\jaeoh\Desktop\workspace\pet-lab"
|
||||
python -m venv venv
|
||||
venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
- [ ] **Step 5: config.py 작성**
|
||||
|
||||
```python
|
||||
"""pet-lab 설정 상수."""
|
||||
import os
|
||||
|
||||
# 캐릭터 크기 (높이 기준 px, 너비는 비율 유지)
|
||||
SIZES = {"small": 100, "medium": 150, "large": 200}
|
||||
DEFAULT_SIZE = "medium"
|
||||
|
||||
# 수평 위치 프리셋 (화면 너비 비율)
|
||||
POSITIONS = {"left": 0.1, "center": 0.5, "right": 0.9}
|
||||
DEFAULT_POSITION = "right"
|
||||
|
||||
# 시선 추적
|
||||
TIMER_INTERVAL_MS = 30
|
||||
MAX_TILT_ANGLE = 15.0
|
||||
|
||||
# 태스크바
|
||||
TASKBAR_HEIGHT = 48
|
||||
|
||||
# 애니메이션
|
||||
JUMP_HEIGHT = 30
|
||||
JUMP_DURATION_MS = 300
|
||||
SHAKE_OFFSET = 10
|
||||
SHAKE_DURATION_MS = 400
|
||||
|
||||
# 에셋 경로
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
CHARACTER_DIR = os.path.join(BASE_DIR, "assets", "characters")
|
||||
DEFAULT_CHARACTER = "박뚱냥.png"
|
||||
```
|
||||
|
||||
- [ ] **Step 6: test_config.py 작성**
|
||||
|
||||
```python
|
||||
"""config 상수 검증."""
|
||||
from app.config import SIZES, POSITIONS, DEFAULT_SIZE, DEFAULT_POSITION
|
||||
from app.config import TIMER_INTERVAL_MS, MAX_TILT_ANGLE, CHARACTER_DIR
|
||||
import os
|
||||
|
||||
|
||||
def test_sizes_has_three_presets():
|
||||
assert set(SIZES.keys()) == {"small", "medium", "large"}
|
||||
assert all(isinstance(v, int) and v > 0 for v in SIZES.values())
|
||||
|
||||
|
||||
def test_default_size_is_valid():
|
||||
assert DEFAULT_SIZE in SIZES
|
||||
|
||||
|
||||
def test_positions_has_three_presets():
|
||||
assert set(POSITIONS.keys()) == {"left", "center", "right"}
|
||||
assert all(0.0 < v < 1.0 for v in POSITIONS.values())
|
||||
|
||||
|
||||
def test_default_position_is_valid():
|
||||
assert DEFAULT_POSITION in POSITIONS
|
||||
|
||||
|
||||
def test_timer_interval_is_reasonable():
|
||||
assert 10 <= TIMER_INTERVAL_MS <= 100
|
||||
|
||||
|
||||
def test_max_tilt_angle_is_reasonable():
|
||||
assert 5.0 <= MAX_TILT_ANGLE <= 45.0
|
||||
|
||||
|
||||
def test_character_dir_exists():
|
||||
assert os.path.isdir(CHARACTER_DIR)
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 테스트 실행**
|
||||
|
||||
```bash
|
||||
cd "C:\Users\jaeoh\Desktop\workspace\pet-lab"
|
||||
python -m pytest tests/test_config.py -v
|
||||
```
|
||||
|
||||
Expected: 7 passed
|
||||
|
||||
- [ ] **Step 8: .gitignore 생성 및 커밋**
|
||||
|
||||
`.gitignore`:
|
||||
```
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
dist/
|
||||
build/
|
||||
*.spec
|
||||
```
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: 프로젝트 초기화 — config, 캐릭터 에셋, 테스트"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: eye_tracker.py — 시선 계산 모듈
|
||||
|
||||
**Files:**
|
||||
- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\app\eye_tracker.py`
|
||||
- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\tests\test_eye_tracker.py`
|
||||
|
||||
- [ ] **Step 1: test_eye_tracker.py 작성**
|
||||
|
||||
```python
|
||||
"""eye_tracker 시선 계산 테스트."""
|
||||
import math
|
||||
from app.eye_tracker import compute_gaze
|
||||
|
||||
|
||||
def test_mouse_right_of_character():
|
||||
"""마우스가 캐릭터 오른쪽 → 양수 기울기, flip=False."""
|
||||
angle, flip = compute_gaze(
|
||||
char_center_x=500, char_center_y=900,
|
||||
mouse_x=800, mouse_y=500,
|
||||
max_angle=15.0,
|
||||
)
|
||||
assert 0 < angle <= 15.0
|
||||
assert flip is False
|
||||
|
||||
|
||||
def test_mouse_left_of_character():
|
||||
"""마우스가 캐릭터 왼쪽 → 음수 기울기, flip=True."""
|
||||
angle, flip = compute_gaze(
|
||||
char_center_x=500, char_center_y=900,
|
||||
mouse_x=200, mouse_y=500,
|
||||
max_angle=15.0,
|
||||
)
|
||||
assert -15.0 <= angle < 0
|
||||
assert flip is True
|
||||
|
||||
|
||||
def test_mouse_directly_above():
|
||||
"""마우스가 캐릭터 바로 위 → 기울기 0, flip=False."""
|
||||
angle, flip = compute_gaze(
|
||||
char_center_x=500, char_center_y=900,
|
||||
mouse_x=500, mouse_y=100,
|
||||
max_angle=15.0,
|
||||
)
|
||||
assert angle == 0.0
|
||||
assert flip is False
|
||||
|
||||
|
||||
def test_mouse_at_character_position():
|
||||
"""마우스가 캐릭터 위치와 동일 → 기울기 0, flip=False."""
|
||||
angle, flip = compute_gaze(
|
||||
char_center_x=500, char_center_y=500,
|
||||
mouse_x=500, mouse_y=500,
|
||||
max_angle=15.0,
|
||||
)
|
||||
assert angle == 0.0
|
||||
assert flip is False
|
||||
|
||||
|
||||
def test_angle_clamped_to_max():
|
||||
"""기울기가 max_angle을 초과하지 않아야 한다."""
|
||||
angle, flip = compute_gaze(
|
||||
char_center_x=500, char_center_y=500,
|
||||
mouse_x=10000, mouse_y=500,
|
||||
max_angle=15.0,
|
||||
)
|
||||
assert abs(angle) <= 15.0
|
||||
|
||||
|
||||
def test_mouse_far_left():
|
||||
"""마우스가 매우 왼쪽 → 기울기 -max_angle에 근접."""
|
||||
angle, flip = compute_gaze(
|
||||
char_center_x=500, char_center_y=500,
|
||||
mouse_x=0, mouse_y=500,
|
||||
max_angle=15.0,
|
||||
)
|
||||
assert angle < 0
|
||||
assert flip is True
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실행 — 실패 확인**
|
||||
|
||||
```bash
|
||||
python -m pytest tests/test_eye_tracker.py -v
|
||||
```
|
||||
|
||||
Expected: FAIL with `ModuleNotFoundError: No module named 'app.eye_tracker'`
|
||||
|
||||
- [ ] **Step 3: eye_tracker.py 구현**
|
||||
|
||||
```python
|
||||
"""마우스 위치 기반 시선/기울기 계산 — 순수 함수 모듈."""
|
||||
import math
|
||||
|
||||
|
||||
def compute_gaze(
|
||||
char_center_x: float,
|
||||
char_center_y: float,
|
||||
mouse_x: float,
|
||||
mouse_y: float,
|
||||
max_angle: float = 15.0,
|
||||
) -> tuple[float, bool]:
|
||||
"""캐릭터 중심과 마우스 위치로 기울기 각도와 좌우 반전 여부를 계산한다.
|
||||
|
||||
Returns:
|
||||
(tilt_angle, flip_horizontal)
|
||||
- tilt_angle: -max_angle ~ +max_angle (도). 양수=우측 기울기, 음수=좌측 기울기.
|
||||
- flip_horizontal: True면 이미지를 좌우 반전 (마우스가 캐릭터 왼쪽).
|
||||
"""
|
||||
dx = mouse_x - char_center_x
|
||||
dy = mouse_y - char_center_y
|
||||
|
||||
if dx == 0 and dy == 0:
|
||||
return 0.0, False
|
||||
|
||||
# dx 방향의 비율로 기울기 결정 (atan2로 각도 → 비율 변환)
|
||||
angle_rad = math.atan2(abs(dx), max(abs(dy), 1))
|
||||
ratio = angle_rad / (math.pi / 2) # 0~1 범위
|
||||
tilt = ratio * max_angle
|
||||
|
||||
if dx < 0:
|
||||
tilt = -tilt
|
||||
|
||||
# max_angle 클램핑
|
||||
tilt = max(-max_angle, min(max_angle, tilt))
|
||||
|
||||
flip = dx < 0
|
||||
|
||||
return tilt, flip
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 테스트 실행 — 통과 확인**
|
||||
|
||||
```bash
|
||||
python -m pytest tests/test_eye_tracker.py -v
|
||||
```
|
||||
|
||||
Expected: 6 passed
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
|
||||
```bash
|
||||
git add app/eye_tracker.py tests/test_eye_tracker.py
|
||||
git commit -m "feat: eye_tracker — 마우스 시선 기울기 계산 모듈"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: pet_widget.py — 투명 윈도우 + 캐릭터 렌더링
|
||||
|
||||
**Files:**
|
||||
- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\app\pet_widget.py`
|
||||
|
||||
- [ ] **Step 1: pet_widget.py 작성**
|
||||
|
||||
```python
|
||||
"""투명 윈도우 위에 캐릭터를 렌더링하고 시선을 추적하는 메인 위젯."""
|
||||
from PyQt5.QtWidgets import QWidget, QLabel, QApplication
|
||||
from PyQt5.QtCore import Qt, QTimer, QPoint
|
||||
from PyQt5.QtGui import QPixmap, QCursor, QTransform
|
||||
import os
|
||||
|
||||
from app.config import (
|
||||
SIZES, DEFAULT_SIZE, POSITIONS, DEFAULT_POSITION,
|
||||
TIMER_INTERVAL_MS, MAX_TILT_ANGLE, TASKBAR_HEIGHT,
|
||||
CHARACTER_DIR, DEFAULT_CHARACTER,
|
||||
)
|
||||
from app.eye_tracker import compute_gaze
|
||||
|
||||
|
||||
class PetWidget(QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._size_key = DEFAULT_SIZE
|
||||
self._position_key = DEFAULT_POSITION
|
||||
self._always_on_top = True
|
||||
self._last_mouse_pos = None
|
||||
self._base_y = 0
|
||||
|
||||
self._init_window()
|
||||
self._load_character()
|
||||
self._position_on_screen()
|
||||
self._start_tracking()
|
||||
|
||||
def _init_window(self):
|
||||
flags = Qt.FramelessWindowHint | Qt.Tool
|
||||
if self._always_on_top:
|
||||
flags |= Qt.WindowStaysOnTopHint
|
||||
self.setWindowFlags(flags)
|
||||
self.setAttribute(Qt.WA_TranslucentBackground)
|
||||
|
||||
def _load_character(self):
|
||||
path = os.path.join(CHARACTER_DIR, DEFAULT_CHARACTER)
|
||||
self._original_pixmap = QPixmap(path)
|
||||
self._label = QLabel(self)
|
||||
self._apply_size()
|
||||
|
||||
def _apply_size(self):
|
||||
height = SIZES[self._size_key]
|
||||
scaled = self._original_pixmap.scaledToHeight(height, Qt.SmoothTransformation)
|
||||
self._label.setPixmap(scaled)
|
||||
self._label.setFixedSize(scaled.size())
|
||||
self.setFixedSize(scaled.size())
|
||||
|
||||
def _position_on_screen(self):
|
||||
screen = QApplication.primaryScreen().geometry()
|
||||
char_height = SIZES[self._size_key]
|
||||
self._base_y = screen.height() - TASKBAR_HEIGHT - char_height
|
||||
x_ratio = POSITIONS[self._position_key]
|
||||
x = int(screen.width() * x_ratio) - self.width() // 2
|
||||
self.move(x, self._base_y)
|
||||
|
||||
def _start_tracking(self):
|
||||
self._timer = QTimer(self)
|
||||
self._timer.timeout.connect(self._update_gaze)
|
||||
self._timer.start(TIMER_INTERVAL_MS)
|
||||
|
||||
def _update_gaze(self):
|
||||
mouse_pos = QCursor.pos()
|
||||
if self._last_mouse_pos == mouse_pos:
|
||||
return
|
||||
self._last_mouse_pos = mouse_pos
|
||||
|
||||
center = self.geometry().center()
|
||||
tilt, flip = compute_gaze(
|
||||
center.x(), center.y(),
|
||||
mouse_pos.x(), mouse_pos.y(),
|
||||
MAX_TILT_ANGLE,
|
||||
)
|
||||
|
||||
height = SIZES[self._size_key]
|
||||
scaled = self._original_pixmap.scaledToHeight(height, Qt.SmoothTransformation)
|
||||
|
||||
transform = QTransform()
|
||||
if flip:
|
||||
transform.scale(-1, 1)
|
||||
transform.rotate(tilt)
|
||||
|
||||
rotated = scaled.transformed(transform, Qt.SmoothTransformation)
|
||||
self._label.setPixmap(rotated)
|
||||
self._label.setFixedSize(rotated.size())
|
||||
self.setFixedSize(rotated.size())
|
||||
|
||||
# ── 크기/위치 변경 (interaction.py에서 호출) ──
|
||||
|
||||
def set_size(self, size_key: str):
|
||||
self._size_key = size_key
|
||||
self._apply_size()
|
||||
self._position_on_screen()
|
||||
|
||||
def set_position(self, position_key: str):
|
||||
self._position_key = position_key
|
||||
self._position_on_screen()
|
||||
|
||||
def toggle_always_on_top(self):
|
||||
self._always_on_top = not self._always_on_top
|
||||
flags = Qt.FramelessWindowHint | Qt.Tool
|
||||
if self._always_on_top:
|
||||
flags |= Qt.WindowStaysOnTopHint
|
||||
self.setWindowFlags(flags)
|
||||
self.show()
|
||||
|
||||
@property
|
||||
def always_on_top(self) -> bool:
|
||||
return self._always_on_top
|
||||
|
||||
@property
|
||||
def base_y(self) -> int:
|
||||
return self._base_y
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 수동 테스트 — 투명 윈도우에 캐릭터 표시 확인**
|
||||
|
||||
임시 실행 스크립트:
|
||||
```bash
|
||||
cd "C:\Users\jaeoh\Desktop\workspace\pet-lab"
|
||||
python -c "
|
||||
import sys
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from app.pet_widget import PetWidget
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
pet = PetWidget()
|
||||
pet.show()
|
||||
sys.exit(app.exec_())
|
||||
"
|
||||
```
|
||||
|
||||
Expected: 화면 우하단에 박뚱냥이 표시되고, 마우스 이동 시 기울기/반전이 바뀜.
|
||||
|
||||
- [ ] **Step 3: 커밋**
|
||||
|
||||
```bash
|
||||
git add app/pet_widget.py
|
||||
git commit -m "feat: pet_widget — 투명 윈도우 + 시선 추적 렌더링"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: interaction.py — 클릭 반응 + 우클릭 메뉴
|
||||
|
||||
**Files:**
|
||||
- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\app\interaction.py`
|
||||
- Modify: `C:\Users\jaeoh\Desktop\workspace\pet-lab\app\pet_widget.py` (마우스 이벤트 연결)
|
||||
|
||||
- [ ] **Step 1: interaction.py 작성**
|
||||
|
||||
```python
|
||||
"""클릭 애니메이션 + 우클릭 컨텍스트 메뉴."""
|
||||
from PyQt5.QtWidgets import QMenu, QAction, QApplication
|
||||
from PyQt5.QtCore import QPropertyAnimation, QEasingCurve, QPoint, QSequentialAnimationGroup
|
||||
|
||||
from app.config import (
|
||||
JUMP_HEIGHT, JUMP_DURATION_MS,
|
||||
SHAKE_OFFSET, SHAKE_DURATION_MS,
|
||||
SIZES, POSITIONS,
|
||||
)
|
||||
|
||||
|
||||
def play_jump(widget):
|
||||
"""좌클릭 — 위로 점프 후 복귀."""
|
||||
start = widget.pos()
|
||||
top = QPoint(start.x(), start.y() - JUMP_HEIGHT)
|
||||
|
||||
anim = QPropertyAnimation(widget, b"pos")
|
||||
anim.setDuration(JUMP_DURATION_MS)
|
||||
anim.setStartValue(start)
|
||||
anim.setKeyValueAt(0.4, top)
|
||||
anim.setEndValue(start)
|
||||
anim.setEasingCurve(QEasingCurve.OutBounce)
|
||||
|
||||
# prevent garbage collection
|
||||
widget._current_anim = anim
|
||||
anim.start()
|
||||
|
||||
|
||||
def play_shake(widget):
|
||||
"""더블클릭 — 좌우 흔들기."""
|
||||
start = widget.pos()
|
||||
left = QPoint(start.x() - SHAKE_OFFSET, start.y())
|
||||
right = QPoint(start.x() + SHAKE_OFFSET, start.y())
|
||||
|
||||
group = QSequentialAnimationGroup(widget)
|
||||
|
||||
for end_pos in [left, right, left, right, start]:
|
||||
anim = QPropertyAnimation(widget, b"pos")
|
||||
anim.setDuration(SHAKE_DURATION_MS // 5)
|
||||
anim.setEndValue(end_pos)
|
||||
group.addAnimation(anim)
|
||||
|
||||
widget._current_anim = group
|
||||
group.start()
|
||||
|
||||
|
||||
def show_context_menu(widget, global_pos):
|
||||
"""우클릭 — 컨텍스트 메뉴 표시."""
|
||||
menu = QMenu()
|
||||
|
||||
# 위치 서브메뉴
|
||||
pos_menu = menu.addMenu("위치")
|
||||
for key, label in [("left", "좌"), ("center", "중앙"), ("right", "우")]:
|
||||
action = pos_menu.addAction(label)
|
||||
action.triggered.connect(lambda checked, k=key: widget.set_position(k))
|
||||
|
||||
# 크기 서브메뉴
|
||||
size_menu = menu.addMenu("크기")
|
||||
for key, label in [("small", "소 (100px)"), ("medium", "중 (150px)"), ("large", "대 (200px)")]:
|
||||
action = size_menu.addAction(label)
|
||||
action.triggered.connect(lambda checked, k=key: widget.set_size(k))
|
||||
|
||||
# 항상 위 토글
|
||||
top_action = menu.addAction("항상 위" + (" ✓" if widget.always_on_top else ""))
|
||||
top_action.triggered.connect(widget.toggle_always_on_top)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
# 종료
|
||||
quit_action = menu.addAction("종료")
|
||||
quit_action.triggered.connect(QApplication.quit)
|
||||
|
||||
menu.exec_(global_pos)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: pet_widget.py에 마우스 이벤트 연결**
|
||||
|
||||
`pet_widget.py`의 `PetWidget` 클래스에 다음 메서드를 추가:
|
||||
|
||||
```python
|
||||
# ── 마우스 이벤트 (파일 하단, toggle_always_on_top 뒤에 추가) ──
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.RightButton:
|
||||
from app.interaction import show_context_menu
|
||||
show_context_menu(self, event.globalPos())
|
||||
|
||||
def mouseDoubleClickEvent(self, event):
|
||||
if event.button() == Qt.LeftButton:
|
||||
from app.interaction import play_shake
|
||||
play_shake(self)
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if event.button() == Qt.LeftButton:
|
||||
from app.interaction import play_jump
|
||||
play_jump(self)
|
||||
```
|
||||
|
||||
파일 상단 import에 추가 필요 없음 (lazy import 사용).
|
||||
|
||||
- [ ] **Step 3: 수동 테스트**
|
||||
|
||||
```bash
|
||||
cd "C:\Users\jaeoh\Desktop\workspace\pet-lab"
|
||||
python -c "
|
||||
import sys
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from app.pet_widget import PetWidget
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
pet = PetWidget()
|
||||
pet.show()
|
||||
sys.exit(app.exec_())
|
||||
"
|
||||
```
|
||||
|
||||
테스트 항목:
|
||||
- 좌클릭 → 점프 애니메이션
|
||||
- 더블클릭 → 흔들기 애니메이션
|
||||
- 우클릭 → 메뉴 표시 (위치/크기/항상위/종료)
|
||||
- 메뉴에서 위치 변경 → 캐릭터 이동
|
||||
- 메뉴에서 크기 변경 → 캐릭터 크기 변경
|
||||
- 종료 → 앱 종료
|
||||
|
||||
- [ ] **Step 4: 커밋**
|
||||
|
||||
```bash
|
||||
git add app/interaction.py app/pet_widget.py
|
||||
git commit -m "feat: interaction — 클릭 점프/흔들기 + 우클릭 메뉴"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: main.py — 엔트리포인트
|
||||
|
||||
**Files:**
|
||||
- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\app\main.py`
|
||||
|
||||
- [ ] **Step 1: main.py 작성**
|
||||
|
||||
```python
|
||||
"""pet-lab 엔트리포인트."""
|
||||
import sys
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from app.pet_widget import PetWidget
|
||||
|
||||
|
||||
def main():
|
||||
app = QApplication(sys.argv)
|
||||
app.setQuitOnLastWindowClosed(False)
|
||||
|
||||
pet = PetWidget()
|
||||
pet.show()
|
||||
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실행 확인**
|
||||
|
||||
```bash
|
||||
cd "C:\Users\jaeoh\Desktop\workspace\pet-lab"
|
||||
python -m app.main
|
||||
```
|
||||
|
||||
Expected: 박뚱냥이 화면 우하단에 표시되고, 시선 추적 + 클릭 반응 + 우클릭 메뉴 모두 동작.
|
||||
|
||||
- [ ] **Step 3: 커밋**
|
||||
|
||||
```bash
|
||||
git add app/main.py
|
||||
git commit -m "feat: main.py 엔트리포인트 — python -m app.main으로 실행"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
**Spec coverage:**
|
||||
- [x] 투명 윈도우 (Task 3: `FramelessWindowHint`, `WA_TranslucentBackground`, `Tool`)
|
||||
- [x] 바닥 고정 (Task 3: `_position_on_screen`)
|
||||
- [x] 시선 추적 (Task 2: `compute_gaze`, Task 3: `_update_gaze`)
|
||||
- [x] 좌클릭 점프 (Task 4: `play_jump`)
|
||||
- [x] 더블클릭 흔들기 (Task 4: `play_shake`)
|
||||
- [x] 우클릭 메뉴 — 위치/크기/항상위/종료 (Task 4: `show_context_menu`)
|
||||
- [x] config 상수 (Task 1: `config.py`)
|
||||
- [x] 성능 최적화 — 마우스 변화 없으면 스킵 (Task 3: `_last_mouse_pos`)
|
||||
|
||||
**Placeholder scan:** 없음. 모든 step에 실제 코드 포함.
|
||||
|
||||
**Type consistency:** `compute_gaze` 시그니처 — Task 2 구현과 Task 3 호출 일치. `set_size`/`set_position` — Task 3 정의와 Task 4 호출 일치.
|
||||
2594
docs/superpowers/plans/2026-04-08-music-lab-suno-enhancement.md
Normal file
2594
docs/superpowers/plans/2026-04-08-music-lab-suno-enhancement.md
Normal file
File diff suppressed because it is too large
Load Diff
2961
docs/superpowers/plans/2026-04-11-agent-office.md
Normal file
2961
docs/superpowers/plans/2026-04-11-agent-office.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,402 @@
|
||||
# Lotto 구매 연동 + 전략 진화 시스템 설계
|
||||
|
||||
> 작성일: 2026-04-05
|
||||
> 상태: 승인 대기
|
||||
|
||||
---
|
||||
|
||||
## 1. 목표
|
||||
|
||||
로또 번호 추천 기능을 고도화하여:
|
||||
1. **동행복권 실 구매 연동** — 추천 번호를 클립보드 복사 + 동행복권 바로가기로 실제 구매 지원
|
||||
2. **가상 구매 모드** — 돈을 쓰지 않고 "이 번호로 구매한다"를 등록, 결과 발표 후 자동 가상 수익률 계산
|
||||
3. **전략 진화 시스템** — 구매 이력 기반으로 각 추천 전략(combined, simulation, heatmap, manual, custom)의 성과를 추적하고, EMA + Softmax로 가중치를 자동 조정하는 메타 전략
|
||||
4. **통합 구매 이력** — 실제/가상 구매를 하나의 리스트에서 관리하되, 실 구매는 시각적으로 강조
|
||||
|
||||
---
|
||||
|
||||
## 2. 접근 방식
|
||||
|
||||
**방식 1 (단일 확장) 채택**: 기존 `lotto-backend`(backend/) 서비스 내부에 모듈 추가.
|
||||
- NAS Celeron J4025 환경에서 새 컨테이너 추가는 리소스 부담
|
||||
- 기존 checker/recommender/DB와 자연스러운 연동 가능
|
||||
- 파일 수준 모듈 분리로 유지보수성 확보
|
||||
|
||||
---
|
||||
|
||||
## 3. 데이터 모델
|
||||
|
||||
### 3.1 기존 `purchase_history` 테이블 마이그레이션
|
||||
|
||||
현재 스키마:
|
||||
```sql
|
||||
CREATE TABLE purchase_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
draw_no INTEGER NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
sets INTEGER NOT NULL DEFAULT 1,
|
||||
prize INTEGER NOT NULL DEFAULT 0,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
```
|
||||
|
||||
마이그레이션 전략: **ALTER TABLE로 컬럼 추가** (기존 데이터 보존)
|
||||
|
||||
```sql
|
||||
ALTER TABLE purchase_history ADD COLUMN numbers TEXT NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE purchase_history ADD COLUMN is_real INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE purchase_history ADD COLUMN source_strategy TEXT NOT NULL DEFAULT 'manual';
|
||||
ALTER TABLE purchase_history ADD COLUMN source_detail TEXT NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE purchase_history ADD COLUMN checked INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE purchase_history ADD COLUMN results TEXT NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE purchase_history ADD COLUMN total_prize INTEGER NOT NULL DEFAULT 0;
|
||||
```
|
||||
|
||||
- 기존 레코드: `is_real=1`, `source_strategy='manual'`, `checked=0` (기본값)
|
||||
- 기존 `prize` 컬럼은 하위호환용으로 유지. 신규 로직은 `total_prize` + `results` 사용
|
||||
- 기존 `sets` 컬럼은 하위호환용으로 유지. 신규 로직은 `numbers` JSON 배열 길이로 세트 수 산출
|
||||
|
||||
### 3.2 신규 `strategy_performance` 테이블
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS strategy_performance (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
strategy TEXT NOT NULL,
|
||||
draw_no INTEGER NOT NULL,
|
||||
sets_count INTEGER NOT NULL DEFAULT 0,
|
||||
total_correct INTEGER NOT NULL DEFAULT 0,
|
||||
max_correct INTEGER NOT NULL DEFAULT 0,
|
||||
prize_total INTEGER NOT NULL DEFAULT 0,
|
||||
avg_score REAL NOT NULL DEFAULT 0.0,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
UNIQUE(strategy, draw_no)
|
||||
);
|
||||
```
|
||||
|
||||
### 3.3 신규 `strategy_weights` 테이블
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS strategy_weights (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
strategy TEXT NOT NULL UNIQUE,
|
||||
weight REAL NOT NULL DEFAULT 0.2,
|
||||
ema_score REAL NOT NULL DEFAULT 0.15,
|
||||
total_sets INTEGER NOT NULL DEFAULT 0,
|
||||
total_hits_3plus INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
```
|
||||
|
||||
초기 가중치 (첫 실행 시 seed):
|
||||
|
||||
| strategy | weight | ema_score |
|
||||
|-----------|--------|-----------|
|
||||
| combined | 0.30 | 0.15 |
|
||||
| simulation | 0.25 | 0.15 |
|
||||
| heatmap | 0.20 | 0.15 |
|
||||
| manual | 0.15 | 0.15 |
|
||||
| custom | 0.10 | 0.15 |
|
||||
|
||||
---
|
||||
|
||||
## 4. API 설계
|
||||
|
||||
### 4.1 구매 API (기존 경로 확장)
|
||||
|
||||
| 메서드 | 경로 | 변경 사항 |
|
||||
|--------|------|----------|
|
||||
| `POST` | `/api/lotto/purchase` | 요청 바디 확장 (numbers, is_real, source_strategy, source_detail 추가) |
|
||||
| `GET` | `/api/lotto/purchase` | 필터 추가: `is_real`, `strategy`, `checked` |
|
||||
| `GET` | `/api/lotto/purchase/stats` | 응답 확장: total/real/virtual + by_strategy 섹션 |
|
||||
| `PUT` | `/api/lotto/purchase/{id}` | 기존 그대로 (allowed 필드 확장) |
|
||||
| `DELETE` | `/api/lotto/purchase/{id}` | 기존 그대로 |
|
||||
|
||||
**POST 요청 바디:**
|
||||
```json
|
||||
{
|
||||
"draw_no": 1125,
|
||||
"numbers": [[3, 12, 23, 34, 38, 45], [7, 14, 21, 29, 36, 42]],
|
||||
"is_real": true,
|
||||
"amount": 2000,
|
||||
"source_strategy": "combined",
|
||||
"source_detail": {"recommendation_ids": [451, 452]},
|
||||
"note": ""
|
||||
}
|
||||
```
|
||||
|
||||
하위호환: `numbers`가 빈 배열이면 기존 방식(sets + amount만)으로 동작. `is_real` 미지정 시 기본값 `true`.
|
||||
|
||||
**GET /purchase/stats 응답:**
|
||||
```json
|
||||
{
|
||||
"total": {"sets": 48, "invested": 48000, "prize": 15000, "roi": -68.75, "win_rate": 12.5},
|
||||
"real": {"sets": 20, "invested": 20000, "prize": 10000, "roi": -50.0, "win_rate": 15.0},
|
||||
"virtual": {"sets": 28, "invested": 28000, "prize": 5000, "roi": -82.14, "win_rate": 10.7},
|
||||
"by_strategy": {
|
||||
"combined": {"sets": 15, "avg_correct": 1.8, "hits_3plus": 3, "roi": -45.0},
|
||||
"simulation": {"sets": 12, "avg_correct": 2.1, "hits_3plus": 4, "roi": -30.0}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 전략 진화 API (신규)
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| `GET` | `/api/lotto/strategy/weights` | 현재 전략별 가중치 + 성과 요약 + trend |
|
||||
| `GET` | `/api/lotto/strategy/performance` | 전략별 회차 성과 이력 (차트용, `days` 파라미터) |
|
||||
| `POST` | `/api/lotto/strategy/evolve` | 수동 가중치 재계산 트리거 |
|
||||
|
||||
**GET /strategy/weights 응답:**
|
||||
```json
|
||||
{
|
||||
"weights": [
|
||||
{"strategy": "combined", "weight": 0.32, "ema_score": 0.285, "total_sets": 15, "hits_3plus": 3, "trend": "up"},
|
||||
{"strategy": "simulation", "weight": 0.28, "ema_score": 0.312, "total_sets": 12, "hits_3plus": 4, "trend": "up"},
|
||||
{"strategy": "heatmap", "weight": 0.18, "ema_score": 0.195, "total_sets": 10, "hits_3plus": 1, "trend": "down"},
|
||||
{"strategy": "manual", "weight": 0.14, "ema_score": 0.160, "total_sets": 8, "hits_3plus": 1, "trend": "stable"},
|
||||
{"strategy": "custom", "weight": 0.08, "ema_score": 0.105, "total_sets": 3, "hits_3plus": 0, "trend": "stable"}
|
||||
],
|
||||
"last_evolved": "2026-04-05T09:10:00",
|
||||
"min_data_draws": 10,
|
||||
"current_data_draws": 32,
|
||||
"status": "active"
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 스마트 추천 API (신규)
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| `GET` | `/api/lotto/recommend/smart` | 전략 가중치 기반 메타 전략 추천. `sets` 파라미터 (기본 5) |
|
||||
|
||||
**응답:**
|
||||
```json
|
||||
{
|
||||
"sets": [
|
||||
{
|
||||
"numbers": [3, 12, 23, 34, 38, 45],
|
||||
"meta_score": 0.847,
|
||||
"source_strategy": "simulation",
|
||||
"contribution": {"simulation": 0.42, "combined": 0.31, "heatmap": 0.27},
|
||||
"individual_scores": {"frequency": 0.82, "fingerprint": 0.91, "gap": 0.78, "cooccur": 0.85, "diversity": 0.73}
|
||||
}
|
||||
],
|
||||
"strategy_weights_used": {"combined": 0.32, "simulation": 0.28, "heatmap": 0.18, "manual": 0.14, "custom": 0.08},
|
||||
"learning_status": {"draws_learned": 32, "status": "active", "message": ""}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 전략 진화 알고리즘
|
||||
|
||||
### 5.1 성과 점수 산출 (회차별, 세트별)
|
||||
|
||||
```python
|
||||
set_score = correct_count / 6.0
|
||||
|
||||
# 당첨 등수별 보너스
|
||||
RANK_BONUS = {5: 0.1, 4: 0.3, 3: 0.6, 2: 0.8, 1: 1.0}
|
||||
set_score += RANK_BONUS.get(rank, 0)
|
||||
|
||||
# 한 구매 건의 draw_score = avg(set_scores)
|
||||
```
|
||||
|
||||
### 5.2 EMA 갱신
|
||||
|
||||
```python
|
||||
ALPHA = 0.3 # 최근 3~4회차가 EMA의 ~65% 차지
|
||||
new_ema = ALPHA * draw_score + (1 - ALPHA) * old_ema
|
||||
```
|
||||
|
||||
### 5.3 가중치 변환 (Softmax)
|
||||
|
||||
```python
|
||||
TEMPERATURE = 2.0
|
||||
MIN_WEIGHT = 0.05
|
||||
|
||||
raw = {s: exp(ema / TEMPERATURE) for s, ema in ema_scores.items()}
|
||||
total = sum(raw.values())
|
||||
weights = {s: max(v / total, MIN_WEIGHT) for s, v in raw.items()}
|
||||
# 재정규화하여 합 = 1.0
|
||||
remainder = 1.0 - sum(weights.values())
|
||||
# ... 비례 배분으로 조정
|
||||
```
|
||||
|
||||
### 5.4 재계산 타이밍
|
||||
|
||||
- **자동**: `check_results_for_draw()` → purchases 체크 → strategy_performance 갱신 → weights 재계산
|
||||
- **수동**: `POST /api/lotto/strategy/evolve`
|
||||
|
||||
### 5.5 스마트 추천 흐름
|
||||
|
||||
1. `strategy_weights` 로드
|
||||
2. 각 전략에서 후보 10세트 생성:
|
||||
- `combined`: `generate_combined_recommendation()` x 10
|
||||
- `simulation`: `get_best_picks()` 상위 10개
|
||||
- `heatmap`: `recommend_with_heatmap()` x 10
|
||||
- `manual`: `recommend_numbers()` x 10
|
||||
- `custom`: 데이터 없으면 skip
|
||||
3. `meta_score = original_score x strategy_weight`
|
||||
4. 전체 풀에서 중복 제거 후 상위 N세트 선출
|
||||
5. 각 세트에 출처 전략 + 기여도 breakdown 첨부
|
||||
|
||||
### 5.6 콜드 스타트
|
||||
|
||||
- 구매 이력 0건: 초기 가중치 그대로 사용
|
||||
- 특정 전략 구매 0건: 해당 전략 EMA 초기값(0.15) 유지
|
||||
- 10회차 미만: 스마트 추천 응답에 `status: "learning"` + 기존 combined 추천 병행
|
||||
|
||||
### 5.7 Trend 판정
|
||||
|
||||
```python
|
||||
recent_delta = current_ema - ema_5_draws_ago
|
||||
if recent_delta > 0.02: trend = "up"
|
||||
elif recent_delta < -0.02: trend = "down"
|
||||
else: trend = "stable"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 체커 연동 (자동 파이프라인)
|
||||
|
||||
기존 흐름에 purchase 체크를 연결:
|
||||
|
||||
```
|
||||
Scheduler (09:10 / 21:10)
|
||||
→ sync_latest()
|
||||
→ 새 회차 감지 시:
|
||||
→ check_results_for_draw() # 기존: recommendations 체크
|
||||
→ check_purchases_for_draw() # 신규: purchases 체크
|
||||
→ 각 세트별 rank/correct/bonus 계산 (checker._calc_rank 재사용)
|
||||
→ purchases.results, total_prize, checked=1 갱신
|
||||
→ strategy_performance upsert
|
||||
→ strategy_evolver.recalculate_weights()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 백엔드 모듈 구조
|
||||
|
||||
### 7.1 신규 파일
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `purchase_manager.py` | 구매 이력 관리 + 결과 체크 |
|
||||
| `strategy_evolver.py` | EMA 계산 + 가중치 진화 + 스마트 추천 |
|
||||
|
||||
### 7.2 수정 파일
|
||||
|
||||
| 파일 | 변경 내용 |
|
||||
|------|----------|
|
||||
| `db.py` | purchase_history ALTER + 신규 테이블 2개 + CRUD 함수 추가 |
|
||||
| `main.py` | 신규 엔드포인트 9개 + Pydantic 모델 + import |
|
||||
| `checker.py` | `check_results_for_draw()` 끝에 purchase 체크 호출 추가 |
|
||||
|
||||
### 7.3 기존 유지 파일 (변경 없음)
|
||||
|
||||
`recommender.py`, `generator.py`, `analyzer.py`, `collector.py`, `utils.py`
|
||||
|
||||
---
|
||||
|
||||
## 8. 프론트엔드 변경
|
||||
|
||||
### 8.1 신규 컴포넌트
|
||||
|
||||
| 컴포넌트 | 역할 |
|
||||
|----------|------|
|
||||
| `SmartRecommendPanel.jsx` | 전략 진화 기반 메타 추천 + 구매 버튼 |
|
||||
| `PurchaseHub.jsx` | 통합 구매 이력 (기존 PurchasePanel 대체) |
|
||||
| `StrategyDashboard.jsx` | 전략 가중치 시각화 + 성과 추이 차트 |
|
||||
| `PurchaseButton.jsx` | 공통 구매 버튼 (실구매/가상구매) |
|
||||
|
||||
### 8.2 수정 컴포넌트
|
||||
|
||||
| 컴포넌트 | 변경 내용 |
|
||||
|----------|----------|
|
||||
| `CombinedRecommendPanel.jsx` | 구매 버튼(PurchaseButton) 추가 |
|
||||
| `Functions.jsx` | 신규 패널 3개 추가 + import |
|
||||
|
||||
### 8.3 신규 훅
|
||||
|
||||
| 훅 | 역할 |
|
||||
|----|------|
|
||||
| `useStrategyWeights.js` | 전략 가중치/성과 데이터 fetch |
|
||||
|
||||
### 8.4 수정 훅
|
||||
|
||||
| 훅 | 변경 내용 |
|
||||
|----|----------|
|
||||
| `usePurchases.js` | 새 API 스키마 연동 (numbers, is_real, source_strategy 등) |
|
||||
|
||||
### 8.5 API 헬퍼 추가 (`api.js`)
|
||||
|
||||
```javascript
|
||||
// 전략
|
||||
getStrategyWeights() // GET /api/lotto/strategy/weights
|
||||
getStrategyPerformance(days) // GET /api/lotto/strategy/performance
|
||||
triggerStrategyEvolve() // POST /api/lotto/strategy/evolve
|
||||
|
||||
// 스마트 추천
|
||||
getSmartRecommend(sets) // GET /api/lotto/recommend/smart
|
||||
```
|
||||
|
||||
### 8.6 동행복권 바로가기
|
||||
|
||||
별도 API 없음. 프론트엔드 PurchaseButton에서:
|
||||
1. 번호를 클립보드에 복사
|
||||
2. `window.open('https://dhlottery.co.kr/gameResult.do?method=byWin')` — 새 탭
|
||||
3. 확인 다이얼로그 "구매 완료했나요?" → 예 → `POST /api/lotto/purchase (is_real=1)`
|
||||
|
||||
### 8.7 UI 시각 구분
|
||||
|
||||
- 실 구매: 금색/강조 배경 + 지갑 아이콘
|
||||
- 가상 구매: 기본 배경 + 게임패드 아이콘
|
||||
- 미확인: 시계 아이콘
|
||||
- 당첨: 초록 하이라이트 + 체크 아이콘
|
||||
|
||||
---
|
||||
|
||||
## 9. 전체 데이터 흐름
|
||||
|
||||
```
|
||||
추천(기존) ──[구매 버튼]──→ POST /purchase
|
||||
│
|
||||
스마트 추천(신규) ──[구매 버튼]──┘
|
||||
↓
|
||||
purchase_history 테이블
|
||||
│
|
||||
매주 토요일 추첨 결과 ──→ sync_latest()
|
||||
↓
|
||||
check_results_for_draw()
|
||||
├── recommendations 체크 (기존)
|
||||
└── check_purchases_for_draw() (신규)
|
||||
↓
|
||||
strategy_performance 갱신
|
||||
↓
|
||||
recalculate_weights()
|
||||
↓
|
||||
strategy_weights 갱신
|
||||
↓
|
||||
다음 스마트 추천에 반영 ──→ 순환
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 비기능 요구사항
|
||||
|
||||
- **하위호환**: 기존 purchase API 사용자(프론트 PurchasePanel)는 마이그레이션 중에도 동작해야 함
|
||||
- **성능**: 스마트 추천은 각 전략 10세트 생성 → 총 50세트 중 상위 N개 선출. 1-2초 내 응답 목표
|
||||
- **데이터 안전**: ALTER TABLE은 SQLite 트랜잭션으로 안전하게 실행. 기존 데이터 유실 없음
|
||||
- **콜드 스타트**: 구매 데이터 없어도 스마트 추천 동작 (초기 가중치 사용)
|
||||
|
||||
---
|
||||
|
||||
## 11. 범위 외 (추후 고려)
|
||||
|
||||
- 동행복권 자동 로그인/자동 구매 (CAPTCHA + 보안 정책으로 불가)
|
||||
- 번호 자동 입력 브라우저 확장 프로그램
|
||||
- 푸시 알림 (당첨 결과 통보)
|
||||
- 다중 사용자 지원
|
||||
342
docs/superpowers/specs/2026-04-05-realestate-lab-design.md
Normal file
342
docs/superpowers/specs/2026-04-05-realestate-lab-design.md
Normal file
@@ -0,0 +1,342 @@
|
||||
# realestate-lab 설계 스펙
|
||||
|
||||
> 부동산 청약 공고 자동 수집 + 프로필 기반 자격 매칭 서비스
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요
|
||||
|
||||
공공데이터포털(한국부동산원 청약홈 분양정보 API)에서 청약 공고를 자동 수집하고, 사용자 프로필 기반으로 지원 가능 여부를 자동 판별하는 독립 서비스.
|
||||
|
||||
**핵심 목표:**
|
||||
- 수동 공고 등록 없이 자동 수집 → DB 저장
|
||||
- 프로필 기반 자격 매칭 → 지원 가능한 청약만 필터링
|
||||
- 프론트에서 "새 공고 N건" 확인 → 향후 텔레그램 알림 확장
|
||||
|
||||
---
|
||||
|
||||
## 2. 서비스 아키텍처
|
||||
|
||||
### 독립 서비스 구조
|
||||
|
||||
```
|
||||
realestate-lab/ # 포트 18800
|
||||
├── app/
|
||||
│ ├── main.py # FastAPI 앱 + APScheduler
|
||||
│ ├── db.py # SQLite CRUD (realestate.db)
|
||||
│ ├── collector.py # 공공데이터포털 API 수집기
|
||||
│ ├── matcher.py # 프로필 기반 자격 매칭 엔진
|
||||
│ └── models.py # Pydantic 요청/응답 모델
|
||||
├── Dockerfile
|
||||
└── requirements.txt
|
||||
```
|
||||
|
||||
### 수집 흐름
|
||||
|
||||
```
|
||||
APScheduler (매일 09:00)
|
||||
→ collector.py: 청약홈 API 5개 엔드포인트 호출
|
||||
→ DB에 신규 공고 upsert (HOUSE_MANAGE_NO + PBLANC_NO 기준)
|
||||
→ matcher.py: 프로필 매칭 → 적격 공고에 match_status 부여
|
||||
→ 신규 매칭 공고 카운트 → (향후) 텔레그램 알림
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 데이터 소스
|
||||
|
||||
### 공공데이터포털 — 한국부동산원_청약홈 분양정보 조회 서비스
|
||||
|
||||
- **Base URL**: `https://api.odcloud.kr/api`
|
||||
- **서비스 키**: `DATA_GO_KR_API_KEY` 환경변수
|
||||
- **일 호출 제한**: 40,000건
|
||||
- **데이터 포맷**: JSON
|
||||
|
||||
### 수집 대상 API 엔드포인트
|
||||
|
||||
| 엔드포인트 | 설명 |
|
||||
|-----------|------|
|
||||
| `/ApplyhomeInfoDetailSvc/v1/getAPTLttotPblancDetail` | APT 분양정보 상세 |
|
||||
| `/ApplyhomeInfoDetailSvc/v1/getUrbtyOfctlLttotPblancDetail` | 오피스텔/도시형/민간임대 상세 |
|
||||
| `/ApplyhomeInfoDetailSvc/v1/getRemndrLttotPblancDetail` | 잔여세대 상세 |
|
||||
| `/ApplyhomeInfoDetailSvc/v1/getPblPvtRentLttotPblancDetail` | 공공지원 민간임대 상세 |
|
||||
| `/ApplyhomeInfoDetailSvc/v1/getOPTLttotPblancDetail` | 임의공급 상세 |
|
||||
|
||||
### 주택형별 상세 API (모델별 세대수·분양가)
|
||||
|
||||
| 엔드포인트 | 설명 |
|
||||
|-----------|------|
|
||||
| `/ApplyhomeInfoDetailSvc/v1/getAPTLttotPblancMdl` | APT 주택형별 |
|
||||
| `/ApplyhomeInfoDetailSvc/v1/getUrbtyOfctlLttotPblancMdl` | 오피스텔 주택형별 |
|
||||
| `/ApplyhomeInfoDetailSvc/v1/getRemndrLttotPblancMdl` | 잔여세대 주택형별 |
|
||||
| `/ApplyhomeInfoDetailSvc/v1/getPblPvtRentLttotPblancMdl` | 공공지원 민간임대 주택형별 |
|
||||
| `/ApplyhomeInfoDetailSvc/v1/getOPTLttotPblancMdl` | 임의공급 주택형별 |
|
||||
|
||||
### 공통 쿼리 파라미터
|
||||
|
||||
- `page` (기본: 1), `perPage` (기본: 100)
|
||||
- `serviceKey` — 인코딩된 API 키
|
||||
- `cond[RCRIT_PBLANC_DE::GTE]` / `cond[RCRIT_PBLANC_DE::LTE]` — 모집공고일 범위 필터
|
||||
|
||||
---
|
||||
|
||||
## 4. DB 스키마 (realestate.db)
|
||||
|
||||
### announcements (청약 공고)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | INTEGER PK | 자동 증가 |
|
||||
| house_manage_no | TEXT NOT NULL | 주택관리번호 |
|
||||
| pblanc_no | TEXT NOT NULL | 공고번호 |
|
||||
| house_nm | TEXT | 주택명 |
|
||||
| house_secd | TEXT | 주택구분코드 (01:APT, 02:오피스텔, 04:무순위 등) |
|
||||
| house_dtl_secd | TEXT | 주택상세구분코드 (01:민영, 03:국민 등) |
|
||||
| rent_secd | TEXT | 분양구분 (0:분양, 1:임대) |
|
||||
| region_code | TEXT | 공급지역코드 |
|
||||
| region_name | TEXT | 공급지역명 |
|
||||
| address | TEXT | 공급위치 |
|
||||
| total_units | INTEGER | 공급규모 |
|
||||
| rcrit_date | TEXT | 모집공고일 |
|
||||
| receipt_start | TEXT | 청약접수시작일 |
|
||||
| receipt_end | TEXT | 청약접수종료일 |
|
||||
| spsply_start | TEXT | 특별공급 접수시작일 |
|
||||
| spsply_end | TEXT | 특별공급 접수종료일 |
|
||||
| gnrl_rank1_start | TEXT | 1순위 접수시작일 |
|
||||
| gnrl_rank1_end | TEXT | 1순위 접수종료일 |
|
||||
| winner_date | TEXT | 당첨자발표일 |
|
||||
| contract_start | TEXT | 계약시작일 |
|
||||
| contract_end | TEXT | 계약종료일 |
|
||||
| homepage_url | TEXT | 홈페이지 |
|
||||
| pblanc_url | TEXT | 공고 URL |
|
||||
| constructor | TEXT | 시공사 |
|
||||
| developer | TEXT | 시행사 |
|
||||
| move_in_month | TEXT | 입주예정월 |
|
||||
| is_speculative_area | TEXT | 투기과열지구 |
|
||||
| is_price_cap | TEXT | 분양가상한제 |
|
||||
| contact | TEXT | 문의처 |
|
||||
| status | TEXT | 청약예정/청약중/결과발표/완료 (자동 계산) |
|
||||
| source | TEXT | auto/manual |
|
||||
| created_at | TEXT | |
|
||||
| updated_at | TEXT | |
|
||||
|
||||
- UNIQUE 제약: `(house_manage_no, pblanc_no)`
|
||||
- INDEX: `idx_realestate_status` on `status`
|
||||
- INDEX: `idx_realestate_region` on `region_name`
|
||||
|
||||
### announcement_models (주택형별 상세)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | INTEGER PK | |
|
||||
| house_manage_no | TEXT | FK → announcements |
|
||||
| pblanc_no | TEXT | FK → announcements |
|
||||
| model_no | TEXT | 모델번호 |
|
||||
| house_ty | TEXT | 주택형 (84A 등) |
|
||||
| supply_area | REAL | 공급면적(㎡) |
|
||||
| general_units | INTEGER | 일반공급 세대수 |
|
||||
| special_units | INTEGER | 특별공급 세대수 |
|
||||
| multi_child_units | INTEGER | 다자녀 |
|
||||
| newlywed_units | INTEGER | 신혼부부 |
|
||||
| first_life_units | INTEGER | 생애최초 |
|
||||
| old_parent_units | INTEGER | 노부모부양 |
|
||||
| institution_units | INTEGER | 기관추천 |
|
||||
| youth_units | INTEGER | 청년 |
|
||||
| newborn_units | INTEGER | 신생아 |
|
||||
| top_amount | INTEGER | 분양최고금액(만원) |
|
||||
|
||||
- UNIQUE: `(house_manage_no, pblanc_no, model_no)`
|
||||
|
||||
### user_profile (사용자 청약 프로필)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | INTEGER PK | 항상 1 (단일 사용자) |
|
||||
| name | TEXT | 이름 |
|
||||
| age | INTEGER | 나이 |
|
||||
| is_homeless | BOOLEAN | 무주택 여부 |
|
||||
| is_householder | BOOLEAN | 세대주 여부 |
|
||||
| subscription_months | INTEGER | 청약통장 가입개월수 |
|
||||
| subscription_amount | INTEGER | 청약통장 납입총액(만원) |
|
||||
| family_members | INTEGER | 세대원 수 |
|
||||
| has_dependents | BOOLEAN | 부양가족 유무 |
|
||||
| children_count | INTEGER | 미성년 자녀수 |
|
||||
| is_newlywed | BOOLEAN | 신혼부부 여부 |
|
||||
| marriage_months | INTEGER | 혼인기간(개월) |
|
||||
| has_newborn | BOOLEAN | 2세 이하 자녀 유무 |
|
||||
| is_first_home | BOOLEAN | 생애최초 해당 여부 |
|
||||
| income_level | TEXT | 소득수준 (100%이하/100~130%/130~160%) |
|
||||
| preferred_regions | TEXT | 관심지역 JSON 배열 |
|
||||
| preferred_types | TEXT | 관심주택유형 JSON 배열 |
|
||||
| min_area | REAL | 최소 희망면적(㎡) |
|
||||
| max_area | REAL | 최대 희망면적(㎡) |
|
||||
| max_price | INTEGER | 최대 분양가(만원) |
|
||||
| updated_at | TEXT | |
|
||||
|
||||
### match_results (매칭 결과)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | INTEGER PK | |
|
||||
| announcement_id | INTEGER | FK → announcements |
|
||||
| model_id | INTEGER | FK → announcement_models (nullable) |
|
||||
| match_score | INTEGER | 매칭 점수 (0~100) |
|
||||
| match_reasons | TEXT | 매칭 사유 JSON 배열 |
|
||||
| eligible_types | TEXT | 지원 가능 유형 JSON 배열 |
|
||||
| is_new | BOOLEAN | 신규 매칭 여부 (알림용) |
|
||||
| created_at | TEXT | |
|
||||
|
||||
- UNIQUE: `(announcement_id, model_id)`
|
||||
|
||||
---
|
||||
|
||||
## 5. API 엔드포인트
|
||||
|
||||
### 청약 공고
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/realestate/announcements` | 공고 목록 (필터: region, status, house_type, matched_only, sort, page, size) |
|
||||
| GET | `/api/realestate/announcements/{id}` | 공고 상세 (주택형별 포함) |
|
||||
| POST | `/api/realestate/announcements` | 수동 공고 등록 |
|
||||
| PUT | `/api/realestate/announcements/{id}` | 공고 수정 |
|
||||
| DELETE | `/api/realestate/announcements/{id}` | 공고 삭제 |
|
||||
|
||||
### 수집 관리
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| POST | `/api/realestate/collect` | 수동 수집 트리거 |
|
||||
| GET | `/api/realestate/collect/status` | 마지막 수집 결과 (수집일시, 신규건수, 에러) |
|
||||
|
||||
### 프로필
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/realestate/profile` | 내 프로필 조회 |
|
||||
| PUT | `/api/realestate/profile` | 프로필 수정 (upsert) |
|
||||
|
||||
### 매칭
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/realestate/matches` | 매칭 결과 목록 (점수순, 신규 우선) |
|
||||
| POST | `/api/realestate/matches/refresh` | 매칭 재계산 |
|
||||
| PATCH | `/api/realestate/matches/{id}/read` | 신규 알림 읽음 처리 |
|
||||
|
||||
### 대시보드
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/realestate/dashboard` | 요약 (진행중 공고수, 신규 매칭수, 다가오는 일정) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 매칭 엔진
|
||||
|
||||
### 점수 산출 (0~100)
|
||||
|
||||
| 기준 | 가중치 | 로직 |
|
||||
|------|--------|------|
|
||||
| 지역 매칭 | 30 | preferred_regions에 포함 → 30점 |
|
||||
| 주택유형 매칭 | 10 | preferred_types에 포함 → 10점 |
|
||||
| 면적 매칭 | 15 | min_area~max_area 범위 내 주택형 존재 → 15점 |
|
||||
| 가격 매칭 | 15 | max_price 이하 주택형 존재 → 15점 |
|
||||
| 자격 매칭 | 30 | 지원 가능 공급유형 수에 비례 |
|
||||
|
||||
### 자격 매칭 세부
|
||||
|
||||
| 공급유형 | 판별 조건 |
|
||||
|----------|----------|
|
||||
| 일반 1순위 | 무주택 + 세대주 + 청약통장 가입기간 충족 (투기과열 24개월, 그 외 12개월) |
|
||||
| 일반 2순위 | 1순위 미충족 시 |
|
||||
| 특별-신혼부부 | is_newlywed + 무주택 + 소득기준 |
|
||||
| 특별-생애최초 | is_first_home + 무주택 + 소득기준 |
|
||||
| 특별-다자녀 | children_count >= 2 + 무주택 |
|
||||
| 특별-노부모부양 | has_dependents + 무주택 |
|
||||
| 특별-청년 | age 19~39 + 무주택 |
|
||||
| 특별-신생아 | has_newborn + 무주택 |
|
||||
|
||||
- 1개 유형 → 10점, 2개 → 20점, 3개 이상 → 30점
|
||||
- `eligible_types`: 지원 가능 유형 목록 저장
|
||||
- `match_reasons`: 각 판별 사유 저장
|
||||
|
||||
### 상태 자동 계산
|
||||
|
||||
```
|
||||
오늘 < receipt_start → 청약예정
|
||||
receipt_start ≤ 오늘 ≤ receipt_end → 청약중
|
||||
receipt_end < 오늘 ≤ winner_date → 결과발표
|
||||
오늘 > winner_date → 완료
|
||||
```
|
||||
|
||||
### 매칭 실행 시점
|
||||
|
||||
- 신규 공고 수집 후 자동 실행
|
||||
- 프로필 변경 시 `POST /matches/refresh`로 재계산
|
||||
- 매일 00:00 상태 갱신 시 재매칭
|
||||
|
||||
---
|
||||
|
||||
## 7. 인프라 통합
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```yaml
|
||||
realestate-lab:
|
||||
build: ./realestate-lab
|
||||
container_name: realestate-lab
|
||||
ports:
|
||||
- "18800:8000"
|
||||
volumes:
|
||||
- ${RUNTIME_PATH:-.}/data:/app/data
|
||||
environment:
|
||||
- DATA_GO_KR_API_KEY=${DATA_GO_KR_API_KEY}
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
### Nginx
|
||||
|
||||
```nginx
|
||||
location /api/realestate/ {
|
||||
proxy_pass http://realestate-lab:8000;
|
||||
}
|
||||
```
|
||||
|
||||
### APScheduler
|
||||
|
||||
| 시간 | Job | 설명 |
|
||||
|------|-----|------|
|
||||
| 매일 09:00 | `run_collection` | 5개 API 수집 → 매칭 |
|
||||
| 매일 00:00 | `update_statuses` | 날짜 기반 상태 갱신 |
|
||||
|
||||
### 배포
|
||||
|
||||
- `scripts/deploy-nas.sh`에 `realestate-lab/` rsync 대상 추가
|
||||
|
||||
---
|
||||
|
||||
## 8. lotto-backend 제거 대상
|
||||
|
||||
| 파일 | 제거 항목 |
|
||||
|------|----------|
|
||||
| `backend/app/db.py` | `realestate_complexes` 테이블 생성, CRUD 함수 5개 |
|
||||
| `backend/app/main.py` | `ComplexCreate`/`ComplexUpdate` 모델, `/api/realestate/complexes` 라우트 4개 |
|
||||
|
||||
기존 `realestate_complexes` 테이블 데이터는 마이그레이션 불필요 (스키마 완전 상이).
|
||||
|
||||
---
|
||||
|
||||
## 9. 환경변수
|
||||
|
||||
| 변수 | 설명 | 필수 |
|
||||
|------|------|------|
|
||||
| `DATA_GO_KR_API_KEY` | 공공데이터포털 API 키 | 선택 (미설정 시 수동 등록만 가능) |
|
||||
|
||||
---
|
||||
|
||||
## 10. 향후 확장
|
||||
|
||||
- **텔레그램 알림**: 신규 매칭 공고 발생 시 텔레그램 봇으로 push (알림 모듈 분리 구조 대비)
|
||||
- **경쟁률 조회**: 청약 접수 기간 중 경쟁률 실시간 수집
|
||||
- **실거래가 비교**: 주변 시세와 분양가 비교 분석
|
||||
163
docs/superpowers/specs/2026-04-07-pet-lab-design.md
Normal file
163
docs/superpowers/specs/2026-04-07-pet-lab-design.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# Pet Lab - Desktop Pet Application Design
|
||||
|
||||
## Overview
|
||||
|
||||
Windows PC 바탕화면에 항상 떠있는 데스크톱 펫 애플리케이션. 캐릭터(박뚱냥)가 화면 하단에 고정되어 마우스 방향으로 시선을 추적하고, 클릭/우클릭으로 상호작용할 수 있다.
|
||||
|
||||
**프로젝트 위치**: `C:\Users\jaeoh\Desktop\workspace\pet-lab` (독립 프로젝트, web-backend 모노레포 외부)
|
||||
|
||||
**기술 스택**: Python 3.12 + PyQt5
|
||||
|
||||
**배포**: 로컬 Windows PC 실행 전용 (NAS 배포 불필요). 추후 PyInstaller로 .exe 패킹.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
pet-lab/
|
||||
├── app/
|
||||
│ ├── main.py # 엔트리포인트 (QApplication 초기화, 시스템 트레이)
|
||||
│ ├── pet_widget.py # 메인 위젯 (투명 윈도우 + 캐릭터 렌더링)
|
||||
│ ├── eye_tracker.py # 마우스 위치 기반 시선/기울기 계산
|
||||
│ ├── interaction.py # 클릭 반응 애니메이션 + 우클릭 컨텍스트 메뉴
|
||||
│ └── config.py # 설정값 (크기, 위치, 속도 상수)
|
||||
├── assets/
|
||||
│ └── characters/
|
||||
│ └── 박뚱냥.png # 캐릭터 이미지 (투명 배경 PNG)
|
||||
├── requirements.txt # PyQt5
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### Component Responsibilities
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `main.py` | QApplication 생성, PetWidget 인스턴스화, 이벤트 루프 시작 |
|
||||
| `pet_widget.py` | 투명 프레임리스 윈도우, 캐릭터 이미지 표시, QTimer 루프로 시선 업데이트 |
|
||||
| `eye_tracker.py` | 마우스 좌표 → 기울기 각도/좌우 반전 여부 계산 (순수 계산 모듈) |
|
||||
| `interaction.py` | 좌클릭(점프), 더블클릭(흔들기) 애니메이션, 우클릭 메뉴 생성/처리 |
|
||||
| `config.py` | 상수 정의: 캐릭터 크기(소/중/대), 틸트 범위, 타이머 간격 등 |
|
||||
|
||||
---
|
||||
|
||||
## Core Behavior
|
||||
|
||||
### 투명 윈도우
|
||||
|
||||
PyQt5 윈도우 플래그 조합:
|
||||
- `Qt.FramelessWindowHint`: 타이틀바 제거
|
||||
- `Qt.WindowStaysOnTopHint`: 항상 위 (토글 가능)
|
||||
- `Qt.Tool`: 태스크바에 표시 안 함
|
||||
- `WA_TranslucentBackground`: 배경 투명
|
||||
|
||||
캐릭터 이미지 영역만 클릭 이벤트 수신. 투명 영역은 `WA_TransparentForMouseEvents`가 아닌, 위젯 크기를 캐릭터 이미지 크기에 맞춰서 처리.
|
||||
|
||||
### 바닥 고정 위치
|
||||
|
||||
- Y = 화면 높이 - 태스크바 높이(기본 48px) - 캐릭터 높이
|
||||
- X = 수평 위치 프리셋: 좌(화면 10%), 중앙(50%), 우(90%)
|
||||
- 기본 위치: 화면 우측(90%)
|
||||
- 태스크바 높이는 Windows API 없이 기본값 48px 사용 (충분히 실용적)
|
||||
|
||||
### 시선 추적
|
||||
|
||||
QTimer(30ms 간격, 약 33fps)로 글로벌 마우스 좌표 폴링:
|
||||
|
||||
1. `QCursor.pos()`로 마우스 절대 좌표 획득
|
||||
2. 캐릭터 중심점과 마우스 사이의 각도 계산 (`math.atan2`)
|
||||
3. 각도를 기울기로 변환:
|
||||
- 마우스가 캐릭터 왼쪽 → 이미지 좌측 기울기 (음수 각도)
|
||||
- 마우스가 캐릭터 오른쪽 → 이미지 우측 기울기 (양수 각도)
|
||||
- 기울기 범위: -15도 ~ +15도
|
||||
4. 마우스가 캐릭터 왼쪽이면 이미지 좌우 반전 (`QTransform.scale(-1, 1)`)
|
||||
5. `QTransform.rotate(angle)`로 기울기 적용
|
||||
6. 마우스 좌표 변화 없으면 렌더링 스킵 (성능 최적화)
|
||||
|
||||
### 클릭 반응
|
||||
|
||||
**좌클릭 — 점프**:
|
||||
- `QPropertyAnimation`으로 위젯 Y좌표를 위로 30px 이동 후 복귀
|
||||
- duration: 300ms, easing: `QEasingCurve.OutBounce`
|
||||
|
||||
**더블클릭 — 흔들기**:
|
||||
- `QPropertyAnimation`으로 X좌표를 좌우 진동
|
||||
- duration: 400ms, 좌(-10) → 우(+10) → 원위치
|
||||
|
||||
### 우클릭 컨텍스트 메뉴
|
||||
|
||||
| 메뉴 항목 | 동작 |
|
||||
|-----------|------|
|
||||
| 위치: 좌/중앙/우 | 캐릭터 수평 위치 변경 |
|
||||
| 크기: 소/중/대 | 캐릭터 크기 변경 (100/150/200px) |
|
||||
| 항상 위 | `WindowStaysOnTopHint` 토글 |
|
||||
| 종료 | 애플리케이션 종료 |
|
||||
|
||||
`QMenu`로 구현. 서브메뉴 사용하여 위치/크기를 그룹화.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Constants (`config.py`)
|
||||
|
||||
```python
|
||||
# 캐릭터 크기 (높이 기준, 너비는 비율 유지)
|
||||
SIZES = {"small": 100, "medium": 150, "large": 200}
|
||||
DEFAULT_SIZE = "medium"
|
||||
|
||||
# 수평 위치 프리셋 (화면 너비 비율)
|
||||
POSITIONS = {"left": 0.1, "center": 0.5, "right": 0.9}
|
||||
DEFAULT_POSITION = "right"
|
||||
|
||||
# 시선 추적
|
||||
TIMER_INTERVAL_MS = 30 # 약 33fps
|
||||
MAX_TILT_ANGLE = 15.0 # 최대 기울기 (도)
|
||||
|
||||
# 태스크바
|
||||
TASKBAR_HEIGHT = 48 # Windows 기본 태스크바 높이
|
||||
|
||||
# 애니메이션
|
||||
JUMP_HEIGHT = 30 # 점프 높이 (px)
|
||||
JUMP_DURATION_MS = 300
|
||||
SHAKE_OFFSET = 10 # 흔들기 좌우 폭 (px)
|
||||
SHAKE_DURATION_MS = 400
|
||||
|
||||
# 에셋 경로
|
||||
CHARACTER_DIR = "assets/characters"
|
||||
DEFAULT_CHARACTER = "박뚱냥.png"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
PyQt5>=5.15,<6.0
|
||||
```
|
||||
|
||||
개발 시 추가:
|
||||
```
|
||||
pyinstaller>=6.0 # .exe 패킹용 (나중에)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Windows 전용**: PyQt5 투명 윈도우는 Windows에서 가장 안정적. macOS/Linux는 고려하지 않음.
|
||||
- **이미지 1장으로 시작**: 현재 박뚱냥.png 정면 포즈 1장. 시선은 이미지 기울기 + 좌우 반전으로 표현.
|
||||
- **NAS 배포 불필요**: Docker, docker-compose.yml, deploy.sh 수정 없음.
|
||||
- **독립 프로젝트**: `C:\Users\jaeoh\Desktop\workspace\pet-lab`에 별도 Git 저장소.
|
||||
|
||||
---
|
||||
|
||||
## Future Extensions
|
||||
|
||||
- 스프라이트 시트 추가: idle, walk, sit, sleep 등 포즈별 이미지 → 상태 머신 기반 애니메이션
|
||||
- 자율 행동: 일정 시간 마우스 비활동 시 졸기/잠자기 상태 전환
|
||||
- 시스템 트레이 아이콘: 종료/설정 접근
|
||||
- 설정 파일 저장/로드: JSON으로 크기/위치/캐릭터 선택 영속화
|
||||
- 다중 캐릭터: `assets/characters/` 디렉토리에 여러 캐릭터 추가, 우클릭 메뉴에서 선택
|
||||
- PyInstaller .exe 패킹: 단독 배포용 실행파일 생성
|
||||
- 웹 서비스 연동: pet-lab API 서버 → 캐릭터 다운로드/공유
|
||||
@@ -0,0 +1,398 @@
|
||||
# Music Lab Suno API 전체 기능 확장 설계
|
||||
|
||||
> 작성일: 2026-04-08
|
||||
> 범위: 백엔드 (web-backend/music-lab) + 프론트엔드 (web-ui/src/pages/music)
|
||||
|
||||
---
|
||||
|
||||
## 1. 목표
|
||||
|
||||
Suno API의 미사용 기능을 전부 활용하여 music-lab을 완전한 AI 음악 프로덕션 스튜디오로 업그레이드한다. 실용도 기준 3단계로 점진 확장.
|
||||
|
||||
## 2. 단계별 기능 목록
|
||||
|
||||
### Phase 1: 핵심 생성 강화
|
||||
|
||||
| # | 기능 | 설명 |
|
||||
|---|------|------|
|
||||
| 1-1 | 크레딧 잔액 상시 표시 | 헤더에 실시간 크레딧 배지, 10 이하 경고 |
|
||||
| 1-2 | 보컬 성별 선택 | Male / Female / Auto 3버튼 |
|
||||
| 1-3 | negativeTags | 제외 스타일 텍스트 + 프리셋 칩 |
|
||||
| 1-4 | V5_5 모델 추가 | SUNO_MODELS 딕셔너리에 추가 |
|
||||
| 1-5 | styleWeight / audioWeight | 0~1.0 슬라이더 2개 |
|
||||
| 1-6 | 커버 이미지 생성 | 라이브러리 카드에서 앨범아트 2장 생성 |
|
||||
|
||||
### Phase 2: 후처리 파워업
|
||||
|
||||
| # | 기능 | 설명 |
|
||||
|---|------|------|
|
||||
| 2-1 | WAV 고음질 변환 | MP3→WAV 변환 다운로드 |
|
||||
| 2-2 | 12스템 분리 | 드럼/베이스/기타 등 12개 개별 추출 |
|
||||
| 2-3 | 타임스탬프 가사 | 가라오케 스타일 싱크 재생 |
|
||||
| 2-4 | 스타일 부스트 | AI로 최적 스타일 프롬프트 자동 생성 |
|
||||
|
||||
### Phase 3: 고급 크리에이티브
|
||||
|
||||
| # | 기능 | 설명 |
|
||||
|---|------|------|
|
||||
| 3-1 | 오디오 업로드 + 커버 | 외부 음원을 Suno 스타일로 리메이크 |
|
||||
| 3-2 | 오디오 업로드 + 확장 | 외부 음원 이어서 만들기 |
|
||||
| 3-3 | 보컬 추가 | 인스트루멘탈에 AI 보컬 입히기 |
|
||||
| 3-4 | 인스트루멘탈 추가 | 보컬에 AI 반주 입히기 |
|
||||
| 3-5 | 뮤직비디오 생성 | MP4 자동 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 백엔드 API 설계
|
||||
|
||||
### 3.1 기존 엔드포인트 수정
|
||||
|
||||
#### GenerateRequest 스키마 확장 (main.py)
|
||||
|
||||
```python
|
||||
class GenerateRequest(BaseModel):
|
||||
# 기존 필드 유지
|
||||
provider: str = "suno"
|
||||
model: str = "V4"
|
||||
title: str = ""
|
||||
genre: str = ""
|
||||
moods: list[str] = []
|
||||
instruments: list[str] = []
|
||||
duration_sec: int | None = None
|
||||
bpm: int | None = None
|
||||
key: str = ""
|
||||
scale: str = ""
|
||||
prompt: str = ""
|
||||
lyrics: str = ""
|
||||
instrumental: bool = False
|
||||
|
||||
# Phase 1 추가
|
||||
vocal_gender: str | None = None # "m" | "f" | None(auto)
|
||||
negative_tags: str | None = None # 제외 스타일
|
||||
style_weight: float | None = None # 0.0~1.0
|
||||
audio_weight: float | None = None # 0.0~1.0
|
||||
```
|
||||
|
||||
#### SUNO_MODELS 확장 (suno_provider.py)
|
||||
|
||||
```python
|
||||
SUNO_MODELS = {
|
||||
"V4": {"name": "V4", "max_duration": 240},
|
||||
"V4_5": {"name": "V4.5", "max_duration": 480},
|
||||
"V4_5PLUS": {"name": "V4.5+", "max_duration": 480},
|
||||
"V4_5ALL": {"name": "V4.5 All","max_duration": 480},
|
||||
"V5": {"name": "V5", "max_duration": 480},
|
||||
"V5_5": {"name": "V5.5", "max_duration": 480}, # 추가
|
||||
}
|
||||
```
|
||||
|
||||
#### _build_suno_payload 확장
|
||||
|
||||
새 파라미터를 Suno API 페이로드에 매핑:
|
||||
- `vocal_gender` → `vocalGender`
|
||||
- `negative_tags` → `negativeTags`
|
||||
- `style_weight` → `styleWeight`
|
||||
- `audio_weight` → `audioWeight`
|
||||
|
||||
None이 아닌 경우에만 페이로드에 포함.
|
||||
|
||||
### 3.2 신규 엔드포인트
|
||||
|
||||
#### Phase 1
|
||||
|
||||
```
|
||||
POST /api/music/cover-image
|
||||
Request: { "task_id": str, "suno_id": str }
|
||||
Response: { "task_id": str } → 폴링 → { "images": [url1, url2] }
|
||||
```
|
||||
|
||||
#### Phase 2
|
||||
|
||||
```
|
||||
POST /api/music/wav
|
||||
Request: { "task_id": str, "suno_id": str }
|
||||
Response: { "task_id": str } → 폴링 → { "wav_url": str }
|
||||
|
||||
POST /api/music/stem-split
|
||||
Request: { "task_id": str, "suno_id": str }
|
||||
Response: { "task_id": str } → 폴링 → { "stems": { vocal: url, drums: url, ... } }
|
||||
|
||||
GET /api/music/timestamped-lyrics?task_id=...&suno_id=...
|
||||
Response: { "aligned_words": [...], "waveform_data": [...] }
|
||||
|
||||
POST /api/music/style-boost
|
||||
Request: { "content": str }
|
||||
Response: { "result": str, "credits_consumed": float }
|
||||
```
|
||||
|
||||
#### Phase 3
|
||||
|
||||
```
|
||||
POST /api/music/upload-cover
|
||||
Request: { "upload_url": str, "model": str, "custom_mode": bool,
|
||||
"instrumental": bool, "prompt"?: str, "style"?: str, "title"?: str,
|
||||
"vocal_gender"?: str, "negative_tags"?: str,
|
||||
"style_weight"?: float, "audio_weight"?: float }
|
||||
Response: { "task_id": str }
|
||||
|
||||
POST /api/music/upload-extend
|
||||
Request: { "upload_url": str, "model": str, "continue_at"?: float,
|
||||
"default_param_flag": bool, "prompt"?: str, "style"?: str, "title"?: str,
|
||||
"vocal_gender"?: str, "negative_tags"?: str }
|
||||
Response: { "task_id": str }
|
||||
|
||||
POST /api/music/add-vocals
|
||||
Request: { "upload_url": str, "prompt": str, "title": str, "style": str,
|
||||
"negative_tags": str, "vocal_gender"?: str, "model"?: str,
|
||||
"style_weight"?: float, "audio_weight"?: float }
|
||||
Response: { "task_id": str }
|
||||
|
||||
POST /api/music/add-instrumental
|
||||
Request: { "upload_url": str, "title": str, "tags": str, "negative_tags": str,
|
||||
"vocal_gender"?: str, "model"?: str,
|
||||
"style_weight"?: float, "audio_weight"?: float }
|
||||
Response: { "task_id": str }
|
||||
|
||||
POST /api/music/video
|
||||
Request: { "task_id": str, "suno_id": str, "author"?: str, "domain_name"?: str }
|
||||
Response: { "task_id": str } → 폴링 → { "video_url": str }
|
||||
```
|
||||
|
||||
### 3.3 suno_provider.py 리팩토링
|
||||
|
||||
**공통 폴링 헬퍼 추출:**
|
||||
|
||||
```python
|
||||
def _poll_suno_task(
|
||||
record_info_url: str,
|
||||
task_id: str,
|
||||
max_attempts: int = 40,
|
||||
interval: int = 8,
|
||||
success_extractor: Callable[[dict], Any] = None
|
||||
) -> dict:
|
||||
"""
|
||||
범용 Suno 작업 폴링.
|
||||
record_info_url: 예) "/api/v1/generate/record-info"
|
||||
success_extractor: SUCCESS 상태일 때 결과 추출 함수
|
||||
"""
|
||||
```
|
||||
|
||||
기존 `run_suno_generation`, `run_suno_extend`, `run_vocal_removal`도 이 헬퍼를 사용하도록 리팩토링.
|
||||
|
||||
**신규 함수 목록:**
|
||||
|
||||
| 함수 | Phase | Suno 엔드포인트 | 비동기 |
|
||||
|------|-------|----------------|--------|
|
||||
| `run_cover_image` | 1 | `POST /api/v1/suno/cover/generate` | 폴링 |
|
||||
| `run_wav_convert` | 2 | `POST /api/v1/wav/generate` | 폴링 |
|
||||
| `run_stem_split` | 2 | `POST /api/v1/vocal-removal/generate` (type=split_stem) | 폴링 |
|
||||
| `get_timestamped_lyrics` | 2 | `POST /api/v1/generate/get-timestamped-lyrics` | 동기 |
|
||||
| `generate_style_boost` | 2 | `POST /api/v1/style/generate` | 동기 |
|
||||
| `run_upload_cover` | 3 | `POST /api/v1/generate/upload-cover` | 폴링 |
|
||||
| `run_upload_extend` | 3 | `POST /api/v1/generate/upload-extend` | 폴링 |
|
||||
| `run_add_vocals` | 3 | `POST /api/v1/generate/add-vocals` | 폴링 |
|
||||
| `run_add_instrumental` | 3 | `POST /api/v1/generate/add-instrumental` | 폴링 |
|
||||
| `run_video_generate` | 3 | `POST /api/v1/mp4/generate` | 폴링 |
|
||||
|
||||
### 3.4 DB 스키마 변경
|
||||
|
||||
**music_library 테이블 컬럼 추가 (ALTER TABLE 마이그레이션):**
|
||||
|
||||
```sql
|
||||
ALTER TABLE music_library ADD COLUMN cover_images TEXT NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE music_library ADD COLUMN wav_url TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE music_library ADD COLUMN video_url TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE music_library ADD COLUMN stem_urls TEXT NOT NULL DEFAULT '{}';
|
||||
```
|
||||
|
||||
**db.py 함수 추가:**
|
||||
|
||||
```python
|
||||
def update_track_cover_images(track_id: int, images: list[str])
|
||||
def update_track_wav_url(track_id: int, wav_url: str)
|
||||
def update_track_video_url(track_id: int, video_url: str)
|
||||
def update_track_stem_urls(track_id: int, stems: dict)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 프론트엔드 UI/UX 설계
|
||||
|
||||
### 4.1 파일 구조 (컴포넌트 분할)
|
||||
|
||||
```
|
||||
web-ui/src/pages/music/
|
||||
├── MusicStudio.jsx -- 메인 (탭 라우팅 + 공유 상태)
|
||||
├── MusicStudio.css -- 전체 스타일
|
||||
├── components/
|
||||
│ ├── CreateTab.jsx -- 생성 폼 (6단계 + Phase 1 확장)
|
||||
│ ├── LyricsTab.jsx -- 가사 관리
|
||||
│ ├── LibraryTab.jsx -- 라이브러리 + 카드
|
||||
│ ├── RemixTab.jsx -- Phase 3: 업로드/리믹스
|
||||
│ ├── AudioPlayer.jsx -- 오디오 플레이어
|
||||
│ ├── LibraryCard.jsx -- 트랙 카드 + 액션 메뉴
|
||||
│ ├── StemModal.jsx -- 12스템 결과 모달
|
||||
│ ├── CoverArtModal.jsx -- 커버 이미지 선택 모달
|
||||
│ ├── SyncedLyricsPlayer.jsx -- 타임스탬프 가사 오버레이
|
||||
│ └── CreditsBadge.jsx -- 크레딧 잔액 배지
|
||||
```
|
||||
|
||||
### 4.2 Phase 1 UI 변경
|
||||
|
||||
#### 크레딧 배지 (CreditsBadge)
|
||||
- 위치: 헤더 우측 상단, 탭 옆
|
||||
- 표시: `⚡ 127 credits`
|
||||
- 10 이하: 빨간색 + pulse 애니메이션
|
||||
- 갱신 시점: 페이지 로드, 생성 완료 후, 30초 자동 갱신
|
||||
|
||||
#### Create 탭 Step 4 확장
|
||||
|
||||
**Vocal Gender (Suno 전용):**
|
||||
- 3버튼 토글: `♂ Male` | `♀ Female` | `Auto`
|
||||
- 기본값: Auto
|
||||
- 스타일: `.ms-gender-toggle` (기존 duration-rail과 유사)
|
||||
|
||||
**Negative Tags:**
|
||||
- 텍스트 입력 필드 + 프리셋 칩
|
||||
- 프리셋: screaming, autotune, distortion, whisper, falsetto, rap
|
||||
- 칩 클릭 시 텍스트에 추가/제거
|
||||
- 스타일: `.ms-negative-tags` (기존 mood-rack과 유사)
|
||||
|
||||
**Style Weight / Audio Weight:**
|
||||
- range 슬라이더 2개 (기존 BPM 슬라이더와 동일 스타일)
|
||||
- 레이블: "Prompt ↔ Style Balance" / "Original ↔ AI Balance"
|
||||
- 0~100 표시, API 전송 시 0.0~1.0 변환
|
||||
- 기본값: 미설정 (슬라이더 중앙, 값 전송 안 함)
|
||||
|
||||
#### Library 카드 액션 메뉴 확장
|
||||
|
||||
기존 5개 버튼 → 6개 (Cover Art 추가)
|
||||
4개 초과 시 `•••` 더보기 드롭다운 메뉴로 분기:
|
||||
- 기본 노출: Play, Download, Delete
|
||||
- 더보기: Extend, Vocal Split, Cover Art (+ Phase 2/3 추가분)
|
||||
|
||||
#### CoverArtModal
|
||||
- 2장 이미지 좌우 비교 표시
|
||||
- 각 이미지 아래 "이 이미지 사용" 버튼
|
||||
- 선택 시 라이브러리 카드 썸네일 업데이트
|
||||
|
||||
### 4.3 Phase 2 UI 변경
|
||||
|
||||
#### Library 카드 더보기 메뉴 추가
|
||||
- WAV 다운로드
|
||||
- Stem Split (12스템)
|
||||
- Synced Lyrics
|
||||
- Style Boost (Create 탭 프롬프트로 전달)
|
||||
|
||||
#### StemModal
|
||||
- 3×4 그리드 카드 레이아웃
|
||||
- 각 스템: 이름 아이콘 + 미니 재생 버튼 + 다운로드 버튼
|
||||
- 12개 스템: vocal, backing_vocals, drums, bass, guitar, keyboard, strings, brass, woodwinds, percussion, synth, fx
|
||||
- 스타일: 기존 라이브러리 카드의 축소 버전
|
||||
|
||||
#### SyncedLyricsPlayer
|
||||
- AudioPlayer 교체/오버레이 모드
|
||||
- 재생 중 현재 단어를 accent 컬러로 하이라이트
|
||||
- 하단에 waveformData 기반 파형 바
|
||||
- 닫기 버튼으로 일반 플레이어 복귀
|
||||
|
||||
#### Style Boost 버튼
|
||||
- Create 탭 장르 선택 영역에 `✨ Style Boost` 버튼
|
||||
- 클릭 시: 현재 genre + moods 조합 → API 호출 → 결과를 프롬프트에 삽입
|
||||
- 로딩 중 버튼 스피너
|
||||
|
||||
### 4.4 Phase 3 UI 변경
|
||||
|
||||
#### Remix 탭 (신규 4번째 탭)
|
||||
- 탭 레이블: `REMIX`
|
||||
- 상단: 오디오 URL 입력 필드 (또는 라이브러리에서 선택)
|
||||
- 4개 액션 카드 그리드 (2×2):
|
||||
- **AI Cover**: 아이콘 + 설명 + 파라미터 폼 (펼침)
|
||||
- **Extend**: 아이콘 + 설명 + continue_at 입력
|
||||
- **Add Vocals**: 아이콘 + 설명 + prompt/style 입력
|
||||
- **Add Instrumental**: 아이콘 + 설명 + tags 입력
|
||||
- 선택한 카드만 펼쳐서 세부 옵션 표시
|
||||
- 하단: 뮤직비디오 생성 버튼 (라이브러리에서 선택한 곡 대상)
|
||||
|
||||
### 4.5 디자인 토큰 추가
|
||||
|
||||
```css
|
||||
/* Phase 1 추가 토큰 */
|
||||
--ms-danger: #e74c3c; /* 크레딧 경고 빨간색 */
|
||||
--ms-male: #4a9eff; /* 남성 보컬 파란색 */
|
||||
--ms-female: #ff6b9d; /* 여성 보컬 분홍색 */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. api.js 추가 함수
|
||||
|
||||
```javascript
|
||||
// Phase 1
|
||||
export const generateCoverImage = (payload) => apiPost('/api/music/cover-image', payload);
|
||||
|
||||
// Phase 2
|
||||
export const convertToWav = (payload) => apiPost('/api/music/wav', payload);
|
||||
export const splitStems = (payload) => apiPost('/api/music/stem-split', payload);
|
||||
export const getTimestampedLyrics = (taskId, sunoId) =>
|
||||
apiGet(`/api/music/timestamped-lyrics?task_id=${taskId}&suno_id=${sunoId}`);
|
||||
export const generateStyleBoost = (content) => apiPost('/api/music/style-boost', { content });
|
||||
|
||||
// Phase 3
|
||||
export const uploadAndCover = (payload) => apiPost('/api/music/upload-cover', payload);
|
||||
export const uploadAndExtend = (payload) => apiPost('/api/music/upload-extend', payload);
|
||||
export const addVocals = (payload) => apiPost('/api/music/add-vocals', payload);
|
||||
export const addInstrumental = (payload) => apiPost('/api/music/add-instrumental', payload);
|
||||
export const generateVideo = (payload) => apiPost('/api/music/video', payload);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 폴링 패턴
|
||||
|
||||
모든 비동기 작업은 기존 음악 생성과 동일한 폴링 패턴:
|
||||
|
||||
1. POST 요청 → `{ task_id }` 반환
|
||||
2. 프론트: 3초 간격 `GET /api/music/status/{task_id}` 폴링
|
||||
3. 백엔드: BackgroundTask에서 Suno 폴링 → music_tasks 상태 업데이트
|
||||
4. `status: succeeded` → 결과 반환 + 라이브러리 자동 갱신
|
||||
|
||||
동기 API (타임스탬프 가사, 스타일 부스트)는 즉시 응답.
|
||||
|
||||
---
|
||||
|
||||
## 7. 구현 순서
|
||||
|
||||
### Phase 1 (핵심 생성 강화)
|
||||
1. 백엔드: SUNO_MODELS에 V5_5 추가 + 공통 폴링 헬퍼 추출
|
||||
2. 백엔드: GenerateRequest 스키마 확장 + _build_suno_payload 매핑
|
||||
3. 백엔드: 커버 이미지 생성 엔드포인트 + suno_provider 함수
|
||||
4. 백엔드: DB 마이그레이션 (cover_images, wav_url, video_url, stem_urls)
|
||||
5. 프론트: 컴포넌트 분할 (MusicStudio → CreateTab, LyricsTab, LibraryTab, etc.)
|
||||
6. 프론트: CreditsBadge 구현
|
||||
7. 프론트: Create 탭 Step 4 확장 (vocal gender, negative tags, weight 슬라이더)
|
||||
8. 프론트: LibraryCard 더보기 메뉴 + CoverArtModal
|
||||
9. 프론트: api.js 함수 추가
|
||||
|
||||
### Phase 2 (후처리 파워업)
|
||||
10. 백엔드: WAV/스템/타임스탬프/스타일부스트 엔드포인트
|
||||
11. 프론트: StemModal + SyncedLyricsPlayer + Style Boost 버튼
|
||||
12. 프론트: Library 카드 Phase 2 액션 추가
|
||||
|
||||
### Phase 3 (고급 크리에이티브)
|
||||
13. 백엔드: upload-cover/upload-extend/add-vocals/add-instrumental/video 엔드포인트
|
||||
14. 프론트: RemixTab 구현
|
||||
15. 프론트: Library 카드 Phase 3 액션 (Video)
|
||||
|
||||
---
|
||||
|
||||
## 8. 제약사항 및 주의점
|
||||
|
||||
- **Suno 파일 보관**: 14~15일 후 자동 삭제 → 로컬 다운로드 필수 (기존 패턴 유지)
|
||||
- **동시 요청 제한**: 10초당 20건 → 배치 작업 시 rate limiting 고려
|
||||
- **12스템 분리 비용**: 50크레딧 → UI에 비용 경고 표시
|
||||
- **WAV 중복 변환**: 409 에러 → 이미 변환된 경우 기존 URL 반환
|
||||
- **뮤직비디오**: taskId + audioId 필요 → 라이브러리에 task_id 저장 필수
|
||||
- **V5_5 모델**: 커스텀 모델 → 문서상 제한사항 추가 확인 필요
|
||||
- **크레딧 조회 엔드포인트**: 기존 `/api/v1/get-credits` vs 문서 `/api/v1/generate/credit` → 두 엔드포인트 폴백 시도
|
||||
- **upload 계열 API**: upload_url은 외부 접근 가능한 URL이어야 함 → 로컬 파일은 NAS nginx URL로 변환
|
||||
444
docs/superpowers/specs/2026-04-11-agent-office-design.md
Normal file
444
docs/superpowers/specs/2026-04-11-agent-office-design.md
Normal file
@@ -0,0 +1,444 @@
|
||||
# Agent Office - AI 에이전트 사무실 시각화 설계
|
||||
|
||||
## 개요
|
||||
|
||||
Lab 하위에 2D 픽셀아트 스타일의 가상 사무실을 구현하여, AI 에이전트들이 실시간으로 작업하는 모습을 게임처럼 시각화하고 상호작용하는 페이지.
|
||||
|
||||
### 핵심 컨셉
|
||||
- **게임 같은 사무실**: 2D 픽셀아트 오픈 오피스에 에이전트 캐릭터들이 배치
|
||||
- **실제 작업 수행**: 에이전트들이 기존 백엔드 서비스 API를 호출하여 실제 결과물 생성
|
||||
- **직접 지시**: 에이전트 클릭 → 채팅/명령 패널로 지시, 승인 요청 시 알림 표시
|
||||
- **텔레그램 양방향**: 알림 발송 + 인라인 버튼으로 승인/거절/수정
|
||||
- **아이들 행동**: 장시간 명령 없으면 휴게실에서 커피, 졸기, 동료 잡담 등
|
||||
|
||||
### MVP 범위
|
||||
- **에이전트 2개**: StockAgent (주식 뉴스/주가 알람), MusicAgent (작곡 파이프라인)
|
||||
- **사무실**: 단일 오픈 오피스 (향후 방/층 확장 가능)
|
||||
- **텔레그램**: 양방향 (알림 + 인라인 버튼 승인)
|
||||
|
||||
---
|
||||
|
||||
## 1. 아키텍처
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Frontend (React) │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌─────────────────────────┐ │
|
||||
│ │ OfficeCanvas │ │ React Overlay │ │
|
||||
│ │ (Canvas 2D) │ │ - ChatPanel │ │
|
||||
│ │ - 타일맵 렌더 │ │ - AgentStatus │ │
|
||||
│ │ - 스프라이트 │ │ - TaskHistory │ │
|
||||
│ │ - 클릭 히트맵 │ │ - ApprovalDialog │ │
|
||||
│ └──────────────┘ └─────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────┐ │
|
||||
│ │ useAgentManager (상태 + WebSocket) │ │
|
||||
│ └──────────────────────────────────────────┘ │
|
||||
└──────────────────┬──────────────────────────────┘
|
||||
│ WebSocket + REST
|
||||
┌──────────────────▼──────────────────────────────┐
|
||||
│ Backend: agent-office (새 서비스, 포트 18900) │
|
||||
│ │
|
||||
│ ┌────────────┐ ┌────────────┐ ┌──────────────┐ │
|
||||
│ │ Scheduler │ │ Agent FSM │ │ Telegram Bot │ │
|
||||
│ │(APScheduler)│ │ (상태머신) │ │ (양방향) │ │
|
||||
│ └────────────┘ └────────────┘ └──────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────┐ │
|
||||
│ │ Service Proxy (기존 서비스 API 호출) │ │
|
||||
│ │ stock-lab / music-lab 등 │ │
|
||||
│ └──────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 핵심 결정
|
||||
- **agent-office**: 새 백엔드 서비스 (포트 18900). 기존 서비스는 수정하지 않음
|
||||
- **Service Proxy 패턴**: agent-office가 기존 서비스 API를 HTTP 호출
|
||||
- **WebSocket**: 에이전트 상태 변화를 실시간 전달
|
||||
- **Canvas + React 오버레이 하이브리드**: 게임 렌더링은 Canvas, UI 패널은 React DOM
|
||||
|
||||
---
|
||||
|
||||
## 2. 에이전트 상태 머신 (FSM)
|
||||
|
||||
### 상태 전이
|
||||
|
||||
```
|
||||
┌──────┐ 스케줄/지시 ┌──────────┐ 완료 ┌──────────┐
|
||||
│ idle │ ──────────────→ │ working │ ───────→ │ reporting│
|
||||
└──┬───┘ └────┬─────┘ └────┬─────┘
|
||||
│ │ 승인 필요 │
|
||||
│ 장시간 idle ▼ │ 결과 전달 후
|
||||
│ ┌───────────┐ │
|
||||
▼ │ waiting │ │
|
||||
┌──────┐ │ (승인대기) │ │
|
||||
│ break│ └───────────┘ │
|
||||
│ (휴식)│ │
|
||||
└──┬───┘◄───────────────────────────────────────────┘
|
||||
│ 새 작업 발생
|
||||
└──────────→ idle
|
||||
```
|
||||
|
||||
### 상태별 시각화
|
||||
|
||||
| 상태 | 캐릭터 행동 | 위치 | 오버레이 |
|
||||
|------|------------|------|---------|
|
||||
| `idle` | 모니터 보며 대기 애니메이션 | 자기 데스크 | 없음 |
|
||||
| `working` | 타이핑 애니메이션, 모니터에 진행 표시 | 자기 데스크 | 작업명 말풍선 |
|
||||
| `waiting` | 살짝 좌우 흔들림 | 자기 데스크 | `❗` 아이콘 (클릭 유도) |
|
||||
| `reporting` | 결과물 들고 걸어감 | 데스크 → 회의 테이블 | 결과 요약 말풍선 |
|
||||
| `break` | 커피 마시기/졸기/산책/잡담 | 휴게실/복도 | `☕`/`💤` 아이콘 |
|
||||
|
||||
### 아이들 행동 규칙
|
||||
- idle 상태 5분 경과 → 50% 확률로 break 전환
|
||||
- break 지속: 1~3분 랜덤 → idle 복귀
|
||||
- break 중 에이전트끼리 근처에 있으면 잡담 애니메이션
|
||||
- 새 작업 발생 시 즉시 break 종료 → idle → working
|
||||
|
||||
### 승인 흐름별 분류
|
||||
|
||||
| 에이전트 | 자동 실행 | 승인 필요 |
|
||||
|---------|----------|----------|
|
||||
| Stock | 뉴스 요약, 주가 알람 | - |
|
||||
| Music | - | 작곡 (프롬프트 확인 후) |
|
||||
| Lotto (향후) | 통계 분석, 추천번호 | 구매 관련 |
|
||||
| Blog (향후) | - | 키워드 제시 후 글 생성 |
|
||||
| Realestate (향후) | 공고 수집, 매칭 | - |
|
||||
| Claude AI (향후) | - | 직접 지시 + 승인 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 사무실 맵 & 렌더링
|
||||
|
||||
### 타일맵 구조 (MVP: 단일 오픈 오피스)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
|
||||
│ │Stock│ │Music│ │Claude│ │ (빈) │ │
|
||||
│ │Desk │ │Desk │ │Desk │ │향후용│ │
|
||||
│ └─────┘ └─────┘ └─────┘ └─────┘ │
|
||||
│ │
|
||||
│ ┌───────────┐ │
|
||||
│ │ 회의 테이블 │ │
|
||||
│ │ (보고구역) │ │
|
||||
│ └───────────┘ │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌─────────────────┐ │
|
||||
│ │ 휴게실 │ │ CEO 데스크 (나) │ │
|
||||
│ │ coffee │ │ │ │
|
||||
│ └──────────┘ └─────────────────┘ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 렌더링 계층 (아래→위)
|
||||
1. **바닥 타일**: 카펫, 나무 바닥
|
||||
2. **가구**: 데스크, 의자, 소파, 화분, 커피머신
|
||||
3. **캐릭터**: 에이전트 스프라이트 (상태별 애니메이션)
|
||||
4. **오버레이**: 말풍선, 상태 아이콘, 이름표
|
||||
|
||||
### 스프라이트 에셋
|
||||
- 무료 픽셀아트 에셋팩 활용 (타일셋, 가구)
|
||||
- 에이전트 캐릭터: 기본 인물 스프라이트 + 액세서리로 구분
|
||||
- Stock: 넥타이 + 차트 모니터
|
||||
- Music: 헤드폰 + 음표 이펙트
|
||||
- Claude: 보라색 톤 + AI 아이콘
|
||||
- 스프라이트시트: 4방향 × 4프레임 (idle, walk, work, break)
|
||||
|
||||
### Canvas 렌더링 엔진
|
||||
- **게임 루프**: `requestAnimationFrame` 기반, 60fps 타겟
|
||||
- **카메라**: 고정 뷰 (MVP), 향후 줌/팬 추가 가능
|
||||
- **클릭 히트맵**: 캐릭터 바운딩 박스 체크 → 클릭 시 React 이벤트 발생
|
||||
- **이동**: 웨이포인트 기반 lerp (데스크↔회의실↔휴게실)
|
||||
|
||||
---
|
||||
|
||||
## 4. 백엔드: agent-office 서비스
|
||||
|
||||
### 파일 구조
|
||||
|
||||
```
|
||||
agent-office/
|
||||
├── app/
|
||||
│ ├── main.py # FastAPI + WebSocket + lifespan
|
||||
│ ├── db.py # SQLite (agent_tasks, agent_logs, agent_config)
|
||||
│ ├── config.py # 환경변수, 서비스 URL 설정
|
||||
│ ├── scheduler.py # APScheduler 스케줄 관리
|
||||
│ ├── telegram_bot.py # Telegram Bot API 양방향
|
||||
│ ├── websocket_manager.py # WebSocket 연결 관리 + 브로드캐스트
|
||||
│ ├── service_proxy.py # 기존 서비스 API 호출 래퍼
|
||||
│ ├── agents/
|
||||
│ │ ├── base.py # BaseAgent (FSM, 공통 로직)
|
||||
│ │ ├── stock.py # StockAgent
|
||||
│ │ └── music.py # MusicAgent
|
||||
│ └── models.py # Pydantic 모델
|
||||
├── Dockerfile
|
||||
└── requirements.txt
|
||||
```
|
||||
|
||||
### DB 테이블 (agent_office.db)
|
||||
|
||||
**agent_config**
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| agent_id | TEXT PK | 에이전트 식별자 (stock, music, ...) |
|
||||
| display_name | TEXT | 표시명 ("주식 트레이더") |
|
||||
| enabled | BOOLEAN | 활성 상태 |
|
||||
| schedule_config | TEXT (JSON) | 스케줄 설정 |
|
||||
| custom_config | TEXT (JSON) | 에이전트별 커스텀 설정 (감시 종목 등) |
|
||||
| created_at | TEXT | 생성 시각 |
|
||||
| updated_at | TEXT | 수정 시각 |
|
||||
|
||||
**agent_tasks**
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | TEXT PK (UUID) | 작업 ID |
|
||||
| agent_id | TEXT FK | 에이전트 |
|
||||
| task_type | TEXT | 작업 유형 (news_summary, price_alert, compose, ...) |
|
||||
| status | TEXT | pending / approved / working / succeeded / failed |
|
||||
| input_data | TEXT (JSON) | 입력 파라미터 |
|
||||
| result_data | TEXT (JSON) | 결과 데이터 |
|
||||
| requires_approval | BOOLEAN | 승인 필요 여부 |
|
||||
| approved_at | TEXT | 승인 시각 |
|
||||
| approved_via | TEXT | 승인 경로 (web / telegram) |
|
||||
| created_at | TEXT | 생성 시각 |
|
||||
| completed_at | TEXT | 완료 시각 |
|
||||
|
||||
**agent_logs**
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | INTEGER PK | 자동 증가 |
|
||||
| agent_id | TEXT FK | 에이전트 |
|
||||
| task_id | TEXT FK | 관련 작업 (nullable) |
|
||||
| level | TEXT | info / warn / error |
|
||||
| message | TEXT | 로그 메시지 |
|
||||
| created_at | TEXT | 시각 |
|
||||
|
||||
**telegram_state**
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| callback_id | TEXT PK | 텔레그램 콜백 ID |
|
||||
| task_id | TEXT FK | 매핑된 작업 |
|
||||
| agent_id | TEXT FK | 매핑된 에이전트 |
|
||||
| action | TEXT | approve / reject / modify |
|
||||
| responded | BOOLEAN | 응답 완료 여부 |
|
||||
| created_at | TEXT | 생성 시각 |
|
||||
|
||||
### BaseAgent 인터페이스
|
||||
|
||||
```python
|
||||
class BaseAgent:
|
||||
agent_id: str
|
||||
state: str # idle, working, waiting, reporting, break
|
||||
|
||||
async def on_schedule(self) -> None:
|
||||
"""스케줄러에 의해 호출. 자동 작업 실행."""
|
||||
|
||||
async def on_command(self, command: str, params: dict) -> dict:
|
||||
"""사용자 직접 지시 처리."""
|
||||
|
||||
async def on_approval(self, task_id: str, approved: bool, feedback: str) -> None:
|
||||
"""승인/거절 콜백."""
|
||||
|
||||
async def get_status(self) -> dict:
|
||||
"""현재 상태 + 최근 작업 요약."""
|
||||
```
|
||||
|
||||
### MVP 에이전트 상세
|
||||
|
||||
**StockAgent:**
|
||||
- 스케줄: 매일 08:00 `on_schedule()` → `stock-lab GET /api/stock/news` 호출
|
||||
- AI 요약: 뉴스 데이터를 Ollama(192.168.45.59)로 요약 생성
|
||||
- 텔레그램 전송: 요약 결과를 포맷팅하여 발송 (자동, 승인 불필요)
|
||||
- 주가 알람: `agent_config.custom_config`에 감시 종목/조건 저장, 주기적 체크
|
||||
- 상태 전이: idle → working(뉴스 수집) → reporting(텔레그램 전송) → idle
|
||||
|
||||
**MusicAgent:**
|
||||
- 트리거: 사용자 웹/텔레그램 지시 → `on_command()`
|
||||
- 프롬프트 확인: 사용자 입력 프롬프트를 텔레그램으로 전송 + 인라인 버튼
|
||||
- 승인 시: `music-lab POST /api/music/generate` 호출
|
||||
- 상태 폴링: `music-lab GET /api/music/status/{task_id}` → 완료까지 반복
|
||||
- 결과 알림: 생성된 음악 URL을 텔레그램 + 웹에 전달
|
||||
- 상태 전이: idle → waiting(프롬프트 승인 대기) → working(생성 중) → reporting(결과 전달) → idle
|
||||
|
||||
---
|
||||
|
||||
## 5. 텔레그램 봇
|
||||
|
||||
### 구성
|
||||
- **Telegram Bot API** + **Webhook 수신** (NAS에서)
|
||||
- agent-office 서비스 내부에 통합 (별도 프로세스 아님)
|
||||
- Nginx: `/api/agent-office/telegram/webhook` → `agent-office:8000`
|
||||
|
||||
### 환경변수
|
||||
- `TELEGRAM_BOT_TOKEN`: Bot Father에서 발급
|
||||
- `TELEGRAM_CHAT_ID`: 사용자 채팅 ID (1:1 봇)
|
||||
- `TELEGRAM_WEBHOOK_URL`: Webhook 수신 URL (NAS 외부 접근 가능 URL)
|
||||
|
||||
### 메시지 포맷
|
||||
|
||||
**자동 알림 (뉴스 요약):**
|
||||
```
|
||||
📈 [주식 에이전트] 아침 뉴스 요약
|
||||
━━━━━━━━━━━━━━━━
|
||||
• 삼성전자: 반도체 수출 호조...
|
||||
• 코스피: 외인 순매수 전환...
|
||||
• 미국 CPI 발표 예정...
|
||||
|
||||
📊 관심종목 현황
|
||||
삼성전자 82,500원 (+2.1%)
|
||||
AAPL $185.20 (+1.2%)
|
||||
```
|
||||
|
||||
**승인 요청 (작곡):**
|
||||
```
|
||||
🎵 [음악 에이전트] 작곡 요청
|
||||
━━━━━━━━━━━━━━━━
|
||||
프롬프트: "Lo-fi hip hop, rainy day, piano"
|
||||
스타일: Chill, Ambient
|
||||
모델: V5.5
|
||||
|
||||
[✅ 승인] [❌ 거절] [✏️ 수정]
|
||||
```
|
||||
|
||||
**주가 알람:**
|
||||
```
|
||||
🚨 [주식 에이전트] 주가 알림
|
||||
━━━━━━━━━━━━━━━━
|
||||
삼성전자 82,500원
|
||||
조건: 82,000원 이상 → 도달!
|
||||
현재 등락: +2.1%
|
||||
```
|
||||
|
||||
### 양방향 흐름
|
||||
1. 에이전트 → `telegram_bot.send_message()` → 텔레그램
|
||||
2. 사용자 → 인라인 버튼 클릭 or 텍스트 입력
|
||||
3. 텔레그램 → Webhook POST → `telegram_bot.handle_webhook()`
|
||||
4. `handle_webhook()` → `telegram_state` 조회 → 에이전트 `on_approval()` 호출
|
||||
5. 에이전트 FSM 상태 전이 → WebSocket 브로드캐스트 → 프론트엔드 반영
|
||||
|
||||
---
|
||||
|
||||
## 6. 프론트엔드 구조
|
||||
|
||||
### 파일 구조
|
||||
|
||||
```
|
||||
src/pages/agent-office/
|
||||
├── AgentOffice.jsx # 메인 페이지 (Canvas + Overlay 컨테이너)
|
||||
├── AgentOffice.css # 스타일
|
||||
├── canvas/
|
||||
│ ├── OfficeRenderer.js # Canvas 렌더링 엔진 (게임루프)
|
||||
│ ├── SpriteSheet.js # 스프라이트시트 로더 + 프레임 애니메이션
|
||||
│ ├── TileMap.js # 타일맵 데이터 + 렌더링
|
||||
│ └── AgentSprite.js # 에이전트 캐릭터 (위치, 상태, 이동, 애니메이션)
|
||||
├── components/
|
||||
│ ├── ChatPanel.jsx # 에이전트 채팅/명령 패널
|
||||
│ ├── AgentBubble.jsx # 말풍선/상태 아이콘 오버레이
|
||||
│ ├── TaskHistory.jsx # 작업 이력 사이드패널
|
||||
│ └── ApprovalDialog.jsx # 승인 요청 다이얼로그
|
||||
├── hooks/
|
||||
│ ├── useAgentManager.js # WebSocket + 에이전트 상태 관리
|
||||
│ └── useOfficeCanvas.js # Canvas 초기화 + 이벤트 바인딩
|
||||
└── assets/
|
||||
├── tileset.png # 사무실 타일셋 (16x16 or 32x32)
|
||||
├── agents.png # 에이전트 스프라이트시트
|
||||
└── office-map.json # 타일맵 데이터
|
||||
```
|
||||
|
||||
### WebSocket 프로토콜
|
||||
|
||||
**서버 → 클라이언트:**
|
||||
```json
|
||||
{"type": "agent_state", "agent": "stock", "state": "working", "detail": "뉴스 수집 중..."}
|
||||
{"type": "agent_state", "agent": "music", "state": "waiting", "detail": "프롬프트 승인 대기", "task_id": "abc-123"}
|
||||
{"type": "task_complete", "agent": "stock", "task_id": "...", "result": {"summary": "..."}}
|
||||
{"type": "agent_move", "agent": "stock", "target": "break_room"}
|
||||
```
|
||||
|
||||
**클라이언트 → 서버:**
|
||||
```json
|
||||
{"type": "command", "agent": "music", "action": "compose", "params": {"prompt": "...", "style": "..."}}
|
||||
{"type": "approval", "agent": "music", "task_id": "abc-123", "approved": true}
|
||||
{"type": "query", "agent": "stock", "action": "status"}
|
||||
```
|
||||
|
||||
### ChatPanel 기능
|
||||
- 에이전트별 채팅 히스토리 표시
|
||||
- 텍스트 입력 + 빠른 액션 버튼
|
||||
- 승인 대기 중인 작업 강조 표시
|
||||
- 최근 작업 결과 인라인 표시
|
||||
|
||||
---
|
||||
|
||||
## 7. 인프라 변경
|
||||
|
||||
### Docker Compose 추가
|
||||
|
||||
```yaml
|
||||
agent-office:
|
||||
build: ./agent-office
|
||||
container_name: agent-office
|
||||
ports:
|
||||
- "18900:8000"
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/data:/app/data
|
||||
environment:
|
||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
||||
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID}
|
||||
- TELEGRAM_WEBHOOK_URL=${TELEGRAM_WEBHOOK_URL}
|
||||
- STOCK_LAB_URL=http://stock-lab:8000
|
||||
- MUSIC_LAB_URL=http://music-lab:8000
|
||||
depends_on:
|
||||
- stock-lab
|
||||
- music-lab
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
### Nginx 라우팅 추가
|
||||
|
||||
```nginx
|
||||
location /api/agent-office/ {
|
||||
proxy_pass http://agent-office:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade"; # WebSocket 지원
|
||||
}
|
||||
```
|
||||
|
||||
### 라우팅 (React Router)
|
||||
|
||||
```javascript
|
||||
// routes.jsx
|
||||
{ path: 'agent-office', lazy: () => import('./pages/agent-office/AgentOffice') }
|
||||
```
|
||||
|
||||
Lab 페이지(EffectLab.jsx)의 LAB_ITEMS에 Agent Office 항목 추가.
|
||||
|
||||
---
|
||||
|
||||
## 8. 향후 확장 (Phase 2+)
|
||||
|
||||
| 단계 | 내용 |
|
||||
|------|------|
|
||||
| Phase 2 | LottoAgent, BlogAgent, RealestateAgent 추가 |
|
||||
| Phase 3 | Claude AI Agent (자연어 복합 지시) |
|
||||
| Phase 4 | 방/층 확장 (부서별 공간 분리) |
|
||||
| Phase 5 | 에이전트 간 협업 시각화 (회의 테이블에서 토론) |
|
||||
| Phase 6 | 에이전트 커스텀 (이름, 외형, 성격 설정) |
|
||||
|
||||
---
|
||||
|
||||
## 9. 기술 스택 요약
|
||||
|
||||
| 레이어 | 기술 |
|
||||
|--------|------|
|
||||
| 사무실 렌더링 | HTML5 Canvas 2D (커스텀 엔진) |
|
||||
| 프론트엔드 | React 18 + Vite |
|
||||
| 실시간 통신 | WebSocket (FastAPI) |
|
||||
| 백엔드 | FastAPI (Python 3.12) |
|
||||
| DB | SQLite (agent_office.db) |
|
||||
| 스케줄러 | APScheduler |
|
||||
| 메시징 | Telegram Bot API (Webhook) |
|
||||
| 서비스 연동 | HTTP Proxy (기존 서비스 API 호출) |
|
||||
6
music-lab/.dockerignore
Normal file
6
music-lab/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
.git
|
||||
__pycache__
|
||||
*.pyc
|
||||
.env
|
||||
.env.*
|
||||
*.md
|
||||
10
music-lab/Dockerfile
Normal file
10
music-lab/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM python:3.12-alpine
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
0
music-lab/app/__init__.py
Normal file
0
music-lab/app/__init__.py
Normal file
345
music-lab/app/db.py
Normal file
345
music-lab/app/db.py
Normal file
@@ -0,0 +1,345 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
DB_PATH = "/app/data/music.db"
|
||||
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS music_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
progress INTEGER NOT NULL DEFAULT 0,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
audio_url TEXT,
|
||||
error TEXT,
|
||||
params TEXT NOT NULL DEFAULT '{}',
|
||||
provider TEXT NOT NULL DEFAULT 'local',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_created ON music_tasks(created_at DESC)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS music_library (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
genre TEXT NOT NULL DEFAULT '',
|
||||
moods TEXT NOT NULL DEFAULT '[]',
|
||||
instruments TEXT NOT NULL DEFAULT '[]',
|
||||
duration_sec INTEGER,
|
||||
bpm INTEGER,
|
||||
key TEXT NOT NULL DEFAULT '',
|
||||
scale TEXT NOT NULL DEFAULT '',
|
||||
prompt TEXT NOT NULL DEFAULT '',
|
||||
audio_url TEXT NOT NULL DEFAULT '',
|
||||
file_path TEXT NOT NULL DEFAULT '',
|
||||
task_id TEXT,
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
provider TEXT NOT NULL DEFAULT 'local',
|
||||
lyrics TEXT NOT NULL DEFAULT '',
|
||||
image_url TEXT NOT NULL DEFAULT '',
|
||||
suno_id TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_library_created ON music_library(created_at DESC)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS saved_lyrics (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
text TEXT NOT NULL DEFAULT '',
|
||||
prompt TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_lyrics_created ON saved_lyrics(created_at DESC)")
|
||||
|
||||
# 기존 테이블 마이그레이션 (컬럼 없으면 추가)
|
||||
for col, default in [
|
||||
("provider", "'local'"), ("lyrics", "''"),
|
||||
("image_url", "''"), ("suno_id", "''"),
|
||||
("file_hash", "''"),
|
||||
]:
|
||||
try:
|
||||
conn.execute(f"ALTER TABLE music_library ADD COLUMN {col} TEXT NOT NULL DEFAULT {default}")
|
||||
except sqlite3.OperationalError:
|
||||
pass # 이미 존재
|
||||
try:
|
||||
conn.execute("ALTER TABLE music_tasks ADD COLUMN provider TEXT NOT NULL DEFAULT 'local'")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
|
||||
# Phase 1~3 신규 컬럼 마이그레이션
|
||||
for col, default in [
|
||||
("cover_images", "'[]'"),
|
||||
("wav_url", "''"),
|
||||
("video_url", "''"),
|
||||
("stem_urls", "'{}'"),
|
||||
]:
|
||||
try:
|
||||
conn.execute(f"ALTER TABLE music_library ADD COLUMN {col} TEXT NOT NULL DEFAULT {default}")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
|
||||
|
||||
# ── music_tasks CRUD ──────────────────────────────────────────────────────────
|
||||
|
||||
def _task_row_to_dict(r) -> Dict[str, Any]:
|
||||
return {
|
||||
"task_id": r["id"],
|
||||
"status": r["status"],
|
||||
"progress": r["progress"],
|
||||
"message": r["message"],
|
||||
"audio_url": r["audio_url"],
|
||||
"error": r["error"],
|
||||
"params": json.loads(r["params"]),
|
||||
"provider": r["provider"] if "provider" in r.keys() else "local",
|
||||
"created_at": r["created_at"],
|
||||
"updated_at": r["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
def create_task(task_id: str, params: Dict[str, Any], provider: str = "local") -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO music_tasks (id, params, provider) VALUES (?, ?, ?)",
|
||||
(task_id, json.dumps(params), provider),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM music_tasks WHERE id = ?", (task_id,)).fetchone()
|
||||
return _task_row_to_dict(row)
|
||||
|
||||
|
||||
def update_task(
|
||||
task_id: str,
|
||||
status: str,
|
||||
progress: int,
|
||||
message: str,
|
||||
audio_url: str = None,
|
||||
error: str = None,
|
||||
) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE music_tasks
|
||||
SET status = ?, progress = ?, message = ?, audio_url = ?, error = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||
WHERE id = ?
|
||||
""",
|
||||
(status, progress, message, audio_url, error, task_id),
|
||||
)
|
||||
|
||||
|
||||
def get_task(task_id: str) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM music_tasks WHERE id = ?", (task_id,)).fetchone()
|
||||
return _task_row_to_dict(row) if row else None
|
||||
|
||||
|
||||
# ── music_library CRUD ────────────────────────────────────────────────────────
|
||||
|
||||
def _track_row_to_dict(r) -> Dict[str, Any]:
|
||||
keys = r.keys()
|
||||
return {
|
||||
"id": r["id"],
|
||||
"title": r["title"],
|
||||
"genre": r["genre"],
|
||||
"moods": json.loads(r["moods"]) if r["moods"] else [],
|
||||
"instruments": json.loads(r["instruments"]) if r["instruments"] else [],
|
||||
"duration_sec": r["duration_sec"],
|
||||
"bpm": r["bpm"],
|
||||
"key": r["key"],
|
||||
"scale": r["scale"],
|
||||
"prompt": r["prompt"],
|
||||
"audio_url": r["audio_url"],
|
||||
"file_path": r["file_path"],
|
||||
"task_id": r["task_id"],
|
||||
"tags": json.loads(r["tags"]) if r["tags"] else [],
|
||||
"provider": r["provider"] if "provider" in keys else "local",
|
||||
"lyrics": r["lyrics"] if "lyrics" in keys else "",
|
||||
"image_url": r["image_url"] if "image_url" in keys else "",
|
||||
"suno_id": r["suno_id"] if "suno_id" in keys else "",
|
||||
"file_hash": r["file_hash"] if "file_hash" in keys else "",
|
||||
"cover_images": json.loads(r["cover_images"]) if "cover_images" in keys and r["cover_images"] else [],
|
||||
"wav_url": r["wav_url"] if "wav_url" in keys else "",
|
||||
"video_url": r["video_url"] if "video_url" in keys else "",
|
||||
"stem_urls": json.loads(r["stem_urls"]) if "stem_urls" in keys and r["stem_urls"] else {},
|
||||
"created_at": r["created_at"],
|
||||
}
|
||||
|
||||
|
||||
def get_all_tracks() -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM music_library ORDER BY created_at DESC").fetchall()
|
||||
return [_track_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def add_track(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO music_library
|
||||
(title, genre, moods, instruments, duration_sec, bpm, key, scale,
|
||||
prompt, audio_url, file_path, task_id, tags,
|
||||
provider, lyrics, image_url, suno_id, file_hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
data.get("title", ""),
|
||||
data.get("genre", ""),
|
||||
json.dumps(data.get("moods", [])),
|
||||
json.dumps(data.get("instruments", [])),
|
||||
data.get("duration_sec"),
|
||||
data.get("bpm"),
|
||||
data.get("key", ""),
|
||||
data.get("scale", ""),
|
||||
data.get("prompt", ""),
|
||||
data.get("audio_url", ""),
|
||||
data.get("file_path", ""),
|
||||
data.get("task_id"),
|
||||
json.dumps(data.get("tags", [])),
|
||||
data.get("provider", "local"),
|
||||
data.get("lyrics", ""),
|
||||
data.get("image_url", ""),
|
||||
data.get("suno_id", ""),
|
||||
data.get("file_hash", ""),
|
||||
),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM music_library WHERE rowid = last_insert_rowid()").fetchone()
|
||||
return _track_row_to_dict(row)
|
||||
|
||||
|
||||
def delete_track(track_id: int) -> bool:
|
||||
with _conn() as conn:
|
||||
# 파일 경로 먼저 조회 (삭제 후 파일도 지울 수 있도록)
|
||||
row = conn.execute("SELECT file_path FROM music_library WHERE id = ?", (track_id,)).fetchone()
|
||||
if not row:
|
||||
return False
|
||||
conn.execute("DELETE FROM music_library WHERE id = ?", (track_id,))
|
||||
return True
|
||||
|
||||
|
||||
def get_track_by_task_id(task_id: str) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM music_library WHERE task_id = ?", (task_id,)).fetchone()
|
||||
return _track_row_to_dict(row) if row else None
|
||||
|
||||
|
||||
def update_track_duration(track_id: int, duration_sec: int) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE music_library SET duration_sec = ? WHERE id = ? AND duration_sec IS NULL",
|
||||
(duration_sec, track_id),
|
||||
)
|
||||
|
||||
|
||||
def update_track_file_info(track_id: int, title: str, audio_url: str, file_path: str) -> None:
|
||||
"""파일 rename 시 파일 관련 정보만 업데이트 (태그 등 메타데이터 보존)."""
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE music_library SET title=?, audio_url=?, file_path=? WHERE id=?",
|
||||
(title, audio_url, file_path, track_id),
|
||||
)
|
||||
|
||||
|
||||
def update_track_hash(track_id: int, file_hash: str) -> None:
|
||||
"""트랙의 file_hash를 업데이트."""
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE music_library SET file_hash=? WHERE id=?",
|
||||
(file_hash, track_id),
|
||||
)
|
||||
|
||||
|
||||
def get_track_file_path(track_id: int) -> Optional[str]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT file_path FROM music_library WHERE id = ?", (track_id,)).fetchone()
|
||||
return row["file_path"] if row else None
|
||||
|
||||
|
||||
# ── saved_lyrics CRUD ────────────────────────────────────────────────────────
|
||||
|
||||
def _lyrics_row_to_dict(r) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r["id"],
|
||||
"title": r["title"],
|
||||
"text": r["text"],
|
||||
"prompt": r["prompt"],
|
||||
"created_at": r["created_at"],
|
||||
"updated_at": r["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
def get_all_lyrics() -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM saved_lyrics ORDER BY created_at DESC").fetchall()
|
||||
return [_lyrics_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def add_lyrics(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO saved_lyrics (title, text, prompt) VALUES (?, ?, ?)",
|
||||
(data.get("title", ""), data.get("text", ""), data.get("prompt", "")),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM saved_lyrics WHERE rowid = last_insert_rowid()").fetchone()
|
||||
return _lyrics_row_to_dict(row)
|
||||
|
||||
|
||||
def update_lyrics(lyrics_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
fields = []
|
||||
values = []
|
||||
for k in ("title", "text", "prompt"):
|
||||
if k in data:
|
||||
fields.append(f"{k} = ?")
|
||||
values.append(data[k])
|
||||
if not fields:
|
||||
return None
|
||||
fields.append("updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')")
|
||||
values.append(lyrics_id)
|
||||
conn.execute(f"UPDATE saved_lyrics SET {', '.join(fields)} WHERE id = ?", values)
|
||||
row = conn.execute("SELECT * FROM saved_lyrics WHERE id = ?", (lyrics_id,)).fetchone()
|
||||
return _lyrics_row_to_dict(row) if row else None
|
||||
|
||||
|
||||
def update_track_cover_images(track_id: int, images: list) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("UPDATE music_library SET cover_images=? WHERE id=?", (json.dumps(images), track_id))
|
||||
|
||||
|
||||
def update_track_wav_url(track_id: int, wav_url: str) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("UPDATE music_library SET wav_url=? WHERE id=?", (wav_url, track_id))
|
||||
|
||||
|
||||
def update_track_video_url(track_id: int, video_url: str) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("UPDATE music_library SET video_url=? WHERE id=?", (video_url, track_id))
|
||||
|
||||
|
||||
def update_track_stem_urls(track_id: int, stems: dict) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("UPDATE music_library SET stem_urls=? WHERE id=?", (json.dumps(stems), track_id))
|
||||
|
||||
|
||||
def delete_lyrics(lyrics_id: int) -> bool:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT id FROM saved_lyrics WHERE id = ?", (lyrics_id,)).fetchone()
|
||||
if not row:
|
||||
return False
|
||||
conn.execute("DELETE FROM saved_lyrics WHERE id = ?", (lyrics_id,))
|
||||
return True
|
||||
122
music-lab/app/local_provider.py
Normal file
122
music-lab/app/local_provider.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
Local MusicGen Provider — Windows AI 서버(MusicGen)를 통한 음악 생성
|
||||
기존 _run_generation 로직을 그대로 분리.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
import requests
|
||||
|
||||
from .db import update_task, add_track
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MUSIC_AI_SERVER_URL = os.getenv("MUSIC_AI_SERVER_URL", "")
|
||||
MUSIC_DATA_DIR = "/app/data"
|
||||
MUSIC_MEDIA_BASE = os.getenv("MUSIC_MEDIA_BASE", "/media/music")
|
||||
|
||||
|
||||
def run_local_generation(task_id: str, params: dict) -> None:
|
||||
"""BackgroundTask: Windows AI 서버(MusicGen)에 생성 요청 → 파일 저장 → 라이브러리 등록"""
|
||||
try:
|
||||
update_task(task_id, "processing", 10, "AI 서버에 연결 중...")
|
||||
|
||||
if not MUSIC_AI_SERVER_URL:
|
||||
update_task(task_id, "failed", 0, "",
|
||||
error="MUSIC_AI_SERVER_URL이 설정되지 않았습니다")
|
||||
return
|
||||
|
||||
update_task(task_id, "processing", 30, "음악 생성 중... (수 분 소요될 수 있습니다)")
|
||||
|
||||
# 1단계: 생성 요청 → ai_task_id 반환
|
||||
resp = requests.post(
|
||||
f"{MUSIC_AI_SERVER_URL}/generate",
|
||||
json=params,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
update_task(task_id, "failed", 0, "",
|
||||
error=f"AI 서버 오류: {resp.status_code} {resp.text[:200]}")
|
||||
return
|
||||
|
||||
ai_task_id = resp.json().get("task_id")
|
||||
if not ai_task_id:
|
||||
update_task(task_id, "failed", 0, "",
|
||||
error="AI 서버 응답에 task_id가 없습니다")
|
||||
return
|
||||
|
||||
# 2단계: 상태 폴링 (최대 10분, 5초 간격)
|
||||
remote_url = None
|
||||
for _ in range(120):
|
||||
time.sleep(5)
|
||||
status_resp = requests.get(
|
||||
f"{MUSIC_AI_SERVER_URL}/status/{ai_task_id}", timeout=10,
|
||||
)
|
||||
status_data = status_resp.json()
|
||||
ai_status = status_data.get("status")
|
||||
|
||||
ai_progress = status_data.get("progress", 0)
|
||||
ai_message = status_data.get("message", "음악 생성 중...")
|
||||
scaled = 30 + int(ai_progress * 0.49) # 30% ~ 79%
|
||||
update_task(task_id, "processing", scaled, ai_message)
|
||||
|
||||
if ai_status == "succeeded":
|
||||
remote_url = status_data.get("audio_url")
|
||||
break
|
||||
elif ai_status == "failed":
|
||||
update_task(task_id, "failed", 0, "",
|
||||
error=status_data.get("error", "AI 서버 생성 실패"))
|
||||
return
|
||||
|
||||
if not remote_url:
|
||||
update_task(task_id, "failed", 0, "",
|
||||
error="AI 서버 타임아웃 (10분 초과)")
|
||||
return
|
||||
|
||||
update_task(task_id, "processing", 80, "파일 저장 중...")
|
||||
|
||||
filename = f"{task_id}.mp3"
|
||||
file_path = os.path.join(MUSIC_DATA_DIR, filename)
|
||||
|
||||
# 3단계: 오디오 파일 다운로드
|
||||
dl = requests.get(remote_url, timeout=120, stream=True)
|
||||
with open(file_path, "wb") as f:
|
||||
for chunk in dl.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
audio_url = f"{MUSIC_MEDIA_BASE}/{filename}"
|
||||
|
||||
# 라이브러리 자동 등록
|
||||
genre = params.get("genre", "")
|
||||
moods = params.get("moods", [])
|
||||
mood_str = moods[0] if moods else "Original"
|
||||
title = params.get("title") or (
|
||||
f"{genre} — {mood_str} Mix" if genre else f"{mood_str} Mix"
|
||||
)
|
||||
|
||||
add_track({
|
||||
"title": title,
|
||||
"genre": genre,
|
||||
"moods": params.get("moods", []),
|
||||
"instruments": params.get("instruments", []),
|
||||
"duration_sec": params.get("duration_sec"),
|
||||
"bpm": params.get("bpm"),
|
||||
"key": params.get("key", ""),
|
||||
"scale": params.get("scale", ""),
|
||||
"prompt": params.get("prompt", ""),
|
||||
"audio_url": audio_url,
|
||||
"file_path": file_path,
|
||||
"task_id": task_id,
|
||||
"provider": "local",
|
||||
})
|
||||
|
||||
update_task(task_id, "succeeded", 100, "생성 완료", audio_url=audio_url)
|
||||
|
||||
except requests.Timeout:
|
||||
update_task(task_id, "failed", 0, "",
|
||||
error="AI 서버 타임아웃 (10분 초과)")
|
||||
except Exception as e:
|
||||
logger.exception("Local generation error for task %s", task_id)
|
||||
update_task(task_id, "failed", 0, "", error=str(e))
|
||||
671
music-lab/app/main.py
Normal file
671
music-lab/app/main.py
Normal file
@@ -0,0 +1,671 @@
|
||||
import os
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
from fastapi import FastAPI, HTTPException, BackgroundTasks
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .db import (
|
||||
init_db,
|
||||
create_task, get_task,
|
||||
get_all_tracks, add_track, delete_track, get_track_file_path, get_track_by_task_id,
|
||||
update_track_duration, update_track_file_info, update_track_hash,
|
||||
get_all_lyrics, add_lyrics, update_lyrics, delete_lyrics,
|
||||
)
|
||||
from .local_provider import run_local_generation
|
||||
from .suno_provider import (
|
||||
run_suno_generation, run_suno_extend, run_vocal_removal,
|
||||
run_cover_image, run_wav_convert, run_stem_split,
|
||||
run_upload_cover, run_upload_extend, run_add_vocals, run_add_instrumental, run_video_generate,
|
||||
generate_lyrics, get_credits, get_timestamped_lyrics, generate_style_boost,
|
||||
SUNO_API_KEY, SUNO_MODELS,
|
||||
)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
_cors_origins = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080").split(",")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[o.strip() for o in _cors_origins],
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type"],
|
||||
)
|
||||
|
||||
MUSIC_DATA_DIR = "/app/data"
|
||||
|
||||
|
||||
def _get_mp3_duration(file_path: str) -> Optional[int]:
|
||||
"""MP3 파일에서 실제 재생 시간(초) 추출."""
|
||||
try:
|
||||
from mutagen.mp3 import MP3
|
||||
audio = MP3(file_path)
|
||||
return int(audio.info.length)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _backfill_durations():
|
||||
"""duration_sec이 없는 기존 트랙에 MP3 메타데이터에서 길이 채우기."""
|
||||
for t in get_all_tracks():
|
||||
if t["duration_sec"] is None and t.get("file_path"):
|
||||
dur = _get_mp3_duration(t["file_path"])
|
||||
if dur:
|
||||
update_track_duration(t["id"], dur)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
init_db()
|
||||
os.makedirs(MUSIC_DATA_DIR, exist_ok=True)
|
||||
_backfill_durations()
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/music/providers")
|
||||
def get_providers():
|
||||
"""사용 가능한 음악 생성 프로바이더 목록 반환."""
|
||||
providers = []
|
||||
if os.getenv("MUSIC_AI_SERVER_URL"):
|
||||
providers.append({
|
||||
"id": "local",
|
||||
"name": "MusicGen",
|
||||
"description": "로컬 AI 서버 (인스트루멘탈 전용)",
|
||||
"features": ["instrumental"],
|
||||
})
|
||||
if SUNO_API_KEY:
|
||||
providers.append({
|
||||
"id": "suno",
|
||||
"name": "Suno",
|
||||
"description": "Suno AI (보컬·가사·인스트루멘탈)",
|
||||
"features": ["vocals", "lyrics", "instrumental"],
|
||||
})
|
||||
return {"providers": providers}
|
||||
|
||||
|
||||
# ── 음악 생성 API ─────────────────────────────────────────────────────────────
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
provider: str = "suno" # "suno" | "local"
|
||||
model: str = "V4" # Suno 모델 (V4, V4_5, V5 등)
|
||||
title: str = ""
|
||||
genre: str = ""
|
||||
moods: List[str] = []
|
||||
instruments: List[str] = []
|
||||
duration_sec: Optional[int] = None
|
||||
bpm: Optional[int] = None
|
||||
key: str = ""
|
||||
scale: str = ""
|
||||
prompt: str = ""
|
||||
# Suno 전용
|
||||
lyrics: str = "" # 커스텀 가사 ([Verse], [Chorus] 등)
|
||||
instrumental: bool = False # True면 보컬 없이 인스트루멘탈만
|
||||
# Phase 1 신규
|
||||
vocal_gender: Optional[str] = None # "m" | "f"
|
||||
negative_tags: Optional[str] = None # 제외 스타일
|
||||
style_weight: Optional[float] = None # 0.0~1.0
|
||||
audio_weight: Optional[float] = None # 0.0~1.0
|
||||
|
||||
|
||||
@app.post("/api/music/generate")
|
||||
def generate_music(req: GenerateRequest, background_tasks: BackgroundTasks):
|
||||
"""
|
||||
음악 생성 작업 시작. task_id 즉시 반환 후 백그라운드에서 AI 서버 호출.
|
||||
provider: "suno" (Suno API) 또는 "local" (MusicGen)
|
||||
"""
|
||||
provider = req.provider
|
||||
if provider == "suno" and not SUNO_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
|
||||
if provider == "local" and not os.getenv("MUSIC_AI_SERVER_URL"):
|
||||
raise HTTPException(status_code=400, detail="로컬 AI 서버 URL이 설정되지 않았습니다")
|
||||
if provider not in ("suno", "local"):
|
||||
raise HTTPException(status_code=400, detail=f"지원하지 않는 provider: {provider}")
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
params = req.model_dump()
|
||||
create_task(task_id, params, provider=provider)
|
||||
|
||||
if provider == "suno":
|
||||
background_tasks.add_task(run_suno_generation, task_id, params)
|
||||
else:
|
||||
background_tasks.add_task(run_local_generation, task_id, params)
|
||||
|
||||
return {"task_id": task_id, "provider": provider}
|
||||
|
||||
|
||||
@app.get("/api/music/status/{task_id}")
|
||||
def get_status(task_id: str):
|
||||
"""
|
||||
생성 작업 상태 조회. 프론트는 succeeded 또는 failed가 될 때까지 폴링.
|
||||
status: queued | processing | succeeded | failed
|
||||
succeeded 시 track 메타데이터 포함.
|
||||
"""
|
||||
task = get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
resp = {
|
||||
"status": task["status"],
|
||||
"progress": task["progress"],
|
||||
"message": task["message"],
|
||||
"audio_url": task["audio_url"],
|
||||
"error": task["error"],
|
||||
"provider": task["provider"],
|
||||
}
|
||||
|
||||
if task["status"] == "succeeded":
|
||||
track = get_track_by_task_id(task_id)
|
||||
resp["track"] = track
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
# ── 가사 생성 API (Suno 전용) ────────────────────────────────────────────────
|
||||
|
||||
class LyricsRequest(BaseModel):
|
||||
prompt: str
|
||||
|
||||
|
||||
@app.post("/api/music/lyrics")
|
||||
def gen_lyrics(req: LyricsRequest):
|
||||
"""Suno AI로 가사를 생성합니다. 곡 생성 전 가사 미리보기용."""
|
||||
if not SUNO_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
|
||||
result = generate_lyrics(req.prompt)
|
||||
if not result:
|
||||
raise HTTPException(status_code=502, detail="가사 생성에 실패했습니다")
|
||||
return result
|
||||
|
||||
|
||||
# ── 라이브러리 API ────────────────────────────────────────────────────────────
|
||||
|
||||
class TrackCreate(BaseModel):
|
||||
title: str = ""
|
||||
genre: str = ""
|
||||
moods: List[str] = []
|
||||
instruments: List[str] = []
|
||||
duration_sec: Optional[int] = None
|
||||
bpm: Optional[int] = None
|
||||
key: str = ""
|
||||
scale: str = ""
|
||||
prompt: str = ""
|
||||
audio_url: str = ""
|
||||
file_path: str = ""
|
||||
task_id: Optional[str] = None
|
||||
tags: List[str] = []
|
||||
provider: str = "local"
|
||||
lyrics: str = ""
|
||||
image_url: str = ""
|
||||
suno_id: str = ""
|
||||
|
||||
|
||||
@app.get("/api/music/library")
|
||||
def list_library():
|
||||
"""저장된 트랙 목록 전체 조회 (생성일 내림차순). 파일시스템과 자동 동기화."""
|
||||
_sync_library_with_disk()
|
||||
return {"tracks": get_all_tracks()}
|
||||
|
||||
|
||||
def _calc_file_hash(file_path: str) -> str:
|
||||
"""MD5 해시 계산 (파일 동일성 체크용)."""
|
||||
import hashlib
|
||||
h = hashlib.md5()
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(8192), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _sync_library_with_disk():
|
||||
"""파일시스템의 .mp3 파일과 DB를 동기화 (해시 기반 rename 감지).
|
||||
|
||||
1단계: 파일명 매칭 (빠른 경로)
|
||||
2단계: 미매칭 파일/레코드를 해시로 비교 → rename 감지 → 메타데이터 보존 업데이트
|
||||
3단계: 나머지 → 삭제/추가
|
||||
"""
|
||||
tracks = get_all_tracks()
|
||||
media_base = os.getenv("MUSIC_MEDIA_BASE", "/media/music")
|
||||
|
||||
# 디스크의 .mp3 파일 목록
|
||||
disk_files = set()
|
||||
try:
|
||||
for f in os.listdir(MUSIC_DATA_DIR):
|
||||
if f.lower().endswith(".mp3"):
|
||||
disk_files.add(f)
|
||||
except OSError:
|
||||
return
|
||||
|
||||
# ── 1단계: 파일명 매칭 ──────────────────────────────────────
|
||||
db_by_filename = {} # filename → track
|
||||
for t in tracks:
|
||||
if t.get("audio_url"):
|
||||
fname = t["audio_url"].split("/")[-1]
|
||||
db_by_filename[fname] = t
|
||||
|
||||
matched_disk = set()
|
||||
matched_db_ids = set()
|
||||
|
||||
for f in disk_files:
|
||||
if f in db_by_filename:
|
||||
matched_disk.add(f)
|
||||
track = db_by_filename[f]
|
||||
matched_db_ids.add(track["id"])
|
||||
# 기존 트랙에 file_hash 없으면 채우기
|
||||
if not track.get("file_hash"):
|
||||
file_hash = _calc_file_hash(os.path.join(MUSIC_DATA_DIR, f))
|
||||
if file_hash:
|
||||
update_track_hash(track["id"], file_hash)
|
||||
|
||||
unmatched_disk = disk_files - matched_disk
|
||||
unmatched_db = [t for t in tracks if t["id"] not in matched_db_ids]
|
||||
|
||||
# ── 2단계: 해시 기반 rename 감지 ────────────────────────────
|
||||
if unmatched_disk and unmatched_db:
|
||||
# DB 미매칭 레코드의 해시 맵
|
||||
db_hash_map = {} # hash → track
|
||||
for t in unmatched_db:
|
||||
h = t.get("file_hash", "")
|
||||
if h:
|
||||
db_hash_map[h] = t
|
||||
|
||||
resolved_disk = set()
|
||||
resolved_db_ids = set()
|
||||
|
||||
for f in unmatched_disk:
|
||||
file_path = os.path.join(MUSIC_DATA_DIR, f)
|
||||
file_hash = _calc_file_hash(file_path)
|
||||
if not file_hash:
|
||||
continue
|
||||
|
||||
if file_hash in db_hash_map:
|
||||
# rename 감지 — 기존 레코드 업데이트 (태그·메타데이터 보존)
|
||||
track = db_hash_map[file_hash]
|
||||
new_title = os.path.splitext(f)[0].replace("-", " ").replace("_", " ")
|
||||
update_track_file_info(
|
||||
track["id"],
|
||||
title=new_title,
|
||||
audio_url=f"{media_base}/{f}",
|
||||
file_path=file_path,
|
||||
)
|
||||
resolved_disk.add(f)
|
||||
resolved_db_ids.add(track["id"])
|
||||
|
||||
unmatched_disk -= resolved_disk
|
||||
unmatched_db = [t for t in unmatched_db if t["id"] not in resolved_db_ids]
|
||||
|
||||
# ── 3단계: 나머지 처리 ──────────────────────────────────────
|
||||
# DB에만 남은 레코드 → 파일 삭제됨 → DB 삭제
|
||||
for t in unmatched_db:
|
||||
delete_track(t["id"])
|
||||
|
||||
# 디스크에만 남은 파일 → 신규 → DB 추가 (해시 포함)
|
||||
for f in unmatched_disk:
|
||||
file_path = os.path.join(MUSIC_DATA_DIR, f)
|
||||
title = os.path.splitext(f)[0].replace("-", " ").replace("_", " ")
|
||||
file_hash = _calc_file_hash(file_path)
|
||||
add_track({
|
||||
"title": title,
|
||||
"audio_url": f"{media_base}/{f}",
|
||||
"file_path": file_path,
|
||||
"provider": "suno",
|
||||
"duration_sec": _get_mp3_duration(file_path),
|
||||
"file_hash": file_hash,
|
||||
})
|
||||
|
||||
|
||||
@app.post("/api/music/library", status_code=201)
|
||||
def save_to_library(req: TrackCreate):
|
||||
"""트랙 수동 추가 (외부 파일 등록 또는 프론트 직접 저장용)"""
|
||||
track = add_track(req.model_dump())
|
||||
return track
|
||||
|
||||
|
||||
@app.delete("/api/music/library/{track_id}")
|
||||
def remove_from_library(track_id: int):
|
||||
"""라이브러리에서 트랙 삭제. 로컬 파일도 함께 삭제."""
|
||||
file_path = get_track_file_path(track_id)
|
||||
ok = delete_track(track_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Track not found")
|
||||
|
||||
if file_path and os.path.isfile(file_path):
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── 모델 목록 API ────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/music/models")
|
||||
def get_models():
|
||||
"""사용 가능한 Suno AI 모델 목록."""
|
||||
return {"models": SUNO_MODELS}
|
||||
|
||||
|
||||
# ── 크레딧 조회 API ──────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/music/credits")
|
||||
def check_credits():
|
||||
"""Suno 잔여 크레딧 조회."""
|
||||
if not SUNO_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
|
||||
result = get_credits()
|
||||
if result is None:
|
||||
raise HTTPException(status_code=502, detail="크레딧 조회 실패")
|
||||
return result
|
||||
|
||||
|
||||
# ── 곡 연장 API ──────────────────────────────────────────────────────────────
|
||||
|
||||
class ExtendRequest(BaseModel):
|
||||
suno_id: str # 원본 Suno 곡 ID
|
||||
continue_at: int = 0 # 연장 시작 지점 (초)
|
||||
prompt: str = "" # 추가 가사/프롬프트
|
||||
style: str = "" # 스타일 오버라이드
|
||||
title: str = ""
|
||||
model: str = "V4"
|
||||
|
||||
|
||||
@app.post("/api/music/extend")
|
||||
def extend_music(req: ExtendRequest, background_tasks: BackgroundTasks):
|
||||
"""기존 곡을 특정 지점부터 연장 (Suno Extend API)."""
|
||||
if not SUNO_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
params = req.model_dump()
|
||||
create_task(task_id, params, provider="suno")
|
||||
background_tasks.add_task(run_suno_extend, task_id, params)
|
||||
return {"task_id": task_id, "provider": "suno"}
|
||||
|
||||
|
||||
# ── 보컬 분리 API ────────────────────────────────────────────────────────────
|
||||
|
||||
class VocalRemovalRequest(BaseModel):
|
||||
suno_id: str # Suno 곡 ID
|
||||
title: str = "" # 원본 트랙 제목
|
||||
|
||||
|
||||
@app.post("/api/music/vocal-removal")
|
||||
def vocal_removal(req: VocalRemovalRequest, background_tasks: BackgroundTasks):
|
||||
"""트랙에서 보컬과 인스트루멘탈을 분리 (Suno Vocal Removal API)."""
|
||||
if not SUNO_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
params = req.model_dump()
|
||||
create_task(task_id, params, provider="suno")
|
||||
background_tasks.add_task(run_vocal_removal, task_id, params)
|
||||
return {"task_id": task_id, "provider": "suno"}
|
||||
|
||||
|
||||
# ── 커버 이미지 생성 API ────────────────────────────────────────────────────
|
||||
|
||||
class CoverImageRequest(BaseModel):
|
||||
suno_task_id: str # Suno 생성 task ID
|
||||
track_id: Optional[int] = None # 라이브러리 트랙 ID (결과 저장용)
|
||||
|
||||
|
||||
@app.post("/api/music/cover-image")
|
||||
def cover_image(req: CoverImageRequest, background_tasks: BackgroundTasks):
|
||||
"""Suno 곡의 커버 이미지 2장 생성."""
|
||||
if not SUNO_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
params = req.model_dump()
|
||||
create_task(task_id, params, provider="suno")
|
||||
background_tasks.add_task(run_cover_image, task_id, params)
|
||||
return {"task_id": task_id, "provider": "suno"}
|
||||
|
||||
|
||||
# ── WAV 변환 API ────────────────────────────────────────────────────────────
|
||||
|
||||
class WavRequest(BaseModel):
|
||||
suno_task_id: str
|
||||
suno_id: str
|
||||
track_id: Optional[int] = None
|
||||
|
||||
|
||||
@app.post("/api/music/wav")
|
||||
def wav_convert(req: WavRequest, background_tasks: BackgroundTasks):
|
||||
"""곡을 WAV 포맷으로 변환."""
|
||||
if not SUNO_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
|
||||
task_id = str(uuid.uuid4())
|
||||
params = req.model_dump()
|
||||
create_task(task_id, params, provider="suno")
|
||||
background_tasks.add_task(run_wav_convert, task_id, params)
|
||||
return {"task_id": task_id, "provider": "suno"}
|
||||
|
||||
|
||||
# ── 12스템 분리 API ─────────────────────────────────────────────────────────
|
||||
|
||||
class StemSplitRequest(BaseModel):
|
||||
suno_task_id: str
|
||||
suno_id: str
|
||||
track_id: Optional[int] = None
|
||||
|
||||
|
||||
@app.post("/api/music/stem-split")
|
||||
def stem_split(req: StemSplitRequest, background_tasks: BackgroundTasks):
|
||||
"""곡을 12개 스템으로 분리 (50 크레딧). 보컬, 드럼, 베이스, 기타 등."""
|
||||
if not SUNO_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
|
||||
task_id = str(uuid.uuid4())
|
||||
params = req.model_dump()
|
||||
create_task(task_id, params, provider="suno")
|
||||
background_tasks.add_task(run_stem_split, task_id, params)
|
||||
return {"task_id": task_id, "provider": "suno"}
|
||||
|
||||
|
||||
# ── 타임스탬프 가사 API ─────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/music/timestamped-lyrics")
|
||||
def timestamped_lyrics(task_id: str, suno_id: str):
|
||||
"""타임스탬프 가사 조회 (가라오케 스타일 싱크용)."""
|
||||
if not SUNO_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
|
||||
result = get_timestamped_lyrics(task_id, suno_id)
|
||||
if not result:
|
||||
raise HTTPException(status_code=502, detail="타임스탬프 가사 조회 실패")
|
||||
return result
|
||||
|
||||
|
||||
# ── 스타일 부스트 API ───────────────────────────────────────────────────────
|
||||
|
||||
class StyleBoostRequest(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
@app.post("/api/music/style-boost")
|
||||
def style_boost(req: StyleBoostRequest):
|
||||
"""AI로 최적 스타일 프롬프트 생성."""
|
||||
if not SUNO_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
|
||||
result = generate_style_boost(req.content)
|
||||
if not result:
|
||||
raise HTTPException(status_code=502, detail="스타일 부스트 생성 실패")
|
||||
return result
|
||||
|
||||
|
||||
# ── Phase 3: 업로드 + 커버 ──────────────────────────────────────────────────
|
||||
|
||||
class UploadCoverRequest(BaseModel):
|
||||
upload_url: str
|
||||
model: str = "V4"
|
||||
custom_mode: bool = True
|
||||
instrumental: bool = False
|
||||
prompt: str = ""
|
||||
style: str = ""
|
||||
title: str = ""
|
||||
vocal_gender: Optional[str] = None
|
||||
negative_tags: Optional[str] = None
|
||||
style_weight: Optional[float] = None
|
||||
audio_weight: Optional[float] = None
|
||||
|
||||
|
||||
@app.post("/api/music/upload-cover")
|
||||
def upload_cover(req: UploadCoverRequest, background_tasks: BackgroundTasks):
|
||||
"""외부 오디오를 Suno 스타일로 리메이크."""
|
||||
if not SUNO_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
|
||||
task_id = str(uuid.uuid4())
|
||||
params = req.model_dump()
|
||||
create_task(task_id, params, provider="suno")
|
||||
background_tasks.add_task(run_upload_cover, task_id, params)
|
||||
return {"task_id": task_id, "provider": "suno"}
|
||||
|
||||
|
||||
# ── Phase 3: 업로드 + 확장 ──────────────────────────────────────────────────
|
||||
|
||||
class UploadExtendRequest(BaseModel):
|
||||
upload_url: str
|
||||
model: str = "V4"
|
||||
default_param_flag: bool = True
|
||||
continue_at: Optional[float] = None
|
||||
prompt: str = ""
|
||||
style: str = ""
|
||||
title: str = ""
|
||||
instrumental: bool = False
|
||||
vocal_gender: Optional[str] = None
|
||||
negative_tags: Optional[str] = None
|
||||
|
||||
|
||||
@app.post("/api/music/upload-extend")
|
||||
def upload_extend(req: UploadExtendRequest, background_tasks: BackgroundTasks):
|
||||
"""외부 오디오를 이어서 확장."""
|
||||
if not SUNO_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
|
||||
task_id = str(uuid.uuid4())
|
||||
params = req.model_dump()
|
||||
create_task(task_id, params, provider="suno")
|
||||
background_tasks.add_task(run_upload_extend, task_id, params)
|
||||
return {"task_id": task_id, "provider": "suno"}
|
||||
|
||||
|
||||
# ── Phase 3: 보컬 추가 ──────────────────────────────────────────────────────
|
||||
|
||||
class AddVocalsRequest(BaseModel):
|
||||
upload_url: str
|
||||
prompt: str
|
||||
title: str
|
||||
style: str
|
||||
negative_tags: str = ""
|
||||
vocal_gender: Optional[str] = None
|
||||
model: str = "V4_5PLUS"
|
||||
style_weight: Optional[float] = None
|
||||
audio_weight: Optional[float] = None
|
||||
|
||||
|
||||
@app.post("/api/music/add-vocals")
|
||||
def add_vocals(req: AddVocalsRequest, background_tasks: BackgroundTasks):
|
||||
"""인스트루멘탈에 AI 보컬 추가."""
|
||||
if not SUNO_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
|
||||
task_id = str(uuid.uuid4())
|
||||
params = req.model_dump()
|
||||
create_task(task_id, params, provider="suno")
|
||||
background_tasks.add_task(run_add_vocals, task_id, params)
|
||||
return {"task_id": task_id, "provider": "suno"}
|
||||
|
||||
|
||||
# ── Phase 3: 인스트루멘탈 추가 ──────────────────────────────────────────────
|
||||
|
||||
class AddInstrumentalRequest(BaseModel):
|
||||
upload_url: str
|
||||
title: str
|
||||
tags: str
|
||||
negative_tags: str = ""
|
||||
vocal_gender: Optional[str] = None
|
||||
model: str = "V4_5PLUS"
|
||||
style_weight: Optional[float] = None
|
||||
audio_weight: Optional[float] = None
|
||||
|
||||
|
||||
@app.post("/api/music/add-instrumental")
|
||||
def add_instrumental(req: AddInstrumentalRequest, background_tasks: BackgroundTasks):
|
||||
"""보컬에 AI 반주 추가."""
|
||||
if not SUNO_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
|
||||
task_id = str(uuid.uuid4())
|
||||
params = req.model_dump()
|
||||
create_task(task_id, params, provider="suno")
|
||||
background_tasks.add_task(run_add_instrumental, task_id, params)
|
||||
return {"task_id": task_id, "provider": "suno"}
|
||||
|
||||
|
||||
# ── Phase 3: 뮤직비디오 생성 ────────────────────────────────────────────────
|
||||
|
||||
class VideoRequest(BaseModel):
|
||||
suno_task_id: str
|
||||
suno_id: str
|
||||
author: str = ""
|
||||
domain_name: str = ""
|
||||
track_id: Optional[int] = None
|
||||
|
||||
|
||||
@app.post("/api/music/video")
|
||||
def video_generate(req: VideoRequest, background_tasks: BackgroundTasks):
|
||||
"""뮤직비디오(MP4) 생성."""
|
||||
if not SUNO_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
|
||||
task_id = str(uuid.uuid4())
|
||||
params = req.model_dump()
|
||||
create_task(task_id, params, provider="suno")
|
||||
background_tasks.add_task(run_video_generate, task_id, params)
|
||||
return {"task_id": task_id, "provider": "suno"}
|
||||
|
||||
|
||||
# ── 저장된 가사 CRUD API ────────────────────────────────────────────────────
|
||||
|
||||
class LyricsSave(BaseModel):
|
||||
title: str = ""
|
||||
text: str = ""
|
||||
prompt: str = ""
|
||||
|
||||
|
||||
class LyricsUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
text: Optional[str] = None
|
||||
prompt: Optional[str] = None
|
||||
|
||||
|
||||
@app.get("/api/music/lyrics/library")
|
||||
def list_saved_lyrics():
|
||||
"""저장된 가사 목록 전체 조회 (생성일 내림차순)."""
|
||||
return {"lyrics": get_all_lyrics()}
|
||||
|
||||
|
||||
@app.post("/api/music/lyrics/library", status_code=201)
|
||||
def save_lyrics(req: LyricsSave):
|
||||
"""가사 저장."""
|
||||
return add_lyrics(req.model_dump())
|
||||
|
||||
|
||||
@app.put("/api/music/lyrics/library/{lyrics_id}")
|
||||
def edit_lyrics(lyrics_id: int, req: LyricsUpdate):
|
||||
"""가사 수정."""
|
||||
data = {k: v for k, v in req.model_dump().items() if v is not None}
|
||||
result = update_lyrics(lyrics_id, data)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Lyrics not found")
|
||||
return result
|
||||
|
||||
|
||||
@app.delete("/api/music/lyrics/library/{lyrics_id}")
|
||||
def remove_lyrics(lyrics_id: int):
|
||||
"""가사 삭제."""
|
||||
if not delete_lyrics(lyrics_id):
|
||||
raise HTTPException(status_code=404, detail="Lyrics not found")
|
||||
return {"ok": True}
|
||||
1050
music-lab/app/suno_provider.py
Normal file
1050
music-lab/app/suno_provider.py
Normal file
File diff suppressed because it is too large
Load Diff
5
music-lab/requirements.txt
Normal file
5
music-lab/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.30.6
|
||||
requests==2.32.3
|
||||
python-multipart==0.0.12
|
||||
mutagen==1.47.0
|
||||
@@ -2,6 +2,11 @@ server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
@@ -17,6 +22,41 @@ server {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# music media — Nginx가 직접 오디오 파일 서빙
|
||||
location ^~ /media/music/ {
|
||||
alias /data/music/;
|
||||
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000" always;
|
||||
add_header Accept-Ranges bytes always; # 오디오 스트리밍 범위 요청 지원
|
||||
|
||||
autoindex off;
|
||||
}
|
||||
|
||||
# music API — 변수 기반 proxy_pass + $request_uri로 전체 경로 전달
|
||||
location /api/music/ {
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
set $music_backend music-lab:8000;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 660s;
|
||||
proxy_pass http://$music_backend$request_uri;
|
||||
}
|
||||
|
||||
# realestate API
|
||||
location /api/realestate/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://realestate-lab:8000/api/realestate/;
|
||||
}
|
||||
|
||||
# travel thumbnails (generated by travel-proxy, stored in /data/thumbs)
|
||||
location ^~ /media/travel/.thumb/ {
|
||||
alias /data/thumbs/;
|
||||
@@ -54,6 +94,67 @@ server {
|
||||
proxy_pass http://travel-proxy:8000/api/travel/;
|
||||
}
|
||||
|
||||
# stock API
|
||||
location /api/stock/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://stock-lab:8000/api/stock/;
|
||||
}
|
||||
|
||||
# trade API (Stock Lab Proxy)
|
||||
location /api/trade/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://stock-lab:8000/api/trade/;
|
||||
}
|
||||
|
||||
# blog-marketing API
|
||||
location /api/blog-marketing/ {
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
set $blog_backend blog-lab:8000;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_pass http://$blog_backend$request_uri;
|
||||
}
|
||||
|
||||
# portfolio API (Stock Lab) — trailing slash 유무 모두 매칭
|
||||
location /api/portfolio {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://stock-lab:8000/api/portfolio;
|
||||
}
|
||||
|
||||
|
||||
# agent-office API + WebSocket
|
||||
location /api/agent-office/ {
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
set $agent_office_backend agent-office:8000;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_pass http://$agent_office_backend$request_uri;
|
||||
}
|
||||
|
||||
# API 프록시 (여기가 포인트: /api/ 중복 제거)
|
||||
location /api/ {
|
||||
proxy_http_version 1.1;
|
||||
@@ -66,6 +167,62 @@ server {
|
||||
proxy_pass http://backend:8000;
|
||||
}
|
||||
|
||||
# Fear & Greed Index (CNN 공개 API)
|
||||
# 프로덕션 nginx에서는 아래 proxy_pass 추가 필요:
|
||||
location /ext/feargreed {
|
||||
proxy_pass https://production.dataviz.cnn.io/index/fearandgreed/graphdata;
|
||||
proxy_set_header Host production.dataviz.cnn.io;
|
||||
}
|
||||
|
||||
# VIX (CBOE 변동성 지수) — Yahoo Finance 공개 API
|
||||
# 프로덕션 nginx에서는 아래 proxy_pass 추가 필요:
|
||||
location /ext/vix {
|
||||
proxy_pass https://query1.finance.yahoo.com/v8/finance/chart/%5EVIX?interval=1d&range=1d;
|
||||
proxy_set_header Host query1.finance.yahoo.com;
|
||||
}
|
||||
|
||||
# 미국 10년물 국채 금리 (^TNX) — Yahoo Finance
|
||||
# 프로덕션 nginx 설정 필요:
|
||||
location /ext/treasury {
|
||||
proxy_pass https://query1.finance.yahoo.com/v8/finance/chart/%5ETNX?interval=1d&range=1d;
|
||||
proxy_set_header Host query1.finance.yahoo.com;
|
||||
}
|
||||
|
||||
# WTI 원유 선물 (CL=F) — Yahoo Finance
|
||||
# 프로덕션 nginx 설정 필요:
|
||||
location /ext/wti {
|
||||
proxy_pass https://query1.finance.yahoo.com/v8/finance/chart/CL%3DF?interval=1d&range=1d;
|
||||
proxy_set_header Host query1.finance.yahoo.com;
|
||||
}
|
||||
|
||||
# Brent 원유 선물 (BZ=F) — Yahoo Finance
|
||||
# 프로덕션 nginx 설정 필요:
|
||||
location /ext/brent {
|
||||
proxy_pass https://query1.finance.yahoo.com/v8/finance/chart/BZ%3DF?interval=1d&range=1d;
|
||||
proxy_set_header Host query1.finance.yahoo.com;
|
||||
}
|
||||
|
||||
# webhook receiver (handle both /webhook and /webhook/)
|
||||
location = /webhook {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_pass http://deployer:9000/webhook;
|
||||
}
|
||||
|
||||
location /webhook/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_pass http://deployer:9000/webhook;
|
||||
}
|
||||
|
||||
# SPA 라우팅 (마지막에 두는 게 안전)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
10
realestate-lab/Dockerfile
Normal file
10
realestate-lab/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM python:3.12-alpine
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
0
realestate-lab/app/__init__.py
Normal file
0
realestate-lab/app/__init__.py
Normal file
172
realestate-lab/app/collector.py
Normal file
172
realestate-lab/app/collector.py
Normal file
@@ -0,0 +1,172 @@
|
||||
import os
|
||||
import logging
|
||||
import requests
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from .db import upsert_announcement, upsert_model, save_collect_log
|
||||
|
||||
logger = logging.getLogger("realestate-lab")
|
||||
|
||||
API_BASE = "https://api.odcloud.kr/api/ApplyhomeInfoDetailSvc/v1"
|
||||
API_KEY = os.getenv("DATA_GO_KR_API_KEY", "")
|
||||
|
||||
# 5 detail+model endpoint pairs
|
||||
DETAIL_ENDPOINTS = [
|
||||
("getAPTLttotPblancDetail", "getAPTLttotPblancMdl"),
|
||||
("getUrbtyOfctlLttotPblancDetail", "getUrbtyOfctlLttotPblancMdl"),
|
||||
("getRemndrLttotPblancDetail", "getRemndrLttotPblancMdl"),
|
||||
("getPblPvtRentLttotPblancDetail", "getPblPvtRentLttotPblancMdl"),
|
||||
("getOPTLttotPblancDetail", "getOPTLttotPblancMdl"),
|
||||
]
|
||||
|
||||
|
||||
def _api_call(endpoint: str, params: Dict[str, Any] = None) -> List[Dict]:
|
||||
"""페이지네이션 처리하여 API 전체 데이터를 반환한다."""
|
||||
if not API_KEY:
|
||||
logger.warning("DATA_GO_KR_API_KEY 미설정 — API 호출 건너뜀")
|
||||
return []
|
||||
|
||||
base_params = {
|
||||
"serviceKey": API_KEY,
|
||||
"perPage": 100,
|
||||
"returnType": "JSON",
|
||||
}
|
||||
if params:
|
||||
base_params.update(params)
|
||||
|
||||
url = f"{API_BASE}/{endpoint}"
|
||||
all_data: List[Dict] = []
|
||||
page = 1
|
||||
|
||||
while True:
|
||||
base_params["page"] = page
|
||||
try:
|
||||
resp = requests.get(url, params=base_params, timeout=30)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
except requests.RequestException as e:
|
||||
logger.error("API 호출 실패 [%s page=%d]: %s", endpoint, page, e)
|
||||
break
|
||||
except ValueError as e:
|
||||
logger.error("JSON 파싱 실패 [%s page=%d]: %s", endpoint, page, e)
|
||||
break
|
||||
|
||||
data = body.get("data", [])
|
||||
total_count = body.get("totalCount", 0)
|
||||
all_data.extend(data)
|
||||
|
||||
if len(all_data) >= total_count:
|
||||
break
|
||||
page += 1
|
||||
|
||||
logger.info("[%s] %d건 수집", endpoint, len(all_data))
|
||||
return all_data
|
||||
|
||||
|
||||
def _parse_apt_detail(raw: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""API 응답 필드를 DB 스키마에 맞게 매핑한다."""
|
||||
return {
|
||||
"house_manage_no": raw.get("HOUSE_MANAGE_NO", ""),
|
||||
"pblanc_no": raw.get("PBLANC_NO", ""),
|
||||
"house_nm": raw.get("HOUSE_NM"),
|
||||
"house_secd": raw.get("HOUSE_SECD"),
|
||||
"house_dtl_secd": raw.get("HOUSE_DTL_SECD"),
|
||||
"rent_secd": raw.get("RENT_SECD"),
|
||||
"region_code": raw.get("SUBSCRPT_AREA_CODE"),
|
||||
"region_name": raw.get("SUBSCRPT_AREA_CODE_NM"),
|
||||
"address": raw.get("HSSPLY_ADRES"),
|
||||
"total_units": raw.get("TOT_SUPLY_HSHLDCO"),
|
||||
"rcrit_date": raw.get("RCRIT_PBLANC_DE"),
|
||||
"receipt_start": raw.get("RCEPT_BGNDE") or raw.get("SUBSCRPT_RCEPT_BGNDE"),
|
||||
"receipt_end": raw.get("RCEPT_ENDDE") or raw.get("SUBSCRPT_RCEPT_ENDDE"),
|
||||
"spsply_start": raw.get("SPSPLY_RCEPT_BGNDE"),
|
||||
"spsply_end": raw.get("SPSPLY_RCEPT_ENDDE"),
|
||||
"gnrl_rank1_start": raw.get("GNRL_RNK1_CRSPAREA_RCPTDE") or raw.get("GNRL_RCEPT_BGNDE"),
|
||||
"gnrl_rank1_end": raw.get("GNRL_RNK1_CRSPAREA_ENDDE") or raw.get("GNRL_RCEPT_ENDDE"),
|
||||
"winner_date": raw.get("PRZWNER_PRESNATN_DE"),
|
||||
"contract_start": raw.get("CNTRCT_CNCLS_BGNDE"),
|
||||
"contract_end": raw.get("CNTRCT_CNCLS_ENDDE"),
|
||||
"homepage_url": raw.get("HMPG_ADRES"),
|
||||
"pblanc_url": raw.get("PBLANC_URL"),
|
||||
"constructor": raw.get("CNSTRCT_ENTRPS_NM"),
|
||||
"developer": raw.get("BSNS_MBY_NM"),
|
||||
"move_in_month": raw.get("MVN_PREARNGE_YM"),
|
||||
"is_speculative_area": raw.get("SPECLT_RDN_EARTH_AT"),
|
||||
"is_price_cap": raw.get("PARCPRC_ULS_AT"),
|
||||
"contact": raw.get("MDHS_TELNO"),
|
||||
"source": "auto",
|
||||
}
|
||||
|
||||
|
||||
def _parse_top_amount(val: Any) -> int | None:
|
||||
"""최고 금액 문자열에서 콤마를 제거하고 정수로 변환한다."""
|
||||
if val is None:
|
||||
return None
|
||||
try:
|
||||
return int(str(val).replace(",", ""))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_model(raw: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""모델 API 응답 필드를 DB 스키마에 맞게 매핑한다."""
|
||||
return {
|
||||
"house_manage_no": raw.get("HOUSE_MANAGE_NO", ""),
|
||||
"pblanc_no": raw.get("PBLANC_NO", ""),
|
||||
"model_no": raw.get("MODEL_NO"),
|
||||
"house_ty": raw.get("HOUSE_TY"),
|
||||
"supply_area": float(raw["SUPLY_AR"]) if raw.get("SUPLY_AR") is not None else None,
|
||||
"general_units": raw.get("SUPLY_HSHLDCO") or 0,
|
||||
"special_units": raw.get("SPSPLY_HSHLDCO") or 0,
|
||||
"multi_child_units": raw.get("MNYCH_HSHLDCO") or 0,
|
||||
"newlywed_units": raw.get("NWWDS_HSHLDCO") or 0,
|
||||
"first_life_units": raw.get("LFE_FRST_HSHLDCO") or 0,
|
||||
"old_parent_units": raw.get("OLD_PARNTS_SUPORT_HSHLDCO") or 0,
|
||||
"institution_units": raw.get("INSTT_RECOMEND_HSHLDCO") or 0,
|
||||
"youth_units": raw.get("YGMN_HSHLDCO") or 0,
|
||||
"newborn_units": raw.get("NWBB_HSHLDCO") or 0,
|
||||
"top_amount": _parse_top_amount(raw.get("LTTOT_TOP_AMOUNT")),
|
||||
}
|
||||
|
||||
|
||||
def collect_all() -> Dict[str, Any]:
|
||||
"""모든 엔드포인트를 순회하며 공고 + 모델 데이터를 수집·저장한다."""
|
||||
if not API_KEY:
|
||||
logger.warning("API 키 미설정 — 수집 중단")
|
||||
save_collect_log(0, 0, "API 키 미설정")
|
||||
return {"new_count": 0, "total_count": 0}
|
||||
|
||||
total_count = 0
|
||||
new_count = 0
|
||||
|
||||
for detail_ep, model_ep in DETAIL_ENDPOINTS:
|
||||
# 공고 상세 수집
|
||||
detail_rows = _api_call(detail_ep)
|
||||
for raw in detail_rows:
|
||||
try:
|
||||
parsed = _parse_apt_detail(raw)
|
||||
# 일정 정보가 하나도 없는 공고는 건너뜀
|
||||
has_dates = any(parsed.get(f) for f in (
|
||||
"receipt_start", "receipt_end", "spsply_start",
|
||||
"gnrl_rank1_start", "winner_date", "contract_start",
|
||||
))
|
||||
if not has_dates:
|
||||
continue
|
||||
_, is_new = upsert_announcement(parsed)
|
||||
total_count += 1
|
||||
if is_new:
|
||||
new_count += 1
|
||||
except Exception as e:
|
||||
logger.error("공고 upsert 실패 [%s]: %s", detail_ep, e)
|
||||
|
||||
# 모델(평형) 수집
|
||||
model_rows = _api_call(model_ep)
|
||||
for raw in model_rows:
|
||||
try:
|
||||
parsed = _parse_model(raw)
|
||||
upsert_model(parsed)
|
||||
except Exception as e:
|
||||
logger.error("모델 upsert 실패 [%s]: %s", model_ep, e)
|
||||
save_collect_log(new_count, total_count)
|
||||
logger.info("수집 완료: new=%d, total=%d", new_count, total_count)
|
||||
return {"new_count": new_count, "total_count": total_count}
|
||||
735
realestate-lab/app/db.py
Normal file
735
realestate-lab/app/db.py
Normal file
@@ -0,0 +1,735 @@
|
||||
# realestate-lab/app/db.py
|
||||
import json
|
||||
import sqlite3
|
||||
import logging
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import date
|
||||
|
||||
logger = logging.getLogger("realestate-lab")
|
||||
|
||||
DB_PATH = "/app/data/realestate.db"
|
||||
|
||||
|
||||
def _conn():
|
||||
c = sqlite3.connect(DB_PATH, timeout=10)
|
||||
c.row_factory = sqlite3.Row
|
||||
c.execute("PRAGMA journal_mode=WAL;")
|
||||
c.execute("PRAGMA foreign_keys=ON;")
|
||||
return c
|
||||
|
||||
|
||||
def init_db():
|
||||
with _conn() as conn:
|
||||
# ── announcements ────────────────────────────────────────────────
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS announcements (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
house_manage_no TEXT NOT NULL,
|
||||
pblanc_no TEXT NOT NULL,
|
||||
house_nm TEXT,
|
||||
house_secd TEXT,
|
||||
house_dtl_secd TEXT,
|
||||
rent_secd TEXT,
|
||||
region_code TEXT,
|
||||
region_name TEXT,
|
||||
address TEXT,
|
||||
total_units INTEGER,
|
||||
rcrit_date TEXT,
|
||||
receipt_start TEXT,
|
||||
receipt_end TEXT,
|
||||
spsply_start TEXT,
|
||||
spsply_end TEXT,
|
||||
gnrl_rank1_start TEXT,
|
||||
gnrl_rank1_end TEXT,
|
||||
winner_date TEXT,
|
||||
contract_start TEXT,
|
||||
contract_end TEXT,
|
||||
homepage_url TEXT,
|
||||
pblanc_url TEXT,
|
||||
constructor TEXT,
|
||||
developer TEXT,
|
||||
move_in_month TEXT,
|
||||
is_speculative_area TEXT,
|
||||
is_price_cap TEXT,
|
||||
contact TEXT,
|
||||
status TEXT NOT NULL DEFAULT '청약예정',
|
||||
is_bookmarked INTEGER NOT NULL DEFAULT 0,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
UNIQUE(house_manage_no, pblanc_no)
|
||||
);
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_ann_status ON announcements(status);")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_ann_region ON announcements(region_name);")
|
||||
|
||||
# ── 마이그레이션: is_bookmarked 컬럼 추가 ──
|
||||
try:
|
||||
conn.execute("SELECT is_bookmarked FROM announcements LIMIT 1")
|
||||
except Exception:
|
||||
conn.execute("ALTER TABLE announcements ADD COLUMN is_bookmarked INTEGER NOT NULL DEFAULT 0")
|
||||
|
||||
# ── announcement_models ──────────────────────────────────────────
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS announcement_models (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
house_manage_no TEXT NOT NULL,
|
||||
pblanc_no TEXT NOT NULL,
|
||||
model_no TEXT,
|
||||
house_ty TEXT,
|
||||
supply_area REAL,
|
||||
general_units INTEGER DEFAULT 0,
|
||||
special_units INTEGER DEFAULT 0,
|
||||
multi_child_units INTEGER DEFAULT 0,
|
||||
newlywed_units INTEGER DEFAULT 0,
|
||||
first_life_units INTEGER DEFAULT 0,
|
||||
old_parent_units INTEGER DEFAULT 0,
|
||||
institution_units INTEGER DEFAULT 0,
|
||||
youth_units INTEGER DEFAULT 0,
|
||||
newborn_units INTEGER DEFAULT 0,
|
||||
top_amount INTEGER,
|
||||
UNIQUE(house_manage_no, pblanc_no, model_no)
|
||||
);
|
||||
""")
|
||||
|
||||
# ── user_profile ─────────────────────────────────────────────────
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_profile (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||
name TEXT,
|
||||
age INTEGER,
|
||||
is_homeless INTEGER,
|
||||
is_householder INTEGER,
|
||||
subscription_months INTEGER,
|
||||
subscription_amount INTEGER,
|
||||
family_members INTEGER,
|
||||
has_dependents INTEGER,
|
||||
children_count INTEGER DEFAULT 0,
|
||||
is_newlywed INTEGER,
|
||||
marriage_months INTEGER,
|
||||
has_newborn INTEGER,
|
||||
is_first_home INTEGER,
|
||||
income_level TEXT,
|
||||
preferred_regions TEXT NOT NULL DEFAULT '[]',
|
||||
preferred_types TEXT NOT NULL DEFAULT '[]',
|
||||
min_area REAL,
|
||||
max_area REAL,
|
||||
max_price INTEGER,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
""")
|
||||
|
||||
# ── match_results ────────────────────────────────────────────────
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS match_results (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
announcement_id INTEGER NOT NULL REFERENCES announcements(id) ON DELETE CASCADE,
|
||||
model_id INTEGER,
|
||||
match_score INTEGER NOT NULL DEFAULT 0,
|
||||
match_reasons TEXT NOT NULL DEFAULT '[]',
|
||||
eligible_types TEXT NOT NULL DEFAULT '[]',
|
||||
is_new INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
UNIQUE(announcement_id, model_id)
|
||||
);
|
||||
""")
|
||||
|
||||
# ── collect_log ──────────────────────────────────────────────────
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS collect_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collected_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
new_count INTEGER NOT NULL DEFAULT 0,
|
||||
total_count INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT
|
||||
);
|
||||
""")
|
||||
|
||||
|
||||
# ── 상태 자동 계산 ───────────────────────────────────────────────────────────
|
||||
|
||||
def compute_status(receipt_start: str, receipt_end: str, winner_date: str) -> str:
|
||||
today = date.today().isoformat()
|
||||
if receipt_start and today < receipt_start:
|
||||
return "청약예정"
|
||||
if receipt_start and receipt_end and receipt_start <= today <= receipt_end:
|
||||
return "청약중"
|
||||
if receipt_end and winner_date and receipt_end < today <= winner_date:
|
||||
return "결과발표"
|
||||
if winner_date and today > winner_date:
|
||||
return "완료"
|
||||
return "청약예정"
|
||||
|
||||
|
||||
# ── announcements CRUD ───────────────────────────────────────────────────────
|
||||
|
||||
def _ann_row_to_dict(r) -> Dict[str, Any]:
|
||||
return {c: r[c] for c in r.keys()}
|
||||
|
||||
|
||||
def upsert_announcement(data: Dict[str, Any]) -> tuple:
|
||||
"""공고 upsert — house_manage_no + pblanc_no 기준. Returns (dict, is_new: bool)."""
|
||||
status = compute_status(
|
||||
data.get("receipt_start", ""),
|
||||
data.get("receipt_end", ""),
|
||||
data.get("winner_date", ""),
|
||||
)
|
||||
with _conn() as conn:
|
||||
exists = conn.execute(
|
||||
"SELECT 1 FROM announcements WHERE house_manage_no = ? AND pblanc_no = ?",
|
||||
(data["house_manage_no"], data["pblanc_no"]),
|
||||
).fetchone()
|
||||
is_new = exists is None
|
||||
conn.execute("""
|
||||
INSERT INTO announcements (
|
||||
house_manage_no, pblanc_no, house_nm, house_secd, house_dtl_secd,
|
||||
rent_secd, region_code, region_name, address, total_units,
|
||||
rcrit_date, receipt_start, receipt_end, spsply_start, spsply_end,
|
||||
gnrl_rank1_start, gnrl_rank1_end, winner_date, contract_start,
|
||||
contract_end, homepage_url, pblanc_url, constructor, developer,
|
||||
move_in_month, is_speculative_area, is_price_cap, contact,
|
||||
status, source
|
||||
) VALUES (
|
||||
:house_manage_no, :pblanc_no, :house_nm, :house_secd, :house_dtl_secd,
|
||||
:rent_secd, :region_code, :region_name, :address, :total_units,
|
||||
:rcrit_date, :receipt_start, :receipt_end, :spsply_start, :spsply_end,
|
||||
:gnrl_rank1_start, :gnrl_rank1_end, :winner_date, :contract_start,
|
||||
:contract_end, :homepage_url, :pblanc_url, :constructor, :developer,
|
||||
:move_in_month, :is_speculative_area, :is_price_cap, :contact,
|
||||
:status, :source
|
||||
)
|
||||
ON CONFLICT(house_manage_no, pblanc_no) DO UPDATE SET
|
||||
house_nm=excluded.house_nm,
|
||||
house_secd=excluded.house_secd,
|
||||
house_dtl_secd=excluded.house_dtl_secd,
|
||||
rent_secd=excluded.rent_secd,
|
||||
region_code=excluded.region_code,
|
||||
region_name=excluded.region_name,
|
||||
address=excluded.address,
|
||||
total_units=excluded.total_units,
|
||||
rcrit_date=excluded.rcrit_date,
|
||||
receipt_start=excluded.receipt_start,
|
||||
receipt_end=excluded.receipt_end,
|
||||
spsply_start=excluded.spsply_start,
|
||||
spsply_end=excluded.spsply_end,
|
||||
gnrl_rank1_start=excluded.gnrl_rank1_start,
|
||||
gnrl_rank1_end=excluded.gnrl_rank1_end,
|
||||
winner_date=excluded.winner_date,
|
||||
contract_start=excluded.contract_start,
|
||||
contract_end=excluded.contract_end,
|
||||
homepage_url=excluded.homepage_url,
|
||||
pblanc_url=excluded.pblanc_url,
|
||||
constructor=excluded.constructor,
|
||||
developer=excluded.developer,
|
||||
move_in_month=excluded.move_in_month,
|
||||
is_speculative_area=excluded.is_speculative_area,
|
||||
is_price_cap=excluded.is_price_cap,
|
||||
contact=excluded.contact,
|
||||
status=excluded.status,
|
||||
updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||
""", {**data, "status": status})
|
||||
row = conn.execute(
|
||||
"SELECT * FROM announcements WHERE house_manage_no = ? AND pblanc_no = ?",
|
||||
(data["house_manage_no"], data["pblanc_no"]),
|
||||
).fetchone()
|
||||
return _ann_row_to_dict(row), is_new
|
||||
|
||||
|
||||
def _enrich_items(conn, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""공고 목록에 모델 기반 가격 범위 + 매칭 점수를 추가한다."""
|
||||
for item in items:
|
||||
ann_id = item.get("id")
|
||||
hmno = item.get("house_manage_no")
|
||||
pno = item.get("pblanc_no")
|
||||
# 가격 정보
|
||||
if hmno and pno:
|
||||
price_row = conn.execute(
|
||||
"SELECT MIN(top_amount) as min_price, MAX(top_amount) as max_price "
|
||||
"FROM announcement_models WHERE house_manage_no = ? AND pblanc_no = ? AND top_amount IS NOT NULL",
|
||||
(hmno, pno),
|
||||
).fetchone()
|
||||
if price_row and price_row["min_price"] is not None:
|
||||
item["min_price"] = price_row["min_price"]
|
||||
item["max_price_display"] = price_row["max_price"]
|
||||
# 매칭 점수
|
||||
if ann_id:
|
||||
match_row = conn.execute(
|
||||
"SELECT match_score, match_reasons, eligible_types FROM match_results WHERE announcement_id = ?",
|
||||
(ann_id,),
|
||||
).fetchone()
|
||||
if match_row:
|
||||
item["match_score"] = match_row["match_score"]
|
||||
item["match_reasons"] = json.loads(match_row["match_reasons"]) if match_row["match_reasons"] else []
|
||||
item["eligible_types"] = json.loads(match_row["eligible_types"]) if match_row["eligible_types"] else []
|
||||
return items
|
||||
|
||||
|
||||
def get_announcements(
|
||||
region: str = None,
|
||||
status: str = None,
|
||||
house_type: str = None,
|
||||
matched_only: bool = False,
|
||||
bookmarked: bool = False,
|
||||
sort: str = "date",
|
||||
page: int = 1,
|
||||
size: int = 20,
|
||||
) -> Dict[str, Any]:
|
||||
conditions, params = [], []
|
||||
if region:
|
||||
conditions.append("a.region_name LIKE ?")
|
||||
params.append(f"%{region}%")
|
||||
if status:
|
||||
conditions.append("a.status = ?")
|
||||
params.append(status)
|
||||
if house_type:
|
||||
conditions.append("a.house_secd = ?")
|
||||
params.append(house_type)
|
||||
if bookmarked:
|
||||
conditions.append("a.is_bookmarked = 1")
|
||||
|
||||
if matched_only:
|
||||
conditions.append("a.id IN (SELECT announcement_id FROM match_results)")
|
||||
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
|
||||
order_map = {"date": "a.rcrit_date DESC", "score": "a.id DESC", "price": "a.id ASC"}
|
||||
order = order_map.get(sort, "a.rcrit_date DESC")
|
||||
if matched_only and sort == "score":
|
||||
order = "(SELECT MAX(match_score) FROM match_results WHERE announcement_id = a.id) DESC"
|
||||
|
||||
offset = (page - 1) * size
|
||||
|
||||
with _conn() as conn:
|
||||
total = conn.execute(
|
||||
f"SELECT COUNT(*) FROM announcements a {where}", params
|
||||
).fetchone()[0]
|
||||
rows = conn.execute(
|
||||
f"SELECT a.* FROM announcements a {where} ORDER BY {order} LIMIT ? OFFSET ?",
|
||||
params + [size, offset],
|
||||
).fetchall()
|
||||
items = [_ann_row_to_dict(r) for r in rows]
|
||||
items = _enrich_items(conn, items)
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size,
|
||||
}
|
||||
|
||||
|
||||
def get_announcement(ann_id: int) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM announcements WHERE id = ?", (ann_id,)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
ann = _ann_row_to_dict(row)
|
||||
models = conn.execute(
|
||||
"SELECT * FROM announcement_models WHERE house_manage_no = ? AND pblanc_no = ?",
|
||||
(ann["house_manage_no"], ann["pblanc_no"]),
|
||||
).fetchall()
|
||||
ann["models"] = [dict(m) for m in models]
|
||||
return ann
|
||||
|
||||
|
||||
def create_announcement(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""수동 공고 등록 (house_manage_no 자동 생성)."""
|
||||
import uuid
|
||||
data["house_manage_no"] = data.get("house_manage_no", f"MANUAL-{uuid.uuid4().hex[:8]}")
|
||||
data["pblanc_no"] = data.get("pblanc_no", "00")
|
||||
data["source"] = "manual"
|
||||
result, _ = upsert_announcement(data)
|
||||
return result
|
||||
|
||||
|
||||
ANNOUNCEMENT_COLUMNS = {
|
||||
"house_nm", "house_secd", "house_dtl_secd", "rent_secd",
|
||||
"region_code", "region_name", "address", "total_units",
|
||||
"rcrit_date", "receipt_start", "receipt_end", "spsply_start", "spsply_end",
|
||||
"gnrl_rank1_start", "gnrl_rank1_end", "winner_date",
|
||||
"contract_start", "contract_end", "homepage_url", "pblanc_url",
|
||||
"constructor", "developer", "move_in_month",
|
||||
"is_speculative_area", "is_price_cap", "contact",
|
||||
}
|
||||
|
||||
|
||||
def update_announcement(ann_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
fields = {k: v for k, v in data.items() if v is not None and k in ANNOUNCEMENT_COLUMNS}
|
||||
if not fields:
|
||||
return get_announcement(ann_id)
|
||||
|
||||
# 날짜 변경 시 status 재계산
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM announcements WHERE id = ?", (ann_id,)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
current = _ann_row_to_dict(row)
|
||||
merged = {**current, **fields}
|
||||
status = compute_status(
|
||||
merged.get("receipt_start", ""),
|
||||
merged.get("receipt_end", ""),
|
||||
merged.get("winner_date", ""),
|
||||
)
|
||||
fields["status"] = status
|
||||
|
||||
set_clauses = ", ".join(f"{k} = ?" for k in fields)
|
||||
set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
|
||||
conn.execute(
|
||||
f"UPDATE announcements SET {set_clauses} WHERE id = ?",
|
||||
list(fields.values()) + [ann_id],
|
||||
)
|
||||
return get_announcement(ann_id)
|
||||
|
||||
|
||||
def toggle_bookmark(ann_id: int) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT id, is_bookmarked FROM announcements WHERE id = ?", (ann_id,)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
new_val = 0 if row["is_bookmarked"] else 1
|
||||
conn.execute(
|
||||
"UPDATE announcements SET is_bookmarked = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
|
||||
(new_val, ann_id),
|
||||
)
|
||||
updated = conn.execute("SELECT * FROM announcements WHERE id = ?", (ann_id,)).fetchone()
|
||||
return _ann_row_to_dict(updated)
|
||||
|
||||
|
||||
def delete_announcement(ann_id: int) -> bool:
|
||||
with _conn() as conn:
|
||||
# match_results는 FK CASCADE로 자동 삭제
|
||||
cur = conn.execute("DELETE FROM announcements WHERE id = ?", (ann_id,))
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def update_all_statuses():
|
||||
"""모든 진행중 공고의 status를 날짜 기반으로 재계산."""
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, status, receipt_start, receipt_end, winner_date FROM announcements "
|
||||
"WHERE status != '완료' AND (receipt_start IS NOT NULL OR receipt_end IS NOT NULL OR winner_date IS NOT NULL)"
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
new_status = compute_status(r["receipt_start"], r["receipt_end"], r["winner_date"])
|
||||
if new_status != r["status"]: # only update if status actually changed
|
||||
conn.execute(
|
||||
"UPDATE announcements SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
|
||||
(new_status, r["id"]),
|
||||
)
|
||||
|
||||
|
||||
# ── announcement_models CRUD ─────────────────────────────────────────────────
|
||||
|
||||
def upsert_model(data: Dict[str, Any]):
|
||||
with _conn() as conn:
|
||||
conn.execute("""
|
||||
INSERT INTO announcement_models (
|
||||
house_manage_no, pblanc_no, model_no, house_ty, supply_area,
|
||||
general_units, special_units, multi_child_units, newlywed_units,
|
||||
first_life_units, old_parent_units, institution_units,
|
||||
youth_units, newborn_units, top_amount
|
||||
) VALUES (
|
||||
:house_manage_no, :pblanc_no, :model_no, :house_ty, :supply_area,
|
||||
:general_units, :special_units, :multi_child_units, :newlywed_units,
|
||||
:first_life_units, :old_parent_units, :institution_units,
|
||||
:youth_units, :newborn_units, :top_amount
|
||||
)
|
||||
ON CONFLICT(house_manage_no, pblanc_no, model_no) DO UPDATE SET
|
||||
house_ty=excluded.house_ty,
|
||||
supply_area=excluded.supply_area,
|
||||
general_units=excluded.general_units,
|
||||
special_units=excluded.special_units,
|
||||
multi_child_units=excluded.multi_child_units,
|
||||
newlywed_units=excluded.newlywed_units,
|
||||
first_life_units=excluded.first_life_units,
|
||||
old_parent_units=excluded.old_parent_units,
|
||||
institution_units=excluded.institution_units,
|
||||
youth_units=excluded.youth_units,
|
||||
newborn_units=excluded.newborn_units,
|
||||
top_amount=excluded.top_amount
|
||||
""", data)
|
||||
|
||||
|
||||
# ── 청약 가점 계산 ───────────────────────────────────────────────────────────
|
||||
|
||||
def calculate_subscription_points(profile: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""청약 가점제 점수 계산 (총 84점 만점).
|
||||
|
||||
1. 무주택기간 (0~32점): 만 30세부터 기산, 연 2점
|
||||
2. 부양가족 수 (0~35점): 인당 5점, 6명+ 만점
|
||||
3. 청약통장 가입기간 (0~17점): 6개월 미만 1점 ~ 15년+ 17점
|
||||
"""
|
||||
result = {
|
||||
"homeless_duration": {"score": 0, "max": 32, "detail": ""},
|
||||
"dependents": {"score": 0, "max": 35, "detail": ""},
|
||||
"subscription_period": {"score": 0, "max": 17, "detail": ""},
|
||||
"total": 0,
|
||||
"max_total": 84,
|
||||
}
|
||||
|
||||
if not profile:
|
||||
return result
|
||||
|
||||
# 1. 무주택기간 (만 30세부터 기산, 연 2점, 최대 32점)
|
||||
age = profile.get("age") or 0
|
||||
is_homeless = profile.get("is_homeless", False)
|
||||
if is_homeless and age >= 30:
|
||||
homeless_years = age - 30
|
||||
score = min(homeless_years * 2, 32)
|
||||
# 1년 미만도 2점
|
||||
if homeless_years == 0:
|
||||
score = 2
|
||||
result["homeless_duration"]["score"] = score
|
||||
result["homeless_duration"]["detail"] = f"만 {age}세, 무주택 약 {homeless_years}년"
|
||||
elif is_homeless and age < 30:
|
||||
result["homeless_duration"]["score"] = 0
|
||||
result["homeless_duration"]["detail"] = f"만 {age}세 (30세 미만, 기간 미산정)"
|
||||
else:
|
||||
result["homeless_duration"]["detail"] = "유주택자"
|
||||
|
||||
# 2. 부양가족 수 (인당 5점, 최대 35점)
|
||||
family_members = profile.get("family_members") or 0
|
||||
dependents = max(family_members - 1, 0) # 본인 제외
|
||||
dep_score = min(dependents * 5, 35)
|
||||
result["dependents"]["score"] = dep_score
|
||||
result["dependents"]["detail"] = f"{dependents}명" if dependents > 0 else "0명 (본인만)"
|
||||
|
||||
# 3. 청약통장 가입기간 (6개월 미만 1점, 이후 1년마다 +1점, 최대 17점)
|
||||
months = profile.get("subscription_months") or 0
|
||||
if months <= 0:
|
||||
sub_score = 0
|
||||
sub_detail = "미가입"
|
||||
elif months < 6:
|
||||
sub_score = 1
|
||||
sub_detail = f"{months}개월 (6개월 미만)"
|
||||
else:
|
||||
years = months / 12
|
||||
# 6개월~1년 = 2점, 1~2년 = 3점, ..., 14~15년 = 16점, 15년+ = 17점
|
||||
sub_score = min(int(years) + 2, 17)
|
||||
if years < 1:
|
||||
sub_score = 2
|
||||
if years >= 1:
|
||||
y = int(years)
|
||||
sub_detail = f"{y}년 {months - y*12}개월"
|
||||
else:
|
||||
sub_detail = f"{months}개월"
|
||||
result["subscription_period"]["score"] = sub_score
|
||||
result["subscription_period"]["detail"] = sub_detail
|
||||
|
||||
result["total"] = (
|
||||
result["homeless_duration"]["score"]
|
||||
+ result["dependents"]["score"]
|
||||
+ result["subscription_period"]["score"]
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── user_profile CRUD ────────────────────────────────────────────────────────
|
||||
|
||||
def _profile_row_to_dict(r) -> Dict[str, Any]:
|
||||
d = {}
|
||||
for c in r.keys():
|
||||
val = r[c]
|
||||
if c in ("is_homeless", "is_householder", "has_dependents", "is_newlywed",
|
||||
"has_newborn", "is_first_home"):
|
||||
d[c] = bool(val) if val is not None else None
|
||||
elif c in ("preferred_regions", "preferred_types"):
|
||||
d[c] = json.loads(val) if val else []
|
||||
else:
|
||||
d[c] = val
|
||||
return d
|
||||
|
||||
|
||||
def get_profile() -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
r = conn.execute("SELECT * FROM user_profile WHERE id = 1").fetchone()
|
||||
if not r:
|
||||
return None
|
||||
profile = _profile_row_to_dict(r)
|
||||
profile["subscription_points"] = calculate_subscription_points(profile)
|
||||
return profile
|
||||
|
||||
|
||||
PROFILE_COLUMNS = {
|
||||
"name", "age", "is_homeless", "is_householder",
|
||||
"subscription_months", "subscription_amount", "family_members",
|
||||
"has_dependents", "children_count", "is_newlywed", "marriage_months",
|
||||
"has_newborn", "is_first_home", "income_level",
|
||||
"preferred_regions", "preferred_types",
|
||||
"min_area", "max_area", "max_price",
|
||||
}
|
||||
|
||||
|
||||
def upsert_profile(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
updates = {}
|
||||
for k, v in data.items():
|
||||
if v is None or k not in PROFILE_COLUMNS:
|
||||
continue
|
||||
if isinstance(v, bool):
|
||||
updates[k] = 1 if v else 0
|
||||
elif isinstance(v, list):
|
||||
updates[k] = json.dumps(v)
|
||||
else:
|
||||
updates[k] = v
|
||||
|
||||
with _conn() as conn:
|
||||
existing = conn.execute("SELECT id FROM user_profile WHERE id = 1").fetchone()
|
||||
if existing:
|
||||
if updates:
|
||||
set_clauses = ", ".join(f"{k} = ?" for k in updates)
|
||||
set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
|
||||
conn.execute(
|
||||
f"UPDATE user_profile SET {set_clauses} WHERE id = 1",
|
||||
list(updates.values()),
|
||||
)
|
||||
else:
|
||||
cols = ["id"] + list(updates.keys())
|
||||
vals = [1] + list(updates.values())
|
||||
placeholders = ", ".join("?" for _ in vals)
|
||||
conn.execute(
|
||||
f"INSERT INTO user_profile ({', '.join(cols)}) VALUES ({placeholders})",
|
||||
vals,
|
||||
)
|
||||
row = conn.execute("SELECT * FROM user_profile WHERE id = 1").fetchone()
|
||||
profile = _profile_row_to_dict(row)
|
||||
profile["subscription_points"] = calculate_subscription_points(profile)
|
||||
return profile
|
||||
|
||||
|
||||
# ── match_results CRUD ───────────────────────────────────────────────────────
|
||||
|
||||
def save_match_result(data: Dict[str, Any]):
|
||||
with _conn() as conn:
|
||||
conn.execute("""
|
||||
INSERT INTO match_results (announcement_id, model_id, match_score, match_reasons, eligible_types, is_new)
|
||||
VALUES (:announcement_id, :model_id, :match_score, :match_reasons, :eligible_types, 1)
|
||||
ON CONFLICT(announcement_id, model_id) DO UPDATE SET
|
||||
match_score=excluded.match_score,
|
||||
match_reasons=excluded.match_reasons,
|
||||
eligible_types=excluded.eligible_types
|
||||
""", {
|
||||
**data,
|
||||
"match_reasons": json.dumps(data.get("match_reasons", [])),
|
||||
"eligible_types": json.dumps(data.get("eligible_types", [])),
|
||||
})
|
||||
|
||||
|
||||
def get_matches(page: int = 1, size: int = 20) -> Dict[str, Any]:
|
||||
offset = (page - 1) * size
|
||||
with _conn() as conn:
|
||||
# 프로필 가점 계산
|
||||
profile_row = conn.execute("SELECT * FROM user_profile WHERE id = 1").fetchone()
|
||||
points = None
|
||||
if profile_row:
|
||||
profile = _profile_row_to_dict(profile_row)
|
||||
points = calculate_subscription_points(profile)
|
||||
|
||||
total = conn.execute("SELECT COUNT(*) FROM match_results").fetchone()[0]
|
||||
rows = conn.execute("""
|
||||
SELECT m.*, a.house_nm, a.region_name, a.address, a.status as ann_status,
|
||||
a.receipt_start, a.receipt_end, a.winner_date, a.pblanc_url,
|
||||
a.house_secd, a.is_speculative_area
|
||||
FROM match_results m
|
||||
JOIN announcements a ON a.id = m.announcement_id
|
||||
ORDER BY m.is_new DESC, m.match_score DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""", (size, offset)).fetchall()
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
d = {c: r[c] for c in r.keys()}
|
||||
d["match_reasons"] = json.loads(d["match_reasons"]) if d["match_reasons"] else []
|
||||
d["eligible_types"] = json.loads(d["eligible_types"]) if d["eligible_types"] else []
|
||||
items.append(d)
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size,
|
||||
"my_points": points,
|
||||
}
|
||||
|
||||
|
||||
def mark_match_read(match_id: int) -> bool:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute("UPDATE match_results SET is_new = 0 WHERE id = ?", (match_id,))
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
# ── collect_log CRUD ─────────────────────────────────────────────────────────
|
||||
|
||||
def save_collect_log(new_count: int, total_count: int, error: str = None):
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO collect_log (new_count, total_count, error) VALUES (?, ?, ?)",
|
||||
(new_count, total_count, error),
|
||||
)
|
||||
|
||||
|
||||
def get_last_collect_log() -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
r = conn.execute("SELECT * FROM collect_log ORDER BY id DESC LIMIT 1").fetchone()
|
||||
return dict(r) if r else None
|
||||
|
||||
|
||||
# ── 대시보드 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def get_dashboard() -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
active = conn.execute(
|
||||
"SELECT COUNT(*) FROM announcements WHERE status IN ('청약예정', '청약중')"
|
||||
).fetchone()[0]
|
||||
new_matches = conn.execute(
|
||||
"SELECT COUNT(*) FROM match_results WHERE is_new = 1"
|
||||
).fetchone()[0]
|
||||
bookmarked_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM announcements WHERE is_bookmarked = 1"
|
||||
).fetchone()[0]
|
||||
|
||||
# 다가오는 일정을 개별 이벤트로 분해
|
||||
upcoming_rows = conn.execute("""
|
||||
SELECT id, house_nm, receipt_start, receipt_end,
|
||||
spsply_start, gnrl_rank1_start, winner_date,
|
||||
contract_start, status
|
||||
FROM announcements
|
||||
WHERE status IN ('청약예정', '청약중')
|
||||
ORDER BY receipt_start ASC
|
||||
LIMIT 20
|
||||
""").fetchall()
|
||||
|
||||
today = date.today().isoformat()
|
||||
schedules = []
|
||||
for r in upcoming_rows:
|
||||
events = [
|
||||
("특별공급 접수", r["spsply_start"]),
|
||||
("1순위 접수", r["gnrl_rank1_start"]),
|
||||
("청약 접수", r["receipt_start"]),
|
||||
("당첨자 발표", r["winner_date"]),
|
||||
("계약 시작", r["contract_start"]),
|
||||
]
|
||||
for event, d in events:
|
||||
if d and d >= today:
|
||||
schedules.append({
|
||||
"announcement_id": r["id"],
|
||||
"house_nm": r["house_nm"],
|
||||
"event": event,
|
||||
"date": d,
|
||||
})
|
||||
schedules.sort(key=lambda s: s["date"])
|
||||
schedules = schedules[:10]
|
||||
|
||||
# 즐겨찾기 공고
|
||||
bookmarked_rows = conn.execute("""
|
||||
SELECT * FROM announcements WHERE is_bookmarked = 1
|
||||
ORDER BY receipt_start ASC
|
||||
""").fetchall()
|
||||
bookmarked_items = [_ann_row_to_dict(r) for r in bookmarked_rows]
|
||||
bookmarked_items = _enrich_items(conn, bookmarked_items)
|
||||
|
||||
return {
|
||||
"active_count": active,
|
||||
"new_match_count": new_matches,
|
||||
"bookmarked_count": bookmarked_count,
|
||||
"upcoming_schedules": schedules,
|
||||
"bookmarked": bookmarked_items,
|
||||
}
|
||||
191
realestate-lab/app/main.py
Normal file
191
realestate-lab/app/main.py
Normal file
@@ -0,0 +1,191 @@
|
||||
import os
|
||||
import logging
|
||||
import threading
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import BackgroundTasks, FastAPI, Query, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
from .db import (
|
||||
init_db, get_announcements, get_announcement, create_announcement,
|
||||
update_announcement, delete_announcement, toggle_bookmark,
|
||||
update_all_statuses,
|
||||
get_profile, upsert_profile, get_matches, mark_match_read,
|
||||
get_last_collect_log, get_dashboard,
|
||||
)
|
||||
from .collector import collect_all
|
||||
from .matcher import run_matching
|
||||
from .models import AnnouncementCreate, AnnouncementUpdate, ProfileUpdate
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
|
||||
logger = logging.getLogger("realestate-lab")
|
||||
|
||||
scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
||||
|
||||
|
||||
def scheduled_collect():
|
||||
"""매일 09:00 — 수집 + 매칭"""
|
||||
logger.info("스케줄 수집 시작")
|
||||
collect_all()
|
||||
run_matching()
|
||||
logger.info("스케줄 수집 + 매칭 완료")
|
||||
|
||||
|
||||
def scheduled_status_update():
|
||||
"""매일 00:00 — 상태 갱신 + 재매칭"""
|
||||
logger.info("상태 갱신 시작")
|
||||
update_all_statuses()
|
||||
run_matching()
|
||||
logger.info("상태 갱신 + 재매칭 완료")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
scheduler.add_job(scheduled_collect, "cron", hour=9, minute=0, id="collect")
|
||||
scheduler.add_job(scheduled_status_update, "cron", hour=0, minute=0, id="status_update")
|
||||
scheduler.start()
|
||||
logger.info("realestate-lab 시작")
|
||||
yield
|
||||
scheduler.shutdown()
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
_cors_origins = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080").split(",")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[o.strip() for o in _cors_origins],
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── 공고 API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/realestate/announcements")
|
||||
def api_announcements(
|
||||
region: str = None,
|
||||
status: str = None,
|
||||
house_type: str = None,
|
||||
matched_only: bool = False,
|
||||
bookmarked: bool = False,
|
||||
sort: str = "date",
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
):
|
||||
return get_announcements(region, status, house_type, matched_only, bookmarked, sort, page, size)
|
||||
|
||||
|
||||
@app.get("/api/realestate/announcements/{ann_id}")
|
||||
def api_announcement_detail(ann_id: int):
|
||||
ann = get_announcement(ann_id)
|
||||
if not ann:
|
||||
raise HTTPException(status_code=404, detail="Announcement not found")
|
||||
return ann
|
||||
|
||||
|
||||
@app.post("/api/realestate/announcements", status_code=201)
|
||||
def api_announcement_create(body: AnnouncementCreate):
|
||||
return create_announcement(body.model_dump())
|
||||
|
||||
|
||||
@app.put("/api/realestate/announcements/{ann_id}")
|
||||
def api_announcement_update(ann_id: int, body: AnnouncementUpdate):
|
||||
updated = update_announcement(ann_id, body.model_dump(exclude_none=True))
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="Announcement not found")
|
||||
return updated
|
||||
|
||||
|
||||
@app.patch("/api/realestate/announcements/{ann_id}/bookmark")
|
||||
def api_announcement_bookmark(ann_id: int):
|
||||
result = toggle_bookmark(ann_id)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="Announcement not found")
|
||||
return result
|
||||
|
||||
|
||||
@app.delete("/api/realestate/announcements/{ann_id}")
|
||||
def api_announcement_delete(ann_id: int):
|
||||
if not delete_announcement(ann_id):
|
||||
raise HTTPException(status_code=404, detail="Announcement not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── 수집 API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_collect_lock = threading.Lock()
|
||||
|
||||
|
||||
def _run_collect_and_match():
|
||||
if not _collect_lock.acquire(blocking=False):
|
||||
logger.info("수집 이미 진행 중 — 건너뜀")
|
||||
return
|
||||
try:
|
||||
collect_all()
|
||||
run_matching()
|
||||
finally:
|
||||
_collect_lock.release()
|
||||
|
||||
|
||||
@app.post("/api/realestate/collect")
|
||||
def api_collect(background_tasks: BackgroundTasks):
|
||||
background_tasks.add_task(_run_collect_and_match)
|
||||
return {"ok": True, "message": "수집 시작됨"}
|
||||
|
||||
|
||||
@app.get("/api/realestate/collect/status")
|
||||
def api_collect_status():
|
||||
log = get_last_collect_log()
|
||||
return log if log else {"collected_at": None, "new_count": 0, "total_count": 0, "error": None}
|
||||
|
||||
|
||||
# ── 프로필 API ───────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/realestate/profile")
|
||||
def api_profile_get():
|
||||
profile = get_profile()
|
||||
return profile if profile else {}
|
||||
|
||||
|
||||
@app.put("/api/realestate/profile")
|
||||
def api_profile_update(body: ProfileUpdate):
|
||||
return upsert_profile(body.model_dump(exclude_none=True))
|
||||
|
||||
|
||||
# ── 매칭 API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/realestate/matches")
|
||||
def api_matches(page: int = Query(1, ge=1), size: int = Query(20, ge=1, le=100)):
|
||||
return get_matches(page, size)
|
||||
|
||||
|
||||
@app.post("/api/realestate/matches/refresh")
|
||||
def api_matches_refresh():
|
||||
try:
|
||||
run_matching()
|
||||
except Exception as e:
|
||||
logger.exception("매칭 실행 실패")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.patch("/api/realestate/matches/{match_id}/read")
|
||||
def api_match_read(match_id: int):
|
||||
if not mark_match_read(match_id):
|
||||
raise HTTPException(status_code=404, detail="Match not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── 대시보드 API ─────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/realestate/dashboard")
|
||||
def api_dashboard():
|
||||
return get_dashboard()
|
||||
163
realestate-lab/app/matcher.py
Normal file
163
realestate-lab/app/matcher.py
Normal file
@@ -0,0 +1,163 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any, List
|
||||
|
||||
from .db import _conn, _profile_row_to_dict
|
||||
|
||||
logger = logging.getLogger("realestate-lab")
|
||||
|
||||
# house_secd → 주택유형 이름 매핑
|
||||
_HOUSE_TYPE_MAP = {
|
||||
"01": "APT",
|
||||
"02": "오피스텔",
|
||||
"04": "무순위",
|
||||
"09": "민간사전청약",
|
||||
"10": "신혼희망타운",
|
||||
}
|
||||
|
||||
|
||||
def _check_eligible_types(profile: Dict[str, Any], ann: Dict[str, Any]) -> List[str]:
|
||||
"""프로필 기반으로 신청 가능한 공급유형 목록을 반환한다."""
|
||||
eligible: List[str] = []
|
||||
is_homeless = profile.get("is_homeless", False)
|
||||
is_speculative = ann.get("is_speculative_area") == "Y"
|
||||
required_months = 24 if is_speculative else 12
|
||||
subscription_months = profile.get("subscription_months") or 0
|
||||
|
||||
# 일반공급
|
||||
if is_homeless and profile.get("is_householder") and subscription_months >= required_months:
|
||||
eligible.append("일반1순위")
|
||||
elif is_homeless:
|
||||
eligible.append("일반2순위")
|
||||
|
||||
# 특별공급 — 신혼부부
|
||||
# NOTE: 소득기준 검증은 향후 구현 예정 (income_level 필드 활용)
|
||||
if profile.get("is_newlywed") and is_homeless:
|
||||
eligible.append("특별-신혼부부")
|
||||
|
||||
if profile.get("is_first_home") and is_homeless:
|
||||
eligible.append("특별-생애최초")
|
||||
|
||||
children_count = profile.get("children_count") or 0
|
||||
if children_count >= 2 and is_homeless:
|
||||
eligible.append("특별-다자녀")
|
||||
|
||||
if profile.get("has_dependents") and is_homeless:
|
||||
eligible.append("특별-노부모부양")
|
||||
|
||||
age = profile.get("age") or 0
|
||||
if 19 <= age <= 39 and is_homeless:
|
||||
eligible.append("특별-청년")
|
||||
|
||||
if profile.get("has_newborn") and is_homeless:
|
||||
eligible.append("특별-신생아")
|
||||
|
||||
return eligible
|
||||
|
||||
|
||||
def _compute_score(
|
||||
profile: Dict[str, Any],
|
||||
ann: Dict[str, Any],
|
||||
models: List[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""매칭 점수(0-100)와 사유를 계산한다."""
|
||||
score = 0
|
||||
reasons: List[str] = []
|
||||
|
||||
# 1. 지역 (30점)
|
||||
preferred_regions = profile.get("preferred_regions") or []
|
||||
region_name = ann.get("region_name") or ""
|
||||
if region_name and any(r in region_name for r in preferred_regions):
|
||||
score += 30
|
||||
reasons.append(f"선호 지역 일치: {region_name}")
|
||||
|
||||
# 2. 주택유형 (10점)
|
||||
preferred_types = profile.get("preferred_types") or []
|
||||
house_secd = ann.get("house_secd") or ""
|
||||
type_name = _HOUSE_TYPE_MAP.get(house_secd, house_secd)
|
||||
if type_name and type_name in preferred_types:
|
||||
score += 10
|
||||
reasons.append(f"선호 유형 일치: {type_name}")
|
||||
|
||||
# 3. 면적 (15점)
|
||||
min_area = profile.get("min_area")
|
||||
max_area = profile.get("max_area")
|
||||
if min_area is not None and max_area is not None and models:
|
||||
for m in models:
|
||||
supply_area = m.get("supply_area")
|
||||
if supply_area is not None and min_area <= supply_area <= max_area:
|
||||
score += 15
|
||||
reasons.append(f"희망 면적 범위 내 모델 존재 ({supply_area}㎡)")
|
||||
break
|
||||
|
||||
# 4. 가격 (15점)
|
||||
max_price = profile.get("max_price")
|
||||
if max_price is not None and models:
|
||||
for m in models:
|
||||
top_amount = m.get("top_amount")
|
||||
if top_amount is not None and top_amount <= max_price:
|
||||
score += 15
|
||||
reasons.append(f"예산 범위 내 모델 존재 (최고가 {top_amount:,}만원)")
|
||||
break
|
||||
|
||||
# 5. 자격 (30점)
|
||||
eligible_types = _check_eligible_types(profile, ann)
|
||||
eligibility_score = min(len(eligible_types) * 10, 30)
|
||||
if eligibility_score > 0:
|
||||
score += eligibility_score
|
||||
reasons.append(f"자격 유형 {len(eligible_types)}개: {', '.join(eligible_types)}")
|
||||
|
||||
return {
|
||||
"match_score": score,
|
||||
"match_reasons": reasons,
|
||||
"eligible_types": eligible_types,
|
||||
}
|
||||
|
||||
|
||||
def run_matching():
|
||||
"""프로필 기반 매칭을 실행하여 결과를 저장한다.
|
||||
단일 connection으로 프로필 조회 + 매칭 + 저장을 처리하여 DB lock 방지.
|
||||
"""
|
||||
with _conn() as conn:
|
||||
profile_row = conn.execute("SELECT * FROM user_profile WHERE id = 1").fetchone()
|
||||
if not profile_row:
|
||||
logger.info("프로필 미설정 — 매칭 건너뜀")
|
||||
return
|
||||
profile = _profile_row_to_dict(profile_row)
|
||||
|
||||
anns = conn.execute(
|
||||
"SELECT * FROM announcements WHERE status IN ('청약예정', '청약중')"
|
||||
).fetchall()
|
||||
|
||||
for ann_row in anns:
|
||||
ann = {c: ann_row[c] for c in ann_row.keys()}
|
||||
models = conn.execute(
|
||||
"SELECT * FROM announcement_models WHERE house_manage_no = ? AND pblanc_no = ?",
|
||||
(ann["house_manage_no"], ann["pblanc_no"]),
|
||||
).fetchall()
|
||||
model_list = [dict(m) for m in models]
|
||||
|
||||
result = _compute_score(profile, ann, model_list)
|
||||
if result["match_score"] > 0:
|
||||
conn.execute("""
|
||||
INSERT INTO match_results (announcement_id, model_id, match_score, match_reasons, eligible_types, is_new)
|
||||
VALUES (?, ?, ?, ?, ?, 1)
|
||||
ON CONFLICT(announcement_id, model_id) DO UPDATE SET
|
||||
match_score=excluded.match_score,
|
||||
match_reasons=excluded.match_reasons,
|
||||
eligible_types=excluded.eligible_types
|
||||
""", (
|
||||
ann["id"],
|
||||
None,
|
||||
result["match_score"],
|
||||
json.dumps(result["match_reasons"]),
|
||||
json.dumps(result["eligible_types"]),
|
||||
))
|
||||
|
||||
# Clean up stale match results for completed announcements
|
||||
conn.execute(
|
||||
"DELETE FROM match_results WHERE announcement_id NOT IN "
|
||||
"(SELECT id FROM announcements WHERE status IN ('청약예정', '청약중'))"
|
||||
)
|
||||
|
||||
logger.info("매칭 완료")
|
||||
82
realestate-lab/app/models.py
Normal file
82
realestate-lab/app/models.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AnnouncementCreate(BaseModel):
|
||||
house_nm: str
|
||||
house_secd: str = "01"
|
||||
house_dtl_secd: Optional[str] = None
|
||||
rent_secd: Optional[str] = None
|
||||
region_code: Optional[str] = None
|
||||
region_name: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
total_units: Optional[int] = None
|
||||
rcrit_date: Optional[str] = None
|
||||
receipt_start: Optional[str] = None
|
||||
receipt_end: Optional[str] = None
|
||||
spsply_start: Optional[str] = None
|
||||
spsply_end: Optional[str] = None
|
||||
gnrl_rank1_start: Optional[str] = None
|
||||
gnrl_rank1_end: Optional[str] = None
|
||||
winner_date: Optional[str] = None
|
||||
contract_start: Optional[str] = None
|
||||
contract_end: Optional[str] = None
|
||||
homepage_url: Optional[str] = None
|
||||
pblanc_url: Optional[str] = None
|
||||
constructor: Optional[str] = None
|
||||
developer: Optional[str] = None
|
||||
move_in_month: Optional[str] = None
|
||||
is_speculative_area: Optional[str] = None
|
||||
is_price_cap: Optional[str] = None
|
||||
contact: Optional[str] = None
|
||||
|
||||
|
||||
class AnnouncementUpdate(BaseModel):
|
||||
house_nm: Optional[str] = None
|
||||
house_secd: Optional[str] = None
|
||||
house_dtl_secd: Optional[str] = None
|
||||
rent_secd: Optional[str] = None
|
||||
region_code: Optional[str] = None
|
||||
region_name: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
total_units: Optional[int] = None
|
||||
rcrit_date: Optional[str] = None
|
||||
receipt_start: Optional[str] = None
|
||||
receipt_end: Optional[str] = None
|
||||
spsply_start: Optional[str] = None
|
||||
spsply_end: Optional[str] = None
|
||||
gnrl_rank1_start: Optional[str] = None
|
||||
gnrl_rank1_end: Optional[str] = None
|
||||
winner_date: Optional[str] = None
|
||||
contract_start: Optional[str] = None
|
||||
contract_end: Optional[str] = None
|
||||
homepage_url: Optional[str] = None
|
||||
pblanc_url: Optional[str] = None
|
||||
constructor: Optional[str] = None
|
||||
developer: Optional[str] = None
|
||||
move_in_month: Optional[str] = None
|
||||
is_speculative_area: Optional[str] = None
|
||||
is_price_cap: Optional[str] = None
|
||||
contact: Optional[str] = None
|
||||
|
||||
|
||||
class ProfileUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
age: Optional[int] = None
|
||||
is_homeless: Optional[bool] = None
|
||||
is_householder: Optional[bool] = None
|
||||
subscription_months: Optional[int] = None
|
||||
subscription_amount: Optional[int] = None
|
||||
family_members: Optional[int] = None
|
||||
has_dependents: Optional[bool] = None
|
||||
children_count: Optional[int] = None
|
||||
is_newlywed: Optional[bool] = None
|
||||
marriage_months: Optional[int] = None
|
||||
has_newborn: Optional[bool] = None
|
||||
is_first_home: Optional[bool] = None
|
||||
income_level: Optional[str] = None
|
||||
preferred_regions: Optional[List[str]] = None
|
||||
preferred_types: Optional[List[str]] = None
|
||||
min_area: Optional[float] = None
|
||||
max_area: Optional[float] = None
|
||||
max_price: Optional[int] = None
|
||||
5
realestate-lab/requirements.txt
Normal file
5
realestate-lab/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
requests==2.32.3
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.30.6
|
||||
apscheduler==3.10.4
|
||||
pydantic>=2.0
|
||||
@@ -1,21 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="/volume1/docker/webpage"
|
||||
|
||||
cd "$ROOT"
|
||||
|
||||
echo "[1/5] git fetch + pull"
|
||||
git fetch --all --prune
|
||||
git pull --ff-only
|
||||
|
||||
echo "[2/5] docker compose build"
|
||||
docker compose build --pull
|
||||
|
||||
echo "[3/5] docker compose up"
|
||||
docker compose up -d
|
||||
|
||||
echo "[4/5] status"
|
||||
docker compose ps
|
||||
|
||||
echo "[5/5] done"
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE="http://127.0.0.1"
|
||||
|
||||
echo "backend health:"
|
||||
curl -fsS "${BASE}:18000/health" | sed 's/^/ /'
|
||||
|
||||
echo "backend latest:"
|
||||
curl -fsS "${BASE}:18000/api/lotto/latest" | head -c 200; echo
|
||||
|
||||
echo "travel regions:"
|
||||
curl -fsS "${BASE}:19000/api/travel/regions" | head -c 200; echo
|
||||
|
||||
echo "OK"
|
||||
68
scripts/deploy-nas.sh
Normal file
68
scripts/deploy-nas.sh
Normal file
@@ -0,0 +1,68 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# 1. 자동 감지: Docker 컨테이너 내부인가?
|
||||
if [ -d "/repo" ] && [ -d "/runtime" ]; then
|
||||
echo "Detected Docker Container environment."
|
||||
SRC="/repo"
|
||||
DST="/runtime"
|
||||
else
|
||||
# 2. Host 환경: .env 로드 시도
|
||||
if [ -f ".env" ]; then
|
||||
echo "Loading .env file..."
|
||||
set -a; source .env; set +a
|
||||
fi
|
||||
|
||||
# 환경변수가 없으면 현재 디렉토리를 SRC로
|
||||
SRC="${REPO_PATH:-$(pwd)}"
|
||||
DST="${RUNTIME_PATH:-}"
|
||||
|
||||
if [ -z "$DST" ]; then
|
||||
echo "Error: RUNTIME_PATH is not set. Please create .env file with RUNTIME_PATH defined."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Source: $SRC"
|
||||
echo "Target: $DST"
|
||||
|
||||
cd "$SRC"
|
||||
|
||||
# 레포에서 운영으로 반영할 항목들만 복사/동기화 (필요한 것만 적기)
|
||||
# backend, travel-proxy, deployer, nginx, scripts, docker-compose.yml, .env 등
|
||||
RSYNC_OPTS="-rl --delete --no-owner --no-group --exclude .git --exclude __pycache__ --exclude *.pyc --exclude data/"
|
||||
|
||||
for dir in backend travel-proxy deployer stock-lab music-lab blog-lab realestate-lab nginx scripts; do
|
||||
rsync $RSYNC_OPTS "$SRC/$dir/" "$DST/$dir/" || {
|
||||
rc=$?
|
||||
if [ $rc -ne 23 ]; then
|
||||
echo "rsync failed for $dir with exit code $rc"
|
||||
exit $rc
|
||||
fi
|
||||
}
|
||||
done
|
||||
|
||||
# compose 파일만 동기화 (.env는 절대 동기화하지 않음 — 운영 시크릿 보호)
|
||||
rsync -rl --no-owner --no-group "$SRC/docker-compose.yml" "$DST/docker-compose.yml" || {
|
||||
rc=$?
|
||||
if [ $rc -ne 23 ]; then
|
||||
echo "rsync failed for docker-compose.yml with exit code $rc"
|
||||
exit $rc
|
||||
fi
|
||||
}
|
||||
|
||||
# 파일 권한 설정 — bgg8988:users 755
|
||||
# 호스트(bgg8988)에서는 본인 소유 파일만 변경 가능, deployer 컨테이너(root)에서는 전부 가능
|
||||
DEPLOY_USER="bgg8988"
|
||||
DEPLOY_GROUP="users"
|
||||
DEPLOY_MODE="755"
|
||||
|
||||
echo "Setting ownership ${DEPLOY_USER}:${DEPLOY_GROUP} and mode ${DEPLOY_MODE}..."
|
||||
for dir in backend travel-proxy deployer stock-lab music-lab blog-lab realestate-lab nginx scripts; do
|
||||
chown -R "${DEPLOY_USER}:${DEPLOY_GROUP}" "$DST/$dir/" 2>/dev/null || true
|
||||
chmod -R "$DEPLOY_MODE" "$DST/$dir/" 2>/dev/null || true
|
||||
done
|
||||
chown "${DEPLOY_USER}:${DEPLOY_GROUP}" "$DST/docker-compose.yml" 2>/dev/null || true
|
||||
chmod "$DEPLOY_MODE" "$DST/docker-compose.yml" 2>/dev/null || true
|
||||
|
||||
echo "SYNC_OK"
|
||||
93
scripts/deploy.sh
Normal file
93
scripts/deploy.sh
Normal file
@@ -0,0 +1,93 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# ── 동시 배포 방지 (flock) ──
|
||||
exec 200>/tmp/deploy.lock
|
||||
flock -n 200 || { echo "Deploy already running, skipping"; exit 0; }
|
||||
|
||||
# 1. 자동 감지: Docker 컨테이너 내부인가?
|
||||
if [ -d "/repo" ] && [ -d "/runtime" ]; then
|
||||
echo "Detected Docker Container environment."
|
||||
SRC="/repo"
|
||||
DST="/runtime"
|
||||
else
|
||||
# 2. Host 환경: .env 로드 시도
|
||||
if [ -f ".env" ]; then
|
||||
echo "Loading .env file..."
|
||||
set -a; source .env; set +a
|
||||
fi
|
||||
|
||||
SRC="${REPO_PATH:-$(pwd)}"
|
||||
DST="${RUNTIME_PATH:-/volume1/docker/webpage}"
|
||||
|
||||
if [ -z "$DST" ]; then
|
||||
echo "Error: RUNTIME_PATH is not set."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Source: $SRC"
|
||||
echo "Target: $DST"
|
||||
|
||||
git config --global --add safe.directory "$SRC"
|
||||
|
||||
cd "$SRC"
|
||||
git fetch --all --prune
|
||||
git pull --ff-only
|
||||
|
||||
# ── 릴리즈 백업 (롤백용) ──
|
||||
TAG="$(date +%Y%m%d-%H%M%S)"
|
||||
BACKUP_DIR="$DST/.releases/$TAG"
|
||||
mkdir -p "$BACKUP_DIR.tmp"
|
||||
rsync -a --delete \
|
||||
--exclude ".releases" \
|
||||
--exclude "data" \
|
||||
"$DST/" "$BACKUP_DIR.tmp/"
|
||||
mv "$BACKUP_DIR.tmp" "$BACKUP_DIR"
|
||||
|
||||
# 오래된 릴리즈 정리 (최근 5개만 유지)
|
||||
ls -dt "$DST/.releases"/*/ 2>/dev/null | tail -n +6 | xargs -r rm -rf
|
||||
|
||||
# ── 소스 → 운영 반영 ──
|
||||
bash "$SRC/scripts/deploy-nas.sh"
|
||||
|
||||
# ── data 디렉토리 보장 (볼륨 마운트 실패 방지) ──
|
||||
mkdir -p "$DST/data" "$DST/data/music" "$DST/data/stock" "$DST/data/blog" "$DST/data/realestate"
|
||||
|
||||
# ── 서비스 재빌드 (deployer 제외 — 자기 자신을 재빌드하면 스크립트 중단됨) ──
|
||||
cd "$DST"
|
||||
|
||||
BUILD_TARGETS="backend travel-proxy stock-lab music-lab blog-lab realestate-lab frontend"
|
||||
|
||||
# 1) compose가 관리하는 컨테이너 정리 (deployer 제외)
|
||||
docker compose stop $BUILD_TARGETS 2>/dev/null || true
|
||||
docker compose rm -f $BUILD_TARGETS 2>/dev/null || true
|
||||
|
||||
# 2) Synology NAS 고아 컨테이너(해시 prefix 포함) 추가 정리
|
||||
for cname in lotto-backend stock-lab music-lab blog-lab realestate-lab travel-proxy lotto-frontend; do
|
||||
docker rm -f "$cname" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# 3) 재빌드 및 시작
|
||||
docker compose up -d --build $BUILD_TARGETS
|
||||
docker exec lotto-frontend nginx -s reload 2>/dev/null || true
|
||||
|
||||
# ── 배포 후 헬스체크 ──
|
||||
echo "Waiting for services to start..."
|
||||
sleep 5
|
||||
|
||||
HEALTH_OK=true
|
||||
# 컨테이너 내부에서는 서비스명 + 내부포트(8000)로 접근
|
||||
for endpoint in "http://backend:8000/health" "http://stock-lab:8000/health" "http://travel-proxy:8000/health" "http://music-lab:8000/health" "http://blog-lab:8000/health" "http://realestate-lab:8000/health"; do
|
||||
if ! curl -sf --max-time 10 --retry 2 --retry-delay 3 "$endpoint" > /dev/null 2>&1; then
|
||||
echo "HEALTH_FAIL: $endpoint"
|
||||
HEALTH_OK=false
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$HEALTH_OK" = false ]; then
|
||||
echo "DEPLOY_FAIL: Some services failed health check. Backup available: $TAG"
|
||||
exit 1
|
||||
else
|
||||
echo "DEPLOY_OK $TAG"
|
||||
fi
|
||||
59
scripts/healthcheck.sh
Normal file
59
scripts/healthcheck.sh
Normal file
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# NAS 내부 헬스체크용 (localhost 사용)
|
||||
# 포트: backend(18000), travel-proxy(19000), frontend(8080)
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo "======================================"
|
||||
echo " Starting Health Check..."
|
||||
echo "======================================"
|
||||
|
||||
check_url() {
|
||||
local name="$1"
|
||||
local url="$2"
|
||||
|
||||
# HTTP 상태 코드만 가져옴 (타임아웃 5초)
|
||||
status=$(curl -o /dev/null -s -w "%{http_code}" --max-time 5 "$url" || echo "FAIL")
|
||||
|
||||
if [[ "$status" == "200" ]]; then
|
||||
echo -e "[${GREEN}OK${NC}] $name ($url) -> $status"
|
||||
else
|
||||
echo -e "[${RED}XX${NC}] $name ($url) -> $status"
|
||||
# 하나라도 실패하면 exit 1 (CI/CD용)
|
||||
# exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "--- 1. Backend Service ---"
|
||||
check_url "Backend Health" "http://localhost:18000/health"
|
||||
check_url "Lotto Latest" "http://localhost:18000/api/lotto/latest"
|
||||
check_url "Stats API" "http://localhost:18000/api/lotto/stats"
|
||||
|
||||
echo ""
|
||||
echo "--- 2. Travel Proxy Service ---"
|
||||
# Travel Proxy는 Main.py에서 루트(/) 엔드포인트가 없을 수 있어서 regions 체크
|
||||
check_url "Travel Regions" "http://localhost:19000/api/travel/regions"
|
||||
|
||||
echo ""
|
||||
echo "--- 3. Stock Lab Service ---"
|
||||
check_url "Stock Health" "http://localhost:18500/health"
|
||||
check_url "Stock News" "http://localhost:18500/api/stock/news"
|
||||
check_url "Stock Indices" "http://localhost:18500/api/stock/indices"
|
||||
|
||||
echo ""
|
||||
echo "--- 4. Frontend (Nginx) ---"
|
||||
# 외부 포트 8080으로 접속 테스트
|
||||
check_url "Frontend Home" "http://localhost:8080"
|
||||
# Nginx가 Backend로 잘 프록시하는지 체크 (실제 존재하는 api 호출)
|
||||
check_url "Nginx->Backend Proxy" "http://localhost:8080/api/lotto/latest"
|
||||
|
||||
echo ""
|
||||
echo "======================================"
|
||||
echo " Health Check Completed."
|
||||
echo "======================================"
|
||||
6
stock-lab/.dockerignore
Normal file
6
stock-lab/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
.git
|
||||
__pycache__
|
||||
*.pyc
|
||||
.env
|
||||
.env.*
|
||||
*.md
|
||||
9
stock-lab/Dockerfile
Normal file
9
stock-lab/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
||||
FROM python:3.12-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
276
stock-lab/app/db.py
Normal file
276
stock-lab/app/db.py
Normal file
@@ -0,0 +1,276 @@
|
||||
import sqlite3
|
||||
import os
|
||||
import hashlib
|
||||
from typing import List, Dict, Any
|
||||
|
||||
DB_PATH = "/app/data/stock.db"
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def init_db():
|
||||
with _conn() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS articles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
hash TEXT UNIQUE NOT NULL,
|
||||
category TEXT DEFAULT 'domestic',
|
||||
title TEXT NOT NULL,
|
||||
link TEXT,
|
||||
summary TEXT,
|
||||
press TEXT,
|
||||
pub_date TEXT,
|
||||
crawled_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_crawled ON articles(crawled_at DESC)")
|
||||
|
||||
# 컬럼 추가 (기존 테이블 마이그레이션)
|
||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(articles)").fetchall()}
|
||||
if "category" not in cols:
|
||||
conn.execute("ALTER TABLE articles ADD COLUMN category TEXT DEFAULT 'domestic'")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS portfolio (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
broker TEXT NOT NULL,
|
||||
ticker TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
avg_price INTEGER NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now','localtime')),
|
||||
updated_at TEXT DEFAULT (datetime('now','localtime'))
|
||||
)
|
||||
""")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS broker_cash (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
broker TEXT UNIQUE NOT NULL,
|
||||
cash INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT DEFAULT (datetime('now','localtime'))
|
||||
)
|
||||
""")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS asset_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT UNIQUE NOT NULL,
|
||||
total_eval INTEGER NOT NULL,
|
||||
total_cash INTEGER NOT NULL,
|
||||
total_assets INTEGER NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now','localtime'))
|
||||
)
|
||||
""")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS sell_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
broker TEXT NOT NULL,
|
||||
ticker TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
avg_price REAL NOT NULL,
|
||||
sell_price REAL NOT NULL,
|
||||
commission REAL NOT NULL DEFAULT 0,
|
||||
buy_amount REAL NOT NULL,
|
||||
sell_amount REAL NOT NULL,
|
||||
realized_profit REAL NOT NULL,
|
||||
realized_rate REAL NOT NULL,
|
||||
sold_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
# sell_history 마이그레이션: commission 컬럼 추가
|
||||
sh_cols = {r["name"] for r in conn.execute("PRAGMA table_info(sell_history)").fetchall()}
|
||||
if "commission" not in sh_cols:
|
||||
conn.execute("ALTER TABLE sell_history ADD COLUMN commission REAL NOT NULL DEFAULT 0")
|
||||
|
||||
def save_articles(articles: List[Dict[str, str]]) -> int:
|
||||
count = 0
|
||||
with _conn() as conn:
|
||||
for a in articles:
|
||||
# 중복 체크용 해시 (제목+링크)
|
||||
unique_str = f"{a['title']}|{a['link']}"
|
||||
h = hashlib.md5(unique_str.encode()).hexdigest()
|
||||
|
||||
try:
|
||||
cat = a.get("category", "domestic")
|
||||
conn.execute("""
|
||||
INSERT INTO articles (hash, category, title, link, summary, press, pub_date, crawled_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (h, cat, a['title'], a['link'], a['summary'], a['press'], a['date'], a['crawled_at']))
|
||||
count += 1
|
||||
except sqlite3.IntegrityError:
|
||||
pass # 이미 존재함
|
||||
return count
|
||||
|
||||
def get_latest_articles(limit: int = 20, category: str = None) -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
if category:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM articles WHERE category = ? ORDER BY crawled_at DESC, id DESC LIMIT ?",
|
||||
(category, limit)
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM articles ORDER BY crawled_at DESC, id DESC LIMIT ?",
|
||||
(limit,)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# --- Portfolio CRUD ---
|
||||
|
||||
def add_portfolio_item(broker: str, ticker: str, name: str, quantity: int, avg_price: int) -> int:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO portfolio (broker, ticker, name, quantity, avg_price) VALUES (?, ?, ?, ?, ?)",
|
||||
(broker, ticker, name, quantity, avg_price),
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def get_all_portfolio() -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM portfolio ORDER BY id").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_portfolio_item(item_id: int) -> Dict[str, Any] | None:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM portfolio WHERE id = ?", (item_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def update_portfolio_item(item_id: int, **kwargs) -> bool:
|
||||
allowed = {"broker", "ticker", "name", "quantity", "avg_price"}
|
||||
fields = {k: v for k, v in kwargs.items() if k in allowed and v is not None}
|
||||
if not fields:
|
||||
return False
|
||||
fields["updated_at"] = __import__("datetime").datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
set_clause = ", ".join(f"{k} = ?" for k in fields)
|
||||
values = list(fields.values()) + [item_id]
|
||||
with _conn() as conn:
|
||||
cur = conn.execute(f"UPDATE portfolio SET {set_clause} WHERE id = ?", values)
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def delete_portfolio_item(item_id: int) -> bool:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute("DELETE FROM portfolio WHERE id = ?", (item_id,))
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
# --- Broker Cash CRUD ---
|
||||
|
||||
def upsert_broker_cash(broker: str, cash: int) -> None:
|
||||
now = __import__("datetime").datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
with _conn() as conn:
|
||||
conn.execute("""
|
||||
INSERT INTO broker_cash (broker, cash, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(broker) DO UPDATE SET cash = excluded.cash, updated_at = excluded.updated_at
|
||||
""", (broker, cash, now))
|
||||
|
||||
|
||||
def get_all_broker_cash() -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM broker_cash ORDER BY broker").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def delete_broker_cash(broker: str) -> bool:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute("DELETE FROM broker_cash WHERE broker = ?", (broker,))
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
# --- Asset Snapshot CRUD ---
|
||||
|
||||
def upsert_asset_snapshot(date: str, total_eval: int, total_cash: int, total_assets: int) -> None:
|
||||
now = __import__("datetime").datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
with _conn() as conn:
|
||||
conn.execute("""
|
||||
INSERT INTO asset_snapshots (date, total_eval, total_cash, total_assets, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(date) DO UPDATE SET
|
||||
total_eval = excluded.total_eval,
|
||||
total_cash = excluded.total_cash,
|
||||
total_assets = excluded.total_assets,
|
||||
created_at = excluded.created_at
|
||||
""", (date, total_eval, total_cash, total_assets, now))
|
||||
|
||||
|
||||
# --- Sell History CRUD ---
|
||||
|
||||
def add_sell_history(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute("""
|
||||
INSERT INTO sell_history
|
||||
(broker, ticker, name, quantity, avg_price, sell_price,
|
||||
commission, buy_amount, sell_amount, realized_profit, realized_rate, sold_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
data["broker"], data["ticker"], data["name"], data["quantity"],
|
||||
data["avg_price"], data["sell_price"], data.get("commission", 0),
|
||||
data["buy_amount"], data["sell_amount"], data["realized_profit"],
|
||||
data["realized_rate"], data["sold_at"],
|
||||
))
|
||||
row = conn.execute("SELECT * FROM sell_history WHERE id = ?", (cur.lastrowid,)).fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
def get_sell_history(broker: str = None, days: int = None) -> List[Dict[str, Any]]:
|
||||
conditions = []
|
||||
params = []
|
||||
if broker:
|
||||
conditions.append("broker = ?")
|
||||
params.append(broker)
|
||||
if days:
|
||||
conditions.append("sold_at >= datetime('now', ? || ' days')")
|
||||
params.append(f"-{days}")
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM sell_history {where} ORDER BY sold_at DESC",
|
||||
params,
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def update_sell_history(record_id: int, data: Dict[str, Any]) -> Dict[str, Any] | None:
|
||||
fields = ["broker", "ticker", "name", "quantity", "avg_price", "sell_price",
|
||||
"commission", "buy_amount", "sell_amount", "realized_profit", "realized_rate", "sold_at"]
|
||||
set_clause = ", ".join(f"{f} = ?" for f in fields)
|
||||
values = [data.get(f, 0) if f == "commission" else data[f] for f in fields] + [record_id]
|
||||
with _conn() as conn:
|
||||
cur = conn.execute(f"UPDATE sell_history SET {set_clause} WHERE id = ?", values)
|
||||
if cur.rowcount == 0:
|
||||
return None
|
||||
row = conn.execute("SELECT * FROM sell_history WHERE id = ?", (record_id,)).fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
def delete_sell_history(record_id: int) -> bool:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute("DELETE FROM sell_history WHERE id = ?", (record_id,))
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def get_asset_snapshots(days: int = 30) -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
if days == 0:
|
||||
rows = conn.execute(
|
||||
"SELECT date, total_eval, total_cash, total_assets FROM asset_snapshots ORDER BY date ASC"
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT date, total_eval, total_cash, total_assets FROM asset_snapshots ORDER BY date DESC LIMIT ?",
|
||||
(days,)
|
||||
).fetchall()
|
||||
rows = list(reversed(rows))
|
||||
return [dict(r) for r in rows]
|
||||
18
stock-lab/app/holidays.json
Normal file
18
stock-lab/app/holidays.json
Normal file
@@ -0,0 +1,18 @@
|
||||
[
|
||||
"2026-01-01",
|
||||
"2026-01-28",
|
||||
"2026-01-29",
|
||||
"2026-01-30",
|
||||
"2026-03-01",
|
||||
"2026-05-05",
|
||||
"2026-05-25",
|
||||
"2026-06-06",
|
||||
"2026-08-15",
|
||||
"2026-09-24",
|
||||
"2026-09-25",
|
||||
"2026-09-26",
|
||||
"2026-10-03",
|
||||
"2026-10-09",
|
||||
"2026-12-25",
|
||||
"2026-12-31"
|
||||
]
|
||||
473
stock-lab/app/main.py
Normal file
473
stock-lab/app/main.py
Normal file
@@ -0,0 +1,473 @@
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from datetime import date as date_type
|
||||
from typing import Optional
|
||||
from fastapi import FastAPI, Query, Header, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import requests
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from pydantic import BaseModel
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
|
||||
logger = logging.getLogger("stock-lab")
|
||||
|
||||
from .db import (
|
||||
init_db, save_articles, get_latest_articles,
|
||||
add_portfolio_item, get_all_portfolio, get_portfolio_item,
|
||||
update_portfolio_item, delete_portfolio_item,
|
||||
upsert_broker_cash, get_all_broker_cash, delete_broker_cash,
|
||||
upsert_asset_snapshot, get_asset_snapshots,
|
||||
add_sell_history, get_sell_history, update_sell_history, delete_sell_history,
|
||||
)
|
||||
from .scraper import fetch_market_news, fetch_major_indices
|
||||
from .price_fetcher import get_current_prices
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# CORS 설정 (프론트엔드 접근 허용)
|
||||
_cors_origins = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080").split(",")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[o.strip() for o in _cors_origins],
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type"],
|
||||
)
|
||||
|
||||
scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
||||
|
||||
# Windows AI Server URL (NAS .env에서 설정)
|
||||
WINDOWS_AI_SERVER_URL = os.getenv("WINDOWS_AI_SERVER_URL", "http://192.168.0.5:8000")
|
||||
|
||||
# Admin API Key 인증
|
||||
ADMIN_API_KEY = os.getenv("ADMIN_API_KEY", "")
|
||||
|
||||
def verify_admin(x_admin_key: str = Header(None)):
|
||||
"""admin/trade 엔드포인트 보호용 API 키 검증"""
|
||||
if not ADMIN_API_KEY:
|
||||
return # 키 미설정 시 인증 비활성화 (개발 환경)
|
||||
if x_admin_key != ADMIN_API_KEY:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
# Anthropic API 프록시용 키 (서버 측 보관)
|
||||
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
||||
|
||||
# 공휴일 목록 로드
|
||||
_HOLIDAYS_PATH = os.path.join(os.path.dirname(__file__), "holidays.json")
|
||||
try:
|
||||
with open(_HOLIDAYS_PATH, "r") as f:
|
||||
_HOLIDAYS: set = set(json.load(f))
|
||||
except Exception:
|
||||
_HOLIDAYS = set()
|
||||
|
||||
def is_market_open(d: date_type) -> bool:
|
||||
return d.weekday() < 5 and d.strftime("%Y-%m-%d") not in _HOLIDAYS
|
||||
|
||||
|
||||
def _calc_portfolio_totals(items, prices):
|
||||
"""포트폴리오 총 매입/평가금 계산 (snapshot과 API에서 공용)"""
|
||||
total_buy = 0
|
||||
total_eval = 0
|
||||
for item in items:
|
||||
buy_amount = item["avg_price"] * item["quantity"]
|
||||
current_price = prices.get(item["ticker"], item["avg_price"])
|
||||
total_buy += buy_amount
|
||||
total_eval += current_price * item["quantity"]
|
||||
return total_buy, total_eval
|
||||
|
||||
|
||||
def save_daily_snapshot():
|
||||
today = date_type.today()
|
||||
if not is_market_open(today):
|
||||
logger.info(f"Snapshot: {today} 휴장일 — 스킵")
|
||||
return
|
||||
|
||||
today_str = today.strftime("%Y-%m-%d")
|
||||
items = get_all_portfolio()
|
||||
cash_rows = get_all_broker_cash()
|
||||
total_cash = sum(r["cash"] for r in cash_rows)
|
||||
|
||||
if items:
|
||||
tickers = list({item["ticker"] for item in items})
|
||||
prices = get_current_prices(tickers)
|
||||
_, total_eval = _calc_portfolio_totals(items, prices)
|
||||
else:
|
||||
total_eval = 0
|
||||
|
||||
total_assets = total_eval + total_cash
|
||||
upsert_asset_snapshot(today_str, total_eval, total_cash, total_assets)
|
||||
logger.info(f"Snapshot: {today_str} 저장 — eval={total_eval}, cash={total_cash}, total={total_assets}")
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
init_db()
|
||||
|
||||
# 매일 아침 8시 뉴스 스크랩 (NAS 자체 수행)
|
||||
scheduler.add_job(run_scraping_job, "cron", hour="8", minute="0")
|
||||
|
||||
# 평일 15:40 총 자산 스냅샷 저장
|
||||
scheduler.add_job(save_daily_snapshot, "cron", day_of_week="mon-fri", hour=15, minute=40)
|
||||
|
||||
# 앱 시작 시에도 한 번 실행 (데이터 없으면)
|
||||
if not get_latest_articles(1):
|
||||
run_scraping_job()
|
||||
|
||||
scheduler.start()
|
||||
|
||||
def run_scraping_job():
|
||||
logger.info("뉴스 스크래핑 시작")
|
||||
|
||||
articles_kr = fetch_market_news()
|
||||
count_kr = save_articles(articles_kr)
|
||||
|
||||
logger.info(f"스크래핑 완료: 국내 {count_kr}건")
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/stock/news")
|
||||
def get_news(limit: int = 20, category: str = None):
|
||||
"""최신 주식 뉴스 조회 (category: 'domestic' | 'overseas')"""
|
||||
return get_latest_articles(limit, category)
|
||||
|
||||
@app.get("/api/stock/indices")
|
||||
def get_indices():
|
||||
"""주요 지표(KOSPI 등) 실시간 크롤링 조회"""
|
||||
return fetch_major_indices()
|
||||
|
||||
@app.post("/api/stock/scrap")
|
||||
def trigger_scrap():
|
||||
"""수동 스크랩 트리거"""
|
||||
run_scraping_job()
|
||||
return {"ok": True}
|
||||
|
||||
# --- Trading API (Windows Proxy, 인증 필요) ---
|
||||
|
||||
@app.get("/api/trade/balance", dependencies=[Depends(verify_admin)])
|
||||
def get_balance():
|
||||
"""계좌 잔고 조회 (Windows AI Server Proxy)"""
|
||||
logger.info(f"Requesting Balance from {WINDOWS_AI_SERVER_URL}")
|
||||
resp = None
|
||||
try:
|
||||
resp = requests.get(f"{WINDOWS_AI_SERVER_URL}/trade/balance", timeout=5)
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"Balance Error: {resp.status_code}")
|
||||
return JSONResponse(status_code=resp.status_code, content=resp.json())
|
||||
return resp.json()
|
||||
except ValueError:
|
||||
status = resp.status_code if resp is not None else 502
|
||||
return JSONResponse(status_code=status, content={"error": f"Upstream error {status}"})
|
||||
except Exception as e:
|
||||
logger.error(f"Balance Connection Failed: {e}")
|
||||
return JSONResponse(status_code=500, content={"error": "Connection Failed"})
|
||||
|
||||
class OrderRequest(BaseModel):
|
||||
ticker: str
|
||||
action: str
|
||||
quantity: int
|
||||
price: int = 0
|
||||
reason: Optional[str] = "Manual Order"
|
||||
|
||||
@app.post("/api/trade/order", dependencies=[Depends(verify_admin)])
|
||||
def order_stock(req: OrderRequest):
|
||||
"""주식 매수/매도 주문 (Windows AI Server Proxy)"""
|
||||
logger.info(f"Order Request: {req.action} {req.ticker} x{req.quantity}")
|
||||
resp = None
|
||||
try:
|
||||
resp = requests.post(f"{WINDOWS_AI_SERVER_URL}/trade/order", json=req.model_dump(), timeout=10)
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"Order Error: {resp.status_code}")
|
||||
return JSONResponse(status_code=resp.status_code, content=resp.json())
|
||||
return resp.json()
|
||||
except ValueError:
|
||||
status = resp.status_code if resp is not None else 502
|
||||
return JSONResponse(status_code=status, content={"error": f"Upstream error {status}"})
|
||||
except Exception as e:
|
||||
logger.error(f"Order Connection Failed: {e}")
|
||||
return JSONResponse(status_code=500, content={"error": "Connection Failed"})
|
||||
|
||||
# --- AI Coach 프록시 (API 키를 서버에 보관) ---
|
||||
|
||||
class AiCoachRequest(BaseModel):
|
||||
model: str = "claude-haiku-4-5-20251001"
|
||||
prompt: str
|
||||
max_tokens: int = 1024
|
||||
|
||||
@app.post("/api/stock/ai-coach")
|
||||
def ai_coach(req: AiCoachRequest):
|
||||
"""AI 포트폴리오 코치 — Anthropic API 프록시 (API 키 서버 보관)"""
|
||||
if not ANTHROPIC_API_KEY:
|
||||
raise HTTPException(503, "AI Coach not configured (ANTHROPIC_API_KEY missing)")
|
||||
|
||||
allowed_models = {"claude-haiku-4-5-20251001", "claude-sonnet-4-6"}
|
||||
model = req.model if req.model in allowed_models else "claude-haiku-4-5-20251001"
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": ANTHROPIC_API_KEY,
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
json={
|
||||
"model": model,
|
||||
"max_tokens": req.max_tokens,
|
||||
"messages": [{"role": "user", "content": req.prompt}],
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"Anthropic API error: {resp.status_code}")
|
||||
return JSONResponse(status_code=resp.status_code, content={"error": "AI API error"})
|
||||
return resp.json()
|
||||
except requests.Timeout:
|
||||
return JSONResponse(status_code=504, content={"error": "AI API timeout"})
|
||||
except Exception as e:
|
||||
logger.error(f"AI Coach error: {e}")
|
||||
return JSONResponse(status_code=500, content={"error": "AI Coach failed"})
|
||||
|
||||
|
||||
|
||||
|
||||
@app.get("/api/version")
|
||||
def version():
|
||||
return {"version": os.getenv("APP_VERSION", "dev")}
|
||||
|
||||
|
||||
# --- Portfolio API ---
|
||||
|
||||
class PortfolioItemRequest(BaseModel):
|
||||
broker: str
|
||||
ticker: str
|
||||
name: str
|
||||
quantity: int
|
||||
avg_price: int
|
||||
|
||||
|
||||
class PortfolioUpdateRequest(BaseModel):
|
||||
broker: Optional[str] = None
|
||||
ticker: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
quantity: Optional[int] = None
|
||||
avg_price: Optional[int] = None
|
||||
|
||||
|
||||
@app.get("/api/portfolio")
|
||||
def get_portfolio():
|
||||
"""전체 포트폴리오 조회 (현재가 + 손익 + 예수금 포함)"""
|
||||
items = get_all_portfolio()
|
||||
cash_rows = get_all_broker_cash()
|
||||
total_cash = sum(r["cash"] for r in cash_rows)
|
||||
|
||||
if not items:
|
||||
return {
|
||||
"holdings": [],
|
||||
"cash": cash_rows,
|
||||
"summary": {
|
||||
"total_buy": 0,
|
||||
"total_eval": 0,
|
||||
"total_profit": 0,
|
||||
"total_profit_rate": 0.0,
|
||||
"total_cash": total_cash,
|
||||
"total_assets": total_cash,
|
||||
},
|
||||
}
|
||||
|
||||
tickers = list({item["ticker"] for item in items})
|
||||
prices = get_current_prices(tickers)
|
||||
|
||||
holdings = []
|
||||
total_buy = 0
|
||||
total_eval = 0
|
||||
|
||||
for item in items:
|
||||
current_price = prices.get(item["ticker"])
|
||||
buy_amount = item["avg_price"] * item["quantity"]
|
||||
eval_amount = current_price * item["quantity"] if current_price is not None else None
|
||||
profit_amount = (eval_amount - buy_amount) if eval_amount is not None else None
|
||||
profit_rate = round((profit_amount / buy_amount) * 100, 2) if (profit_amount is not None and buy_amount) else None
|
||||
|
||||
holdings.append({
|
||||
"id": item["id"],
|
||||
"broker": item["broker"],
|
||||
"ticker": item["ticker"],
|
||||
"name": item["name"],
|
||||
"quantity": item["quantity"],
|
||||
"avg_price": item["avg_price"],
|
||||
"current_price": current_price,
|
||||
"eval_amount": eval_amount,
|
||||
"profit_amount": profit_amount,
|
||||
"profit_rate": profit_rate,
|
||||
})
|
||||
|
||||
total_buy += buy_amount
|
||||
if eval_amount is not None:
|
||||
total_eval += eval_amount
|
||||
|
||||
total_profit = total_eval - total_buy
|
||||
total_profit_rate = round((total_profit / total_buy) * 100, 2) if total_buy else 0.0
|
||||
|
||||
return {
|
||||
"holdings": holdings,
|
||||
"cash": cash_rows,
|
||||
"summary": {
|
||||
"total_buy": total_buy,
|
||||
"total_eval": total_eval,
|
||||
"total_profit": total_profit,
|
||||
"total_profit_rate": total_profit_rate,
|
||||
"total_cash": total_cash,
|
||||
"total_assets": total_eval + total_cash,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/portfolio", status_code=201)
|
||||
def create_portfolio_item(req: PortfolioItemRequest):
|
||||
"""포트폴리오 종목 추가"""
|
||||
item_id = add_portfolio_item(req.broker, req.ticker, req.name, req.quantity, req.avg_price)
|
||||
return {"id": item_id, "ok": True}
|
||||
|
||||
|
||||
# --- Broker Cash API ---
|
||||
# /{item_id} 라우트보다 반드시 먼저 정의해야 /cash가 item_id로 매칭되지 않음
|
||||
|
||||
class BrokerCashRequest(BaseModel):
|
||||
broker: str
|
||||
cash: int
|
||||
|
||||
|
||||
@app.get("/api/portfolio/cash")
|
||||
def list_broker_cash():
|
||||
"""증권사별 예수금 전체 조회"""
|
||||
return get_all_broker_cash()
|
||||
|
||||
|
||||
@app.put("/api/portfolio/cash")
|
||||
def set_broker_cash(req: BrokerCashRequest):
|
||||
"""증권사 예수금 등록 또는 수정 (upsert)"""
|
||||
upsert_broker_cash(req.broker, req.cash)
|
||||
return {"ok": True, "broker": req.broker, "cash": req.cash}
|
||||
|
||||
|
||||
@app.delete("/api/portfolio/cash/{broker}")
|
||||
def remove_broker_cash(broker: str):
|
||||
"""증권사 예수금 삭제"""
|
||||
if not delete_broker_cash(broker):
|
||||
return JSONResponse(status_code=404, content={"error": "Broker not found"})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.put("/api/portfolio/{item_id}")
|
||||
def update_portfolio(item_id: int, req: PortfolioUpdateRequest):
|
||||
"""포트폴리오 종목 수정"""
|
||||
if get_portfolio_item(item_id) is None:
|
||||
return JSONResponse(status_code=404, content={"error": "Item not found"})
|
||||
update_portfolio_item(item_id, **req.model_dump())
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.delete("/api/portfolio/{item_id}")
|
||||
def delete_portfolio(item_id: int):
|
||||
"""포트폴리오 종목 삭제"""
|
||||
if not delete_portfolio_item(item_id):
|
||||
return JSONResponse(status_code=404, content={"error": "Item not found"})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# --- Asset Snapshot API ---
|
||||
|
||||
@app.post("/api/portfolio/snapshot")
|
||||
def create_snapshot():
|
||||
"""총 자산 스냅샷 수동 저장 (오늘 날짜 기준)"""
|
||||
today = date_type.today()
|
||||
today_str = today.strftime("%Y-%m-%d")
|
||||
|
||||
items = get_all_portfolio()
|
||||
cash_rows = get_all_broker_cash()
|
||||
total_cash = sum(r["cash"] for r in cash_rows)
|
||||
|
||||
if items:
|
||||
tickers = list({item["ticker"] for item in items})
|
||||
prices = get_current_prices(tickers)
|
||||
total_eval = sum(
|
||||
prices.get(item["ticker"], item["avg_price"]) * item["quantity"]
|
||||
for item in items
|
||||
)
|
||||
else:
|
||||
total_eval = 0
|
||||
|
||||
total_assets = total_eval + total_cash
|
||||
upsert_asset_snapshot(today_str, total_eval, total_cash, total_assets)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"snapshot": {
|
||||
"date": today_str,
|
||||
"total_eval": total_eval,
|
||||
"total_cash": total_cash,
|
||||
"total_assets": total_assets,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/portfolio/snapshot/history")
|
||||
def get_snapshot_history(days: int = Query(30, ge=0)):
|
||||
"""총 자산 스냅샷 이력 조회 (days=0: 전체, days=N: 최근 N일)"""
|
||||
snapshots = get_asset_snapshots(days)
|
||||
return {"snapshots": snapshots}
|
||||
|
||||
|
||||
# --- Sell History API ---
|
||||
|
||||
class SellHistoryRequest(BaseModel):
|
||||
broker: str
|
||||
ticker: str
|
||||
name: str
|
||||
quantity: int
|
||||
avg_price: float
|
||||
sell_price: float
|
||||
commission: float = 0
|
||||
buy_amount: float
|
||||
sell_amount: float
|
||||
realized_profit: float
|
||||
realized_rate: float
|
||||
sold_at: str
|
||||
|
||||
|
||||
@app.get("/api/portfolio/sell-history")
|
||||
def list_sell_history(broker: Optional[str] = None, days: Optional[int] = None):
|
||||
"""매도 내역 조회 (broker, days 필터 선택)"""
|
||||
records = get_sell_history(broker=broker, days=days)
|
||||
return {"records": records}
|
||||
|
||||
|
||||
@app.post("/api/portfolio/sell-history")
|
||||
def create_sell_history(req: SellHistoryRequest):
|
||||
"""매도 기록 저장"""
|
||||
record = add_sell_history(req.model_dump())
|
||||
return record
|
||||
|
||||
|
||||
@app.put("/api/portfolio/sell-history/{record_id}")
|
||||
def modify_sell_history(record_id: int, req: SellHistoryRequest):
|
||||
"""매도 기록 수정"""
|
||||
record = update_sell_history(record_id, req.model_dump())
|
||||
if record is None:
|
||||
return JSONResponse(status_code=404, content={"error": "not found"})
|
||||
return record
|
||||
|
||||
|
||||
@app.delete("/api/portfolio/sell-history/{record_id}")
|
||||
def remove_sell_history(record_id: int):
|
||||
"""매도 기록 삭제"""
|
||||
if not delete_sell_history(record_id):
|
||||
return JSONResponse(status_code=404, content={"error": "not found"})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
|
||||
|
||||
68
stock-lab/app/price_fetcher.py
Normal file
68
stock-lab/app/price_fetcher.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import time
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from typing import Optional
|
||||
|
||||
_cache: dict[str, tuple[Optional[int], float]] = {} # ticker -> (price, timestamp)
|
||||
_CACHE_TTL = 180 # 3분
|
||||
|
||||
_HEADERS = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/90.0.4430.93 Safari/537.36"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _fetch_from_mobile_api(ticker: str) -> Optional[int]:
|
||||
"""네이버 모바일 주식 API로 현재가 조회"""
|
||||
url = f"https://m.stock.naver.com/api/stock/{ticker}/basic"
|
||||
try:
|
||||
resp = requests.get(url, headers=_HEADERS, timeout=5)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
price_str = data.get("closePrice") or data.get("stockEndPrice") or ""
|
||||
price_str = str(price_str).replace(",", "").strip()
|
||||
return int(price_str) if price_str.isdigit() else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_from_html_fallback(ticker: str) -> Optional[int]:
|
||||
"""네이버 금융 HTML 폴백 (.no_today .blind 파싱)"""
|
||||
url = f"https://finance.naver.com/item/main.naver?code={ticker}"
|
||||
try:
|
||||
resp = requests.get(url, headers=_HEADERS, timeout=5)
|
||||
resp.raise_for_status()
|
||||
soup = BeautifulSoup(resp.content, "html.parser", from_encoding="cp949")
|
||||
tag = soup.select_one(".no_today .blind")
|
||||
if tag:
|
||||
price_str = tag.get_text(strip=True).replace(",", "")
|
||||
return int(price_str) if price_str.isdigit() else None
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_current_price(ticker: str) -> Optional[int]:
|
||||
"""단건 현재가 조회 (3분 캐시)"""
|
||||
now = time.time()
|
||||
cached = _cache.get(ticker)
|
||||
if cached and (now - cached[1]) < _CACHE_TTL:
|
||||
return cached[0]
|
||||
|
||||
price = _fetch_from_mobile_api(ticker)
|
||||
if price is None:
|
||||
price = _fetch_from_html_fallback(ticker)
|
||||
|
||||
_cache[ticker] = (price, now)
|
||||
return price
|
||||
|
||||
|
||||
def get_current_prices(tickers: list[str]) -> dict[str, Optional[int]]:
|
||||
"""배치 현재가 조회 (캐시 미스 종목만 실제 호출)"""
|
||||
result: dict[str, Optional[int]] = {}
|
||||
for ticker in tickers:
|
||||
result[ticker] = get_current_price(ticker)
|
||||
return result
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user