Compare commits
231 Commits
v0.1.0
...
955fc4ee1e
| Author | SHA1 | Date | |
|---|---|---|---|
| 955fc4ee1e | |||
| 1c255152d7 | |||
| 728428ce95 | |||
| 00a610c374 | |||
| 496646fb32 | |||
| cb6e2d992a | |||
| 7011d3ef3a | |||
| eb322b7450 | |||
| 4fde9e6f58 | |||
| 7d78fae77f | |||
| e82ff83a5f | |||
| fac2e65ed8 | |||
| 42242f86eb | |||
| c5682e07a7 | |||
| 8f0b1fbbfa | |||
| e88989d3c1 | |||
| f38631cdae | |||
| b2accba65a | |||
| 8d92e50009 | |||
| bd7875b36a | |||
| 5ac5cce0fe | |||
| ae4f0d4270 | |||
| 447c6babc3 | |||
| 6f62b34b12 | |||
| af3df87672 | |||
| c6de615271 | |||
| 7c4d7b4534 | |||
| cc17c29266 | |||
| 889dc417a9 | |||
| e16cf8f817 | |||
| d4a4849943 | |||
| 21721d34a0 | |||
| 86be8c2a53 | |||
| 753ecdbbf2 | |||
| 1ec45acb95 | |||
| d1fec71bdc | |||
| 4a8b0092d7 | |||
| e1ae0f7501 | |||
| adb5cdb54e | |||
| e691ed9a7d | |||
| c019ab1681 | |||
| c15ea96e2f | |||
| de015a2440 | |||
| 7acc1979c8 | |||
| 3152bc23f4 | |||
| b23346143f | |||
| b867b8ce13 | |||
| f3c7ce72de | |||
| 57b7a4921d | |||
| 916b04af6a | |||
| 43ee920617 | |||
| d11aadce8a | |||
| 5dd7b6d601 | |||
| 1d535519ef | |||
| de80ebd707 | |||
| 6e18782d3b | |||
| 86e7f727eb | |||
| de91f424a3 | |||
| cce84de8be | |||
| 678440a2bd | |||
| a3f9f1cb39 | |||
| 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 |
91
.env.example
91
.env.example
@@ -1,17 +1,84 @@
|
|||||||
# timezone
|
# ---------------------------------------------------------------------------
|
||||||
|
# [Environment Configuration]
|
||||||
|
# 이 파일을 복사하여 .env 파일을 생성하고, 환경에 맞게 주석을 해제/수정하여 사용하세요.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# [COMMON]
|
||||||
|
APP_VERSION=dev
|
||||||
TZ=Asia/Seoul
|
TZ=Asia/Seoul
|
||||||
|
|
||||||
COMPOSE_PROJECT_NAME=webpage
|
|
||||||
|
|
||||||
# backend lotto collector sources
|
|
||||||
LOTTO_ALL_URL=https://smok95.github.io/lotto/results/all.json
|
LOTTO_ALL_URL=https://smok95.github.io/lotto/results/all.json
|
||||||
LOTTO_LATEST_URL=https://smok95.github.io/lotto/results/latest.json
|
LOTTO_LATEST_URL=https://smok95.github.io/lotto/results/latest.json
|
||||||
|
|
||||||
# travel-proxy
|
# [SECURITY]
|
||||||
TRAVEL_ROOT=/data/travel
|
WEBHOOK_SECRET=change_this_secret_in_prod
|
||||||
TRAVEL_THUMB_ROOT=/data/thumbs
|
|
||||||
TRAVEL_MEDIA_BASE=/media/travel
|
|
||||||
TRAVEL_CACHE_TTL=300
|
|
||||||
|
|
||||||
# CORS (travel-proxy)
|
# [PATHS]
|
||||||
CORS_ALLOW_ORIGINS=*
|
# 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 프록시 + 뉴스 요약 Claude provider)
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
ANTHROPIC_MODEL=claude-haiku-4-5-20251001
|
||||||
|
|
||||||
|
# 뉴스 요약 provider 전환: claude (기본) | ollama
|
||||||
|
LLM_PROVIDER=claude
|
||||||
|
|
||||||
|
# Ollama 서버 (LLM_PROVIDER=ollama 일 때만 사용)
|
||||||
|
OLLAMA_URL=http://192.168.45.59:11435
|
||||||
|
OLLAMA_MODEL=qwen3:14b
|
||||||
|
|
||||||
|
# [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
|
||||||
|
|||||||
514
CLAUDE.md
Normal file
514
CLAUDE.md
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
# CLAUDE.md — web-backend 프로젝트 가이드
|
||||||
|
|
||||||
|
> Claude Code가 이 프로젝트를 작업할 때 참조하는 설정 및 구조 문서.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 프로젝트 개요
|
||||||
|
|
||||||
|
Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||||
|
- **서비스**: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, deployer (8개)
|
||||||
|
- **프론트엔드**: 별도 레포 (React + Vite SPA), 빌드 산출물만 NAS에 배포
|
||||||
|
- **인프라**: Docker Compose (9컨테이너) + 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 RTX 5070 Ti (16GB VRAM) + 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` | 주간 공략 리포트 캐시 |
|
||||||
|
| `lotto_briefings` | AI 큐레이터 주간 브리핑 (5세트 + 내러티브 + 토큰·비용 집계) |
|
||||||
|
| `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}` | 블로그 글 삭제 |
|
||||||
|
| GET | `/api/lotto/curator/candidates` | 큐레이터용 후보 N세트 + 피처 |
|
||||||
|
| GET | `/api/lotto/curator/context` | 주간 맥락(핫/콜드·직전 회차) |
|
||||||
|
| GET | `/api/lotto/curator/usage` | 큐레이터 토큰·비용 집계 |
|
||||||
|
| POST | `/api/lotto/briefing` | AI 브리핑 저장 |
|
||||||
|
| GET | `/api/lotto/briefing/latest` | 최신 브리핑 |
|
||||||
|
| GET | `/api/lotto/briefing/{draw_no}` | 특정 회차 브리핑 |
|
||||||
|
| GET | `/api/lotto/briefing` | 브리핑 이력 |
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
- DB: `/data/thumbs/travel.db` (photos, album_covers 테이블)
|
||||||
|
- 메타: `/data/travel/_meta/region_map.json`, `regions.geojson`
|
||||||
|
- 지역 오버라이드: `/data/thumbs/region_map_extra.json` (RW, `_regions_meta` 포함)
|
||||||
|
- 파일 구조: `main.py`, `db.py`, `indexer.py`
|
||||||
|
- 썸네일: 480×480 리사이징 (Pillow), 동기화 시 사전 생성 + 온디맨드 폴백
|
||||||
|
- 데이터 흐름: 수동 sync → 폴더 스캔 → SQLite 인덱싱 + 썸네일 일괄 생성
|
||||||
|
|
||||||
|
**travel.db 테이블**
|
||||||
|
|
||||||
|
| 테이블 | 설명 |
|
||||||
|
|--------|------|
|
||||||
|
| `photos` | 사진 인덱스 (album, filename, mtime, has_thumb) |
|
||||||
|
| `album_covers` | 앨범별 커버 사진 지정 |
|
||||||
|
|
||||||
|
**지역 관리 아키텍처**
|
||||||
|
- `region_map.json` (RO): 원본 지역→앨범 매핑 (`_meta/` 안에 위치)
|
||||||
|
- `region_map_extra.json` (RW): 사용자 수정분 오버라이드 (앨범 이동, 신규 지역)
|
||||||
|
- `_regions_meta`: 커스텀 지역의 이름·좌표 저장 (`{ "region_id": { "name": "...", "coordinates": [lng, lat] } }`)
|
||||||
|
- `regions.geojson` (RO): GeoJSON Polygon 지역 경계
|
||||||
|
- 커스텀 지역: `GET /api/travel/regions`에서 `region_map`에 있지만 GeoJSON에 없는 지역을 자동 추가 (Point geometry 또는 null)
|
||||||
|
|
||||||
|
**travel-proxy API 목록**
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/travel/regions` | 지역 GeoJSON (커스텀 지역 동적 추가 포함) |
|
||||||
|
| GET | `/api/travel/photos` | 사진 목록 (region, page=1, size=20) |
|
||||||
|
| POST | `/api/travel/sync` | 폴더 스캔 → DB 동기화 + 썸네일 생성 |
|
||||||
|
| GET | `/api/travel/albums` | 앨범 목록 + 사진 수 + 커버 + region/regionName |
|
||||||
|
| PUT | `/api/travel/albums/{album}/cover` | 앨범 커버 지정 |
|
||||||
|
| PUT | `/api/travel/albums/{album}/region` | 앨범 지역 변경 (region_map_extra 수정) |
|
||||||
|
| PUT | `/api/travel/regions/{region_id}` | 커스텀 지역 이름/좌표 수정 (지도 핀 표시용) |
|
||||||
|
|
||||||
|
### 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
|
||||||
|
- `TELEGRAM_WIFE_CHAT_ID`: 아내 chat.id (브리핑 공유 + 대화 허용)
|
||||||
|
- `ANTHROPIC_API_KEY`: 자연어 대화용 Claude API 키 (미설정 시 대화 비활성)
|
||||||
|
- `CONVERSATION_MODEL`: 대화 모델 (기본 `claude-haiku-4-5-20251001`)
|
||||||
|
- `CONVERSATION_HISTORY_LIMIT`: 이력 주입 수 (기본 20)
|
||||||
|
- `CONVERSATION_RATE_PER_MIN`: 채팅당 분당 최대 메시지 (기본 6)
|
||||||
|
- `LOTTO_BACKEND_URL`: 기본 `http://lotto-backend:8000`
|
||||||
|
- `LOTTO_CURATOR_MODEL`: 기본 `claude-sonnet-4-5`
|
||||||
|
|
||||||
|
**텔레그램 자연어 대화 (옵션 B)**
|
||||||
|
- 슬래시 명령이 아닌 일반 문장을 보내면 Claude Haiku 4.5가 응답
|
||||||
|
- 프롬프트 캐싱: `system` 블록 + 히스토리 마지막 블록에 `cache_control: ephemeral` → 5분 TTL
|
||||||
|
- 허용 chat_id 화이트리스트: `TELEGRAM_CHAT_ID`, `TELEGRAM_WIFE_CHAT_ID`
|
||||||
|
- 평가 지표: `conversation_messages` 테이블에 tokens / cache_read / cache_write / latency 기록
|
||||||
|
- 조회: `GET /api/agent-office/conversation/stats?days=7`
|
||||||
|
|
||||||
|
**스케줄러 job**
|
||||||
|
- 07:30 매일 — 주식 뉴스 요약 (`stock_news_job`)
|
||||||
|
- 매주 월요일 07:00 — 로또 큐레이터 브리핑 (`lotto_curate`)
|
||||||
|
- 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` | 전체 에이전트 상태 조회 |
|
||||||
|
| GET | `/api/agent-office/conversation/stats` | 텔레그램 자연어 대화 토큰·캐시 통계 (`days` 필터) |
|
||||||
|
|
||||||
|
### 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`으로 비활성화 후 신규 입력
|
||||||
357
README.md
357
README.md
@@ -0,0 +1,357 @@
|
|||||||
|
# web-backend
|
||||||
|
|
||||||
|
Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
|
||||||
|
로또 분석, 주식 포트폴리오, AI 음악 생성, 블로그 마케팅, 부동산 청약, AI 에이전트 오피스, 여행 앨범을 하나의 Docker Compose 스택으로 운영한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 서비스 구성
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ lotto-frontend (Nginx:8080) │
|
||||||
|
│ ├── 정적 SPA 서빙 (React + Vite) │
|
||||||
|
│ └── API 리버스 프록시 │
|
||||||
|
│ ├── /api/ → lotto-backend:8000 (로또·블로그·투두)│
|
||||||
|
│ ├── /api/stock/, /trade/ → stock-lab:8000 │
|
||||||
|
│ ├── /api/portfolio → stock-lab:8000 │
|
||||||
|
│ ├── /api/music/ → music-lab:8000 │
|
||||||
|
│ ├── /api/blog-marketing/ → blog-lab:8000 │
|
||||||
|
│ ├── /api/realestate/ → realestate-lab:8000 │
|
||||||
|
│ ├── /api/agent-office/ → agent-office:8000 (+ WebSocket) │
|
||||||
|
│ ├── /api/travel/ → travel-proxy:8000 │
|
||||||
|
│ ├── /media/music/… (nginx 직접 서빙, 생성 오디오) │
|
||||||
|
│ ├── /media/travel/… (nginx 직접 서빙, 사진/썸네일) │
|
||||||
|
│ └── /webhook → deployer:9000 │
|
||||||
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| 컨테이너 | 포트 | 역할 |
|
||||||
|
|---------|------|------|
|
||||||
|
| `lotto-backend` | 18000 | 로또 데이터 수집·분석·추천 + 블로그·투두 API |
|
||||||
|
| `stock-lab` | 18500 | 주식 뉴스·AI 요약·KIS 실계좌·포트폴리오·자산 추적 |
|
||||||
|
| `music-lab` | 18600 | AI 음악 생성 (Suno + 로컬 MusicGen 듀얼 프로바이더) |
|
||||||
|
| `blog-lab` | 18700 | 블로그 마케팅 수익화 (키워드→글 생성→리뷰→발행) |
|
||||||
|
| `realestate-lab` | 18800 | 청약 공고 자동 수집·프로필 매칭 |
|
||||||
|
| `agent-office` | 18900 | AI 에이전트 가상 오피스 (WebSocket + 텔레그램 봇) |
|
||||||
|
| `travel-proxy` | 19000 | 여행 사진 API + 온디맨드 썸네일 |
|
||||||
|
| `lotto-frontend` | 8080 | SPA 서빙 + 리버스 프록시 |
|
||||||
|
| `webpage-deployer` | 19010 | Gitea Webhook → 자동 배포 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 디렉토리 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
web-backend/
|
||||||
|
├── backend/ # lotto-backend (로또·블로그·투두)
|
||||||
|
├── stock-lab/ # 주식·포트폴리오
|
||||||
|
├── music-lab/ # AI 음악 생성
|
||||||
|
├── blog-lab/ # 블로그 마케팅 파이프라인
|
||||||
|
├── realestate-lab/ # 청약 자동 수집·매칭
|
||||||
|
├── agent-office/ # AI 에이전트 오피스 (WS + 텔레그램)
|
||||||
|
├── travel-proxy/ # 여행 사진 + 썸네일
|
||||||
|
├── deployer/ # Gitea Webhook 수신 → 자동 배포
|
||||||
|
├── nginx/default.conf # 리버스 프록시 + SPA + 캐시
|
||||||
|
├── scripts/ # deploy.sh, deploy-nas.sh, healthcheck.sh
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── .env.example
|
||||||
|
└── CLAUDE.md # Claude Code 작업용 상세 컨텍스트
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 빠른 시작 (로컬 개발)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
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 |
|
||||||
|
| music-lab | http://localhost:18600 |
|
||||||
|
| blog-lab | http://localhost:18700 |
|
||||||
|
| realestate-lab | http://localhost:18800 |
|
||||||
|
| agent-office | http://localhost:18900 |
|
||||||
|
| travel-proxy | http://localhost:19000 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 서비스별 기능
|
||||||
|
|
||||||
|
### 1. lotto-backend (`/api/`)
|
||||||
|
|
||||||
|
로또 당첨번호 수집·통계 분석·몬테카를로 시뮬레이션 기반 추천 + 투두·블로그 CRUD.
|
||||||
|
|
||||||
|
- **로또**: 당첨번호 조회, 5종 통계 분석, 시뮬레이션 최적 번호(`best_picks` 20쌍), 통계/히트맵/스마트/배치 추천, 전략 가중치(EMA+Softmax), 구매 이력 관리
|
||||||
|
- **추천 이력**: 즐겨찾기·태그·메모 관리
|
||||||
|
- **투두리스트**: UUID PK, 상태(todo/in_progress/done)
|
||||||
|
- **블로그**: 일기형 포스트 (tags JSON 배열, date DESC)
|
||||||
|
|
||||||
|
**스케줄러**
|
||||||
|
- 09:10 / 21:10 — 당첨번호 동기화 + 추천 채점
|
||||||
|
- 00:05, 04:05, 08:05, 12:05, 16:05, 20:05 — 몬테카를로 시뮬레이션 (후보 20,000 → 상위 100 → best_picks 20쌍 교체)
|
||||||
|
|
||||||
|
### 2. stock-lab (`/api/stock/`, `/api/trade/`, `/api/portfolio`)
|
||||||
|
|
||||||
|
주식 뉴스 스크래핑 + LLM 요약 + KIS 실계좌 연동 + 포트폴리오·자산 스냅샷.
|
||||||
|
|
||||||
|
- **뉴스**: 네이버 증권 + 해외 사이트 크롤링, LLM 기반 한국어 요약
|
||||||
|
- **실계좌**: Windows AI 서버(192.168.45.59:8000) 프록시 → KIS Open API (잔고/주문)
|
||||||
|
- **포트폴리오**: 종목·예수금·매도 히스토리 관리, 현재가 자동 조회
|
||||||
|
- **자산 스냅샷**: 평일 15:40 자동 저장 (KRX 공휴일 판별, `holidays.json` 매년 갱신)
|
||||||
|
|
||||||
|
**LLM provider 전환** — `LLM_PROVIDER` 환경변수
|
||||||
|
- `claude` (기본): Anthropic Messages API (`claude-haiku-4-5`)
|
||||||
|
- `ollama`: Windows AI 서버 Ollama (`qwen3:14b`)
|
||||||
|
|
||||||
|
**현재가 조회**: 네이버 모바일 API → HTML 파싱 폴백, 3분 TTL 메모리 캐시
|
||||||
|
|
||||||
|
### 3. music-lab (`/api/music/`)
|
||||||
|
|
||||||
|
듀얼 프로바이더 AI 음악 생성.
|
||||||
|
|
||||||
|
- **Suno** (`suno`): REST API 연동, 보컬·가사·인스트루멘탈. 1회 요청 시 2개 variation 생성, 곡 연장, 보컬 분리, WAV 변환, 12스템 분리, 뮤직비디오, AI Cover 등 풀 스위트 지원
|
||||||
|
- **로컬 MusicGen** (`local`): Windows AI PC(RTX 5070 Ti, 16GB VRAM) 인스트루멘탈 전용
|
||||||
|
- **라이브러리**: 생성 파일은 `/app/data/music/`에 저장되고 Nginx가 `/media/music/`으로 직접 서빙
|
||||||
|
- **가사 도구**: 저장·편집·타임스탬프 기반 가라오케 동기
|
||||||
|
|
||||||
|
### 4. blog-lab (`/api/blog-marketing/`)
|
||||||
|
|
||||||
|
블로그 마케팅 수익화 4단계 파이프라인 (`draft → marketed → reviewed → published`).
|
||||||
|
|
||||||
|
```
|
||||||
|
리서치(Naver Search + 상위 블로그 본문 크롤링)
|
||||||
|
→ 작가(AI 초안 생성)
|
||||||
|
→ 마케터(전환율 강화 + 브랜드 링크 삽입)
|
||||||
|
→ 평가자(6기준×10점, 42/60 통과 시 published)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **AI 엔진**: Claude API (`claude-sonnet-4-20250514`)
|
||||||
|
- **키워드 분석**: 네이버 검색(블로그+쇼핑) API + 경쟁도/기회 점수
|
||||||
|
- **수익 추적**: 포스트별 월간 클릭/구매/수익 기록
|
||||||
|
- **프롬프트 템플릿**: DB에 저장 → 코드 배포 없이 수정 가능
|
||||||
|
|
||||||
|
### 5. realestate-lab (`/api/realestate/`)
|
||||||
|
|
||||||
|
공공데이터포털 청약홈 API 연동 + 프로필 기반 자동 매칭.
|
||||||
|
|
||||||
|
- **공고 수집**: 09:00 매일 자동 (`DATA_GO_KR_API_KEY` 필요)
|
||||||
|
- **상태 갱신 + 재매칭**: 00:00 매일 자동
|
||||||
|
- **프로필 매칭**: 지역·주택형·소득·부양가족 등으로 점수화, 신규 매칭 알림
|
||||||
|
- **대시보드**: 진행 중 공고수, 신규 매칭수, 다가오는 일정 요약
|
||||||
|
|
||||||
|
### 6. agent-office (`/api/agent-office/`)
|
||||||
|
|
||||||
|
AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 4명의 에이전트가 실제 작업을 수행한다.
|
||||||
|
|
||||||
|
- **아키텍처**: stock-lab / music-lab / blog-lab / realestate-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음)
|
||||||
|
- **FSM 상태**: `idle → working → waiting(승인 대기) → reporting → break`
|
||||||
|
- **실시간 동기화**: WebSocket `/api/agent-office/ws` (init, agent_state, task_complete, command_result)
|
||||||
|
- **텔레그램 연동**: 양방향 알림 + 인라인 키보드 승인
|
||||||
|
- 봇이 작업 결과를 텔레그램으로 푸시, 명령은 텔레그램에서 바로 에이전트에 전달
|
||||||
|
- Webhook 검증 후 `chat.id` 기준 라우팅
|
||||||
|
|
||||||
|
#### 에이전트 구성
|
||||||
|
|
||||||
|
| 에이전트 | 스케줄 | 승인 | 주요 기능 |
|
||||||
|
|---------|--------|-----|----------|
|
||||||
|
| 📈 **주식 트레이더** (`stock`) | 08:00 매일 | — | 뉴스 요약 (LLM) → 텔레그램 아침 브리핑, 종목 알람 등록 |
|
||||||
|
| 🎵 **음악 프로듀서** (`music`) | 수동 트리거 | ✅ 작곡 | 프롬프트 수신 → 승인 → Suno API 작곡 → 트랙 푸시 |
|
||||||
|
| ✍️ **블로그 마케터** (`blog`) | 10:00 매일 | ✅ 발행 | 트렌드 키워드 1개 선택 → 리서치→작가→마케터→평가 자동 실행 → 점수·본문을 텔레그램 승인 요청 → 승인 시 `published` 전환, 거절 시 재생성 |
|
||||||
|
| 🏢 **청약 애널리스트** (`realestate`) | 09:15 매일 | — | realestate-lab 수집 트리거 → 신규 매칭 상위 5건 + 대시보드 요약을 텔레그램 리포트 (읽음 처리 자동) |
|
||||||
|
|
||||||
|
#### 에이전트별 명령
|
||||||
|
|
||||||
|
**Stock** — `fetch_news`, `list_alerts`, `add_alert`, `test_telegram`
|
||||||
|
**Music** — `compose` (승인 필요), `credits`
|
||||||
|
**Blog** — `research {keyword}`, `add_trend_keyword`, `list_trend_keywords`
|
||||||
|
**Realestate** — `fetch_matches`, `dashboard`
|
||||||
|
|
||||||
|
#### 스케줄러 잡
|
||||||
|
|
||||||
|
- 07:00 월요일 — Lotto: AI 큐레이터 브리핑 (5세트 + 내러티브)
|
||||||
|
- 07:30 — Stock: 뉴스 요약
|
||||||
|
- 09:15 — Realestate: 매칭 리포트
|
||||||
|
- 10:00 — Blog: 자동 파이프라인 (리서치→생성→리뷰→승인 대기)
|
||||||
|
- 60초 interval — 유휴 에이전트 휴식 체크
|
||||||
|
|
||||||
|
### 7. travel-proxy (`/api/travel/`)
|
||||||
|
|
||||||
|
여행 사진 API + SQLite 인덱스 + 온디맨드 썸네일 + 지역 관리.
|
||||||
|
|
||||||
|
- 원본: `/data/travel/` (RO 마운트)
|
||||||
|
- 썸네일: 480×480 Pillow 리사이징, `/data/thumbs/` 영구 캐시 (tmp → rename 원자성 보장)
|
||||||
|
- DB: `/data/thumbs/travel.db` (photos, album_covers 테이블)
|
||||||
|
- 메타: `region_map.json` (RO) + `region_map_extra.json` (RW 오버라이드) + `regions.geojson`
|
||||||
|
- 지역 관리: 앨범 지역 변경, 커스텀 지역 생성, 지도 핀 좌표 지정
|
||||||
|
- 데이터 흐름: 수동 sync → 폴더 스캔 → SQLite 인덱싱 + 썸네일 일괄 생성
|
||||||
|
|
||||||
|
### 8. deployer (`/webhook`)
|
||||||
|
|
||||||
|
Gitea Webhook 수신 → NAS 자동 배포.
|
||||||
|
|
||||||
|
- HMAC SHA256 서명 검증 (`compare_digest`, `WEBHOOK_SECRET`)
|
||||||
|
- 수신 즉시 200 응답 후 BackgroundTask로 배포
|
||||||
|
- 배포 스크립트: `git pull` → `.releases/` 백업 → `rsync` → `docker compose up -d --build` → `chown PUID:PGID`
|
||||||
|
- 타임아웃 10분
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 핵심 로직
|
||||||
|
|
||||||
|
### 몬테카를로 시뮬레이션 (lotto-backend)
|
||||||
|
|
||||||
|
```
|
||||||
|
역대 당첨번호 분석 → 번호별 가중치 산출
|
||||||
|
→ 가중 확률 샘플링으로 후보 20,000개 생성
|
||||||
|
→ 5가지 기법으로 각 조합 점수화
|
||||||
|
→ 상위 100개 DB 저장 → best_picks 20개 교체
|
||||||
|
```
|
||||||
|
|
||||||
|
| 기법 | 가중치 | 내용 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 빈도 Z-score | 25% | 번호 출현 빈도의 표준편차 |
|
||||||
|
| 조합 지문 | 30% | 합계 정규분포 + 홀짝 비율 + 구간분포 |
|
||||||
|
| 갭 분석 | 20% | 마지막 출현 이후 경과 회차 |
|
||||||
|
| 공동 출현 | 15% | 번호 쌍 동시 출현 빈도 |
|
||||||
|
| 다양성 | 10% | 연속번호·범위·구간 커버리지 |
|
||||||
|
|
||||||
|
### LLM 요약 provider 추상화 (stock-lab)
|
||||||
|
|
||||||
|
`ai_summarizer.py`는 provider 분리 구조. `summarize_news(articles)` 시그니처는 provider와 무관하게 고정.
|
||||||
|
|
||||||
|
- `_summarize_with_claude`: Anthropic Messages API 직접 호출 (httpx, SDK 의존성 없음)
|
||||||
|
- `_summarize_with_ollama`: Ollama `/api/generate` (타임아웃 180s, qwen3:14b 첫 로드 대응)
|
||||||
|
- 실패 시 `LLMError` (구 `OllamaError` alias 유지)
|
||||||
|
|
||||||
|
### 총 자산 스냅샷 (stock-lab)
|
||||||
|
|
||||||
|
평일 15:40 자동 실행 → `holidays.json`으로 공휴일 스킵 → 포트폴리오 현재가 조회 + 예수금 합계 → `asset_snapshots` upsert (date UNIQUE).
|
||||||
|
|
||||||
|
### 에이전트 FSM + WS 동기화 (agent-office)
|
||||||
|
|
||||||
|
DB에 저장된 에이전트 상태가 바뀔 때마다 `websocket_manager`가 전체 클라이언트에 브로드캐스트. 텔레그램 봇은 `waiting` 상태 작업에 인라인 키보드를 붙여 승인 요청. 승인/거부 결과가 DB → WS → 프론트로 전파.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 자동 배포
|
||||||
|
|
||||||
|
```
|
||||||
|
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에 수동 업로드 (`scripts/deploy.bat --frontend`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터베이스
|
||||||
|
|
||||||
|
각 서비스는 독립 SQLite DB를 `/app/data/` 볼륨에 저장.
|
||||||
|
|
||||||
|
| DB | 소유 서비스 | 주요 테이블 |
|
||||||
|
|----|------------|-----------|
|
||||||
|
| `lotto.db` | lotto-backend | draws, recommendations, simulation_runs/candidates, best_picks, purchase_history, strategy_performance/weights, weekly_reports, lotto_briefings, todos, blog_posts |
|
||||||
|
| `stock.db` | stock-lab | articles, portfolio, broker_cash, asset_snapshots, sell_history |
|
||||||
|
| `music.db` | music-lab | music_tasks, music_library (provider, lyrics, image_url, suno_id, file_hash, cover_images, wav_url, video_url, stem_urls) |
|
||||||
|
| `blog_marketing.db` | blog-lab | keyword_analyses, blog_posts, brand_links, commissions, generation_tasks, prompt_templates |
|
||||||
|
| `realestate.db` | realestate-lab | announcements, announcement_models, user_profile, match_results, collect_log |
|
||||||
|
| `agent_office.db` | agent-office | agent_config, agent_tasks, agent_logs, telegram_state, conversation_messages |
|
||||||
|
| `travel.db` | travel-proxy | photos (album, filename, mtime, has_thumb), album_covers |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 환경변수
|
||||||
|
|
||||||
|
```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
|
||||||
|
|
||||||
|
# LLM (stock-lab, blog-lab, agent-office 공통)
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
ANTHROPIC_MODEL=claude-haiku-4-5-20251001
|
||||||
|
LLM_PROVIDER=claude # claude | ollama
|
||||||
|
OLLAMA_URL=http://192.168.45.59:11435
|
||||||
|
OLLAMA_MODEL=qwen3:14b
|
||||||
|
|
||||||
|
# music-lab
|
||||||
|
SUNO_API_KEY=
|
||||||
|
MUSIC_AI_SERVER_URL=
|
||||||
|
MUSIC_MEDIA_BASE=/media/music
|
||||||
|
|
||||||
|
# blog-lab
|
||||||
|
NAVER_CLIENT_ID=
|
||||||
|
NAVER_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# realestate-lab
|
||||||
|
DATA_GO_KR_API_KEY=
|
||||||
|
|
||||||
|
# agent-office
|
||||||
|
TELEGRAM_BOT_TOKEN=
|
||||||
|
TELEGRAM_CHAT_ID=
|
||||||
|
TELEGRAM_WEBHOOK_URL=
|
||||||
|
STOCK_LAB_URL=http://stock-lab:8000
|
||||||
|
MUSIC_LAB_URL=http://music-lab:8000
|
||||||
|
BLOG_LAB_URL=http://blog-lab:8000
|
||||||
|
REALESTATE_LAB_URL=http://realestate-lab:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 인프라
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|----|
|
||||||
|
| 장비 | Synology NAS (Intel Celeron J4025, 18GB RAM) |
|
||||||
|
| Docker | Synology Container Manager |
|
||||||
|
| Git 서버 | Gitea (NAS 내부 self-hosted, `gahusb.synology.me`) |
|
||||||
|
| AI 서버 | Windows PC (192.168.45.59) — RTX 5070 Ti (16GB VRAM) + Ollama + MusicGen |
|
||||||
|
| Python | 3.12 (`slim` 기반 이미지) |
|
||||||
|
| DB | SQLite (볼륨 마운트로 영속 저장) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 주의사항
|
||||||
|
|
||||||
|
- **`.env` 파일** — 절대 커밋 금지. `.env.example`만 레포에 포함
|
||||||
|
- **Nginx trailing slash** — `/api/portfolio`는 두 location 블록으로 처리 (trailing slash 유무 모두 매칭)
|
||||||
|
- **라우트 순서** — `DELETE /api/todos/done`은 `/api/todos/{id}` 보다 먼저 등록 필수 (FastAPI prefix 매칭)
|
||||||
|
- **캐시 전략** — `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 고정 예약. Synology Tailscale은 userspace 모드라 TCP 불가 → 로컬 IP 사용
|
||||||
|
- **Suno CDN** — `cdn1.suno.ai` URL은 임시 만료 → 생성 즉시 로컬 다운로드 필수
|
||||||
|
- **LLM provider 롤백** — Claude API 장애 시 `.env`의 `LLM_PROVIDER=ollama`로 전환 후 `docker compose up -d`
|
||||||
|
- **시뮬레이션 교체 방식** — `best_picks`는 교체형 (`is_active=0` 비활성화 후 신규 입력)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 참고 문서
|
||||||
|
|
||||||
|
- `CLAUDE.md` — Claude Code 작업용 상세 컨텍스트 (API 전체 목록, 테이블 스키마 등)
|
||||||
|
- `docs/` — 서비스별 기획·설계 문서
|
||||||
|
|||||||
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
|
||||||
23
agent-office/app/agents/__init__.py
Normal file
23
agent-office/app/agents/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from .stock import StockAgent
|
||||||
|
from .music import MusicAgent
|
||||||
|
from .blog import BlogAgent
|
||||||
|
from .realestate import RealestateAgent
|
||||||
|
from .lotto import LottoAgent
|
||||||
|
|
||||||
|
AGENT_REGISTRY = {}
|
||||||
|
|
||||||
|
def init_agents():
|
||||||
|
AGENT_REGISTRY["stock"] = StockAgent()
|
||||||
|
AGENT_REGISTRY["music"] = MusicAgent()
|
||||||
|
AGENT_REGISTRY["blog"] = BlogAgent()
|
||||||
|
AGENT_REGISTRY["realestate"] = RealestateAgent()
|
||||||
|
AGENT_REGISTRY["lotto"] = LottoAgent()
|
||||||
|
|
||||||
|
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()
|
||||||
|
]
|
||||||
80
agent-office/app/agents/base.py
Normal file
80
agent-office/app/agents/base.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
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 == "working" and old != "working":
|
||||||
|
await self._ws_manager.send_notification(
|
||||||
|
self.agent_id, "task_assigned", task_id, detail or "새 작업 시작"
|
||||||
|
)
|
||||||
|
elif new_state == "idle" and old in ("working", "reporting"):
|
||||||
|
await self._ws_manager.send_notification(
|
||||||
|
self.agent_id, "task_completed", task_id, detail or "작업 완료"
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
}
|
||||||
192
agent-office/app/agents/blog.py
Normal file
192
agent-office/app/agents/blog.py
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import asyncio
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .base import BaseAgent
|
||||||
|
from ..db import (
|
||||||
|
create_task, update_task_status, approve_task, reject_task,
|
||||||
|
get_task, get_agent_config, add_log,
|
||||||
|
)
|
||||||
|
from .. import service_proxy
|
||||||
|
from .. import telegram_bot
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_TREND_KEYWORDS = [
|
||||||
|
"다이어트 식단", "재택근무 꿀템", "캠핑 장비 추천",
|
||||||
|
"홈트레이닝", "제주도 여행", "에어프라이어 레시피",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class BlogAgent(BaseAgent):
|
||||||
|
"""블로그 마케팅 에이전트.
|
||||||
|
|
||||||
|
매일 10:00 자동 실행: 키워드 1개 리서치 → 글 생성 → 마케터 → 평가자
|
||||||
|
→ 평가 점수와 요약을 텔레그램 승인 요청으로 푸시
|
||||||
|
→ 승인 시 `published` 상태로 전환, 거절 시 재생성
|
||||||
|
"""
|
||||||
|
|
||||||
|
agent_id = "blog"
|
||||||
|
display_name = "블로그 마케터"
|
||||||
|
|
||||||
|
async def on_schedule(self) -> None:
|
||||||
|
if self.state not in ("idle", "break"):
|
||||||
|
return
|
||||||
|
|
||||||
|
config = get_agent_config(self.agent_id) or {}
|
||||||
|
custom = config.get("custom_config", {}) or {}
|
||||||
|
keywords = custom.get("trend_keywords") or DEFAULT_TREND_KEYWORDS
|
||||||
|
if not keywords:
|
||||||
|
return
|
||||||
|
|
||||||
|
import random
|
||||||
|
keyword = random.choice(keywords)
|
||||||
|
|
||||||
|
task_id = create_task(
|
||||||
|
self.agent_id,
|
||||||
|
"auto_blog_pipeline",
|
||||||
|
{"keyword": keyword},
|
||||||
|
requires_approval=True,
|
||||||
|
)
|
||||||
|
await self.transition("working", f"리서치: {keyword}", task_id)
|
||||||
|
asyncio.create_task(self._run_pipeline(task_id, keyword))
|
||||||
|
|
||||||
|
async def _await_task(self, step: str, task_id: str, timeout_sec: int = 240) -> Optional[int]:
|
||||||
|
"""blog-lab BackgroundTask 완료 폴링. 완료 시 result_id 반환."""
|
||||||
|
attempts = max(1, timeout_sec // 5)
|
||||||
|
for _ in range(attempts):
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
status = await service_proxy.blog_task_status(task_id)
|
||||||
|
s = status.get("status")
|
||||||
|
if s == "succeeded":
|
||||||
|
return status.get("result_id")
|
||||||
|
if s == "failed":
|
||||||
|
raise Exception(f"{step} failed: {status.get('error')}")
|
||||||
|
raise Exception(f"{step} timeout ({timeout_sec}s 내 완료되지 않음)")
|
||||||
|
|
||||||
|
async def _run_pipeline(self, task_id: str, keyword: str) -> None:
|
||||||
|
try:
|
||||||
|
# 1) 리서치
|
||||||
|
research = await service_proxy.blog_research(keyword)
|
||||||
|
keyword_id = await self._await_task("research", research.get("task_id"), 180)
|
||||||
|
if not keyword_id:
|
||||||
|
raise Exception("research succeeded but result_id missing")
|
||||||
|
|
||||||
|
# 2) 작가 단계 (비동기)
|
||||||
|
await self.transition("working", f"글 생성: {keyword}", task_id)
|
||||||
|
gen = await service_proxy.blog_generate(keyword_id)
|
||||||
|
post_id = await self._await_task("generate", gen.get("task_id"), 300)
|
||||||
|
if not post_id:
|
||||||
|
raise Exception("generate succeeded but post_id missing")
|
||||||
|
|
||||||
|
# 3) 마케터 단계 (비동기)
|
||||||
|
await self.transition("working", "링크 삽입 중", task_id)
|
||||||
|
mkt = await service_proxy.blog_market(post_id)
|
||||||
|
await self._await_task("market", mkt.get("task_id"), 180)
|
||||||
|
|
||||||
|
# 4) 평가자 단계 (비동기)
|
||||||
|
await self.transition("working", "품질 리뷰 중", task_id)
|
||||||
|
rev = await service_proxy.blog_review(post_id)
|
||||||
|
await self._await_task("review", rev.get("task_id"), 180)
|
||||||
|
|
||||||
|
post_after = await service_proxy.blog_get_post(post_id)
|
||||||
|
score = post_after.get("review_score")
|
||||||
|
passed = (score or 0) >= 42
|
||||||
|
|
||||||
|
title = post_after.get("title", "(제목 없음)")
|
||||||
|
excerpt = (post_after.get("body") or "")[:300]
|
||||||
|
|
||||||
|
update_task_status(task_id, "pending", {
|
||||||
|
"keyword": keyword,
|
||||||
|
"post_id": post_id,
|
||||||
|
"score": score,
|
||||||
|
"passed": passed,
|
||||||
|
"title": title,
|
||||||
|
})
|
||||||
|
|
||||||
|
await self.transition("waiting", f"승인 대기 · {score}/60", task_id)
|
||||||
|
|
||||||
|
detail = (
|
||||||
|
f"키워드: {keyword}\n"
|
||||||
|
f"제목: {title}\n"
|
||||||
|
f"평가 점수: {score}/60 ({'통과' if passed else '미통과'})\n\n"
|
||||||
|
f"{excerpt}..."
|
||||||
|
)
|
||||||
|
await telegram_bot.send_approval_request(
|
||||||
|
self.agent_id, task_id,
|
||||||
|
"✍️ [블로그 에이전트] 발행 승인 요청", detail,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
add_log(self.agent_id, f"Blog pipeline failed: {e}", "error", task_id)
|
||||||
|
update_task_status(task_id, "failed", {"error": str(e), "keyword": keyword})
|
||||||
|
await self.transition("idle", f"오류: {e}")
|
||||||
|
await telegram_bot.send_task_result(
|
||||||
|
self.agent_id, "✍️ [블로그 에이전트] 파이프라인 실패",
|
||||||
|
f"키워드: {keyword}\n오류: {e}",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def on_command(self, command: str, params: dict) -> dict:
|
||||||
|
if command == "research":
|
||||||
|
keyword = (params.get("keyword") or "").strip()
|
||||||
|
if not keyword:
|
||||||
|
return {"ok": False, "message": "keyword 필수"}
|
||||||
|
task_id = create_task(
|
||||||
|
self.agent_id, "auto_blog_pipeline",
|
||||||
|
{"keyword": keyword}, requires_approval=True,
|
||||||
|
)
|
||||||
|
await self.transition("working", f"리서치: {keyword}", task_id)
|
||||||
|
asyncio.create_task(self._run_pipeline(task_id, keyword))
|
||||||
|
return {"ok": True, "task_id": task_id, "message": f"파이프라인 시작: {keyword}"}
|
||||||
|
|
||||||
|
if command == "add_trend_keyword":
|
||||||
|
keyword = (params.get("keyword") or "").strip()
|
||||||
|
if not keyword:
|
||||||
|
return {"ok": False, "message": "keyword 필수"}
|
||||||
|
config = get_agent_config(self.agent_id) or {}
|
||||||
|
custom = config.get("custom_config", {}) or {}
|
||||||
|
kws = list(custom.get("trend_keywords") or [])
|
||||||
|
if keyword not in kws:
|
||||||
|
kws.append(keyword)
|
||||||
|
from ..db import update_agent_config
|
||||||
|
update_agent_config(self.agent_id, custom_config={**custom, "trend_keywords": kws})
|
||||||
|
return {"ok": True, "keywords": kws}
|
||||||
|
|
||||||
|
if command == "list_trend_keywords":
|
||||||
|
config = get_agent_config(self.agent_id) or {}
|
||||||
|
custom = config.get("custom_config", {}) or {}
|
||||||
|
return {"ok": True, "keywords": custom.get("trend_keywords") or DEFAULT_TREND_KEYWORDS}
|
||||||
|
|
||||||
|
return {"ok": False, "message": f"Unknown command: {command}"}
|
||||||
|
|
||||||
|
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
|
||||||
|
task = get_task(task_id)
|
||||||
|
if not task:
|
||||||
|
return
|
||||||
|
result = task.get("result_data") or {}
|
||||||
|
post_id = result.get("post_id")
|
||||||
|
|
||||||
|
if not approved:
|
||||||
|
reject_task(task_id)
|
||||||
|
await self.transition("idle", "발행 거절됨")
|
||||||
|
await telegram_bot.send_task_result(
|
||||||
|
self.agent_id, "✍️ [블로그 에이전트] 발행 취소",
|
||||||
|
f"키워드: {result.get('keyword', '')}\n사용자가 거절했습니다.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
approve_task(task_id, via="telegram")
|
||||||
|
await self.transition("reporting", "발행 중...", task_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if post_id:
|
||||||
|
await service_proxy.blog_publish(int(post_id))
|
||||||
|
update_task_status(task_id, "succeeded", {**result, "published": True})
|
||||||
|
await telegram_bot.send_task_result(
|
||||||
|
self.agent_id, "✍️ [블로그 에이전트] 발행 완료",
|
||||||
|
f"키워드: {result.get('keyword', '')}\n제목: {result.get('title', '')}\n"
|
||||||
|
f"점수: {result.get('score')}/60",
|
||||||
|
)
|
||||||
|
await self.transition("idle", "발행 완료")
|
||||||
|
except Exception as e:
|
||||||
|
add_log(self.agent_id, f"Blog publish failed: {e}", "error", task_id)
|
||||||
|
update_task_status(task_id, "failed", {**result, "publish_error": str(e)})
|
||||||
|
await self.transition("idle", f"발행 오류: {e}")
|
||||||
44
agent-office/app/agents/lotto.py
Normal file
44
agent-office/app/agents/lotto.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from .base import BaseAgent
|
||||||
|
from ..db import create_task, update_task_status, add_log
|
||||||
|
from ..curator.pipeline import curate_weekly, CuratorError
|
||||||
|
|
||||||
|
|
||||||
|
class LottoAgent(BaseAgent):
|
||||||
|
agent_id = "lotto"
|
||||||
|
display_name = "로또 큐레이터"
|
||||||
|
|
||||||
|
async def on_schedule(self) -> None:
|
||||||
|
if self.state not in ("idle", "break"):
|
||||||
|
return
|
||||||
|
await self._run(source="auto")
|
||||||
|
|
||||||
|
async def on_command(self, action: str, params: dict) -> dict:
|
||||||
|
if action in ("curate_now", "curate_weekly"):
|
||||||
|
return await self._run(source="manual")
|
||||||
|
if action == "status":
|
||||||
|
return {"ok": True, "message": f"{self.state}: {self.state_detail}"}
|
||||||
|
return {"ok": False, "message": f"unknown action: {action}"}
|
||||||
|
|
||||||
|
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _run(self, source: str) -> dict:
|
||||||
|
task_id = create_task(self.agent_id, "curate_weekly", {"source": source})
|
||||||
|
await self.transition("working", "후보 수집 및 AI 큐레이션 중...", task_id)
|
||||||
|
try:
|
||||||
|
result = await curate_weekly(source=source)
|
||||||
|
update_task_status(task_id, "succeeded", result_data=result)
|
||||||
|
await self.transition("reporting", f"#{result['draw_no']} 브리핑 저장 완료")
|
||||||
|
add_log(self.agent_id, f"큐레이션 완료: #{result['draw_no']} conf={result['confidence']}", task_id=task_id)
|
||||||
|
await self.transition("idle", "대기 중")
|
||||||
|
return {"ok": True, **result}
|
||||||
|
except CuratorError as e:
|
||||||
|
update_task_status(task_id, "failed", result_data={"error": str(e)})
|
||||||
|
add_log(self.agent_id, f"큐레이션 실패: {e}", level="error", task_id=task_id)
|
||||||
|
await self.transition("idle", "오류")
|
||||||
|
return {"ok": False, "message": str(e)}
|
||||||
|
except Exception as e:
|
||||||
|
update_task_status(task_id, "failed", result_data={"error": str(e)})
|
||||||
|
add_log(self.agent_id, f"큐레이션 예외: {e}", level="error", task_id=task_id)
|
||||||
|
await self.transition("idle", "오류")
|
||||||
|
return {"ok": False, "message": f"{type(e).__name__}: {e}"}
|
||||||
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}",
|
||||||
|
)
|
||||||
98
agent-office/app/agents/realestate.py
Normal file
98
agent-office/app/agents/realestate.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import asyncio
|
||||||
|
from .base import BaseAgent
|
||||||
|
from ..db import create_task, update_task_status, add_log
|
||||||
|
from .. import service_proxy
|
||||||
|
from .. import telegram_bot
|
||||||
|
|
||||||
|
|
||||||
|
class RealestateAgent(BaseAgent):
|
||||||
|
"""부동산 청약 에이전트.
|
||||||
|
|
||||||
|
매일 09:15 자동 실행: realestate-lab의 수집을 트리거하고
|
||||||
|
신규 매칭 결과를 텔레그램으로 푸시 (승인 없는 리포트형).
|
||||||
|
"""
|
||||||
|
|
||||||
|
agent_id = "realestate"
|
||||||
|
display_name = "청약 애널리스트"
|
||||||
|
|
||||||
|
async def on_schedule(self) -> None:
|
||||||
|
if self.state not in ("idle", "break"):
|
||||||
|
return
|
||||||
|
|
||||||
|
task_id = create_task(self.agent_id, "daily_match_report", {})
|
||||||
|
await self.transition("working", "청약 공고 수집 중", task_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
collect = await service_proxy.realestate_collect()
|
||||||
|
new_count = collect.get("new_count", 0) or 0
|
||||||
|
|
||||||
|
await self.transition("working", "신규 매칭 조회 중", task_id)
|
||||||
|
matches = await service_proxy.realestate_matches(limit=20)
|
||||||
|
dashboard = await service_proxy.realestate_dashboard()
|
||||||
|
|
||||||
|
await self.transition("reporting", "리포트 전송 중", task_id)
|
||||||
|
|
||||||
|
if not matches:
|
||||||
|
body = (
|
||||||
|
f"수집된 신규 공고: {new_count}건\n"
|
||||||
|
f"진행 중 공고: {dashboard.get('active_count', 0)}건\n"
|
||||||
|
f"신규 매칭: 없음"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines = [
|
||||||
|
f"📌 수집 {new_count}건 / 매칭 {len(matches)}건",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for m in matches[:5]:
|
||||||
|
title = m.get("title") or m.get("announcement_title") or "(제목 없음)"
|
||||||
|
region = m.get("region") or ""
|
||||||
|
score = m.get("match_score") or m.get("score") or ""
|
||||||
|
lines.append(f"• [{region}] {title} (매칭 {score})")
|
||||||
|
if len(matches) > 5:
|
||||||
|
lines.append(f"… 외 {len(matches) - 5}건")
|
||||||
|
body = "\n".join(lines)
|
||||||
|
|
||||||
|
tg = await telegram_bot.send_task_result(
|
||||||
|
self.agent_id,
|
||||||
|
"🏢 [청약 에이전트] 오늘의 매칭 리포트",
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 확인한 매칭 read 처리
|
||||||
|
for m in matches[:5]:
|
||||||
|
mid = m.get("id")
|
||||||
|
if mid:
|
||||||
|
try:
|
||||||
|
await service_proxy.realestate_mark_read(int(mid))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
update_task_status(task_id, "succeeded", {
|
||||||
|
"new_count": new_count,
|
||||||
|
"match_count": len(matches),
|
||||||
|
"telegram_sent": tg.get("ok", False),
|
||||||
|
"telegram_message_id": tg.get("message_id"),
|
||||||
|
})
|
||||||
|
await self.transition("idle", f"매칭 {len(matches)}건")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
add_log(self.agent_id, f"Realestate report 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_matches":
|
||||||
|
await self.on_schedule()
|
||||||
|
return {"ok": True, "message": "매칭 리포트 시작"}
|
||||||
|
|
||||||
|
if command == "dashboard":
|
||||||
|
try:
|
||||||
|
data = await service_proxy.realestate_dashboard()
|
||||||
|
return {"ok": True, "dashboard": data}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "message": str(e)}
|
||||||
|
|
||||||
|
return {"ok": False, "message": f"Unknown command: {command}"}
|
||||||
|
|
||||||
|
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
|
||||||
|
pass
|
||||||
157
agent-office/app/agents/stock.py
Normal file
157
agent-office/app/agents/stock.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import asyncio
|
||||||
|
import html
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _build_briefing_body(result: dict, max_headlines: int = 5) -> str:
|
||||||
|
"""아침 시장 브리핑 본문 조립.
|
||||||
|
|
||||||
|
LLM 요약 + 주요 뉴스 헤드라인(링크) 섹션을 합친다.
|
||||||
|
향후 본문 고도화 시 이 함수만 수정하면 됨 (텔레그램 HTML parse_mode).
|
||||||
|
"""
|
||||||
|
summary = (result.get("summary") or "").strip()
|
||||||
|
articles = result.get("articles") or []
|
||||||
|
|
||||||
|
# body_is_html=True 로 보낼 예정이므로 LLM 요약(plain text)도 escape
|
||||||
|
parts = [html.escape(summary)] if summary else []
|
||||||
|
|
||||||
|
headlines = []
|
||||||
|
for a in articles[:max_headlines]:
|
||||||
|
title = (a.get("title") or "").strip()
|
||||||
|
if not title:
|
||||||
|
continue
|
||||||
|
title_esc = html.escape(title)
|
||||||
|
link = (a.get("link") or "").strip()
|
||||||
|
press = (a.get("press") or "").strip()
|
||||||
|
press_suffix = f" — {html.escape(press)}" if press else ""
|
||||||
|
if link:
|
||||||
|
headlines.append(f'• <a href="{html.escape(link, quote=True)}">{title_esc}</a>{press_suffix}')
|
||||||
|
else:
|
||||||
|
headlines.append(f"• {title_esc}{press_suffix}")
|
||||||
|
|
||||||
|
if headlines:
|
||||||
|
parts.append("📰 <b>주요 뉴스</b>\n" + "\n".join(headlines))
|
||||||
|
|
||||||
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
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", "AI 뉴스 요약 생성 중...", task_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# AI 요약 호출 (뉴스 수집 + LLM 처리는 stock-lab이 담당)
|
||||||
|
result = await service_proxy.summarize_stock_news(limit=15)
|
||||||
|
|
||||||
|
await self.transition("reporting", "뉴스 요약 전송 중...")
|
||||||
|
|
||||||
|
body = _build_briefing_body(result)
|
||||||
|
|
||||||
|
# 새 통합 텔레그램 API 사용
|
||||||
|
from ..telegram import send_agent_message
|
||||||
|
tg_result = await send_agent_message(
|
||||||
|
agent_id=self.agent_id,
|
||||||
|
kind="report",
|
||||||
|
title="아침 시장 브리핑",
|
||||||
|
body=body,
|
||||||
|
body_is_html=True,
|
||||||
|
task_id=task_id,
|
||||||
|
metadata={
|
||||||
|
"tokens": result["tokens"]["total"],
|
||||||
|
"duration_ms": result["duration_ms"],
|
||||||
|
"model": result["model"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 아내 chat 추가 전송 (설정된 경우) — 제목 + 본문만 간결하게
|
||||||
|
from ..config import TELEGRAM_WIFE_CHAT_ID
|
||||||
|
if TELEGRAM_WIFE_CHAT_ID:
|
||||||
|
from ..telegram.messaging import send_raw
|
||||||
|
wife_text = f"📈 <b>아침 시장 브리핑</b>\n\n{body}"
|
||||||
|
wife_result = await send_raw(wife_text, chat_id=TELEGRAM_WIFE_CHAT_ID)
|
||||||
|
if not wife_result.get("ok"):
|
||||||
|
desc = wife_result.get("description") or "unknown"
|
||||||
|
add_log(self.agent_id, f"Wife telegram send failed: {desc}", "warning", task_id)
|
||||||
|
|
||||||
|
update_task_status(task_id, "succeeded", {
|
||||||
|
"summary": result["summary"],
|
||||||
|
"article_count": result.get("article_count", 0),
|
||||||
|
"tokens": result["tokens"],
|
||||||
|
"model": result["model"],
|
||||||
|
"duration_ms": result["duration_ms"],
|
||||||
|
"telegram_sent": tg_result.get("ok", False),
|
||||||
|
"telegram_message_id": tg_result.get("message_id"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if not tg_result.get("ok"):
|
||||||
|
desc = tg_result.get("description") or "unknown"
|
||||||
|
code = tg_result.get("error_code")
|
||||||
|
add_log(self.agent_id, f"Telegram send failed: [{code}] {desc}", "warning", task_id)
|
||||||
|
if self._ws_manager:
|
||||||
|
await self._ws_manager.send_notification(
|
||||||
|
self.agent_id, "telegram_failed", task_id, "텔레그램 전송 실패"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 == "test_telegram":
|
||||||
|
from ..telegram import send_agent_message
|
||||||
|
result = await send_agent_message(
|
||||||
|
agent_id=self.agent_id,
|
||||||
|
kind="info",
|
||||||
|
title="연결 테스트",
|
||||||
|
body="텔레그램 연동이 정상적으로 동작합니다.",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": result.get("ok", False),
|
||||||
|
"message": "텔레그램 전송 성공" if result.get("ok") else "텔레그램 전송 실패",
|
||||||
|
"telegram_message_id": result.get("message_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
36
agent-office/app/config.py
Normal file
36
agent-office/app/config.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
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")
|
||||||
|
BLOG_LAB_URL = os.getenv("BLOG_LAB_URL", "http://localhost:18700")
|
||||||
|
REALESTATE_LAB_URL = os.getenv("REALESTATE_LAB_URL", "http://localhost:18800")
|
||||||
|
|
||||||
|
# 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", "")
|
||||||
|
TELEGRAM_WIFE_CHAT_ID = os.getenv("TELEGRAM_WIFE_CHAT_ID", "")
|
||||||
|
|
||||||
|
# Anthropic (conversational)
|
||||||
|
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
||||||
|
CONVERSATION_MODEL = os.getenv("CONVERSATION_MODEL", "claude-haiku-4-5-20251001")
|
||||||
|
CONVERSATION_HISTORY_LIMIT = int(os.getenv("CONVERSATION_HISTORY_LIMIT", "20"))
|
||||||
|
CONVERSATION_RATE_PER_MIN = int(os.getenv("CONVERSATION_RATE_PER_MIN", "6"))
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Lotto Curator
|
||||||
|
LOTTO_BACKEND_URL = os.getenv("LOTTO_BACKEND_URL", "http://lotto-backend:8000")
|
||||||
|
LOTTO_CURATOR_MODEL = os.getenv("LOTTO_CURATOR_MODEL", "claude-sonnet-4-5")
|
||||||
0
agent-office/app/curator/__init__.py
Normal file
0
agent-office/app/curator/__init__.py
Normal file
121
agent-office/app/curator/pipeline.py
Normal file
121
agent-office/app/curator/pipeline.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""큐레이터 파이프라인 — fetch → claude → validate → save."""
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ..config import ANTHROPIC_API_KEY, LOTTO_CURATOR_MODEL
|
||||||
|
from .. import service_proxy
|
||||||
|
from .prompt import SYSTEM_PROMPT, build_user_message
|
||||||
|
from .schema import validate_response
|
||||||
|
|
||||||
|
|
||||||
|
API_URL = "https://api.anthropic.com/v1/messages"
|
||||||
|
|
||||||
|
|
||||||
|
class CuratorError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_claude(user_text: str, feedback: str = "") -> tuple[dict, dict]:
|
||||||
|
if not ANTHROPIC_API_KEY:
|
||||||
|
raise CuratorError("ANTHROPIC_API_KEY missing")
|
||||||
|
headers = {
|
||||||
|
"x-api-key": ANTHROPIC_API_KEY,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"anthropic-beta": "prompt-caching-2024-07-31",
|
||||||
|
"content-type": "application/json",
|
||||||
|
}
|
||||||
|
system_blocks = [{
|
||||||
|
"type": "text",
|
||||||
|
"text": SYSTEM_PROMPT,
|
||||||
|
"cache_control": {"type": "ephemeral"},
|
||||||
|
}]
|
||||||
|
if feedback:
|
||||||
|
user_text = f"이전 응답이 다음 이유로 거절됨: {feedback}\n올바른 스키마로 다시 응답.\n\n{user_text}"
|
||||||
|
payload = {
|
||||||
|
"model": LOTTO_CURATOR_MODEL,
|
||||||
|
"max_tokens": 4096,
|
||||||
|
"system": system_blocks,
|
||||||
|
"messages": [{"role": "user", "content": [{"type": "text", "text": user_text}]}],
|
||||||
|
}
|
||||||
|
started = time.monotonic()
|
||||||
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
|
r = await client.post(API_URL, headers=headers, json=payload)
|
||||||
|
r.raise_for_status()
|
||||||
|
resp = r.json()
|
||||||
|
latency_ms = int((time.monotonic() - started) * 1000)
|
||||||
|
|
||||||
|
text = "".join(
|
||||||
|
b.get("text", "") for b in resp.get("content", []) if b.get("type") == "text"
|
||||||
|
).strip()
|
||||||
|
if text.startswith("```"):
|
||||||
|
text = text.strip("`")
|
||||||
|
if text.startswith("json"):
|
||||||
|
text = text[4:]
|
||||||
|
text = text.strip()
|
||||||
|
parsed = json.loads(text)
|
||||||
|
|
||||||
|
usage = resp.get("usage", {}) or {}
|
||||||
|
return parsed, {
|
||||||
|
"input": int(usage.get("input_tokens", 0) or 0),
|
||||||
|
"output": int(usage.get("output_tokens", 0) or 0),
|
||||||
|
"cache_read": int(usage.get("cache_read_input_tokens", 0) or 0),
|
||||||
|
"cache_write": int(usage.get("cache_creation_input_tokens", 0) or 0),
|
||||||
|
"latency_ms": latency_ms,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def curate_weekly(source: str = "auto") -> Dict[str, Any]:
|
||||||
|
cand_resp = await service_proxy.lotto_candidates(n=20)
|
||||||
|
draw_no = cand_resp["draw_no"]
|
||||||
|
candidates = cand_resp["candidates"]
|
||||||
|
context = await service_proxy.lotto_context()
|
||||||
|
|
||||||
|
user_text = build_user_message(draw_no, candidates, {
|
||||||
|
"hot_numbers": context.get("hot_numbers", []),
|
||||||
|
"cold_numbers": context.get("cold_numbers", []),
|
||||||
|
"last_draw_summary": context.get("last_draw_summary", ""),
|
||||||
|
"my_recent_performance": context.get("my_recent_performance", []),
|
||||||
|
})
|
||||||
|
|
||||||
|
candidate_numbers = [c["numbers"] for c in candidates]
|
||||||
|
|
||||||
|
usage_total = {"input": 0, "output": 0, "cache_read": 0, "cache_write": 0, "latency_ms": 0}
|
||||||
|
last_error = None
|
||||||
|
validated = None
|
||||||
|
|
||||||
|
for attempt in (0, 1):
|
||||||
|
try:
|
||||||
|
raw, usage = await _call_claude(user_text, feedback=last_error or "")
|
||||||
|
for k in usage_total:
|
||||||
|
usage_total[k] += usage[k]
|
||||||
|
validated = validate_response(raw, candidate_numbers)
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
last_error = f"{type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
if validated is None:
|
||||||
|
raise CuratorError(f"schema validation failed after retry: {last_error}")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"draw_no": draw_no,
|
||||||
|
"picks": [p.model_dump() for p in validated.picks],
|
||||||
|
"narrative": validated.narrative.model_dump(),
|
||||||
|
"confidence": validated.confidence,
|
||||||
|
"model": LOTTO_CURATOR_MODEL,
|
||||||
|
"tokens_input": usage_total["input"],
|
||||||
|
"tokens_output": usage_total["output"],
|
||||||
|
"cache_read": usage_total["cache_read"],
|
||||||
|
"cache_write": usage_total["cache_write"],
|
||||||
|
"latency_ms": usage_total["latency_ms"],
|
||||||
|
"source": source,
|
||||||
|
}
|
||||||
|
await service_proxy.lotto_save_briefing(payload)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"draw_no": draw_no,
|
||||||
|
"confidence": validated.confidence,
|
||||||
|
"tokens": {"input": usage_total["input"], "output": usage_total["output"]},
|
||||||
|
}
|
||||||
46
agent-office/app/curator/prompt.py
Normal file
46
agent-office/app/curator/prompt.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""큐레이터 system/user 프롬프트. system은 정적이므로 캐시 대상."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """당신은 로또 번호 큐레이터입니다. 주어진 후보 20세트 중 5세트를 다음 규칙으로 선별합니다.
|
||||||
|
|
||||||
|
선별 규칙:
|
||||||
|
- 5세트의 리스크 분포는 안정 2 · 균형 2 · 공격 1 을 권장(유연 ±1).
|
||||||
|
- 홀짝 비율, 저/고 구간, 연속번호 포함 여부가 세트끼리 겹치지 않도록 다양성을 확보.
|
||||||
|
- hot_number_count=0 이고 cold_number_count=0 인 '중립형' 세트를 최소 1개 포함.
|
||||||
|
- 후보에 없는 번호 조합은 절대 사용 금지. numbers 필드는 반드시 candidates 중 하나와 정확히 일치해야 함.
|
||||||
|
- 각 세트 reason은 한국어 40자 이내 한 줄. 해당 세트의 features 값과 context 값만 근거로.
|
||||||
|
|
||||||
|
narrative 규칙:
|
||||||
|
- headline: 한 줄, 이번 주 추첨 전망 요약.
|
||||||
|
- summary_3lines: 정확히 3개 항목의 배열.
|
||||||
|
- hot_cold_comment: hot/cold 번호에 대한 한 줄 논평.
|
||||||
|
- warnings: 특별한 주의사항 없으면 빈 문자열.
|
||||||
|
|
||||||
|
출력은 반드시 JSON 하나, 그 외 어떤 텍스트도 금지. 스키마:
|
||||||
|
{
|
||||||
|
"picks": [
|
||||||
|
{"numbers":[int,int,int,int,int,int], "risk_tag":"안정"|"균형"|"공격", "reason": str}
|
||||||
|
],
|
||||||
|
"narrative": {
|
||||||
|
"headline": str,
|
||||||
|
"summary_3lines": [str, str, str],
|
||||||
|
"hot_cold_comment": str,
|
||||||
|
"warnings": str
|
||||||
|
},
|
||||||
|
"confidence": int (0~100)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def build_user_message(draw_no: int, candidates: list, context: dict) -> str:
|
||||||
|
payload = {
|
||||||
|
"draw_no": draw_no,
|
||||||
|
"context": context,
|
||||||
|
"candidates": candidates,
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
f"이번 회차: {draw_no}\n"
|
||||||
|
f"아래 데이터로 5세트를 큐레이션하고 위 스키마로만 응답하세요.\n\n"
|
||||||
|
f"```json\n{json.dumps(payload, ensure_ascii=False)}\n```"
|
||||||
|
)
|
||||||
41
agent-office/app/curator/schema.py
Normal file
41
agent-office/app/curator/schema.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from typing import List, Literal
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class Pick(BaseModel):
|
||||||
|
numbers: List[int] = Field(min_length=6, max_length=6)
|
||||||
|
risk_tag: Literal["안정", "균형", "공격"]
|
||||||
|
reason: str = Field(max_length=80)
|
||||||
|
|
||||||
|
@field_validator("numbers")
|
||||||
|
@classmethod
|
||||||
|
def _check_numbers(cls, v):
|
||||||
|
if len(set(v)) != 6:
|
||||||
|
raise ValueError("numbers must be 6 unique integers")
|
||||||
|
if any(n < 1 or n > 45 for n in v):
|
||||||
|
raise ValueError("numbers must be within 1..45")
|
||||||
|
return sorted(v)
|
||||||
|
|
||||||
|
|
||||||
|
class Narrative(BaseModel):
|
||||||
|
headline: str
|
||||||
|
summary_3lines: List[str] = Field(min_length=3, max_length=3)
|
||||||
|
hot_cold_comment: str = ""
|
||||||
|
warnings: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class CuratorOutput(BaseModel):
|
||||||
|
picks: List[Pick]
|
||||||
|
narrative: Narrative
|
||||||
|
confidence: int = Field(ge=0, le=100)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_response(data: dict, candidate_numbers: List[List[int]]) -> CuratorOutput:
|
||||||
|
out = CuratorOutput.model_validate(data)
|
||||||
|
if len(out.picks) != 5:
|
||||||
|
raise ValueError("picks must have exactly 5 sets")
|
||||||
|
candidate_set = {tuple(sorted(c)) for c in candidate_numbers}
|
||||||
|
for p in out.picks:
|
||||||
|
if tuple(p.numbers) not in candidate_set:
|
||||||
|
raise ValueError(f"pick {p.numbers} not in candidates")
|
||||||
|
return out
|
||||||
503
agent-office/app/db.py
Normal file
503
agent-office/app/db.py
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
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'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS conversation_messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
chat_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
model TEXT,
|
||||||
|
tokens_input INTEGER DEFAULT 0,
|
||||||
|
tokens_output INTEGER DEFAULT 0,
|
||||||
|
cache_read INTEGER DEFAULT 0,
|
||||||
|
cache_write INTEGER DEFAULT 0,
|
||||||
|
latency_ms INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_conv_chat
|
||||||
|
ON conversation_messages(chat_id, created_at DESC)
|
||||||
|
""")
|
||||||
|
# Seed default agent configs
|
||||||
|
for agent_id, name in [
|
||||||
|
("stock", "주식 트레이더"),
|
||||||
|
("music", "음악 프로듀서"),
|
||||||
|
("blog", "블로그 마케터"),
|
||||||
|
("realestate", "청약 애널리스트"),
|
||||||
|
("lotto", "로또 큐레이터"),
|
||||||
|
]:
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_token_usage_stats(agent_id: str, days: int = 1) -> dict:
|
||||||
|
"""지정 에이전트의 최근 N일 토큰 사용량 집계.
|
||||||
|
|
||||||
|
agent_tasks 테이블의 result_data JSON에서 tokens.total을 합산.
|
||||||
|
반환: {"total_tokens": int, "task_count": int, "by_day": [{"date": "YYYY-MM-DD", "tokens": int}]}
|
||||||
|
"""
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT completed_at, result_data
|
||||||
|
FROM agent_tasks
|
||||||
|
WHERE agent_id = ?
|
||||||
|
AND status = 'succeeded'
|
||||||
|
AND completed_at IS NOT NULL
|
||||||
|
AND completed_at >= strftime('%Y-%m-%dT%H:%M:%fZ','now', ?)
|
||||||
|
""",
|
||||||
|
(agent_id, f"-{int(days)} days"),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
total_tokens = 0
|
||||||
|
task_count = 0
|
||||||
|
by_day_map: Dict[str, int] = {}
|
||||||
|
for r in rows:
|
||||||
|
result_data = r["result_data"]
|
||||||
|
if not result_data:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed = json.loads(result_data)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
tokens = parsed.get("tokens") if isinstance(parsed, dict) else None
|
||||||
|
total = 0
|
||||||
|
if isinstance(tokens, dict):
|
||||||
|
total = int(tokens.get("total", 0) or 0)
|
||||||
|
if total <= 0:
|
||||||
|
continue
|
||||||
|
total_tokens += total
|
||||||
|
task_count += 1
|
||||||
|
completed_at = r["completed_at"] or ""
|
||||||
|
day = completed_at[:10] if completed_at else "unknown"
|
||||||
|
by_day_map[day] = by_day_map.get(day, 0) + total
|
||||||
|
|
||||||
|
by_day = [
|
||||||
|
{"date": d, "tokens": t}
|
||||||
|
for d, t in sorted(by_day_map.items())
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"total_tokens": total_tokens,
|
||||||
|
"task_count": task_count,
|
||||||
|
"by_day": by_day,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def save_conversation_message(
|
||||||
|
chat_id: str,
|
||||||
|
role: str,
|
||||||
|
content: str,
|
||||||
|
model: Optional[str] = None,
|
||||||
|
tokens_input: int = 0,
|
||||||
|
tokens_output: int = 0,
|
||||||
|
cache_read: int = 0,
|
||||||
|
cache_write: int = 0,
|
||||||
|
latency_ms: int = 0,
|
||||||
|
) -> None:
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO conversation_messages
|
||||||
|
(chat_id, role, content, model, tokens_input, tokens_output,
|
||||||
|
cache_read, cache_write, latency_ms)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?)
|
||||||
|
""",
|
||||||
|
(str(chat_id), role, content, model, tokens_input, tokens_output,
|
||||||
|
cache_read, cache_write, latency_ms),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_conversation_history(chat_id: str, limit: int = 20) -> List[Dict[str, Any]]:
|
||||||
|
"""최근 N개를 시간순(오래된 → 최신)으로 반환."""
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT role, content FROM conversation_messages
|
||||||
|
WHERE chat_id=? ORDER BY id DESC LIMIT ?
|
||||||
|
""",
|
||||||
|
(str(chat_id), limit),
|
||||||
|
).fetchall()
|
||||||
|
return [{"role": r["role"], "content": r["content"]} for r in reversed(rows)]
|
||||||
|
|
||||||
|
|
||||||
|
def count_recent_user_messages(chat_id: str, seconds: int = 60) -> int:
|
||||||
|
with _conn() as conn:
|
||||||
|
r = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS c FROM conversation_messages
|
||||||
|
WHERE chat_id=? AND role='user'
|
||||||
|
AND created_at >= strftime('%Y-%m-%dT%H:%M:%fZ','now', ?)
|
||||||
|
""",
|
||||||
|
(str(chat_id), f"-{int(seconds)} seconds"),
|
||||||
|
).fetchone()
|
||||||
|
return r["c"] if r else 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_conversation_stats(days: int = 7) -> Dict[str, Any]:
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT chat_id,
|
||||||
|
COUNT(*) AS msg_count,
|
||||||
|
SUM(tokens_input) AS in_tokens,
|
||||||
|
SUM(tokens_output) AS out_tokens,
|
||||||
|
SUM(cache_read) AS cache_read,
|
||||||
|
SUM(cache_write) AS cache_write,
|
||||||
|
AVG(latency_ms) AS avg_latency
|
||||||
|
FROM conversation_messages
|
||||||
|
WHERE role='assistant'
|
||||||
|
AND created_at >= strftime('%Y-%m-%dT%H:%M:%fZ','now', ?)
|
||||||
|
GROUP BY chat_id
|
||||||
|
""",
|
||||||
|
(f"-{int(days)} days",),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
by_chat = []
|
||||||
|
tot_in = tot_out = tot_r = tot_w = tot_msgs = 0
|
||||||
|
for r in rows:
|
||||||
|
ci = int(r["in_tokens"] or 0)
|
||||||
|
co = int(r["out_tokens"] or 0)
|
||||||
|
cr = int(r["cache_read"] or 0)
|
||||||
|
cw = int(r["cache_write"] or 0)
|
||||||
|
mc = int(r["msg_count"] or 0)
|
||||||
|
hit_rate = (cr / (cr + cw)) if (cr + cw) > 0 else 0.0
|
||||||
|
by_chat.append({
|
||||||
|
"chat_id": r["chat_id"],
|
||||||
|
"message_count": mc,
|
||||||
|
"tokens_input": ci,
|
||||||
|
"tokens_output": co,
|
||||||
|
"cache_read": cr,
|
||||||
|
"cache_write": cw,
|
||||||
|
"cache_hit_rate": round(hit_rate, 3),
|
||||||
|
"avg_latency_ms": round(float(r["avg_latency"] or 0), 1),
|
||||||
|
})
|
||||||
|
tot_in += ci; tot_out += co; tot_r += cr; tot_w += cw; tot_msgs += mc
|
||||||
|
|
||||||
|
overall_hit = (tot_r / (tot_r + tot_w)) if (tot_r + tot_w) > 0 else 0.0
|
||||||
|
return {
|
||||||
|
"days": days,
|
||||||
|
"total_messages": tot_msgs,
|
||||||
|
"tokens_input": tot_in,
|
||||||
|
"tokens_output": tot_out,
|
||||||
|
"cache_read": tot_r,
|
||||||
|
"cache_write": tot_w,
|
||||||
|
"cache_hit_rate": round(overall_hit, 3),
|
||||||
|
"by_chat": by_chat,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_activity_feed(limit: int = 50, offset: int = 0) -> dict:
|
||||||
|
with _conn() as conn:
|
||||||
|
total_row = conn.execute("""
|
||||||
|
SELECT (SELECT COUNT(*) FROM agent_tasks) + (SELECT COUNT(*) FROM agent_logs) AS total
|
||||||
|
""").fetchone()
|
||||||
|
total = total_row["total"] if total_row else 0
|
||||||
|
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT 'task' AS type, agent_id, id AS task_id, task_type,
|
||||||
|
status, NULL AS level,
|
||||||
|
COALESCE(
|
||||||
|
json_extract(result_data, '$.summary'),
|
||||||
|
task_type
|
||||||
|
) AS message,
|
||||||
|
created_at, completed_at,
|
||||||
|
result_data
|
||||||
|
FROM agent_tasks
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'log' AS type, agent_id, task_id, NULL AS task_type,
|
||||||
|
NULL AS status, level,
|
||||||
|
message,
|
||||||
|
created_at, NULL AS completed_at,
|
||||||
|
NULL AS result_data
|
||||||
|
FROM agent_logs
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
""", (limit, offset)).fetchall()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for r in rows:
|
||||||
|
item = {
|
||||||
|
"type": r["type"],
|
||||||
|
"agent_id": r["agent_id"],
|
||||||
|
"task_id": r["task_id"],
|
||||||
|
"message": r["message"],
|
||||||
|
"created_at": r["created_at"],
|
||||||
|
}
|
||||||
|
if r["type"] == "task":
|
||||||
|
item["task_type"] = r["task_type"]
|
||||||
|
item["status"] = r["status"]
|
||||||
|
item["completed_at"] = r["completed_at"]
|
||||||
|
if r["created_at"] and r["completed_at"]:
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
start = datetime.fromisoformat(r["created_at"].replace("Z", "+00:00"))
|
||||||
|
end = datetime.fromisoformat(r["completed_at"].replace("Z", "+00:00"))
|
||||||
|
item["duration_seconds"] = round((end - start).total_seconds())
|
||||||
|
except Exception:
|
||||||
|
item["duration_seconds"] = None
|
||||||
|
else:
|
||||||
|
item["duration_seconds"] = None
|
||||||
|
result_data = json.loads(r["result_data"]) if r["result_data"] else None
|
||||||
|
if result_data and "telegram_sent" in result_data:
|
||||||
|
item["telegram_sent"] = result_data["telegram_sent"]
|
||||||
|
else:
|
||||||
|
item["level"] = r["level"]
|
||||||
|
items.append(item)
|
||||||
|
|
||||||
|
return {"items": items, "total": total}
|
||||||
182
agent-office/app/main.py
Normal file
182
agent-office/app/main.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
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, get_activity_feed
|
||||||
|
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 ---
|
||||||
|
|
||||||
|
async def _agent_dispatcher(agent_id: str, command: str, params: dict) -> dict:
|
||||||
|
"""텔레그램 라우터가 호출하는 에이전트 디스패처."""
|
||||||
|
# 전역 상태 조회
|
||||||
|
if agent_id == "__global__" and command == "status":
|
||||||
|
result = {}
|
||||||
|
for aid, agent in AGENT_REGISTRY.items():
|
||||||
|
result[aid] = {"state": agent.state, "detail": agent.state_detail}
|
||||||
|
return result
|
||||||
|
|
||||||
|
agent = AGENT_REGISTRY.get(agent_id)
|
||||||
|
if agent is None:
|
||||||
|
return {"ok": False, "message": f"Unknown agent: {agent_id}"}
|
||||||
|
return await agent.on_command(command, params or {})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/agent-office/telegram/webhook")
|
||||||
|
async def telegram_webhook(data: dict):
|
||||||
|
result = await telegram_bot.handle_webhook(data, agent_dispatcher=_agent_dispatcher)
|
||||||
|
# callback_query (승인/거절) → 기존 승인 흐름
|
||||||
|
if result and "approved" in 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()}
|
||||||
|
|
||||||
|
@app.get("/api/agent-office/agents/{agent_id}/token-usage")
|
||||||
|
def agent_token_usage(agent_id: str, days: int = 1):
|
||||||
|
from .db import get_token_usage_stats
|
||||||
|
return get_token_usage_stats(agent_id, days)
|
||||||
|
|
||||||
|
@app.get("/api/agent-office/conversation/stats")
|
||||||
|
def conversation_stats(days: int = 7):
|
||||||
|
from .db import get_conversation_stats
|
||||||
|
return get_conversation_stats(days)
|
||||||
|
|
||||||
|
@app.get("/api/agent-office/activity")
|
||||||
|
def activity_feed(limit: int = 50, offset: int = 0):
|
||||||
|
return get_activity_feed(limit, offset)
|
||||||
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
|
||||||
38
agent-office/app/scheduler.py
Normal file
38
agent-office/app/scheduler.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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()
|
||||||
|
|
||||||
|
async def _run_realestate_schedule():
|
||||||
|
agent = AGENT_REGISTRY.get("realestate")
|
||||||
|
if agent:
|
||||||
|
await agent.on_schedule()
|
||||||
|
|
||||||
|
async def _run_blog_schedule():
|
||||||
|
agent = AGENT_REGISTRY.get("blog")
|
||||||
|
if agent:
|
||||||
|
await agent.on_schedule()
|
||||||
|
|
||||||
|
async def _run_lotto_schedule():
|
||||||
|
agent = AGENT_REGISTRY.get("lotto")
|
||||||
|
if agent:
|
||||||
|
await agent.on_schedule()
|
||||||
|
|
||||||
|
def init_scheduler():
|
||||||
|
scheduler.add_job(_run_stock_schedule, "cron", hour=7, minute=30, id="stock_news")
|
||||||
|
scheduler.add_job(_run_realestate_schedule, "cron", hour=9, minute=15, id="realestate_report")
|
||||||
|
scheduler.add_job(_run_blog_schedule, "cron", hour=10, minute=0, id="blog_pipeline")
|
||||||
|
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=7, minute=0, id="lotto_curate")
|
||||||
|
scheduler.add_job(_check_idle_breaks, "interval", seconds=60, id="idle_check")
|
||||||
|
scheduler.start()
|
||||||
156
agent-office/app/service_proxy.py
Normal file
156
agent-office/app/service_proxy.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import httpx
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from .config import STOCK_LAB_URL, MUSIC_LAB_URL, BLOG_LAB_URL, REALESTATE_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 summarize_stock_news(limit: int = 15) -> Dict[str, Any]:
|
||||||
|
"""stock-lab의 AI 요약 엔드포인트 호출.
|
||||||
|
반환: {"summary": str, "tokens": {...}, "model": str, "duration_ms": int, "article_count": int}
|
||||||
|
"""
|
||||||
|
# stock-lab 내부 Ollama 호출이 180s까지 가능하므로 여유있게 200s
|
||||||
|
async with httpx.AsyncClient(timeout=200.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{STOCK_LAB_URL}/api/stock/news/summarize",
|
||||||
|
json={"limit": limit},
|
||||||
|
)
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
# --- blog-lab ---
|
||||||
|
|
||||||
|
async def blog_research(keyword: str) -> Dict[str, Any]:
|
||||||
|
"""키워드 리서치 시작 → task_id 반환"""
|
||||||
|
resp = await _client.post(
|
||||||
|
f"{BLOG_LAB_URL}/api/blog-marketing/research",
|
||||||
|
json={"keyword": keyword},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def blog_task_status(task_id: str) -> Dict[str, Any]:
|
||||||
|
resp = await _client.get(f"{BLOG_LAB_URL}/api/blog-marketing/task/{task_id}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def blog_generate(keyword_id: int) -> Dict[str, Any]:
|
||||||
|
resp = await _client.post(
|
||||||
|
f"{BLOG_LAB_URL}/api/blog-marketing/generate",
|
||||||
|
json={"keyword_id": keyword_id},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def blog_market(post_id: int) -> Dict[str, Any]:
|
||||||
|
resp = await _client.post(f"{BLOG_LAB_URL}/api/blog-marketing/market/{post_id}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def blog_review(post_id: int) -> Dict[str, Any]:
|
||||||
|
resp = await _client.post(f"{BLOG_LAB_URL}/api/blog-marketing/review/{post_id}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def blog_publish(post_id: int, url: str = "") -> Dict[str, Any]:
|
||||||
|
resp = await _client.post(
|
||||||
|
f"{BLOG_LAB_URL}/api/blog-marketing/posts/{post_id}/publish",
|
||||||
|
json={"url": url},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def blog_get_post(post_id: int) -> Dict[str, Any]:
|
||||||
|
resp = await _client.get(f"{BLOG_LAB_URL}/api/blog-marketing/posts/{post_id}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
# --- realestate-lab ---
|
||||||
|
|
||||||
|
async def realestate_collect() -> Dict[str, Any]:
|
||||||
|
"""청약 공고 수동 수집 트리거"""
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
resp = await client.post(f"{REALESTATE_LAB_URL}/api/realestate/collect")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def realestate_matches(limit: int = 20) -> List[Dict[str, Any]]:
|
||||||
|
resp = await _client.get(
|
||||||
|
f"{REALESTATE_LAB_URL}/api/realestate/matches",
|
||||||
|
params={"limit": limit, "unread_only": True},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
return data if isinstance(data, list) else data.get("matches", [])
|
||||||
|
|
||||||
|
|
||||||
|
async def realestate_dashboard() -> Dict[str, Any]:
|
||||||
|
resp = await _client.get(f"{REALESTATE_LAB_URL}/api/realestate/dashboard")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def realestate_mark_read(match_id: int) -> Dict[str, Any]:
|
||||||
|
resp = await _client.patch(f"{REALESTATE_LAB_URL}/api/realestate/matches/{match_id}/read")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
# --- lotto-backend ---
|
||||||
|
|
||||||
|
async def lotto_candidates(n: int = 20) -> Dict[str, Any]:
|
||||||
|
from .config import LOTTO_BACKEND_URL
|
||||||
|
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/curator/candidates", params={"n": n})
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def lotto_context() -> Dict[str, Any]:
|
||||||
|
from .config import LOTTO_BACKEND_URL
|
||||||
|
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/curator/context")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def lotto_save_briefing(payload: dict) -> Dict[str, Any]:
|
||||||
|
from .config import LOTTO_BACKEND_URL
|
||||||
|
resp = await _client.post(f"{LOTTO_BACKEND_URL}/api/lotto/briefing", json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
19
agent-office/app/telegram/__init__.py
Normal file
19
agent-office/app/telegram/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
"""Telegram 통합 메시지 패키지."""
|
||||||
|
from .agent_registry import AGENT_META, get_agent_meta, register_agent
|
||||||
|
from .messaging import send_agent_message, send_approval_request, send_raw
|
||||||
|
from .router import parse_command, resolve_agent_command, HELP_TEXT
|
||||||
|
from .webhook import handle_webhook, setup_webhook
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"send_agent_message",
|
||||||
|
"send_approval_request",
|
||||||
|
"send_raw",
|
||||||
|
"handle_webhook",
|
||||||
|
"setup_webhook",
|
||||||
|
"get_agent_meta",
|
||||||
|
"register_agent",
|
||||||
|
"AGENT_META",
|
||||||
|
"parse_command",
|
||||||
|
"resolve_agent_command",
|
||||||
|
"HELP_TEXT",
|
||||||
|
]
|
||||||
34
agent-office/app/telegram/agent_registry.py
Normal file
34
agent-office/app/telegram/agent_registry.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""에이전트 메타 등록소."""
|
||||||
|
|
||||||
|
AGENT_META = {
|
||||||
|
"stock": {
|
||||||
|
"display_name": "주식 트레이더",
|
||||||
|
"emoji": "📈",
|
||||||
|
"color": "#4488cc",
|
||||||
|
},
|
||||||
|
"music": {
|
||||||
|
"display_name": "음악 프로듀서",
|
||||||
|
"emoji": "🎵",
|
||||||
|
"color": "#44aa88",
|
||||||
|
},
|
||||||
|
"lotto": {
|
||||||
|
"emoji": "🎱",
|
||||||
|
"display_name": "로또 큐레이터",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_meta(agent_id: str) -> dict:
|
||||||
|
return AGENT_META.get(
|
||||||
|
agent_id,
|
||||||
|
{"display_name": agent_id, "emoji": "🤖", "color": "#888"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_agent(agent_id: str, display_name: str, emoji: str, color: str = "#888"):
|
||||||
|
"""향후 에이전트 동적 등록용"""
|
||||||
|
AGENT_META[agent_id] = {
|
||||||
|
"display_name": display_name,
|
||||||
|
"emoji": emoji,
|
||||||
|
"color": color,
|
||||||
|
}
|
||||||
18
agent-office/app/telegram/client.py
Normal file
18
agent-office/app/telegram/client.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"""Telegram Bot API 저수준 래퍼."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ..config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, TELEGRAM_WEBHOOK_URL
|
||||||
|
|
||||||
|
_BASE = "https://api.telegram.org/bot"
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled() -> bool:
|
||||||
|
return bool(TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID)
|
||||||
|
|
||||||
|
|
||||||
|
async def api_call(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()
|
||||||
154
agent-office/app/telegram/conversational.py
Normal file
154
agent-office/app/telegram/conversational.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"""텔레그램 자연어 대화 핸들러 — Claude + 프롬프트 캐싱.
|
||||||
|
|
||||||
|
구조:
|
||||||
|
- system prompt(정적) + 최근 대화 이력 + 마지막 user turn
|
||||||
|
- system과 history 끝 블록에 cache_control=ephemeral 적용 → 5분 TTL 프롬프트 캐시
|
||||||
|
- 평가를 위해 토큰·캐시·latency를 DB에 기록
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ..config import (
|
||||||
|
ANTHROPIC_API_KEY,
|
||||||
|
CONVERSATION_MODEL,
|
||||||
|
CONVERSATION_HISTORY_LIMIT,
|
||||||
|
CONVERSATION_RATE_PER_MIN,
|
||||||
|
TELEGRAM_CHAT_ID,
|
||||||
|
TELEGRAM_WIFE_CHAT_ID,
|
||||||
|
)
|
||||||
|
from ..db import (
|
||||||
|
save_conversation_message,
|
||||||
|
get_conversation_history,
|
||||||
|
count_recent_user_messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
API_URL = "https://api.anthropic.com/v1/messages"
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """당신은 'gahusb' 개인 웹 플랫폼의 AI 비서입니다. 텔레그램을 통해 CEO(주인)와 그의 가족과 대화합니다.
|
||||||
|
|
||||||
|
역할과 성격:
|
||||||
|
- 따뜻하지만 간결합니다. 텔레그램에서 읽기 쉽게 2~5문장 위주로 답합니다.
|
||||||
|
- 농담과 위트를 섞되 공손하게. 이모지는 상황에 맞게 1~2개만.
|
||||||
|
- 모르는 것은 솔직히 모른다고 하고, 추측은 명시합니다.
|
||||||
|
|
||||||
|
플랫폼 컨텍스트(대답에 자연스럽게 참고):
|
||||||
|
- 주식 에이전트: 뉴스 요약·시장 브리핑·포트폴리오 관리
|
||||||
|
- 음악 에이전트: AI 음악 생성(Suno/MusicGen)
|
||||||
|
- 블로그 에이전트: 키워드 리서치·포스트 생성·품질 리뷰
|
||||||
|
- 청약 에이전트: 부동산 청약 공고 수집·매칭
|
||||||
|
- 명령은 `/help`, `/agents`, `/status`, `/stock.brief` 같은 슬래시 형식이 있습니다. 사용자가 요청을 설명만 하면 해당 명령을 안내해 주세요.
|
||||||
|
|
||||||
|
응답 규칙:
|
||||||
|
- 장문 설명 금지. 스크롤을 넘기지 않을 분량.
|
||||||
|
- 에이전트 실행을 부탁받으면 지금 이 채널은 '대화'만 가능함을 알리고, 정확한 슬래시 명령을 한 줄로 제시하세요.
|
||||||
|
- HTML·마크다운 태그 없이 평문으로 답합니다."""
|
||||||
|
|
||||||
|
|
||||||
|
_rate_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def is_whitelisted(chat_id: str) -> bool:
|
||||||
|
allowed = {str(x) for x in (TELEGRAM_CHAT_ID, TELEGRAM_WIFE_CHAT_ID) if x}
|
||||||
|
return str(chat_id) in allowed
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_rate_limit(chat_id: str) -> bool:
|
||||||
|
async with _rate_lock:
|
||||||
|
count = count_recent_user_messages(chat_id, seconds=60)
|
||||||
|
return count < CONVERSATION_RATE_PER_MIN
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_claude(messages: list) -> dict:
|
||||||
|
"""Anthropic Messages API 호출 (prompt caching beta)."""
|
||||||
|
headers = {
|
||||||
|
"x-api-key": ANTHROPIC_API_KEY,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"anthropic-beta": "prompt-caching-2024-07-31",
|
||||||
|
"content-type": "application/json",
|
||||||
|
}
|
||||||
|
# system: cache_control 적용하여 정적 프롬프트 캐싱
|
||||||
|
system_blocks = [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": SYSTEM_PROMPT,
|
||||||
|
"cache_control": {"type": "ephemeral"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
payload = {
|
||||||
|
"model": CONVERSATION_MODEL,
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"system": system_blocks,
|
||||||
|
"messages": messages,
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient(timeout=60) as client:
|
||||||
|
r = await client.post(API_URL, headers=headers, json=payload)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_messages(history: list, user_text: str) -> list:
|
||||||
|
"""history: [{role, content(str)}, ...]. 가장 오래된 턴을 제외한 나머지 히스토리 끝 블록에
|
||||||
|
cache_control을 추가하여 누적 이력을 캐시한다."""
|
||||||
|
msgs: list = []
|
||||||
|
for h in history:
|
||||||
|
msgs.append({"role": h["role"], "content": [{"type": "text", "text": h["content"]}]})
|
||||||
|
# 히스토리 마지막 블록에 cache_control → 이전 대화를 캐시
|
||||||
|
if msgs:
|
||||||
|
last = msgs[-1]["content"][-1]
|
||||||
|
last["cache_control"] = {"type": "ephemeral"}
|
||||||
|
msgs.append({"role": "user", "content": [{"type": "text", "text": user_text}]})
|
||||||
|
return msgs
|
||||||
|
|
||||||
|
|
||||||
|
async def respond_to_message(chat_id: str, user_text: str) -> Optional[str]:
|
||||||
|
"""자연어 메시지에 응답. 실패 시 사용자에게 돌려줄 문자열 반환(또는 None = 무시)."""
|
||||||
|
if not ANTHROPIC_API_KEY:
|
||||||
|
return None # 기능 비활성
|
||||||
|
|
||||||
|
if not is_whitelisted(chat_id):
|
||||||
|
return None # 모르는 사용자 무시
|
||||||
|
|
||||||
|
if not await _check_rate_limit(chat_id):
|
||||||
|
return "⏳ 잠시만요, 너무 빠릅니다. 분당 몇 번만 대화해 주세요."
|
||||||
|
|
||||||
|
history = get_conversation_history(chat_id, limit=CONVERSATION_HISTORY_LIMIT)
|
||||||
|
messages = _build_messages(history, user_text)
|
||||||
|
|
||||||
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
|
resp = await _call_claude(messages)
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
body = e.response.text[:200] if e.response is not None else ""
|
||||||
|
return f"⚠️ Claude 호출 실패: {e.response.status_code} {body}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"⚠️ 응답 생성 중 오류: {type(e).__name__}"
|
||||||
|
latency_ms = int((time.monotonic() - started) * 1000)
|
||||||
|
|
||||||
|
try:
|
||||||
|
reply = "".join(
|
||||||
|
blk.get("text", "") for blk in resp.get("content", []) if blk.get("type") == "text"
|
||||||
|
).strip()
|
||||||
|
except Exception:
|
||||||
|
reply = ""
|
||||||
|
if not reply:
|
||||||
|
reply = "(빈 응답)"
|
||||||
|
|
||||||
|
usage = resp.get("usage", {}) or {}
|
||||||
|
t_in = int(usage.get("input_tokens", 0) or 0)
|
||||||
|
t_out = int(usage.get("output_tokens", 0) or 0)
|
||||||
|
c_read = int(usage.get("cache_read_input_tokens", 0) or 0)
|
||||||
|
c_write = int(usage.get("cache_creation_input_tokens", 0) or 0)
|
||||||
|
|
||||||
|
# 기록: user 먼저, assistant 나중 (순서 보존)
|
||||||
|
save_conversation_message(chat_id, "user", user_text)
|
||||||
|
save_conversation_message(
|
||||||
|
chat_id, "assistant", reply,
|
||||||
|
model=CONVERSATION_MODEL,
|
||||||
|
tokens_input=t_in, tokens_output=t_out,
|
||||||
|
cache_read=c_read, cache_write=c_write,
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
)
|
||||||
|
return reply
|
||||||
51
agent-office/app/telegram/formatter.py
Normal file
51
agent-office/app/telegram/formatter.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""에이전트 메시지 포맷팅."""
|
||||||
|
from html import escape as _h
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from .agent_registry import get_agent_meta
|
||||||
|
|
||||||
|
MessageKind = Literal["report", "alert", "approval", "error", "info"]
|
||||||
|
|
||||||
|
KIND_ICONS = {
|
||||||
|
"report": "📊",
|
||||||
|
"alert": "🔔",
|
||||||
|
"approval": "✋",
|
||||||
|
"error": "⚠️",
|
||||||
|
"info": "ℹ️",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_agent_message(
|
||||||
|
agent_id: str,
|
||||||
|
kind: MessageKind,
|
||||||
|
title: str,
|
||||||
|
body: str,
|
||||||
|
metadata: Optional[dict] = None,
|
||||||
|
body_is_html: bool = False,
|
||||||
|
) -> str:
|
||||||
|
meta = get_agent_meta(agent_id)
|
||||||
|
icon = KIND_ICONS.get(kind, "")
|
||||||
|
header = f"{icon} <b>[{_h(meta['emoji'])} {_h(meta['display_name'])}]</b> {_h(title)}"
|
||||||
|
|
||||||
|
# Telegram 단일 메시지 4096자 제한 대응 (헤더/푸터 여유 512자 확보)
|
||||||
|
# body_is_html=True 면 호출자가 이미 HTML-safe하게 구성한 것으로 간주 (예: <a> 링크 포함)
|
||||||
|
safe_body = body if body_is_html else _h(body)
|
||||||
|
if len(safe_body) > 3500:
|
||||||
|
safe_body = safe_body[:3500] + "\n…(생략)"
|
||||||
|
|
||||||
|
lines = [header, "━" * 20, safe_body]
|
||||||
|
|
||||||
|
if metadata:
|
||||||
|
footer_parts = []
|
||||||
|
if "tokens" in metadata:
|
||||||
|
footer_parts.append(f"🧮 {metadata['tokens']:,} tokens")
|
||||||
|
if "duration_ms" in metadata:
|
||||||
|
seconds = metadata["duration_ms"] / 1000
|
||||||
|
footer_parts.append(f"⏱ {seconds:.1f}s")
|
||||||
|
if "model" in metadata:
|
||||||
|
footer_parts.append(f"🤖 {metadata['model']}")
|
||||||
|
if footer_parts:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"<i>{_h(' · '.join(footer_parts))}</i>")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
75
agent-office/app/telegram/messaging.py
Normal file
75
agent-office/app/telegram/messaging.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""고수준 메시지 전송 API."""
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from ..config import TELEGRAM_CHAT_ID
|
||||||
|
from ..db import save_telegram_callback
|
||||||
|
from .client import _enabled, api_call
|
||||||
|
from .formatter import MessageKind, format_agent_message
|
||||||
|
|
||||||
|
|
||||||
|
async def send_raw(text: str, reply_markup: Optional[dict] = None, chat_id: Optional[str] = None) -> dict:
|
||||||
|
"""가장 저수준. 원문 텍스트 그대로 전송. chat_id 생략 시 기본 TELEGRAM_CHAT_ID로."""
|
||||||
|
if not _enabled():
|
||||||
|
return {"ok": False, "message_id": None}
|
||||||
|
payload = {
|
||||||
|
"chat_id": chat_id or TELEGRAM_CHAT_ID,
|
||||||
|
"text": text,
|
||||||
|
"parse_mode": "HTML",
|
||||||
|
}
|
||||||
|
if reply_markup:
|
||||||
|
payload["reply_markup"] = reply_markup
|
||||||
|
result = await api_call("sendMessage", payload)
|
||||||
|
ok = result.get("ok", False)
|
||||||
|
return {
|
||||||
|
"ok": ok,
|
||||||
|
"message_id": result.get("result", {}).get("message_id") if ok else None,
|
||||||
|
"description": result.get("description") if not ok else None,
|
||||||
|
"error_code": result.get("error_code") if not ok else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def send_agent_message(
|
||||||
|
agent_id: str,
|
||||||
|
kind: MessageKind,
|
||||||
|
title: str,
|
||||||
|
body: str,
|
||||||
|
task_id: Optional[str] = None,
|
||||||
|
actions: Optional[list] = None,
|
||||||
|
metadata: Optional[dict] = None,
|
||||||
|
body_is_html: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""통합 에이전트 메시지 API. 모든 에이전트가 이걸 씀.
|
||||||
|
|
||||||
|
body_is_html=True: 호출자가 이미 HTML-safe 포맷(링크 <a> 등) 구성한 경우.
|
||||||
|
"""
|
||||||
|
text = format_agent_message(agent_id, kind, title, body, metadata, body_is_html=body_is_html)
|
||||||
|
reply_markup = None
|
||||||
|
if actions:
|
||||||
|
buttons = []
|
||||||
|
for action in actions:
|
||||||
|
cb_id = f"{action['action']}_{uuid.uuid4().hex[:8]}"
|
||||||
|
save_telegram_callback(cb_id, task_id or "", agent_id)
|
||||||
|
buttons.append({"text": action["label"], "callback_data": cb_id})
|
||||||
|
reply_markup = {"inline_keyboard": [buttons]}
|
||||||
|
return await send_raw(text, reply_markup)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_approval_request(
|
||||||
|
agent_id: str,
|
||||||
|
task_id: str,
|
||||||
|
title: str,
|
||||||
|
detail: str,
|
||||||
|
) -> dict:
|
||||||
|
"""승인/거절 단축 헬퍼."""
|
||||||
|
return await send_agent_message(
|
||||||
|
agent_id=agent_id,
|
||||||
|
kind="approval",
|
||||||
|
title=title,
|
||||||
|
body=detail,
|
||||||
|
task_id=task_id,
|
||||||
|
actions=[
|
||||||
|
{"label": "✅ 승인", "action": "approve"},
|
||||||
|
{"label": "❌ 거절", "action": "reject"},
|
||||||
|
],
|
||||||
|
)
|
||||||
87
agent-office/app/telegram/router.py
Normal file
87
agent-office/app/telegram/router.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"""텔레그램 메시지 명령 → 에이전트 라우팅.
|
||||||
|
새 명령을 추가하려면 AGENT_COMMAND_MAP에 등록만 하면 됨."""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def parse_command(text: str) -> Optional[tuple]:
|
||||||
|
"""슬래시 명령 파싱.
|
||||||
|
|
||||||
|
반환: (agent_id_or_None, command, args_list) 또는 None
|
||||||
|
|
||||||
|
예시:
|
||||||
|
/stock news -> ("stock", "news", [])
|
||||||
|
/status -> (None, "status", [])
|
||||||
|
/music compose 잔잔한 피아노 -> ("music", "compose", ["잔잔한 피아노"])
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
text = text.strip()
|
||||||
|
if not text.startswith("/"):
|
||||||
|
return None
|
||||||
|
parts = text[1:].split(maxsplit=2)
|
||||||
|
if not parts:
|
||||||
|
return None
|
||||||
|
|
||||||
|
first = parts[0].lower()
|
||||||
|
|
||||||
|
# 전역 명령
|
||||||
|
if first in ("status", "agents", "help"):
|
||||||
|
return (None, first, parts[1:] if len(parts) > 1 else [])
|
||||||
|
|
||||||
|
# 에이전트 명령: /<agent> <command> [args...]
|
||||||
|
if len(parts) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
agent_id = first
|
||||||
|
command = parts[1].lower()
|
||||||
|
args = [parts[2]] if len(parts) > 2 else []
|
||||||
|
return (agent_id, command, args)
|
||||||
|
|
||||||
|
|
||||||
|
# 에이전트별 텔레그램 → 내부 command 매핑
|
||||||
|
# 텔레그램에서 친숙한 이름 -> (실제 on_command의 command, 기본 params)
|
||||||
|
AGENT_COMMAND_MAP = {
|
||||||
|
"stock": {
|
||||||
|
"news": ("fetch_news", {}),
|
||||||
|
"alerts": ("list_alerts", {}),
|
||||||
|
"test": ("test_telegram", {}),
|
||||||
|
},
|
||||||
|
"music": {
|
||||||
|
"credits": ("credits", {}),
|
||||||
|
# compose는 인자 필요 — 아래 특수 케이스에서 처리
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_agent_command(agent_id: str, command: str, args: list) -> Optional[tuple]:
|
||||||
|
"""(internal_command, params) 반환. 매핑 없으면 None."""
|
||||||
|
mapping = AGENT_COMMAND_MAP.get(agent_id, {}).get(command)
|
||||||
|
if mapping is None:
|
||||||
|
# 특수 케이스: music compose <prompt>
|
||||||
|
if agent_id == "music" and command == "compose" and args:
|
||||||
|
return ("compose", {"prompt": " ".join(args)})
|
||||||
|
return None
|
||||||
|
internal_cmd, base_params = mapping
|
||||||
|
params = dict(base_params)
|
||||||
|
if args:
|
||||||
|
# args가 있으면 첫 번째(합쳐진 나머지)를 message로 자동 주입
|
||||||
|
params["message"] = " ".join(args)
|
||||||
|
return (internal_cmd, params)
|
||||||
|
|
||||||
|
|
||||||
|
HELP_TEXT = """<b>🤖 Agent Office 텔레그램 명령</b>
|
||||||
|
|
||||||
|
<b>전역</b>
|
||||||
|
/status — 모든 에이전트 상태
|
||||||
|
/agents — 에이전트 목록
|
||||||
|
/help — 이 도움말
|
||||||
|
|
||||||
|
<b>📈 주식 트레이더</b>
|
||||||
|
/stock news — 뉴스 AI 요약 실행
|
||||||
|
/stock alerts — 알람 목록
|
||||||
|
/stock test — 텔레그램 테스트
|
||||||
|
|
||||||
|
<b>🎵 음악 프로듀서</b>
|
||||||
|
/music credits — Suno 크레딧 조회
|
||||||
|
/music compose <프롬프트> — 작곡 시작
|
||||||
|
"""
|
||||||
162
agent-office/app/telegram/webhook.py
Normal file
162
agent-office/app/telegram/webhook.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
"""텔레그램 Webhook 이벤트 처리."""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from ..db import get_telegram_callback, mark_telegram_responded
|
||||||
|
from .client import _enabled, api_call
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_webhook(data: dict, agent_dispatcher=None) -> Optional[dict]:
|
||||||
|
"""텔레그램에서 들어오는 이벤트 처리.
|
||||||
|
|
||||||
|
- callback_query(인라인 버튼)는 항상 처리 → 승인/거절 dict 반환
|
||||||
|
- message(텍스트 슬래시 명령)는 `agent_dispatcher`가 주입된 경우에만 처리
|
||||||
|
|
||||||
|
agent_dispatcher: async (agent_id, command, params) -> dict
|
||||||
|
- agent_id == "__global__", command == "status" 특수 케이스는
|
||||||
|
{agent_id: {state, detail}} dict를 반환해야 함.
|
||||||
|
"""
|
||||||
|
callback_query = data.get("callback_query")
|
||||||
|
if callback_query:
|
||||||
|
return await _handle_callback(callback_query)
|
||||||
|
|
||||||
|
message = data.get("message")
|
||||||
|
if message:
|
||||||
|
chat = message.get("chat", {})
|
||||||
|
print(f"[TG-WEBHOOK] chat.id={chat.get('id')} type={chat.get('type')} text={message.get('text')!r}", flush=True)
|
||||||
|
if message and message.get("text") and agent_dispatcher is not None:
|
||||||
|
return await _handle_message(message, agent_dispatcher)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_callback(callback_query: dict) -> Optional[dict]:
|
||||||
|
"""기존 승인/거절 콜백 처리 로직."""
|
||||||
|
callback_id = callback_query.get("data", "")
|
||||||
|
cb = get_telegram_callback(callback_id)
|
||||||
|
if not cb:
|
||||||
|
return None
|
||||||
|
|
||||||
|
action = callback_id.split("_")[0]
|
||||||
|
mark_telegram_responded(callback_id, action)
|
||||||
|
|
||||||
|
feedback_text = {
|
||||||
|
"approve": "승인됨 ✅",
|
||||||
|
"reject": "거절됨 ❌",
|
||||||
|
}.get(action, f"처리됨: {action}")
|
||||||
|
|
||||||
|
await api_call(
|
||||||
|
"answerCallbackQuery",
|
||||||
|
{
|
||||||
|
"callback_query_id": callback_query["id"],
|
||||||
|
"text": feedback_text,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"task_id": cb["task_id"],
|
||||||
|
"agent_id": cb["agent_id"],
|
||||||
|
"action": action,
|
||||||
|
"approved": action == "approve",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_message(message: dict, agent_dispatcher) -> Optional[dict]:
|
||||||
|
"""슬래시 명령 메시지 처리."""
|
||||||
|
from .router import parse_command, resolve_agent_command, HELP_TEXT
|
||||||
|
from .messaging import send_raw, send_agent_message
|
||||||
|
from .agent_registry import AGENT_META
|
||||||
|
|
||||||
|
text = message.get("text", "")
|
||||||
|
parsed = parse_command(text)
|
||||||
|
if not parsed:
|
||||||
|
# 슬래시 명령이 아니면 자연어 대화로 라우팅
|
||||||
|
chat_id = str(message.get("chat", {}).get("id", ""))
|
||||||
|
if not chat_id:
|
||||||
|
return None
|
||||||
|
from .conversational import respond_to_message
|
||||||
|
reply = await respond_to_message(chat_id, text)
|
||||||
|
if reply:
|
||||||
|
import html as _html
|
||||||
|
await send_raw(_html.escape(reply), chat_id=chat_id)
|
||||||
|
return {"handled": "chat"}
|
||||||
|
return None
|
||||||
|
|
||||||
|
agent_id, command, args = parsed
|
||||||
|
|
||||||
|
# 전역 명령
|
||||||
|
if agent_id is None:
|
||||||
|
if command == "help":
|
||||||
|
await send_raw(HELP_TEXT)
|
||||||
|
return {"handled": "help"}
|
||||||
|
|
||||||
|
if command == "agents":
|
||||||
|
lines = ["<b>📋 등록된 에이전트</b>", ""]
|
||||||
|
for aid, meta in AGENT_META.items():
|
||||||
|
lines.append(
|
||||||
|
f"{meta['emoji']} <b>{meta['display_name']}</b> <code>/{aid}</code>"
|
||||||
|
)
|
||||||
|
await send_raw("\n".join(lines))
|
||||||
|
return {"handled": "agents"}
|
||||||
|
|
||||||
|
if command == "status":
|
||||||
|
try:
|
||||||
|
result = await agent_dispatcher("__global__", "status", {})
|
||||||
|
body_lines = []
|
||||||
|
if isinstance(result, dict):
|
||||||
|
for aid, info in result.items():
|
||||||
|
meta = AGENT_META.get(
|
||||||
|
aid, {"emoji": "🤖", "display_name": aid}
|
||||||
|
)
|
||||||
|
state = info.get("state", "unknown") if isinstance(info, dict) else "unknown"
|
||||||
|
body_lines.append(
|
||||||
|
f"{meta['emoji']} <b>{meta['display_name']}</b>: <code>{state}</code>"
|
||||||
|
)
|
||||||
|
detail = info.get("detail") if isinstance(info, dict) else None
|
||||||
|
if detail:
|
||||||
|
body_lines.append(f" └ {detail}")
|
||||||
|
await send_raw("<b>📊 전체 상태</b>\n\n" + "\n".join(body_lines))
|
||||||
|
except Exception as e:
|
||||||
|
await send_raw(f"⚠️ 상태 조회 실패: {e}")
|
||||||
|
return {"handled": "status"}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 에이전트 명령
|
||||||
|
if agent_id not in AGENT_META:
|
||||||
|
await send_raw(
|
||||||
|
f"⚠️ 알 수 없는 에이전트: <code>{agent_id}</code>\n/help 로 사용 가능한 명령 확인"
|
||||||
|
)
|
||||||
|
return {"handled": "unknown_agent"}
|
||||||
|
|
||||||
|
resolved = resolve_agent_command(agent_id, command, args)
|
||||||
|
if resolved is None:
|
||||||
|
await send_raw(
|
||||||
|
f"⚠️ <code>{agent_id}</code>에서 <code>{command}</code> 명령은 지원하지 않습니다."
|
||||||
|
)
|
||||||
|
return {"handled": "unknown_command"}
|
||||||
|
|
||||||
|
internal_cmd, params = resolved
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await agent_dispatcher(agent_id, internal_cmd, params)
|
||||||
|
ok = result.get("ok", False) if isinstance(result, dict) else False
|
||||||
|
msg = result.get("message", "") if isinstance(result, dict) else str(result)
|
||||||
|
|
||||||
|
await send_agent_message(
|
||||||
|
agent_id=agent_id,
|
||||||
|
kind="info" if ok else "error",
|
||||||
|
title=f"{internal_cmd} 실행 결과",
|
||||||
|
body=msg or str(result),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await send_raw(f"⚠️ 명령 실행 실패: {e}")
|
||||||
|
|
||||||
|
return {"handled": "command", "agent_id": agent_id, "command": internal_cmd}
|
||||||
|
|
||||||
|
|
||||||
|
async def setup_webhook() -> dict:
|
||||||
|
from ..config import TELEGRAM_WEBHOOK_URL
|
||||||
|
|
||||||
|
if not _enabled() or not TELEGRAM_WEBHOOK_URL:
|
||||||
|
return {"ok": False, "description": "Webhook URL not configured"}
|
||||||
|
return await api_call("setWebhook", {"url": TELEGRAM_WEBHOOK_URL})
|
||||||
27
agent-office/app/telegram_bot.py
Normal file
27
agent-office/app/telegram_bot.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""Deprecated: app.telegram 패키지 사용 권장. 하위 호환용 re-export."""
|
||||||
|
from .telegram import handle_webhook, send_approval_request, send_raw, setup_webhook
|
||||||
|
from .telegram.messaging import send_agent_message
|
||||||
|
|
||||||
|
|
||||||
|
# 기존 호출자가 쓰던 이름들
|
||||||
|
async def send_message(text: str, reply_markup: dict = None) -> dict:
|
||||||
|
return await send_raw(text, reply_markup)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_stock_summary(summary: str) -> dict:
|
||||||
|
return await send_raw(summary)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_task_result(agent_id: str, title: str, result: str) -> dict:
|
||||||
|
return await send_agent_message(agent_id, "report", title, result)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"send_message",
|
||||||
|
"send_stock_summary",
|
||||||
|
"send_task_result",
|
||||||
|
"send_approval_request",
|
||||||
|
"send_agent_message",
|
||||||
|
"handle_webhook",
|
||||||
|
"setup_webhook",
|
||||||
|
]
|
||||||
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
|
||||||
55
agent-office/app/websocket_manager.py
Normal file
55
agent-office/app/websocket_manager.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
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})
|
||||||
|
|
||||||
|
async def send_notification(self, agent_id: str, event: str, task_id: str = None, message: str = "") -> None:
|
||||||
|
await self.broadcast({
|
||||||
|
"type": "notification",
|
||||||
|
"agent": agent_id,
|
||||||
|
"event": event,
|
||||||
|
"task_id": task_id,
|
||||||
|
"message": message,
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
60
agent-office/tests/test_curator_schema.py
Normal file
60
agent-office/tests/test_curator_schema.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import pytest
|
||||||
|
from app.curator.schema import validate_response, CuratorOutput
|
||||||
|
|
||||||
|
|
||||||
|
CANDIDATE_NUMBERS = [
|
||||||
|
[1, 2, 3, 4, 5, 6],
|
||||||
|
[7, 8, 9, 10, 11, 12],
|
||||||
|
[13, 14, 15, 16, 17, 18],
|
||||||
|
[19, 20, 21, 22, 23, 24],
|
||||||
|
[25, 26, 27, 28, 29, 30],
|
||||||
|
[31, 32, 33, 34, 35, 36],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_payload():
|
||||||
|
return {
|
||||||
|
"picks": [
|
||||||
|
{"numbers": s, "risk_tag": "안정", "reason": "test"}
|
||||||
|
for s in CANDIDATE_NUMBERS[:5]
|
||||||
|
],
|
||||||
|
"narrative": {
|
||||||
|
"headline": "h", "summary_3lines": ["a", "b", "c"],
|
||||||
|
"hot_cold_comment": "hc", "warnings": "",
|
||||||
|
},
|
||||||
|
"confidence": 80,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_payload_passes():
|
||||||
|
result = validate_response(_valid_payload(), CANDIDATE_NUMBERS)
|
||||||
|
assert isinstance(result, CuratorOutput)
|
||||||
|
assert len(result.picks) == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_number_out_of_candidates():
|
||||||
|
bad = _valid_payload()
|
||||||
|
bad["picks"][0]["numbers"] = [40, 41, 42, 43, 44, 45] # valid numbers but not in candidates
|
||||||
|
with pytest.raises(ValueError, match="not in candidates"):
|
||||||
|
validate_response(bad, CANDIDATE_NUMBERS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_wrong_pick_count():
|
||||||
|
bad = _valid_payload()
|
||||||
|
bad["picks"] = bad["picks"][:3]
|
||||||
|
with pytest.raises(ValueError, match="exactly 5"):
|
||||||
|
validate_response(bad, CANDIDATE_NUMBERS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_duplicate_numbers_within_set():
|
||||||
|
bad = _valid_payload()
|
||||||
|
bad["picks"][0]["numbers"] = [1, 1, 2, 3, 4, 5]
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_response(bad, CANDIDATE_NUMBERS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_invalid_risk_tag():
|
||||||
|
bad = _valid_payload()
|
||||||
|
bad["picks"][0]["risk_tag"] = "미친"
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_response(bad, CANDIDATE_NUMBERS)
|
||||||
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
|
EXPOSE 8000
|
||||||
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "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
|
import requests
|
||||||
from typing import Dict, Any
|
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:
|
def _normalize_item(item: dict) -> dict:
|
||||||
# smok95 all.json / latest.json 구조
|
# smok95 all.json / latest.json 구조
|
||||||
@@ -27,20 +27,13 @@ def sync_all_from_json(all_url: str) -> Dict[str, Any]:
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json() # list[dict]
|
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:
|
return {"mode": "all_json", "url": all_url, "total": len(rows)}
|
||||||
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)}
|
|
||||||
|
|
||||||
def sync_latest(latest_url: str) -> Dict[str, Any]:
|
def sync_latest(latest_url: str) -> Dict[str, Any]:
|
||||||
r = requests.get(latest_url, timeout=30)
|
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"]}
|
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}
|
||||||
|
|
||||||
|
|||||||
151
backend/app/curator_helpers.py
Normal file
151
backend/app/curator_helpers.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""큐레이터용 후보 가공 — 여러 엔진 결과를 하나로 병합, 중복 제거, 피처 계산."""
|
||||||
|
from typing import Dict, List, Any, Set
|
||||||
|
from . import db
|
||||||
|
from .recommender import recommend_numbers, recommend_with_heatmap
|
||||||
|
from .analyzer import get_statistical_report
|
||||||
|
from .strategy_evolver import generate_smart_recommendation
|
||||||
|
|
||||||
|
|
||||||
|
LOW_HIGH_CUT = 22
|
||||||
|
|
||||||
|
|
||||||
|
def compute_features(numbers: List[int], hot: Set[int], cold: Set[int]) -> Dict[str, Any]:
|
||||||
|
nums = sorted(numbers)
|
||||||
|
odd = sum(1 for n in nums if n % 2 == 1)
|
||||||
|
low = sum(1 for n in nums if n <= LOW_HIGH_CUT)
|
||||||
|
buckets = [0, 0, 0, 0, 0]
|
||||||
|
for n in nums:
|
||||||
|
if n <= 10: buckets[0] += 1
|
||||||
|
elif n <= 20: buckets[1] += 1
|
||||||
|
elif n <= 30: buckets[2] += 1
|
||||||
|
elif n <= 40: buckets[3] += 1
|
||||||
|
else: buckets[4] += 1
|
||||||
|
consecutive = any(nums[i+1] - nums[i] == 1 for i in range(len(nums) - 1))
|
||||||
|
return {
|
||||||
|
"odd_count": odd,
|
||||||
|
"even_count": 6 - odd,
|
||||||
|
"low_count": low,
|
||||||
|
"high_count": 6 - low,
|
||||||
|
"range_distribution": buckets,
|
||||||
|
"has_consecutive": consecutive,
|
||||||
|
"hot_number_count": len(set(nums) & hot),
|
||||||
|
"cold_number_count": len(set(nums) & cold),
|
||||||
|
"sum": sum(nums),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _key(numbers: List[int]) -> str:
|
||||||
|
return ",".join(str(n) for n in sorted(numbers))
|
||||||
|
|
||||||
|
|
||||||
|
def collect_candidates(n: int, hot: Set[int], cold: Set[int]) -> List[Dict[str, Any]]:
|
||||||
|
"""우선순위: simulation best_picks → meta → heatmap → statistics. 중복 제거 후 최대 n세트."""
|
||||||
|
seen: Dict[str, Dict[str, Any]] = {}
|
||||||
|
order: List[str] = []
|
||||||
|
|
||||||
|
def _add(numbers: List[int], source: str) -> None:
|
||||||
|
if not numbers:
|
||||||
|
return
|
||||||
|
k = _key(numbers)
|
||||||
|
if k in seen:
|
||||||
|
return
|
||||||
|
seen[k] = {"numbers": sorted(numbers), "source": source}
|
||||||
|
order.append(k)
|
||||||
|
|
||||||
|
# 1. simulation best_picks
|
||||||
|
try:
|
||||||
|
for row in db.get_best_picks(limit=n):
|
||||||
|
_add(row.get("numbers") or [], "simulation")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# draws는 한 번만 로드
|
||||||
|
draws = []
|
||||||
|
try:
|
||||||
|
draws = db.get_all_draw_numbers()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2. meta-strategy (smart)
|
||||||
|
try:
|
||||||
|
meta = generate_smart_recommendation(sets=n)
|
||||||
|
for s in meta.get("sets", []):
|
||||||
|
_add(s.get("numbers") or [], "meta")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 3. heatmap (n번 호출, 중복 회피)
|
||||||
|
if draws:
|
||||||
|
try:
|
||||||
|
for _ in range(n * 2):
|
||||||
|
if len(order) >= n * 2:
|
||||||
|
break
|
||||||
|
r = recommend_with_heatmap(draws, [])
|
||||||
|
_add(r.get("numbers") or [], "heatmap")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 4. statistics
|
||||||
|
if draws:
|
||||||
|
try:
|
||||||
|
for _ in range(n * 2):
|
||||||
|
if len(order) >= n * 2:
|
||||||
|
break
|
||||||
|
r = recommend_numbers(draws)
|
||||||
|
_add(r.get("numbers") or [], "statistics")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for k in order[:n]:
|
||||||
|
item = seen[k]
|
||||||
|
item["features"] = compute_features(item["numbers"], hot, cold)
|
||||||
|
out.append(item)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_context(hot_limit: int = 10, cold_limit: int = 10) -> Dict[str, Any]:
|
||||||
|
"""주간 맥락 패키지 — get_statistical_report가 이미 hot/cold를 제공."""
|
||||||
|
hot: List[int] = []
|
||||||
|
cold: List[int] = []
|
||||||
|
last_summary = ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
draws = db.get_all_draw_numbers()
|
||||||
|
except Exception:
|
||||||
|
draws = []
|
||||||
|
|
||||||
|
if draws:
|
||||||
|
try:
|
||||||
|
report = get_statistical_report(draws)
|
||||||
|
hot = list(report.get("hot_numbers", []))[:hot_limit]
|
||||||
|
cold = list(report.get("cold_numbers", []))[:cold_limit]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
latest = db.get_latest_draw()
|
||||||
|
except Exception:
|
||||||
|
latest = None
|
||||||
|
|
||||||
|
if latest:
|
||||||
|
nums = [latest.get(f"n{i}") for i in range(1, 7)]
|
||||||
|
nums = [n for n in nums if n is not None]
|
||||||
|
if nums:
|
||||||
|
odd = sum(1 for n in nums if n % 2 == 1)
|
||||||
|
low = sum(1 for n in nums if n <= LOW_HIGH_CUT)
|
||||||
|
last_summary = f"{latest.get('drw_no')}회: {', '.join(str(n) for n in nums)} (홀{odd}짝{6-odd}, 저{low}고{6-low})"
|
||||||
|
|
||||||
|
my_perf: List[Dict[str, Any]] = []
|
||||||
|
try:
|
||||||
|
from .purchase_manager import get_recent_performance
|
||||||
|
my_perf = get_recent_performance(limit=3)
|
||||||
|
except Exception:
|
||||||
|
my_perf = []
|
||||||
|
|
||||||
|
return {
|
||||||
|
"hot_numbers": hot,
|
||||||
|
"cold_numbers": cold,
|
||||||
|
"last_draw_summary": last_summary,
|
||||||
|
"my_recent_performance": my_perf,
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
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,91 +1,112 @@
|
|||||||
import os
|
import os
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
from typing import Optional, List, Dict, Any, Tuple
|
from typing import Optional, List, Dict, Any, Tuple
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
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 (
|
from .db import (
|
||||||
init_db, get_draw, get_latest_draw, get_all_draw_numbers,
|
init_db, get_draw, get_latest_draw, get_all_draw_numbers,
|
||||||
save_recommendation_dedup, list_recommendations_ex, delete_recommendation,
|
save_recommendation_dedup, list_recommendations_ex, delete_recommendation,
|
||||||
update_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
|
from .recommender import recommend_numbers, recommend_with_heatmap
|
||||||
from .collector import sync_latest
|
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 .routers import curator as curator_router
|
||||||
|
from .routers import briefing as briefing_router
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
app.include_router(curator_router.router)
|
||||||
|
app.include_router(briefing_router.router)
|
||||||
scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
||||||
|
|
||||||
ALL_URL = os.getenv("LOTTO_ALL_URL", "https://smok95.github.io/lotto/results/all.json")
|
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")
|
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)
|
# 채점 데이터는 하루 2번 스케줄러 실행 시에만 갱신되므로 인메모리 캐시로 충분
|
||||||
s = sum(nums)
|
_PERF_CACHE: Dict[str, Any] = {"data": None, "at": 0.0}
|
||||||
odd = sum(1 for x in nums if x % 2 == 1)
|
_PERF_CACHE_TTL = 3600 # 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 {
|
def _refresh_perf_cache() -> None:
|
||||||
"sum": s,
|
_PERF_CACHE["data"] = get_recommendation_performance()
|
||||||
"odd": odd,
|
_PERF_CACHE["at"] = time.time()
|
||||||
"even": even,
|
logger.info("성과 통계 캐시 갱신")
|
||||||
"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,
|
|
||||||
}
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def on_startup():
|
def on_startup():
|
||||||
init_db()
|
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()
|
scheduler.start()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/lotto/latest")
|
@app.get("/api/lotto/latest")
|
||||||
def api_latest():
|
def api_latest():
|
||||||
row = get_latest_draw()
|
row = get_latest_draw()
|
||||||
@@ -96,8 +117,10 @@ def api_latest():
|
|||||||
"date": row["drw_date"],
|
"date": row["drw_date"],
|
||||||
"numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]],
|
"numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]],
|
||||||
"bonus": row["bonus"],
|
"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}")
|
@app.get("/api/lotto/{drw_no:int}")
|
||||||
def api_draw(drw_no: int):
|
def api_draw(drw_no: int):
|
||||||
row = get_draw(drw_no)
|
row = get_draw(drw_no)
|
||||||
@@ -108,28 +131,381 @@ def api_draw(drw_no: int):
|
|||||||
"date": row["drw_date"],
|
"date": row["drw_date"],
|
||||||
"numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]],
|
"numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]],
|
||||||
"bonus": row["bonus"],
|
"bonus": row["bonus"],
|
||||||
|
"metrics": calc_metrics([row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]]),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/sync_latest")
|
@app.post("/api/admin/sync_latest")
|
||||||
def 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")
|
@app.get("/api/lotto/recommend")
|
||||||
def api_recommend(
|
def api_recommend(
|
||||||
recent_window: int = 200,
|
recent_window: int = 200,
|
||||||
recent_weight: float = 2.0,
|
recent_weight: float = 2.0,
|
||||||
avoid_recent_k: int = 5,
|
avoid_recent_k: int = 5,
|
||||||
|
|
||||||
# ---- optional constraints (Lotto Lab) ----
|
|
||||||
sum_min: Optional[int] = None,
|
sum_min: Optional[int] = None,
|
||||||
sum_max: Optional[int] = None,
|
sum_max: Optional[int] = None,
|
||||||
odd_min: Optional[int] = None,
|
odd_min: Optional[int] = None,
|
||||||
odd_max: Optional[int] = None,
|
odd_max: Optional[int] = None,
|
||||||
range_min: Optional[int] = None,
|
range_min: Optional[int] = None,
|
||||||
range_max: Optional[int] = None,
|
range_max: Optional[int] = None,
|
||||||
max_overlap_latest: Optional[int] = None, # 최근 avoid_recent_k 회차와 중복 허용 개수
|
max_overlap_latest: Optional[int] = None,
|
||||||
max_try: int = 200, # 조건 맞는 조합 찾기 재시도
|
max_try: int = 200,
|
||||||
):
|
):
|
||||||
draws = get_all_draw_numbers()
|
draws = get_all_draw_numbers()
|
||||||
if not draws:
|
if not draws:
|
||||||
@@ -141,7 +517,6 @@ def api_recommend(
|
|||||||
"recent_window": recent_window,
|
"recent_window": recent_window,
|
||||||
"recent_weight": float(recent_weight),
|
"recent_weight": float(recent_weight),
|
||||||
"avoid_recent_k": avoid_recent_k,
|
"avoid_recent_k": avoid_recent_k,
|
||||||
|
|
||||||
"sum_min": sum_min,
|
"sum_min": sum_min,
|
||||||
"sum_max": sum_max,
|
"sum_max": sum_max,
|
||||||
"odd_min": odd_min,
|
"odd_min": odd_min,
|
||||||
@@ -166,7 +541,6 @@ def api_recommend(
|
|||||||
return False
|
return False
|
||||||
if range_max is not None and m["range"] > range_max:
|
if range_max is not None and m["range"] > range_max:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if max_overlap_latest is not None:
|
if max_overlap_latest is not None:
|
||||||
ov = calc_recent_overlap(nums, draws, last_k=avoid_recent_k)
|
ov = calc_recent_overlap(nums, draws, last_k=avoid_recent_k)
|
||||||
if ov["repeats"] > max_overlap_latest:
|
if ov["repeats"] > max_overlap_latest:
|
||||||
@@ -194,11 +568,9 @@ def api_recommend(
|
|||||||
if chosen is None:
|
if chosen is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Constraints too strict. No valid set found in max_try={max_try}. "
|
detail=f"Constraints too strict. No valid set found in max_try={max_try}.",
|
||||||
f"Try relaxing sum/odd/range/overlap constraints.",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# ✅ dedup save
|
|
||||||
saved = save_recommendation_dedup(
|
saved = save_recommendation_dedup(
|
||||||
latest["drw_no"] if latest else None,
|
latest["drw_no"] if latest else None,
|
||||||
chosen,
|
chosen,
|
||||||
@@ -218,10 +590,121 @@ def api_recommend(
|
|||||||
"params": params,
|
"params": params,
|
||||||
"metrics": metrics,
|
"metrics": metrics,
|
||||||
"recent_overlap": overlap,
|
"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")
|
@app.get("/api/history")
|
||||||
def api_history(
|
def api_history(
|
||||||
limit: int = 30,
|
limit: int = 30,
|
||||||
@@ -260,6 +743,7 @@ def api_history(
|
|||||||
"filters": {"favorite": favorite, "tag": tag, "q": q, "sort": sort},
|
"filters": {"favorite": favorite, "tag": tag, "q": q, "sort": sort},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/history/{rec_id:int}")
|
@app.delete("/api/history/{rec_id:int}")
|
||||||
def api_history_delete(rec_id: int):
|
def api_history_delete(rec_id: int):
|
||||||
ok = delete_recommendation(rec_id)
|
ok = delete_recommendation(rec_id)
|
||||||
@@ -267,12 +751,13 @@ def api_history_delete(rec_id: int):
|
|||||||
raise HTTPException(status_code=404, detail="Not found")
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
return {"deleted": True, "id": rec_id}
|
return {"deleted": True, "id": rec_id}
|
||||||
|
|
||||||
# ---------- ✅ history update (favorite/note/tags) ----------
|
|
||||||
class HistoryUpdate(BaseModel):
|
class HistoryUpdate(BaseModel):
|
||||||
favorite: Optional[bool] = None
|
favorite: Optional[bool] = None
|
||||||
note: Optional[str] = None
|
note: Optional[str] = None
|
||||||
tags: Optional[List[str]] = None
|
tags: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/api/history/{rec_id:int}")
|
@app.patch("/api/history/{rec_id:int}")
|
||||||
def api_history_patch(rec_id: int, body: HistoryUpdate):
|
def api_history_patch(rec_id: int, body: HistoryUpdate):
|
||||||
ok = update_recommendation(rec_id, favorite=body.favorite, note=body.note, tags=body.tags)
|
ok = update_recommendation(rec_id, favorite=body.favorite, note=body.note, tags=body.tags)
|
||||||
@@ -280,11 +765,11 @@ def api_history_patch(rec_id: int, body: HistoryUpdate):
|
|||||||
raise HTTPException(status_code=404, detail="Not found or no changes")
|
raise HTTPException(status_code=404, detail="Not found or no changes")
|
||||||
return {"updated": True, "id": rec_id}
|
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):
|
def _batch_unique(draws, count: int, recent_window: int, recent_weight: float, avoid_recent_k: int, max_try: int = 200):
|
||||||
items = []
|
items = []
|
||||||
seen = set()
|
seen = set()
|
||||||
|
|
||||||
tries = 0
|
tries = 0
|
||||||
while len(items) < count and tries < max_try:
|
while len(items) < count and tries < max_try:
|
||||||
tries += 1
|
tries += 1
|
||||||
@@ -294,9 +779,9 @@ def _batch_unique(draws, count: int, recent_window: int, recent_weight: float, a
|
|||||||
continue
|
continue
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
items.append(r)
|
items.append(r)
|
||||||
|
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/lotto/recommend/batch")
|
@app.get("/api/lotto/recommend/batch")
|
||||||
def api_recommend_batch(
|
def api_recommend_batch(
|
||||||
count: int = 5,
|
count: int = 5,
|
||||||
@@ -322,14 +807,20 @@ def api_recommend_batch(
|
|||||||
return {
|
return {
|
||||||
"based_on_latest_draw": latest["drw_no"] if latest else None,
|
"based_on_latest_draw": latest["drw_no"] if latest else None,
|
||||||
"count": count,
|
"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,
|
"params": params,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class BatchSave(BaseModel):
|
class BatchSave(BaseModel):
|
||||||
items: List[List[int]]
|
items: List[List[int]]
|
||||||
params: dict
|
params: dict
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/lotto/recommend/batch")
|
@app.post("/api/lotto/recommend/batch")
|
||||||
def api_recommend_batch_save(body: BatchSave):
|
def api_recommend_batch_save(body: BatchSave):
|
||||||
latest = get_latest_draw()
|
latest = get_latest_draw()
|
||||||
@@ -342,3 +833,105 @@ def api_recommend_batch_save(body: BatchSave):
|
|||||||
|
|
||||||
return {"saved": True, "created_ids": created, "deduped_ids": deduped}
|
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}
|
||||||
|
|||||||
116
backend/app/purchase_manager.py
Normal file
116
backend/app/purchase_manager.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"""
|
||||||
|
구매 이력 관리 + 결과 체크 모듈.
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
|
||||||
|
def get_recent_performance(limit: int = 3) -> list:
|
||||||
|
"""최근 N회차 내 구매 성과 요약. 없으면 빈 리스트."""
|
||||||
|
from . import db
|
||||||
|
purchases = db.get_purchases() or []
|
||||||
|
by_draw: dict = {}
|
||||||
|
for p in purchases:
|
||||||
|
d = p.get("draw_no")
|
||||||
|
if not d:
|
||||||
|
continue
|
||||||
|
results = p.get("results") or []
|
||||||
|
max_correct = max((int(r.get("correct") or 0) for r in results), default=0)
|
||||||
|
slot = by_draw.setdefault(d, {"draw_no": d, "purchased_sets": 0, "best_match": 0})
|
||||||
|
slot["purchased_sets"] += int(p.get("sets") or 1)
|
||||||
|
slot["best_match"] = max(slot["best_match"], max_correct)
|
||||||
|
return sorted(by_draw.values(), key=lambda x: -x["draw_no"])[:limit]
|
||||||
@@ -2,6 +2,8 @@ import random
|
|||||||
from collections import Counter
|
from collections import Counter
|
||||||
from typing import Dict, Any, List, Tuple
|
from typing import Dict, Any, List, Tuple
|
||||||
|
|
||||||
|
from .utils import weighted_sample_6
|
||||||
|
|
||||||
def recommend_numbers(
|
def recommend_numbers(
|
||||||
draws: List[Tuple[int, List[int]]],
|
draws: List[Tuple[int, List[int]]],
|
||||||
*,
|
*,
|
||||||
@@ -40,20 +42,7 @@ def recommend_numbers(
|
|||||||
weights[n] = max(w, 0.1)
|
weights[n] = max(w, 0.1)
|
||||||
|
|
||||||
# 중복 없이 6개 뽑기(가중 샘플링)
|
# 중복 없이 6개 뽑기(가중 샘플링)
|
||||||
chosen = []
|
chosen_sorted = sorted(weighted_sample_6(weights))
|
||||||
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)
|
|
||||||
|
|
||||||
explain = {
|
explain = {
|
||||||
"recent_window": recent_window,
|
"recent_window": recent_window,
|
||||||
@@ -66,3 +55,85 @@ def recommend_numbers(
|
|||||||
|
|
||||||
return {"numbers": chosen_sorted, "explain": explain}
|
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}
|
||||||
|
|
||||||
|
|||||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
53
backend/app/routers/briefing.py
Normal file
53
backend/app/routers/briefing.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""브리핑 저장/조회 + 큐레이터 사용량 엔드포인트."""
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from .. import db
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/lotto")
|
||||||
|
|
||||||
|
|
||||||
|
class BriefingRequest(BaseModel):
|
||||||
|
draw_no: int
|
||||||
|
picks: List[Dict[str, Any]]
|
||||||
|
narrative: Dict[str, Any]
|
||||||
|
confidence: int = Field(ge=0, le=100)
|
||||||
|
model: str
|
||||||
|
tokens_input: int = 0
|
||||||
|
tokens_output: int = 0
|
||||||
|
cache_read: int = 0
|
||||||
|
cache_write: int = 0
|
||||||
|
latency_ms: int = 0
|
||||||
|
source: str = "auto"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/briefing", status_code=201)
|
||||||
|
def save_briefing(body: BriefingRequest):
|
||||||
|
bid = db.save_briefing(body.model_dump())
|
||||||
|
return {"ok": True, "id": bid}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/briefing/latest")
|
||||||
|
def latest():
|
||||||
|
b = db.get_latest_briefing()
|
||||||
|
if not b:
|
||||||
|
raise HTTPException(404, "no briefing yet")
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/briefing/{draw_no}")
|
||||||
|
def get_one(draw_no: int):
|
||||||
|
b = db.get_briefing(draw_no)
|
||||||
|
if not b:
|
||||||
|
raise HTTPException(404, f"no briefing for draw {draw_no}")
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/briefing")
|
||||||
|
def history(limit: int = 10):
|
||||||
|
return {"briefings": db.list_briefings(limit)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/curator/usage")
|
||||||
|
def usage(days: int = 30):
|
||||||
|
return db.get_curator_usage(days)
|
||||||
24
backend/app/routers/curator.py
Normal file
24
backend/app/routers/curator.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""큐레이터 입력 엔드포인트 — agent-office에서만 호출."""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from ..curator_helpers import collect_candidates, build_context
|
||||||
|
from .. import db
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/lotto/curator")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/candidates")
|
||||||
|
def candidates(n: int = 20):
|
||||||
|
ctx = build_context()
|
||||||
|
hot = set(ctx["hot_numbers"])
|
||||||
|
cold = set(ctx["cold_numbers"])
|
||||||
|
latest = db.get_latest_draw()
|
||||||
|
draw_no = (latest["drw_no"] + 1) if latest else 0
|
||||||
|
items = collect_candidates(n, hot, cold)
|
||||||
|
return {"draw_no": draw_no, "candidates": items}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/context")
|
||||||
|
def context():
|
||||||
|
latest = db.get_latest_draw()
|
||||||
|
draw_no = (latest["drw_no"] + 1) if latest else 0
|
||||||
|
return {"draw_no": draw_no, **build_context()}
|
||||||
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:
|
services:
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build:
|
||||||
|
context: ./backend
|
||||||
|
args:
|
||||||
|
APP_VERSION: ${APP_VERSION:-dev}
|
||||||
container_name: lotto-backend
|
container_name: lotto-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -12,36 +15,200 @@ services:
|
|||||||
- LOTTO_ALL_URL=${LOTTO_ALL_URL:-https://smok95.github.io/lotto/results/all.json}
|
- 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}
|
- LOTTO_LATEST_URL=${LOTTO_LATEST_URL:-https://smok95.github.io/lotto/results/latest.json}
|
||||||
volumes:
|
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:-}
|
||||||
|
- ANTHROPIC_MODEL=${ANTHROPIC_MODEL:-claude-haiku-4-5-20251001}
|
||||||
|
- LLM_PROVIDER=${LLM_PROVIDER:-claude}
|
||||||
|
- OLLAMA_URL=${OLLAMA_URL:-http://192.168.45.59:11435}
|
||||||
|
- OLLAMA_MODEL=${OLLAMA_MODEL:-qwen3:14b}
|
||||||
|
- 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
|
||||||
|
- BLOG_LAB_URL=http://blog-lab:8000
|
||||||
|
- REALESTATE_LAB_URL=http://realestate-lab:8000
|
||||||
|
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
|
||||||
|
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
|
||||||
|
- TELEGRAM_WEBHOOK_URL=${TELEGRAM_WEBHOOK_URL:-}
|
||||||
|
- TELEGRAM_WIFE_CHAT_ID=${TELEGRAM_WIFE_CHAT_ID:-}
|
||||||
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||||
|
- LOTTO_BACKEND_URL=${LOTTO_BACKEND_URL:-http://lotto-backend:8000}
|
||||||
|
- LOTTO_CURATOR_MODEL=${LOTTO_CURATOR_MODEL:-claude-sonnet-4-5}
|
||||||
|
- CONVERSATION_MODEL=${CONVERSATION_MODEL:-claude-haiku-4-5-20251001}
|
||||||
|
- CONVERSATION_HISTORY_LIMIT=${CONVERSATION_HISTORY_LIMIT:-20}
|
||||||
|
- CONVERSATION_RATE_PER_MIN=${CONVERSATION_RATE_PER_MIN:-6}
|
||||||
|
volumes:
|
||||||
|
- ${RUNTIME_PATH:-.}/data/agent-office:/app/data
|
||||||
|
depends_on:
|
||||||
|
- stock-lab
|
||||||
|
- music-lab
|
||||||
|
- blog-lab
|
||||||
|
- realestate-lab
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
travel-proxy:
|
travel-proxy:
|
||||||
build: ./travel-proxy
|
build: ./travel-proxy
|
||||||
container_name: travel-proxy
|
container_name: travel-proxy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
user: "1026:100"
|
user: "${PUID}:${PGID}"
|
||||||
ports:
|
ports:
|
||||||
- "19000:8000" # 내부 확인용
|
- "19000:8000"
|
||||||
environment:
|
environment:
|
||||||
- TZ=${TZ:-Asia/Seoul}
|
- TZ=${TZ:-Asia/Seoul}
|
||||||
- TRAVEL_ROOT=${TRAVEL_ROOT:-/data/travel}
|
- TRAVEL_ROOT=${TRAVEL_ROOT:-/data/travel}
|
||||||
- TRAVEL_THUMB_ROOT=${TRAVEL_THUMB_ROOT:-/data/thumbs}
|
- TRAVEL_THUMB_ROOT=${TRAVEL_THUMB_ROOT:-/data/thumbs}
|
||||||
- TRAVEL_MEDIA_BASE=${TRAVEL_MEDIA_BASE:-/media/travel}
|
- TRAVEL_MEDIA_BASE=${TRAVEL_MEDIA_BASE:-/media/travel}
|
||||||
- TRAVEL_CACHE_TTL=${TRAVEL_CACHE_TTL:-300}
|
- TRAVEL_DB_PATH=${TRAVEL_DB_PATH:-/data/thumbs/travel.db}
|
||||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-*}
|
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||||
volumes:
|
volumes:
|
||||||
- /volume1/web/images/webPage/travel:/data/travel:ro
|
- ${PHOTO_PATH}:/data/travel:ro
|
||||||
- /volume1/docker/webpage/travel-thumbs:/data/thumbs:rw
|
- ${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:
|
frontend:
|
||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
container_name: lotto-frontend
|
container_name: lotto-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- music-lab
|
||||||
|
- blog-lab
|
||||||
|
- realestate-lab
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "8080:80"
|
||||||
volumes:
|
volumes:
|
||||||
- /volume1/docker/webpage/frontend:/usr/share/nginx/html:ro
|
- ${FRONTEND_PATH}:/usr/share/nginx/html:ro
|
||||||
- /volume1/docker/webpage/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
- ${RUNTIME_PATH}/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
- /volume1/web/images/webPage/travel:/data/travel:ro
|
- ${PHOTO_PATH}:/data/travel:ro
|
||||||
- /volume1/docker/webpage/travel-thumbs:/data/thumbs:ro
|
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:ro
|
||||||
|
- ${RUNTIME_PATH}/data/music:/data/music:ro
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "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}
|
||||||
|
- PUID=${PUID:-1026}
|
||||||
|
- PGID=${PGID:-100}
|
||||||
|
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
1853
docs/superpowers/plans/2026-04-15-lotto-ai-curator.md
Normal file
1853
docs/superpowers/plans/2026-04-15-lotto-ai-curator.md
Normal file
File diff suppressed because it is too large
Load Diff
2392
docs/superpowers/plans/2026-04-23-responsive-web-design.md
Normal file
2392
docs/superpowers/plans/2026-04-23-responsive-web-design.md
Normal file
File diff suppressed because it is too large
Load Diff
2665
docs/superpowers/plans/2026-04-24-travel-gallery-redesign.md
Normal file
2665
docs/superpowers/plans/2026-04-24-travel-gallery-redesign.md
Normal file
File diff suppressed because it is too large
Load Diff
681
docs/superpowers/plans/2026-04-24-travel-proxy-perf.md
Normal file
681
docs/superpowers/plans/2026-04-24-travel-proxy-perf.md
Normal file
@@ -0,0 +1,681 @@
|
|||||||
|
# Travel-Proxy 성능 개선 구현 계획
|
||||||
|
|
||||||
|
> **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:** travel-proxy의 os.scandir 기반 아키텍처를 SQLite 인덱스 DB로 전환하고, 앨범 커버 지정 + 썸네일 사전 생성을 지원한다.
|
||||||
|
|
||||||
|
**Architecture:** 기존 main.py의 스캔/캐시/썸네일 로직을 db.py(스키마+쿼리)와 indexer.py(동기화+썸네일)로 분리. main.py는 라우트만 담당. DB 경로는 `/data/thumbs/travel.db`.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12, FastAPI, SQLite (표준 라이브러리 sqlite3), Pillow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 파일 구조
|
||||||
|
|
||||||
|
| 파일 | 역할 | 상태 |
|
||||||
|
|------|------|------|
|
||||||
|
| `travel-proxy/app/db.py` | SQLite 스키마 정의, 커넥션 헬퍼, 쿼리 함수 | 신규 |
|
||||||
|
| `travel-proxy/app/indexer.py` | 폴더 스캔 → DB 동기화 + 썸네일 일괄 생성 | 신규 |
|
||||||
|
| `travel-proxy/app/main.py` | FastAPI 라우트 (기존 수정 + 신규 추가) | 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: db.py — SQLite 스키마 및 쿼리 헬퍼
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `travel-proxy/app/db.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: db.py 파일 생성**
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
DB_PATH = os.getenv("TRAVEL_DB_PATH", "/data/thumbs/travel.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
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS photos (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
album TEXT NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
mtime REAL NOT NULL,
|
||||||
|
has_thumb INTEGER DEFAULT 0,
|
||||||
|
indexed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
UNIQUE(album, filename)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_photos_album ON photos(album)")
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS album_covers (
|
||||||
|
album TEXT PRIMARY KEY,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def get_photos_by_region(albums: List[str], page: int, size: int) -> Dict[str, Any]:
|
||||||
|
"""region에 속한 앨범들의 사진을 페이지네이션하여 반환."""
|
||||||
|
if not albums:
|
||||||
|
return {"items": [], "total": 0, "has_next": False, "matched_albums": []}
|
||||||
|
|
||||||
|
placeholders = ",".join("?" for _ in albums)
|
||||||
|
|
||||||
|
with _conn() as conn:
|
||||||
|
# 앨범별 사진 수
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT album, COUNT(*) as cnt FROM photos WHERE album IN ({placeholders}) GROUP BY album",
|
||||||
|
albums,
|
||||||
|
).fetchall()
|
||||||
|
matched_albums = [{"album": r["album"], "count": r["cnt"]} for r in rows]
|
||||||
|
|
||||||
|
# 전체 수
|
||||||
|
total_row = conn.execute(
|
||||||
|
f"SELECT COUNT(*) as cnt FROM photos WHERE album IN ({placeholders})",
|
||||||
|
albums,
|
||||||
|
).fetchone()
|
||||||
|
total = total_row["cnt"]
|
||||||
|
|
||||||
|
# 페이지네이션
|
||||||
|
offset = (page - 1) * size
|
||||||
|
items = conn.execute(
|
||||||
|
f"""SELECT album, filename, mtime FROM photos
|
||||||
|
WHERE album IN ({placeholders})
|
||||||
|
ORDER BY album, filename
|
||||||
|
LIMIT ? OFFSET ?""",
|
||||||
|
[*albums, size, offset],
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": [dict(r) for r in items],
|
||||||
|
"total": total,
|
||||||
|
"has_next": (offset + size) < total,
|
||||||
|
"matched_albums": matched_albums,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_albums() -> List[Dict[str, Any]]:
|
||||||
|
"""전체 앨범 목록 + 사진 수 + 커버 정보."""
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT p.album, COUNT(*) as count,
|
||||||
|
COALESCE(c.filename, MIN(p.filename)) as cover_filename
|
||||||
|
FROM photos p
|
||||||
|
LEFT JOIN album_covers c ON p.album = c.album
|
||||||
|
GROUP BY p.album
|
||||||
|
ORDER BY p.album
|
||||||
|
""").fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def set_album_cover(album: str, filename: str) -> bool:
|
||||||
|
"""앨범 커버 지정. 해당 photo가 존재하면 True, 없으면 False."""
|
||||||
|
with _conn() as conn:
|
||||||
|
exists = conn.execute(
|
||||||
|
"SELECT 1 FROM photos WHERE album = ? AND filename = ?",
|
||||||
|
(album, filename),
|
||||||
|
).fetchone()
|
||||||
|
if not exists:
|
||||||
|
return False
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO album_covers (album, filename, updated_at)
|
||||||
|
VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
ON CONFLICT(album) DO UPDATE SET
|
||||||
|
filename = excluded.filename,
|
||||||
|
updated_at = excluded.updated_at""",
|
||||||
|
(album, filename),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_album_cover(album: str) -> Optional[str]:
|
||||||
|
"""앨범 커버 파일명 반환. 미지정 시 None."""
|
||||||
|
with _conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT filename FROM album_covers WHERE album = ?",
|
||||||
|
(album,),
|
||||||
|
).fetchone()
|
||||||
|
return row["filename"] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_photo(album: str, filename: str, mtime: float) -> str:
|
||||||
|
"""사진 upsert. 반환: 'added' | 'updated' | 'unchanged'."""
|
||||||
|
with _conn() as conn:
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT mtime, has_thumb FROM photos WHERE album = ? AND filename = ?",
|
||||||
|
(album, filename),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO photos (album, filename, mtime, has_thumb) VALUES (?, ?, ?, 0)",
|
||||||
|
(album, filename, mtime),
|
||||||
|
)
|
||||||
|
return "added"
|
||||||
|
elif existing["mtime"] != mtime:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE photos SET mtime = ?, has_thumb = 0 WHERE album = ? AND filename = ?",
|
||||||
|
(mtime, album, filename),
|
||||||
|
)
|
||||||
|
return "updated"
|
||||||
|
return "unchanged"
|
||||||
|
|
||||||
|
|
||||||
|
def remove_missing_photos(album: str, existing_filenames: set) -> int:
|
||||||
|
"""폴더에 없는 사진을 DB에서 제거. 제거 수 반환."""
|
||||||
|
with _conn() as conn:
|
||||||
|
db_rows = conn.execute(
|
||||||
|
"SELECT filename FROM photos WHERE album = ?", (album,)
|
||||||
|
).fetchall()
|
||||||
|
db_filenames = {r["filename"] for r in db_rows}
|
||||||
|
to_remove = db_filenames - existing_filenames
|
||||||
|
|
||||||
|
if to_remove:
|
||||||
|
placeholders = ",".join("?" for _ in to_remove)
|
||||||
|
conn.execute(
|
||||||
|
f"DELETE FROM photos WHERE album = ? AND filename IN ({placeholders})",
|
||||||
|
[album, *to_remove],
|
||||||
|
)
|
||||||
|
# 삭제된 파일이 커버였으면 커버도 제거
|
||||||
|
conn.execute(
|
||||||
|
f"DELETE FROM album_covers WHERE album = ? AND filename IN ({placeholders})",
|
||||||
|
[album, *to_remove],
|
||||||
|
)
|
||||||
|
return len(to_remove)
|
||||||
|
|
||||||
|
|
||||||
|
def get_photos_without_thumb() -> List[Dict[str, str]]:
|
||||||
|
"""썸네일 미생성 사진 목록."""
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT album, filename FROM photos WHERE has_thumb = 0"
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def mark_thumb_done(album: str, filename: str) -> None:
|
||||||
|
"""썸네일 생성 완료 표시."""
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE photos SET has_thumb = 1 WHERE album = ? AND filename = ?",
|
||||||
|
(album, filename),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add travel-proxy/app/db.py
|
||||||
|
git commit -m "feat(travel-proxy): db.py — SQLite 스키마 + 쿼리 헬퍼"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: indexer.py — 폴더 동기화 + 썸네일 일괄 생성
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `travel-proxy/app/indexer.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: indexer.py 파일 생성**
|
||||||
|
|
||||||
|
기존 main.py의 `ensure_thumb` 로직(라인 105-144)과 `scan_album` 로직(라인 146-166)을 기반으로 작성. `IMAGE_EXT`, `THUMB_SIZE`, 경로 상수는 main.py에서 import.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Set
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp"}
|
||||||
|
THUMB_SIZE = (480, 480)
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_folder(folder: Path) -> List[Dict[str, Any]]:
|
||||||
|
"""폴더 내 이미지 파일 목록 수집 (os.scandir)."""
|
||||||
|
if not folder.exists():
|
||||||
|
return []
|
||||||
|
items = []
|
||||||
|
with os.scandir(folder) as entries:
|
||||||
|
for entry in entries:
|
||||||
|
if entry.is_file() and Path(entry.name).suffix.lower() in IMAGE_EXT:
|
||||||
|
items.append({
|
||||||
|
"filename": entry.name,
|
||||||
|
"mtime": entry.stat().st_mtime,
|
||||||
|
})
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_thumb(src: Path, dest: Path) -> bool:
|
||||||
|
"""원본에서 480x480 썸네일 생성. 성공 시 True."""
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = dest.with_name(dest.stem + ".tmp" + dest.suffix)
|
||||||
|
try:
|
||||||
|
with Image.open(src) as im:
|
||||||
|
im.thumbnail(THUMB_SIZE)
|
||||||
|
ext = dest.suffix.lower()
|
||||||
|
if ext in (".jpg", ".jpeg"):
|
||||||
|
fmt = "JPEG"
|
||||||
|
elif ext == ".png":
|
||||||
|
fmt = "PNG"
|
||||||
|
elif ext == ".webp":
|
||||||
|
fmt = "WEBP"
|
||||||
|
else:
|
||||||
|
fmt = (im.format or "").upper() or "JPEG"
|
||||||
|
im.save(tmp, format=fmt, quality=85, optimize=True)
|
||||||
|
tmp.replace(dest)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Thumb generation failed: %s → %s", src, e)
|
||||||
|
try:
|
||||||
|
if tmp.exists():
|
||||||
|
tmp.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def sync(
|
||||||
|
travel_root: Path,
|
||||||
|
thumb_root: Path,
|
||||||
|
region_map_path: Path,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
폴더 스캔 → DB 동기화 + 썸네일 일괄 생성.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"added": int, "removed": int, "thumbs_generated": int, "duration_sec": float}
|
||||||
|
"""
|
||||||
|
start = time.time()
|
||||||
|
|
||||||
|
# 1. region_map.json에서 전체 앨범 폴더 수집
|
||||||
|
with open(region_map_path, "r", encoding="utf-8") as f:
|
||||||
|
region_map = json.load(f)
|
||||||
|
|
||||||
|
all_albums: Set[str] = set()
|
||||||
|
for v in region_map.values():
|
||||||
|
if isinstance(v, list):
|
||||||
|
all_albums.update(v)
|
||||||
|
elif isinstance(v, dict) and isinstance(v.get("albums"), list):
|
||||||
|
all_albums.update(v["albums"])
|
||||||
|
|
||||||
|
# 2. 각 앨범 폴더 스캔 → DB 동기화
|
||||||
|
added = 0
|
||||||
|
removed = 0
|
||||||
|
|
||||||
|
for album in sorted(all_albums):
|
||||||
|
folder = travel_root / album
|
||||||
|
items = _scan_folder(folder)
|
||||||
|
existing_filenames = set()
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
existing_filenames.add(item["filename"])
|
||||||
|
result = db.upsert_photo(album, item["filename"], item["mtime"])
|
||||||
|
if result == "added":
|
||||||
|
added += 1
|
||||||
|
|
||||||
|
removed += db.remove_missing_photos(album, existing_filenames)
|
||||||
|
|
||||||
|
# 3. 썸네일 미생성 분 일괄 생성
|
||||||
|
no_thumb = db.get_photos_without_thumb()
|
||||||
|
thumbs_generated = 0
|
||||||
|
|
||||||
|
for photo in no_thumb:
|
||||||
|
src = travel_root / photo["album"] / photo["filename"]
|
||||||
|
dest = thumb_root / photo["album"] / photo["filename"]
|
||||||
|
if _generate_thumb(src, dest):
|
||||||
|
db.mark_thumb_done(photo["album"], photo["filename"])
|
||||||
|
thumbs_generated += 1
|
||||||
|
|
||||||
|
duration = round(time.time() - start, 2)
|
||||||
|
logger.info(
|
||||||
|
"Sync complete: added=%d removed=%d thumbs=%d duration=%.2fs",
|
||||||
|
added, removed, thumbs_generated, duration,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"added": added,
|
||||||
|
"removed": removed,
|
||||||
|
"thumbs_generated": thumbs_generated,
|
||||||
|
"duration_sec": duration,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add travel-proxy/app/indexer.py
|
||||||
|
git commit -m "feat(travel-proxy): indexer.py — 폴더 동기화 + 썸네일 일괄 생성"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: main.py 리팩토링 — DB 기반 photos API + 캐시 제거
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `travel-proxy/app/main.py`
|
||||||
|
|
||||||
|
이 Task에서 main.py의 메모리 캐시, `scan_album()`, 기존 `photos()` 라우트를 DB 기반으로 교체한다.
|
||||||
|
|
||||||
|
- [ ] **Step 1: main.py를 DB 기반으로 재작성**
|
||||||
|
|
||||||
|
main.py 전체를 아래로 교체:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, List
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Query
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from .db import init_db, get_photos_by_region, get_all_albums, set_album_cover, get_album_cover
|
||||||
|
from .indexer import sync
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Env / Paths
|
||||||
|
# -----------------------------
|
||||||
|
ROOT = Path(os.getenv("TRAVEL_ROOT", "/data/travel")).resolve()
|
||||||
|
MEDIA_BASE = os.getenv("TRAVEL_MEDIA_BASE", "/media/travel")
|
||||||
|
|
||||||
|
META_DIR = ROOT / "_meta"
|
||||||
|
REGION_MAP_PATH = META_DIR / "region_map.json"
|
||||||
|
REGIONS_GEOJSON_PATH = META_DIR / "regions.geojson"
|
||||||
|
|
||||||
|
THUMB_ROOT = Path(os.getenv("TRAVEL_THUMB_ROOT", "/data/thumbs")).resolve()
|
||||||
|
THUMB_SIZE = (480, 480)
|
||||||
|
|
||||||
|
THUMB_ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp"}
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# DB init
|
||||||
|
# -----------------------------
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Helpers
|
||||||
|
# -----------------------------
|
||||||
|
def _read_json(path: Path) -> Any:
|
||||||
|
if not path.exists():
|
||||||
|
raise HTTPException(500, f"Missing required file: {path}")
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def load_region_map() -> dict:
|
||||||
|
return _read_json(REGION_MAP_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def load_regions_geojson() -> dict:
|
||||||
|
return _read_json(REGIONS_GEOJSON_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_albums_for_region(region: str, region_map: dict) -> List[str]:
|
||||||
|
if region not in region_map:
|
||||||
|
raise HTTPException(400, "Unknown region")
|
||||||
|
v = region_map[region]
|
||||||
|
if isinstance(v, list):
|
||||||
|
return v
|
||||||
|
if isinstance(v, dict) and isinstance(v.get("albums"), list):
|
||||||
|
return v["albums"]
|
||||||
|
raise HTTPException(500, "Invalid region_map format")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_thumb_fallback(src: Path, album: str) -> Path:
|
||||||
|
"""온디맨드 썸네일 폴백 (sync 누락 분 대응)."""
|
||||||
|
out = THUMB_ROOT / album / src.name
|
||||||
|
if out.exists():
|
||||||
|
return out
|
||||||
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = out.with_name(out.stem + ".tmp" + out.suffix)
|
||||||
|
try:
|
||||||
|
with Image.open(src) as im:
|
||||||
|
im.thumbnail(THUMB_SIZE)
|
||||||
|
ext = out.suffix.lower()
|
||||||
|
if ext in (".jpg", ".jpeg"):
|
||||||
|
fmt = "JPEG"
|
||||||
|
elif ext == ".png":
|
||||||
|
fmt = "PNG"
|
||||||
|
elif ext == ".webp":
|
||||||
|
fmt = "WEBP"
|
||||||
|
else:
|
||||||
|
fmt = (im.format or "").upper() or "JPEG"
|
||||||
|
im.save(tmp, format=fmt, quality=85, optimize=True)
|
||||||
|
tmp.replace(out)
|
||||||
|
return out
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if tmp.exists():
|
||||||
|
tmp.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Models
|
||||||
|
# -----------------------------
|
||||||
|
class CoverRequest(BaseModel):
|
||||||
|
filename: str
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Routes
|
||||||
|
# -----------------------------
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "healthy", "service": "travel-proxy"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/travel/regions")
|
||||||
|
def regions():
|
||||||
|
return load_regions_geojson()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/travel/photos")
|
||||||
|
def photos(
|
||||||
|
region: str = Query(...),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(20, ge=1, le=100),
|
||||||
|
):
|
||||||
|
region_map = load_region_map()
|
||||||
|
albums = _get_albums_for_region(region, region_map)
|
||||||
|
result = get_photos_by_region(albums, page, size)
|
||||||
|
|
||||||
|
# URL 조합 (DB에는 경로를 저장하지 않음)
|
||||||
|
items = []
|
||||||
|
for row in result["items"]:
|
||||||
|
items.append({
|
||||||
|
"album": row["album"],
|
||||||
|
"file": row["filename"],
|
||||||
|
"url": f"{MEDIA_BASE}/{row['album']}/{row['filename']}",
|
||||||
|
"thumb": f"{MEDIA_BASE}/.thumb/{row['album']}/{row['filename']}",
|
||||||
|
"mtime": row["mtime"],
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"region": region,
|
||||||
|
"page": page,
|
||||||
|
"size": size,
|
||||||
|
"total": result["total"],
|
||||||
|
"has_next": result["has_next"],
|
||||||
|
"items": items,
|
||||||
|
"matched_albums": result["matched_albums"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/travel/sync")
|
||||||
|
def sync_endpoint():
|
||||||
|
result = sync(
|
||||||
|
travel_root=ROOT,
|
||||||
|
thumb_root=THUMB_ROOT,
|
||||||
|
region_map_path=REGION_MAP_PATH,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/travel/albums")
|
||||||
|
def albums_list():
|
||||||
|
rows = get_all_albums()
|
||||||
|
result = []
|
||||||
|
for r in rows:
|
||||||
|
cover = r["cover_filename"]
|
||||||
|
result.append({
|
||||||
|
"album": r["album"],
|
||||||
|
"count": r["count"],
|
||||||
|
"cover_url": f"{MEDIA_BASE}/{r['album']}/{cover}",
|
||||||
|
"cover_thumb": f"{MEDIA_BASE}/.thumb/{r['album']}/{cover}",
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/travel/albums/{album}/cover")
|
||||||
|
def set_cover(album: str, body: CoverRequest):
|
||||||
|
ok = set_album_cover(album, body.filename)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(404, f"Photo not found: {album}/{body.filename}")
|
||||||
|
return {
|
||||||
|
"album": album,
|
||||||
|
"filename": body.filename,
|
||||||
|
"cover_url": f"{MEDIA_BASE}/{album}/{body.filename}",
|
||||||
|
"cover_thumb": f"{MEDIA_BASE}/.thumb/{album}/{body.filename}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/media/travel/.thumb/{album}/{filename}")
|
||||||
|
def get_thumb(album: str, filename: str):
|
||||||
|
if ".." in album or ".." in filename:
|
||||||
|
raise HTTPException(400, "Invalid path")
|
||||||
|
src = (ROOT / album / filename).resolve()
|
||||||
|
if not str(src).startswith(str(ROOT)):
|
||||||
|
raise HTTPException(403, "Access denied")
|
||||||
|
if not src.exists() or not src.is_file():
|
||||||
|
raise HTTPException(404, "Source not found")
|
||||||
|
p = _ensure_thumb_fallback(src, album)
|
||||||
|
if not p.exists() or not p.is_file():
|
||||||
|
raise HTTPException(404, "Thumbnail not found")
|
||||||
|
return FileResponse(str(p))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/version")
|
||||||
|
def version():
|
||||||
|
return {"version": os.getenv("APP_VERSION", "dev")}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add travel-proxy/app/main.py
|
||||||
|
git commit -m "refactor(travel-proxy): main.py DB 기반 전환 — 메모리 캐시 제거 + 신규 API"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: 통합 검증
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- 없음 (기존 파일 검증만)
|
||||||
|
|
||||||
|
- [ ] **Step 1: import 구조 확인**
|
||||||
|
|
||||||
|
travel-proxy/app/ 디렉토리에 `__init__.py`가 필요한지 확인. FastAPI uvicorn 실행 명령이 `app.main:app`이므로 패키지 import가 동작하려면 `__init__.py`가 필요.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls travel-proxy/app/
|
||||||
|
```
|
||||||
|
|
||||||
|
`__init__.py`가 없으면 생성:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# travel-proxy/app/__init__.py
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Dockerfile 확인**
|
||||||
|
|
||||||
|
현재 Dockerfile의 `COPY app /app/app` 라인이 db.py, indexer.py를 포함하는지 확인. 디렉토리 단위 복사이므로 추가 파일은 자동 포함됨. 변경 불필요.
|
||||||
|
|
||||||
|
- [ ] **Step 3: docker-compose.yml 환경변수 확인**
|
||||||
|
|
||||||
|
`TRAVEL_DB_PATH` 환경변수를 docker-compose.yml에 추가:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml의 travel-proxy 서비스 environment에 추가
|
||||||
|
- TRAVEL_DB_PATH=${TRAVEL_DB_PATH:-/data/thumbs/travel.db}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: photos 응답 호환성 검증**
|
||||||
|
|
||||||
|
기존 응답 필드와 비교:
|
||||||
|
- `region` ✓
|
||||||
|
- `page`, `size` ✓
|
||||||
|
- `total`, `has_next` ✓
|
||||||
|
- `items[].album`, `items[].file`, `items[].url`, `items[].thumb`, `items[].mtime` ✓
|
||||||
|
- `matched_albums` — 기존에는 `photos()` 응답에 없었으나 캐시 데이터에 포함. DB 버전은 항상 포함.
|
||||||
|
|
||||||
|
- [ ] **Step 5: 커밋 (변경 있을 시)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add travel-proxy/app/__init__.py docker-compose.yml
|
||||||
|
git commit -m "chore(travel-proxy): __init__.py + TRAVEL_DB_PATH 환경변수 추가"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: CLAUDE.md 업데이트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `CLAUDE.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: travel-proxy 섹션에 DB 정보 추가**
|
||||||
|
|
||||||
|
CLAUDE.md의 travel-proxy 섹션에 아래 내용 추가:
|
||||||
|
|
||||||
|
- DB: `/data/thumbs/travel.db` (photos, album_covers 테이블)
|
||||||
|
- 파일 구조에 `db.py`, `indexer.py` 추가
|
||||||
|
|
||||||
|
API 목록 테이블에 신규 API 3개 추가:
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| POST | `/api/travel/sync` | 폴더 스캔 → DB 동기화 + 썸네일 생성 |
|
||||||
|
| GET | `/api/travel/albums` | 앨범 목록 + 사진 수 + 커버 정보 |
|
||||||
|
| PUT | `/api/travel/albums/{album}/cover` | 앨범 커버 지정 |
|
||||||
|
|
||||||
|
`POST /api/travel/reload` 제거 표기.
|
||||||
|
|
||||||
|
- [ ] **Step 2: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add CLAUDE.md
|
||||||
|
git commit -m "docs: CLAUDE.md travel-proxy DB·API 업데이트"
|
||||||
|
```
|
||||||
@@ -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 호출) |
|
||||||
350
docs/superpowers/specs/2026-04-15-lotto-ai-curator-design.md
Normal file
350
docs/superpowers/specs/2026-04-15-lotto-ai-curator-design.md
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
# Lotto AI 큐레이터 — 설계 문서
|
||||||
|
|
||||||
|
> 작성일: 2026-04-15
|
||||||
|
> 목표: 난잡한 lotto 랩을 **주간 AI 브리핑**을 축으로 재정리. 매주 월요일 아침 자동으로 "이번 주 5세트 + 내러티브 리포트"를 생성해 구매 의사결정 참고.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 배경
|
||||||
|
|
||||||
|
- 현재 lotto 랩은 분석(5가지)·추천(통계/히트맵/메타)·시뮬레이션·전략진화 등 기능이 풍부하지만 출력이 분산되어 "결국 뭘 사야 하지"가 한눈에 들어오지 않음.
|
||||||
|
- `docs/lotto-premium-roadmap.md` Phase 1 방향(신뢰 기반 + 주간 리포트)을 AI 활용으로 압축 실행.
|
||||||
|
|
||||||
|
## 2. 핵심 결정사항
|
||||||
|
|
||||||
|
| 항목 | 결정 |
|
||||||
|
|------|------|
|
||||||
|
| AI 역할 | **큐레이터(Curator)** — 숫자 생성 X, 기존 엔진 후보 중 5세트 선별 + 내러티브 작성 |
|
||||||
|
| 브리핑 형식 | **A+B 조합** — 리포트형 내러티브 + 최종 5세트 카드 |
|
||||||
|
| 트리거 | **매주 월요일 07:00 자동 생성** (웹 UI 전용, 텔레그램 미전송) |
|
||||||
|
| 로직 위치 | **agent-office `lotto` 에이전트** (lotto-backend는 엔진·저장소 역할만) |
|
||||||
|
| 모델 | `claude-sonnet-4-5` (주 1회 호출, 품질 우선) — 환경변수 `LOTTO_CURATOR_MODEL` |
|
||||||
|
| 사용량 노출 | 브리핑 카드 + 큐레이터 사용량 API(월간 집계) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ 월요일 07:00 APScheduler (agent-office) │
|
||||||
|
│ → lotto 에이전트 curate_weekly 태스크 │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 1. GET /api/lotto/curator/candidates?n=20 │ │
|
||||||
|
│ │ 2. GET /api/lotto/curator/context │ │
|
||||||
|
│ │ 3. Claude Sonnet 4.5 호출 (strict JSON out) │ │
|
||||||
|
│ │ 4. 스키마·번호 검증 + 1회 재시도 │ │
|
||||||
|
│ │ 5. POST /api/lotto/briefing (저장) │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 사용자는 웹에서: │
|
||||||
|
│ GET /api/lotto/briefing/latest (최신 표시) │
|
||||||
|
│ POST /api/agent-office/command {agent:"lotto", …} (수동) │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
서비스 경계: **lotto-backend = 데이터·엔진 / agent-office = AI 판단**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Backend (lotto-backend)
|
||||||
|
|
||||||
|
### 4.1 신규 API
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/lotto/curator/candidates` | 큐레이터용 후보 N세트 + 세트별 피처 |
|
||||||
|
| GET | `/api/lotto/curator/context` | 주간 맥락(핫/콜드·직전 회차 분석·내 최근 성과) |
|
||||||
|
| POST | `/api/lotto/briefing` | 큐레이터 결과 저장 |
|
||||||
|
| GET | `/api/lotto/briefing/latest` | 최신 브리핑 |
|
||||||
|
| GET | `/api/lotto/briefing/{draw_no}` | 특정 회차 브리핑 |
|
||||||
|
| GET | `/api/lotto/briefing?limit=10` | 브리핑 이력 |
|
||||||
|
| GET | `/api/lotto/curator/usage?days=30` | 큐레이터 토큰·비용 집계 |
|
||||||
|
|
||||||
|
### 4.2 `GET /curator/candidates` 응답 구조
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"draw_no": 1180,
|
||||||
|
"generated_at": "2026-04-13T07:00:00Z",
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"numbers": [3, 14, 22, 29, 35, 41],
|
||||||
|
"source": "simulation" | "meta" | "heatmap" | "statistics",
|
||||||
|
"features": {
|
||||||
|
"odd_count": 3,
|
||||||
|
"even_count": 3,
|
||||||
|
"low_count": 3, // 1~22
|
||||||
|
"high_count": 3, // 23~45
|
||||||
|
"range_distribution": [1,1,1,1,1,1], // 1-10,11-20,...,41-45
|
||||||
|
"has_consecutive": true,
|
||||||
|
"hot_number_count": 1, // context.hot_numbers 교집합
|
||||||
|
"cold_number_count": 2, // context.cold_numbers 교집합
|
||||||
|
"sum": 144,
|
||||||
|
"historical_match_avg": 2.3 // 이 세트가 과거 실제 회차와 평균 몇 개 일치
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
중복 제거: 6숫자 정렬 튜플 기준 set 해시. 각 세트의 `source`는 가장 먼저 포함시킨 엔진.
|
||||||
|
|
||||||
|
### 4.3 `GET /curator/context` 응답 구조
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"draw_no": 1180,
|
||||||
|
"hot_numbers": [3, 17, 28], // 최근 10회 과출현 top
|
||||||
|
"cold_numbers": [7, 22, 41], // 최근 30회 미출현 top
|
||||||
|
"last_draw_summary": "1179회: 7, 12, 18, 24, 31, 40 (홀4짝2, 저4고2)",
|
||||||
|
"recent_analysis": {
|
||||||
|
"avg_sum": 138,
|
||||||
|
"avg_odd_count": 2.8
|
||||||
|
},
|
||||||
|
"my_recent_performance": [
|
||||||
|
{ "draw_no": 1177, "purchased_sets": 5, "best_match": 3 },
|
||||||
|
{ "draw_no": 1178, "purchased_sets": 5, "best_match": 2 },
|
||||||
|
{ "draw_no": 1179, "purchased_sets": 5, "best_match": 4 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 신규 테이블 `lotto_briefings`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE lotto_briefings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
draw_no INTEGER UNIQUE NOT NULL,
|
||||||
|
picks TEXT NOT NULL, -- JSON: 5세트 + reason + risk_tag
|
||||||
|
narrative TEXT NOT NULL, -- JSON: headline/summary_3lines/hot_cold/warnings
|
||||||
|
confidence INTEGER NOT NULL, -- 0~100
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
tokens_input INTEGER DEFAULT 0,
|
||||||
|
tokens_output INTEGER DEFAULT 0,
|
||||||
|
cache_read INTEGER DEFAULT 0,
|
||||||
|
cache_write INTEGER DEFAULT 0,
|
||||||
|
latency_ms INTEGER DEFAULT 0,
|
||||||
|
source TEXT NOT NULL DEFAULT 'auto', -- 'auto' | 'manual'
|
||||||
|
generated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_briefings_draw ON lotto_briefings(draw_no DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 파일 구조 정리
|
||||||
|
|
||||||
|
`backend/app/main.py` 933줄 → 라우터 분리:
|
||||||
|
- `backend/app/routers/briefing.py` — briefing CRUD + curator usage
|
||||||
|
- `backend/app/routers/curator.py` — candidates / context
|
||||||
|
- `backend/app/curator_helpers.py` — 후보 중복 제거, 피처 계산, 맥락 추출
|
||||||
|
|
||||||
|
기존 `main.py`는 라우터 등록과 앱 조립만 담당(목표 ~300줄).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. agent-office `lotto` 에이전트
|
||||||
|
|
||||||
|
### 5.1 파일 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
agent-office/app/
|
||||||
|
agents/lotto.py # LottoAgent (BaseAgent 상속)
|
||||||
|
curator/
|
||||||
|
__init__.py
|
||||||
|
pipeline.py # curate_weekly() 메인 플로우
|
||||||
|
prompt.py # system prompt + 출력 스키마 정의
|
||||||
|
schema.py # pydantic 응답 모델 + 검증
|
||||||
|
service.py # lotto-backend 호출 래퍼 (httpx)
|
||||||
|
```
|
||||||
|
|
||||||
|
`service_proxy.py`에 `lotto_candidates()`, `lotto_context()`, `lotto_save_briefing()` 메서드 추가.
|
||||||
|
|
||||||
|
### 5.2 태스크 타입
|
||||||
|
|
||||||
|
- `curate_weekly` — 자동/수동 공통. 파라미터 없음(draw_no 자동 계산).
|
||||||
|
|
||||||
|
### 5.3 큐레이터 규칙 (system prompt 요지)
|
||||||
|
|
||||||
|
```
|
||||||
|
당신은 로또 번호 큐레이터입니다. 후보 20세트 중 5세트를 다음 규칙으로 선별합니다.
|
||||||
|
|
||||||
|
선별 규칙:
|
||||||
|
- 5세트의 리스크 분포: 안정 2 · 균형 2 · 공격 1 (유연 ±1)
|
||||||
|
- 홀짝 비율, 저/고 구간, 연속번호 포함 여부가 세트끼리 겹치지 않도록 다양성 확보
|
||||||
|
- hot_number_count와 cold_number_count 모두 0인 세트는 최소 1개
|
||||||
|
- 후보 외 번호 사용 절대 금지
|
||||||
|
- 각 세트 reason은 40자 이내 한 줄 (해당 세트 피처와 context 값만 근거)
|
||||||
|
|
||||||
|
출력은 반드시 아래 JSON 스키마로만:
|
||||||
|
{
|
||||||
|
"picks": [
|
||||||
|
{"numbers":[...], "risk_tag":"안정"|"균형"|"공격", "reason":"..."}
|
||||||
|
],
|
||||||
|
"narrative": {
|
||||||
|
"headline": "...",
|
||||||
|
"summary_3lines": ["...","...","..."],
|
||||||
|
"hot_cold_comment": "...",
|
||||||
|
"warnings": "..." // 없으면 빈 문자열
|
||||||
|
},
|
||||||
|
"confidence": 0-100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 파이프라인 의사코드
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def curate_weekly(draw_no: int) -> dict:
|
||||||
|
candidates = await service.lotto_candidates(n=20)
|
||||||
|
context = await service.lotto_context()
|
||||||
|
prompt = build_prompt(candidates, context, draw_no)
|
||||||
|
|
||||||
|
result, usage = await call_claude(prompt, model=LOTTO_CURATOR_MODEL)
|
||||||
|
parsed = validate(result) # 실패 시 1회 재시도
|
||||||
|
if parsed is None:
|
||||||
|
raise CuratorError("schema validation failed after retry")
|
||||||
|
|
||||||
|
await service.lotto_save_briefing({
|
||||||
|
"draw_no": draw_no,
|
||||||
|
"picks": parsed.picks,
|
||||||
|
"narrative": parsed.narrative,
|
||||||
|
"confidence": parsed.confidence,
|
||||||
|
"model": LOTTO_CURATOR_MODEL,
|
||||||
|
"tokens_input": usage.input,
|
||||||
|
"tokens_output": usage.output,
|
||||||
|
"cache_read": usage.cache_read,
|
||||||
|
"cache_write": usage.cache_write,
|
||||||
|
"latency_ms": usage.latency_ms,
|
||||||
|
"source": "auto" | "manual",
|
||||||
|
})
|
||||||
|
return {"ok": True, "draw_no": draw_no, ...}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 검증 로직 (`schema.py`)
|
||||||
|
|
||||||
|
- pydantic 모델로 형식 검증
|
||||||
|
- 번호 제약: 각 세트 정확히 6개 · 중복 없음 · 1~45 범위
|
||||||
|
- 세트 수: 정확히 5
|
||||||
|
- 번호가 **candidates 내에 존재하는 조합인지** 대조 (환각 차단)
|
||||||
|
- risk_tag 분포가 규칙에서 ±1 이상 벗어나면 경고 로그(차단은 안 함)
|
||||||
|
- 실패 시 errors 리스트 담아 1회 재시도(프롬프트에 에러 피드백 포함)
|
||||||
|
|
||||||
|
### 5.6 스케줄러
|
||||||
|
|
||||||
|
`scheduler.py`에 추가:
|
||||||
|
```python
|
||||||
|
scheduler.add_job(_run_lotto_curate, "cron", day_of_week="mon", hour=7, minute=0, id="lotto_curate")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.7 상태 표시
|
||||||
|
|
||||||
|
agent-office 메인 UI에 lotto 에이전트 카드가 추가되어 `idle` / `working` / `error` 상태 실시간 표시(기존 BaseAgent 패턴).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Frontend (web-ui)
|
||||||
|
|
||||||
|
### 6.1 새 탭 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
Lotto
|
||||||
|
├─ 🗓 이번 주 브리핑 (기본)
|
||||||
|
├─ 📊 분석·통계
|
||||||
|
└─ 💰 구매·성과
|
||||||
|
```
|
||||||
|
|
||||||
|
`Functions.jsx` 460줄 → 탭 라우터 ~80줄로 축소. 각 탭은 `pages/lotto/tabs/BriefingTab.jsx`, `AnalysisTab.jsx`, `PurchaseTab.jsx`.
|
||||||
|
|
||||||
|
### 6.2 신규 컴포넌트 (`components/briefing/`)
|
||||||
|
|
||||||
|
- **BriefingHeader.jsx** — 회차 번호, 생성 시각, 신뢰도 바, 재생성 버튼, **사용 토큰 칩**(`42K in · 1.2K out · $0.18`)
|
||||||
|
- **BriefingSummary.jsx** — 3줄 요약 + 핫/콜드 블록 + 주의사항
|
||||||
|
- **PickSetCard.jsx** — 6볼 + risk 뱃지(🟢안정/🟡균형/🔴공격) + reason + "구매 기록" CTA
|
||||||
|
- **BriefingEmpty.jsx** — 브리핑 없을 때 placeholder + "지금 생성" 버튼
|
||||||
|
- **CuratorUsageFooter.jsx** — 페이지 하단 mini 카드. 최근 30일 호출 수·토큰·추정 비용·캐시 히트율
|
||||||
|
|
||||||
|
### 6.3 훅
|
||||||
|
|
||||||
|
- **useBriefing.js**
|
||||||
|
- `GET /api/lotto/briefing/latest`
|
||||||
|
- `regenerate()`: `POST /api/agent-office/command {agent:"lotto", action:"curate_now"}` → 3초 간격 최대 40회(=2분) 폴링으로 신규 briefing 확인
|
||||||
|
- 로딩/에러 상태 분리, 월요일 07:00 이후인데 브리핑 없으면 빈 상태 CTA
|
||||||
|
- **useCuratorUsage.js** — `GET /api/lotto/curator/usage?days=30`
|
||||||
|
|
||||||
|
### 6.4 기존 컴포넌트 처리
|
||||||
|
|
||||||
|
| 컴포넌트 | 조치 |
|
||||||
|
|---------|------|
|
||||||
|
| `FrequencyChart`, `MetricBlock`, `PersonalAnalysisPanel`, `ReportPanel` | 분석 탭으로 이동 |
|
||||||
|
| `PurchasePanel`, `PerformanceBanner` | 구매 탭으로 이동 |
|
||||||
|
| `CombinedRecommendPanel`, `ConfidenceRing` | 제거 후보 — 정리 패스에서 실제 참조 없으면 삭제 |
|
||||||
|
|
||||||
|
### 6.5 토큰·비용 노출 정책
|
||||||
|
|
||||||
|
- **브리핑 카드 헤더**: 이번 브리핑 1건의 in/out 토큰 + 추정 비용 (Sonnet 4.5 단가 기준 계산 — 상수로 프론트에 보유, `$3/$15 per 1M tokens`)
|
||||||
|
- **페이지 하단 푸터**: 최근 30일 누적 — 호출 수, 총 토큰, 추정 비용, 캐시 히트율
|
||||||
|
- **Agent Office 사이드**: 기존 `GET /api/agent-office/agents/lotto/token-usage` 자동 상속
|
||||||
|
|
||||||
|
### 6.6 모바일
|
||||||
|
|
||||||
|
브리핑 탭 세로 스택 기본. PickSetCard는 한 행 1카드 + 6볼 flex-wrap. 헤더 토큰 칩은 768px 이하에서 축약 표시(`$0.18`만).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 환경변수
|
||||||
|
|
||||||
|
| 변수 | 기본값 | 위치 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `ANTHROPIC_API_KEY` | (없음) | agent-office (이미 존재) |
|
||||||
|
| `LOTTO_CURATOR_MODEL` | `claude-sonnet-4-5` | agent-office |
|
||||||
|
| `LOTTO_BACKEND_URL` | `http://lotto-backend:8000` | agent-office (service_proxy) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 에러·폴백
|
||||||
|
|
||||||
|
| 상황 | 처리 |
|
||||||
|
|------|------|
|
||||||
|
| lotto-backend 후보 API 실패 | 에이전트 상태 `error` + 로그 + 슬랙/알림 없음(주 1회라 로그 충분) |
|
||||||
|
| Claude 호출 실패 | 1회 재시도 후 실패 시 error 저장, 기존 최신 브리핑 유지 |
|
||||||
|
| JSON 스키마 검증 실패 | 피드백 포함 1회 재시도 → 실패 시 error |
|
||||||
|
| 월요일 생성 자체가 누락 | 사용자가 웹에서 수동 재생성 버튼으로 보완 가능 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 구현 순서
|
||||||
|
|
||||||
|
1. **Backend**: curator 엔드포인트 + briefing CRUD + 라우터 분리
|
||||||
|
2. **Agent-office**: lotto 에이전트 + curator pipeline + 월요일 스케줄러
|
||||||
|
3. **Frontend**: BriefingTab + 컴포넌트 + 훅 + 탭 재배치
|
||||||
|
4. **미사용 정리 패스**: 아래 "10. 정리 대상" 후보를 실제 참조 grep → 제거
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 정리 대상 (최종 패스에서 검증 후 제거)
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- `components/CombinedRecommendPanel.jsx`
|
||||||
|
- `components/ConfidenceRing.jsx`
|
||||||
|
- `Functions.jsx` 내 인라인 레이아웃 로직 (탭 분리 후 잔재)
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- `strategy_evolver.py` 중 실제 사용되지 않는 EMA 서브 함수
|
||||||
|
- 주간 리포트 관련 `weekly_reports` 테이블 — 브리핑이 대체하므로 드롭 후보
|
||||||
|
- `best_picks` 교체 로직 중 큐레이터 전환 후 사용 안 되는 경로
|
||||||
|
|
||||||
|
### DB 드롭 후보
|
||||||
|
- `weekly_reports` (브리핑이 대체)
|
||||||
|
- `simulation_candidates` (best_picks만 있으면 충분한지 사용처 grep 후 결정)
|
||||||
|
|
||||||
|
정리 패스는 **실제 import/참조 grep → 없으면 제거 → 테스트 → 커밋** 순서로 별도 커밋 분리.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 성공 기준
|
||||||
|
|
||||||
|
- 월요일 07:00 브리핑이 자동 생성되고, 웹 페이지 진입 1초 안에 5세트 + 3줄 요약이 보인다.
|
||||||
|
- 큐레이터는 candidates 내 세트만 선택한다(환각 0건).
|
||||||
|
- 브리핑 카드에 이번 건 토큰/비용, 페이지 하단에 30일 누적 사용량이 표시된다.
|
||||||
|
- 기존 난잡한 패널이 분석/구매 탭으로 정돈되고 브리핑 탭이 기본 진입점이다.
|
||||||
|
- 미사용 테이블·컴포넌트가 최종 정리 패스에서 제거된다.
|
||||||
360
docs/superpowers/specs/2026-04-23-responsive-web-design.md
Normal file
360
docs/superpowers/specs/2026-04-23-responsive-web-design.md
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
# 반응형 웹 UI/UX 전면 개선 설계
|
||||||
|
|
||||||
|
> 모바일에서 UI 짤림 현상 해결 + 풀 모바일 경험 적용
|
||||||
|
> 작성일: 2026-04-23
|
||||||
|
> 리뷰 반영: 2026-04-23 (라우트 경로 수정, breakpoint 예외 명시, 구현 복잡도 보완)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 목표
|
||||||
|
|
||||||
|
- 전체 15개 뷰(12개 라우트 + 3개 서브라우트)에서 모바일 UI 짤림 현상 해결
|
||||||
|
- 현재 다크 네온 사이버펑크 디자인 톤 유지
|
||||||
|
- 모바일 전용 UX 패턴 추가 (바텀 네비게이션, 스와이프, 풀다운 리프레시, FAB, 바텀시트)
|
||||||
|
- 기능적 손실 없이 반응형 적용
|
||||||
|
|
||||||
|
**대상 뷰 목록 (routes.jsx 기준):**
|
||||||
|
|
||||||
|
| # | 라우트 | 컴포넌트 | 비고 |
|
||||||
|
|---|--------|---------|------|
|
||||||
|
| 1 | `/` | Home | |
|
||||||
|
| 2 | `/lotto` | Lotto | 3탭 (Briefing/Analysis/Purchase) |
|
||||||
|
| 3 | `/stock` | Stock | |
|
||||||
|
| 4 | `/stock/trade` | StockTrade | 서브라우트 |
|
||||||
|
| 5 | `/travel` | Travel | |
|
||||||
|
| 6 | `/blog` | Blog | |
|
||||||
|
| 7 | `/blog-lab` | BlogMarketing | |
|
||||||
|
| 8 | `/realestate` | Subscription | |
|
||||||
|
| 9 | `/music` | MusicStudio | |
|
||||||
|
| 10 | `/todo` | Todo | |
|
||||||
|
| 11 | `/agent-office` | AgentOffice | |
|
||||||
|
| 12 | `/lab` | EffectLab | |
|
||||||
|
| 13 | `/lab/sword-stream` | SwordStream | 서브라우트 |
|
||||||
|
| 14 | `/lab/day-calc` | DayCalc | 서브라우트 |
|
||||||
|
|
||||||
|
> Note: `RealEstate.jsx` (`/realestate/property`)는 routes.jsx에 미등록 상태. 반응형 스코프에서 제외.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 접근 방식
|
||||||
|
|
||||||
|
**글로벌 모바일 시스템 구축 → 주요 페이지 적용 → 전체 페이지 확장**
|
||||||
|
|
||||||
|
1. 공통 모바일 인프라(컴포넌트, breakpoint, 앱 셸) 구축
|
||||||
|
2. 주요 4개 페이지 (홈, 로또, 주식, 여행) 우선 적용
|
||||||
|
3. 나머지 페이지 확장 적용
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 글로벌 모바일 인프라
|
||||||
|
|
||||||
|
### 3-1. Breakpoint 시스템 통일
|
||||||
|
|
||||||
|
현재 53개 미디어 쿼리에서 다양한 값이 혼재. 4단계로 통일:
|
||||||
|
|
||||||
|
| 이름 | 값 | 용도 |
|
||||||
|
|------|-----|------|
|
||||||
|
| sm | 480px | 소형 폰 |
|
||||||
|
| md | 768px | 태블릿/대형 폰 (주요 분기점) |
|
||||||
|
| lg | 1024px | 소형 데스크톱 |
|
||||||
|
| xl | 1280px | 대형 데스크톱 |
|
||||||
|
|
||||||
|
기존 미디어 쿼리의 비표준 값(640px, 900px, 960px, 1100px 등)은 기능 손실 없이 가장 가까운 표준 breakpoint로 정리한다.
|
||||||
|
|
||||||
|
**허용 예외 (이동 시 시각적 회귀 발생):**
|
||||||
|
|
||||||
|
| 기존 값 | 파일 | 사유 |
|
||||||
|
|---------|------|------|
|
||||||
|
| 420px | Stock.css (4곳) | 소형 폰 전용 패딩/라벨 축소, 480px로 이동 시 중간 기기에서 불필요한 축소 |
|
||||||
|
| 520px | Stock.css (1곳) | 지표 카드 특수 레이아웃 |
|
||||||
|
| 700px | Stock.css (1곳) | AI 코치 설정 그리드, 768px로 이동 시 태블릿에서 조기 축소 |
|
||||||
|
|
||||||
|
위 값들은 해당 페이지 CSS에서 기존 값을 유지한다.
|
||||||
|
|
||||||
|
### 3-2. 바텀 네비게이션 바 (`BottomNav`)
|
||||||
|
|
||||||
|
- 768px 이하에서 사이드바 대신 표시
|
||||||
|
- 주요 5개 메뉴 아이콘 + "더보기" 메뉴 (나머지 페이지)
|
||||||
|
- 현재 페이지 활성 표시 — 네온 시안 글로우 유지
|
||||||
|
- 사이드바는 모바일에서 완전히 숨김 (기존 햄버거→슬라이드 방식 제거)
|
||||||
|
- 높이: 56~64px
|
||||||
|
- `env(safe-area-inset-bottom)` 대응 (노치/홈 인디케이터 기기)
|
||||||
|
- `index.html`에 `viewport-fit=cover` 추가 필요: `<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">`
|
||||||
|
- 더보기 메뉴: 탭 시 위로 펼쳐지는 오버레이 패널
|
||||||
|
|
||||||
|
**사이드바→바텀네비 마이그레이션 상세:**
|
||||||
|
- `Navbar.jsx`: 768px 이하에서 사이드바 렌더링 제거, `sidebar-toggle` 버튼 제거
|
||||||
|
- `Navbar.css`: `.sidebar` transform/transition 미디어 쿼리 제거, `.sidebar__overlay` 제거
|
||||||
|
- `Navbar.jsx` useEffect: `body.overflow = 'hidden'` 토글 로직 정리
|
||||||
|
- `App.jsx`에서 `BottomNav` 컴포넌트 조건부 렌더링 (`useIsMobile()` 기반)
|
||||||
|
|
||||||
|
**더보기 메뉴 내용 (나머지 네비게이션 항목):**
|
||||||
|
|
||||||
|
| 순서 | 아이콘 | 라벨 | 경로 |
|
||||||
|
|------|--------|------|------|
|
||||||
|
| 1 | 음악 | 뮤직 | `/music` |
|
||||||
|
| 2 | 로봇 | 에이전트 | `/agent-office` |
|
||||||
|
| 3 | 블로그 | 블로그 | `/blog` |
|
||||||
|
| 4 | 마케팅 | 블로그랩 | `/blog-lab` |
|
||||||
|
| 5 | 건물 | 청약 | `/realestate` |
|
||||||
|
| 6 | 체크 | TODO | `/todo` |
|
||||||
|
| 7 | 실험 | 이펙트랩 | `/lab` |
|
||||||
|
|
||||||
|
**기본 5개 메뉴 구성:**
|
||||||
|
|
||||||
|
| 순서 | 아이콘 | 라벨 | 경로 |
|
||||||
|
|------|--------|------|------|
|
||||||
|
| 1 | 홈 | 홈 | `/` |
|
||||||
|
| 2 | 클로버 | 로또 | `/lotto` |
|
||||||
|
| 3 | 차트 | 주식 | `/stock` |
|
||||||
|
| 4 | 카메라 | 여행 | `/travel` |
|
||||||
|
| 5 | 더보기 | 메뉴 | 오버레이 |
|
||||||
|
|
||||||
|
### 3-3. 공통 모바일 컴포넌트
|
||||||
|
|
||||||
|
| 컴포넌트 | 파일 | 역할 |
|
||||||
|
|---------|------|------|
|
||||||
|
| `BottomNav` | `src/components/BottomNav.jsx` | 하단 고정 네비게이션 |
|
||||||
|
| `PullToRefresh` | `src/components/PullToRefresh.jsx` | 터치 풀다운 새로고침 래퍼 |
|
||||||
|
| `SwipeableView` | `src/components/SwipeableView.jsx` | 좌우 스와이프 탭/뷰 전환 |
|
||||||
|
| `FAB` | `src/components/FAB.jsx` | 플로팅 액션 버튼 (바텀 네비 위 배치) |
|
||||||
|
| `MobileSheet` | `src/components/MobileSheet.jsx` | 바텀시트 모달 (드래그 핸들, 스냅 포인트) |
|
||||||
|
|
||||||
|
**공통 훅 (신규 `src/hooks/` 디렉토리 생성):**
|
||||||
|
|
||||||
|
> 기존 훅은 페이지별 디렉토리에 colocate (`src/pages/lotto/hooks/` 등).
|
||||||
|
> 모바일 인프라 훅은 여러 페이지에서 공유하므로 `src/hooks/`에 배치한다.
|
||||||
|
|
||||||
|
| 훅 | 파일 | 역할 |
|
||||||
|
|----|------|------|
|
||||||
|
| `useIsMobile` | `src/hooks/useIsMobile.js` | 768px 이하 감지 (matchMedia) |
|
||||||
|
| `useSwipe` | `src/hooks/useSwipe.js` | 터치 스와이프 방향·거리 감지 |
|
||||||
|
|
||||||
|
**경량 라이브러리 활용:**
|
||||||
|
- `react-swipeable` (~3KB gzipped): SwipeableView/useSwipe 기반으로 활용 — 터치 velocity, threshold snap, 방향 판별을 직접 구현하지 않음
|
||||||
|
- PullToRefresh: 터치 이벤트 직접 구현하되, iOS Safari rubber-banding 및 `overscroll-behavior: contain` 대응 필수
|
||||||
|
- MobileSheet: CSS `transform` + `touch-action: none`으로 구현, 스냅 포인트 2단계 (50%, 90%)
|
||||||
|
|
||||||
|
### 3-4. 앱 셸 레이아웃 변경
|
||||||
|
|
||||||
|
```
|
||||||
|
데스크톱: [사이드바 240px] [콘텐츠]
|
||||||
|
모바일: [탑바 56px]
|
||||||
|
[콘텐츠 (padding-bottom: 바텀네비 높이)]
|
||||||
|
[바텀 네비 56-64px]
|
||||||
|
```
|
||||||
|
|
||||||
|
- 콘텐츠 영역에 `padding-bottom` 추가 (바텀 네비 겹침 방지)
|
||||||
|
- 탑바: 현재 구조 유지, 페이지 타이틀 + 액션 버튼 영역
|
||||||
|
- `body` overflow: 모바일에서 auto (현재와 동일)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 주요 페이지별 모바일 설계
|
||||||
|
|
||||||
|
### 4-1. 홈 (Home) — `/`
|
||||||
|
|
||||||
|
| 영역 | 데스크톱 | 모바일 (≤768px) |
|
||||||
|
|------|---------|-----------------|
|
||||||
|
| 히어로 | 2컬럼 그리드 | 1컬럼 스택, 타이틀 축소 |
|
||||||
|
| 네비 카드 그리드 | auto-fill minmax(180px) | 2컬럼 고정, 카드 높이 축소 |
|
||||||
|
| TODO 보드 | 3컬럼 칸반 | 스와이프 탭 (Todo/진행중/완료) |
|
||||||
|
| 블로그 포스트 | 카드 그리드 | 1컬럼 리스트 |
|
||||||
|
| 프로필 섹션 | 사이드 카드 | 하단 접이식 패널 |
|
||||||
|
|
||||||
|
- 풀다운 리프레시: 블로그 포스트 갱신
|
||||||
|
- FAB: 없음 (네비게이션 허브)
|
||||||
|
|
||||||
|
### 4-2. 로또 (Lotto) — `/lotto`
|
||||||
|
|
||||||
|
| 영역 | 데스크톱 | 모바일 |
|
||||||
|
|------|---------|--------|
|
||||||
|
| 3탭 구조 | 상단 탭바 | 스와이프 탭 전환 |
|
||||||
|
| 브리핑 탭 | 카드 레이아웃 | 1컬럼, 볼 크기 36→32px |
|
||||||
|
| 분석 탭 | 그리드 카드 | 1컬럼 스택 |
|
||||||
|
| 구매 이력 테이블 | 6컬럼 그리드 | 가로 스크롤 테이블 + 행 터치 바텀시트 |
|
||||||
|
| 번호 추천 카드 | 다중 그리드 | 1컬럼, 볼 간격 조정 |
|
||||||
|
| 전략 차트 | 넓은 차트 | 가로스크롤 또는 축소 |
|
||||||
|
|
||||||
|
- FAB: "추천받기" (빠른 번호 추천)
|
||||||
|
- 풀다운 리프레시: 브리핑/분석 데이터 갱신
|
||||||
|
|
||||||
|
### 4-3. 주식 (Stock / StockTrade) — `/stock`, `/stock/trade`
|
||||||
|
|
||||||
|
**Stock (뉴스/지표)**
|
||||||
|
|
||||||
|
| 영역 | 데스크톱 | 모바일 |
|
||||||
|
|------|---------|--------|
|
||||||
|
| 헤더 | 2컬럼 | 1컬럼 스택 |
|
||||||
|
| 뉴스 그리드 | auto-fit minmax(260px) | 1컬럼 카드 리스트 |
|
||||||
|
| 필터 | 가로 나열 | 가로 스크롤 칩 바 |
|
||||||
|
| 지표 카드 | 그리드 | 가로 스크롤 카드 캐러셀 |
|
||||||
|
|
||||||
|
**StockTrade (매매)**
|
||||||
|
|
||||||
|
| 영역 | 데스크톱 | 모바일 |
|
||||||
|
|------|---------|--------|
|
||||||
|
| 포트폴리오 테이블 | 넓은 테이블 | 카드형 리스트 (종목별 카드) |
|
||||||
|
| 매도 이력 | 테이블 | 가로 스크롤 + 행 터치 바텀시트 |
|
||||||
|
| 자산 차트 | 넓은 recharts | 풀 너비, 축 라벨 축소 |
|
||||||
|
| 예수금 섹션 | 인라인 | 접이식 카드 |
|
||||||
|
|
||||||
|
- FAB: "종목 추가" (Stock), "매도 기록" (StockTrade)
|
||||||
|
- 풀다운 리프레시: 뉴스/포트폴리오 갱신
|
||||||
|
|
||||||
|
### 4-4. 여행 (Travel) — `/travel`
|
||||||
|
|
||||||
|
| 영역 | 데스크톱 | 모바일 |
|
||||||
|
|------|---------|--------|
|
||||||
|
| 지역 선택 | Leaflet 지도 | 높이 50vh→35vh, 핀치 줌 |
|
||||||
|
| 사진 그리드 | 다중 컬럼 | 2컬럼 → 1컬럼 (≤480px) |
|
||||||
|
| 사진 상세 | 모달 | 풀스크린 뷰어 + 스와이프 넘기기 |
|
||||||
|
| 지역 필터 | 드롭다운 | 바텀시트 지역 선택 |
|
||||||
|
|
||||||
|
- 풀다운 리프레시: 사진 목록 갱신
|
||||||
|
- FAB: 없음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 나머지 페이지 모바일 설계
|
||||||
|
|
||||||
|
### 5-1. 블로그 (Blog) — `/blog`
|
||||||
|
|
||||||
|
| 영역 | 모바일 변경 |
|
||||||
|
|------|------------|
|
||||||
|
| 글 목록 | 1컬럼 리스트형 |
|
||||||
|
| 글 상세 | 풀 너비, 폰트 크기 조정 |
|
||||||
|
| 태그 필터 | 가로 스크롤 칩 바 |
|
||||||
|
| 작성/수정 폼 | 풀 너비, 툴바 축소 |
|
||||||
|
|
||||||
|
- FAB: "글 쓰기"
|
||||||
|
- 풀다운 리프레시: 글 목록 갱신
|
||||||
|
|
||||||
|
### 5-2. 블로그 마케팅 (BlogMarketing) — `/blog-lab`
|
||||||
|
|
||||||
|
| 영역 | 모바일 변경 |
|
||||||
|
|------|------------|
|
||||||
|
| 대시보드 지표 | 2컬럼 → 1컬럼 (≤480px) |
|
||||||
|
| 파이프라인 테이블 | 카드형 리스트 (상태 배지) |
|
||||||
|
| 키워드 분석 | 접이식 아코디언 |
|
||||||
|
| 수익 내역 | 가로 스크롤 테이블 |
|
||||||
|
|
||||||
|
- FAB: "키워드 분석"
|
||||||
|
- 풀다운 리프레시: 대시보드 갱신
|
||||||
|
|
||||||
|
### 5-3. 부동산 청약 (Subscription) — `/realestate`
|
||||||
|
|
||||||
|
| 영역 | 모바일 변경 |
|
||||||
|
|------|------------|
|
||||||
|
| 공고 목록 | 1컬럼 카드 리스트 |
|
||||||
|
| 필터 | 바텀시트 필터 패널 |
|
||||||
|
| 공고 상세 | 바텀시트 상세보기 |
|
||||||
|
| 매칭 결과 | 1컬럼, 점수 강조 |
|
||||||
|
| 대시보드 | 2컬럼 그리드 |
|
||||||
|
|
||||||
|
- FAB: "공고 등록"
|
||||||
|
- 풀다운 리프레시: 공고/매칭 갱신
|
||||||
|
|
||||||
|
### 5-4. 뮤직 스튜디오 (MusicStudio) — `/music`
|
||||||
|
|
||||||
|
| 영역 | 모바일 변경 |
|
||||||
|
|------|------------|
|
||||||
|
| 헤더 | 1컬럼, 타이틀 클램프 축소 |
|
||||||
|
| 생성 폼 | 풀 너비 스택 |
|
||||||
|
| 라이브러리 | 1컬럼 리스트 (앨범아트 + 제목) |
|
||||||
|
| 플레이어 | 미니 플레이어 바텀 고정 (높이 56px, 바텀 네비 위 = bottom: 64px) |
|
||||||
|
| 가사 에디터 | 풀 너비 |
|
||||||
|
| 레이더 위젯 | 중앙 정렬 |
|
||||||
|
|
||||||
|
- FAB: "음악 생성"
|
||||||
|
- 풀다운 리프레시: 라이브러리 갱신
|
||||||
|
- 미니 플레이어 표시 시 콘텐츠 padding-bottom: 바텀네비(64px) + 미니플레이어(56px) = 120px
|
||||||
|
|
||||||
|
### 5-5. TODO — `/todo`
|
||||||
|
|
||||||
|
| 영역 | 모바일 변경 |
|
||||||
|
|------|------------|
|
||||||
|
| 칸반 보드 | 스와이프 탭 (Todo/진행중/완료) |
|
||||||
|
| 할일 카드 | 스와이프로 상태 변경 |
|
||||||
|
| 입력 폼 | FAB → 바텀시트 입력 폼 |
|
||||||
|
|
||||||
|
- FAB: "할일 추가"
|
||||||
|
|
||||||
|
### 5-6. 에이전트 오피스 (AgentOffice) — `/agent-office`
|
||||||
|
|
||||||
|
| 영역 | 모바일 변경 |
|
||||||
|
|------|------------|
|
||||||
|
| 캔버스 오피스 | 풀스크린 캔버스, 핀치 줌/패닝 |
|
||||||
|
| 에이전트 패널 | 바텀시트 에이전트 상세 |
|
||||||
|
| 작업 로그 | 바텀시트 로그 뷰 |
|
||||||
|
| 명령 입력 | 하단 입력 바 (채팅 UX) |
|
||||||
|
| WebSocket 상태 | 탑바에 연결 상태 아이콘 |
|
||||||
|
|
||||||
|
### 5-7. 이펙트 랩 — `/lab`, `/lab/day-calc`, `/lab/sword-stream`
|
||||||
|
|
||||||
|
| 페이지 | 모바일 변경 |
|
||||||
|
|--------|------------|
|
||||||
|
| EffectLab 허브 | 카드 그리드 → 1컬럼 리스트 |
|
||||||
|
| DayCalc | 풀 너비 스택, 네이티브 날짜 피커 |
|
||||||
|
| SwordStream | 풀스크린 캔버스, 터치 인터랙션 유지, 오버레이 축소 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 터치 타겟 가이드라인
|
||||||
|
|
||||||
|
- 모든 터치 타겟: 최소 44×44px (Apple HIG 기준)
|
||||||
|
- 버튼 간 간격: 최소 8px
|
||||||
|
- FAB 크기: 56×56px
|
||||||
|
- 바텀 네비 아이템: 최소 48×48px 터치 영역
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 성능 고려사항
|
||||||
|
|
||||||
|
- 모바일에서 글로우/그라디언트 효과: box-shadow 개수 줄이기 (3중→1중)
|
||||||
|
- `background-attachment: fixed` → 모바일에서 `scroll` (현재 적용됨, 유지)
|
||||||
|
- 이미지: `loading="lazy"` 속성 확인
|
||||||
|
- 스와이프/터치 이벤트: passive listener 사용
|
||||||
|
- 바텀시트 애니메이션: `transform` + `will-change` 사용 (layout thrashing 방지)
|
||||||
|
- 신규 애니메이션(스와이프, 바텀시트, 풀다운)은 `prefers-reduced-motion: reduce` 쿼리 존중 — Travel.css, MusicStudio.css 기존 패턴과 통일
|
||||||
|
|
||||||
|
### 주의: Stock.css / StockTrade.jsx 커플링
|
||||||
|
|
||||||
|
`StockTrade.jsx`는 `Stock.css`의 스타일을 공유한다. Stock.css의 반응형 수정은 StockTrade에도 영향을 미치므로, 반드시 두 페이지를 함께 검증해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 구현 순서
|
||||||
|
|
||||||
|
### Phase 1: 글로벌 인프라
|
||||||
|
|
||||||
|
**Phase 1a: Breakpoint 정리 (기존 CSS만 수정, 신규 코드 없음)**
|
||||||
|
1. Breakpoint 시스템 통일 — 각 CSS 파일의 비표준 미디어 쿼리를 표준 값으로 정리
|
||||||
|
2. `index.html`에 `viewport-fit=cover` 추가
|
||||||
|
3. 회귀 테스트: 정리 후 각 페이지 데스크톱/모바일 확인
|
||||||
|
|
||||||
|
**Phase 1b: 공통 컴포넌트 & 앱 셸**
|
||||||
|
4. `react-swipeable` 패키지 설치
|
||||||
|
5. `src/hooks/` 디렉토리 생성 + `useIsMobile`, `useSwipe` 훅 구현
|
||||||
|
6. `BottomNav` 컴포넌트 구현 + 사이드바 모바일 제거 마이그레이션 (Navbar.jsx/css 수정)
|
||||||
|
7. `PullToRefresh`, `SwipeableView`, `FAB`, `MobileSheet` 컴포넌트 구현
|
||||||
|
8. 앱 셸 레이아웃 수정 (App.jsx, App.css)
|
||||||
|
|
||||||
|
### Phase 2: 주요 페이지 적용
|
||||||
|
9. 홈 페이지 반응형 개선
|
||||||
|
10. 로또 페이지 반응형 개선
|
||||||
|
11. 주식 페이지 (Stock + StockTrade 함께 검증) 반응형 개선
|
||||||
|
12. 여행 페이지 반응형 개선
|
||||||
|
|
||||||
|
### Phase 3: 나머지 페이지 확장
|
||||||
|
13. 블로그 (`/blog`) + 블로그 마케팅 (`/blog-lab`)
|
||||||
|
14. 부동산 청약 (`/realestate`)
|
||||||
|
15. 뮤직 스튜디오 (`/music`)
|
||||||
|
16. TODO (`/todo`)
|
||||||
|
17. 에이전트 오피스 (`/agent-office`)
|
||||||
|
18. 이펙트 랩 (`/lab` + `/lab/day-calc` + `/lab/sword-stream`)
|
||||||
|
|
||||||
|
### Phase 4: 검증
|
||||||
|
19. 전체 뷰 모바일 UI 검증 — 대상 뷰포트: 360px (Galaxy S), 390px (iPhone 14), 768px (iPad), 1024px (데스크톱)
|
||||||
|
20. `prefers-reduced-motion` 동작 확인
|
||||||
|
21. 터치 타겟 크기 검증 (44×44px 최소)
|
||||||
313
docs/superpowers/specs/2026-04-24-travel-gallery-redesign.md
Normal file
313
docs/superpowers/specs/2026-04-24-travel-gallery-redesign.md
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
# Travel Gallery Redesign — Design Spec
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Travel 여행 기록 갤러리를 앨범 기반 진입 + Masonry 그리드 + HERO 확대 라이트박스로 리디자인한다. 모놀리식 1,024줄 컴포넌트를 7-8개 집중된 파일로 분리하고, 시네마틱 여행 감성을 강화한다.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- **포함**: 프론트엔드 리디자인 (컴포넌트 분리 + 새 UX/UI)
|
||||||
|
- **포함**: 동영상 탭 UI 셸 (플레이스홀더)
|
||||||
|
- **제외**: 백엔드 동영상 API (별도 후속 스펙)
|
||||||
|
- **제외**: 핀치 줌 (복잡도 대비 효과 낮음)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
점진적 리팩토링 — 기존 API 호출/캐싱/페이지네이션 로직을 `useTravelData` 훅으로 추출하여 재활용하고, UI 레이어를 새로 구성한다. 라우팅 변경 없이 React 상태 기반으로 앨범 진입/이탈을 관리한다.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- React 18 (기존)
|
||||||
|
- Leaflet + react-leaflet (기존, 미니맵으로 축소)
|
||||||
|
- react-swipeable (기존, 라이트박스 스와이프)
|
||||||
|
- SwipeableView 컴포넌트 (기존, 사진/영상 탭)
|
||||||
|
- CSS columns (Masonry 레이아웃)
|
||||||
|
- IntersectionObserver (무한스크롤 + 스크롤 리빌)
|
||||||
|
- Web Animations API / CSS transitions (shared element transition)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Component Structure & File Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/pages/travel/
|
||||||
|
├── Travel.jsx # 메인 컨테이너 (미니맵 + 앨범 카드 리스트)
|
||||||
|
├── Travel.css # 전체 레이아웃 + CSS 변수
|
||||||
|
├── AlbumCard.jsx # 여행지 앨범 카드
|
||||||
|
├── AlbumCard.css
|
||||||
|
├── AlbumDetail.jsx # 앨범 상세 (탭 + Masonry)
|
||||||
|
├── AlbumDetail.css
|
||||||
|
├── MasonryGrid.jsx # Masonry 레이아웃 + 무한스크롤
|
||||||
|
├── MasonryGrid.css
|
||||||
|
├── HeroLightbox.jsx # HERO 확대 전환 라이트박스
|
||||||
|
├── HeroLightbox.css
|
||||||
|
├── MiniMap.jsx # Leaflet 미니맵
|
||||||
|
├── MiniMap.css
|
||||||
|
├── VideoTab.jsx # 영상 탭 UI 셸
|
||||||
|
├── VideoTab.css
|
||||||
|
└── useTravelData.js # API 호출 + 캐싱 + 페이지네이션 훅
|
||||||
|
```
|
||||||
|
|
||||||
|
### Responsibilities
|
||||||
|
|
||||||
|
| 파일 | 책임 |
|
||||||
|
|------|------|
|
||||||
|
| `Travel.jsx` | 페이지 레이아웃, 지역 필터 상태, 앨범 선택 상태 관리 |
|
||||||
|
| `useTravelData.js` | API fetch, 10분 TTL 캐시, 앨범별 그룹핑, 페이지네이션 |
|
||||||
|
| `MiniMap.jsx` | Leaflet 지도 렌더링, GeoJSON 폴리곤, 지역 클릭 이벤트 발행 |
|
||||||
|
| `AlbumCard.jsx` | 대표 사진 + 앨범명 + 사진 수 뱃지, 호버 효과 |
|
||||||
|
| `AlbumDetail.jsx` | 앨범 오버레이, 진입/이탈 애니메이션, 사진/영상 탭 전환 |
|
||||||
|
| `MasonryGrid.jsx` | CSS columns Masonry, IntersectionObserver 무한스크롤 + 스크롤 리빌 |
|
||||||
|
| `HeroLightbox.jsx` | shared element transition, 좌우 스와이프, 썸네일 스트립 |
|
||||||
|
| `VideoTab.jsx` | "영상 기능 준비 중" 플레이스홀더 |
|
||||||
|
|
||||||
|
### Page Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Travel.jsx (메인)
|
||||||
|
├── MiniMap (상단, 접기/펼치기 가능)
|
||||||
|
│ └── 지역 클릭 → selectedRegion 상태 변경 → 앨범 필터
|
||||||
|
├── AlbumCard[] (여행지 카드 리스트)
|
||||||
|
│ └── 클릭 → AlbumDetail (오버레이)
|
||||||
|
│ ├── [사진 탭] MasonryGrid
|
||||||
|
│ │ └── 사진 클릭 → HeroLightbox
|
||||||
|
│ └── [영상 탭] VideoTab
|
||||||
|
└── useTravelData (데이터 레이어)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Main View — MiniMap + Album Card List
|
||||||
|
|
||||||
|
### MiniMap
|
||||||
|
|
||||||
|
- 높이: 데스크톱 200px, 모바일 150px
|
||||||
|
- GeoJSON 지역 폴리곤 유지 (기존 MapLayer 로직 추출)
|
||||||
|
- 클릭 시 해당 지역 앨범만 필터링
|
||||||
|
- 선택된 지역: 지역별 악센트 컬러로 하이라이트
|
||||||
|
- "전체 보기" 버튼으로 필터 해제
|
||||||
|
- 접기/펼치기 토글 (기본: 펼침)
|
||||||
|
- 접힌 상태: 높이 0 + overflow hidden, 토글 버튼만 표시
|
||||||
|
|
||||||
|
### Album Card List
|
||||||
|
|
||||||
|
- **카드 구성**: 대표 사진 배경 (object-fit: cover) + 앨범 이름 + 사진 수 뱃지
|
||||||
|
- **대표 사진**: 앨범 첫 번째 사진의 썸네일 URL
|
||||||
|
- **카드 레이아웃**: `display: grid`
|
||||||
|
- 데스크톱 (>1024px): 3열
|
||||||
|
- 태블릿 (769px-1024px): 2열
|
||||||
|
- 모바일 (<=768px): 1열
|
||||||
|
- **카드 높이**: 데스크톱 240px, 모바일 200px
|
||||||
|
- **호버**: scale(1.03) + 지역 악센트 글로우
|
||||||
|
- **지역 필터 전환**: fade 애니메이션 (opacity 300ms)
|
||||||
|
|
||||||
|
### Album Data Grouping
|
||||||
|
|
||||||
|
백엔드 API 변경 없이 프론트에서 처리:
|
||||||
|
|
||||||
|
1. 각 region에 대해 `GET /api/travel/photos?region={id}&page=1&size=1` 호출
|
||||||
|
2. 응답의 `total` 필드로 사진 수 확보, `items[0]`으로 대표 사진 확보
|
||||||
|
3. region_map.json의 albums 목록에서 앨범명 추출
|
||||||
|
4. 기존 10분 TTL 캐시 로직 재활용
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Album Detail — Masonry Grid + Tabs + Transitions
|
||||||
|
|
||||||
|
### Entry Animation (Shared Element Transition)
|
||||||
|
|
||||||
|
1. 앨범 카드 클릭 시 `getBoundingClientRect()`로 카드 시작 위치 캡처
|
||||||
|
2. 카드 clone을 `position: fixed`로 생성
|
||||||
|
3. clone을 `inset: 0` (풀스크린)으로 animate (400ms, cubic-bezier(0.4, 0, 0.2, 1))
|
||||||
|
4. 애니메이션 완료 → clone 제거, AlbumDetail 오버레이 표시
|
||||||
|
|
||||||
|
### Exit Animation
|
||||||
|
|
||||||
|
1. 뒤로가기/닫기 클릭
|
||||||
|
2. AlbumDetail을 숨기고, 원래 카드 위치로 역재생 (400ms)
|
||||||
|
3. 애니메이션 완료 → 앨범 카드 리스트로 복귀
|
||||||
|
|
||||||
|
### Photo/Video Tabs
|
||||||
|
|
||||||
|
- 앨범 상세 상단에 "사진 | 영상" 탭 바
|
||||||
|
- 기존 `SwipeableView` 컴포넌트 재활용 (모바일 스와이프 전환)
|
||||||
|
- 영상 탭: VideoTab 컴포넌트 (플레이스홀더)
|
||||||
|
|
||||||
|
### Masonry Grid (Photo Tab)
|
||||||
|
|
||||||
|
- **레이아웃**: CSS `column-count` 기반
|
||||||
|
- 데스크톱 (>1024px): 4열
|
||||||
|
- 태블릿 (769px-1024px): 3열
|
||||||
|
- 모바일 (<=768px): 2열
|
||||||
|
- **사진 비율**: 원본 유지 (`width: 100%`, `height: auto`)
|
||||||
|
- **갭**: `column-gap: 8px`, 각 사진 `margin-bottom: 8px`
|
||||||
|
- **break-inside**: `avoid` (사진이 컬럼 경계에 걸리지 않도록)
|
||||||
|
- **무한 스크롤**: IntersectionObserver 센티널, rootMargin 300px, page size 20
|
||||||
|
- **스크롤 리빌**: 뷰포트 진입 시 아래에서 20px 올라오며 fade-in, 사진마다 50ms 지연
|
||||||
|
- **lazy loading**: `loading="lazy"` 속성, 첫 8장은 `loading="eager"`
|
||||||
|
|
||||||
|
### Video Tab (Shell)
|
||||||
|
|
||||||
|
- 중앙 정렬된 비디오 아이콘 + "영상 기능 준비 중" 텍스트
|
||||||
|
- 앰버 톤 텍스트, 세리프 폰트
|
||||||
|
- 백엔드 동영상 API 완성 시 이 컴포넌트 내부만 교체
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. HERO Lightbox
|
||||||
|
|
||||||
|
### Shared Element Transition (Photo → Fullscreen)
|
||||||
|
|
||||||
|
1. Masonry에서 사진 클릭 → `getBoundingClientRect()`로 시작 위치 캡처
|
||||||
|
2. 사진 clone을 `position: fixed`로 생성
|
||||||
|
3. clone을 화면 중앙 + 최대 크기로 animate (350ms, cubic-bezier(0.4, 0, 0.2, 1))
|
||||||
|
4. 애니메이션 완료 → clone 제거, 라이트박스 UI 표시
|
||||||
|
5. 배경은 `#000` opacity 0→1 동시 전환
|
||||||
|
|
||||||
|
### Fullscreen Viewer
|
||||||
|
|
||||||
|
- **배경**: 순수 블랙 `#000`, z-index 3000
|
||||||
|
- **사진**: `max-width: 100%`, `max-height: calc(100vh - 140px)`, `object-fit: contain`
|
||||||
|
- **좌우 탐색**:
|
||||||
|
- 데스크톱: 좌우 화살표 버튼 (hover 시 표시)
|
||||||
|
- 모바일: react-swipeable로 좌우 스와이프
|
||||||
|
- 키보드: ArrowLeft/ArrowRight
|
||||||
|
- **하단 썸네일 스트립**:
|
||||||
|
- 높이 68px, 썸네일 52x52px
|
||||||
|
- 활성 썸네일: 앰버 테두리 (2px solid)
|
||||||
|
- 활성 썸네일 자동 센터링 (smooth scroll)
|
||||||
|
- 필름 퍼포레이션 장식 제거 (간소화)
|
||||||
|
- **메타 정보**: 사진 위 또는 아래에 앨범명 + 파일명 (앰버 텍스트, 14px)
|
||||||
|
- **닫기**:
|
||||||
|
- X 버튼 (우상단)
|
||||||
|
- 아래로 스와이프 (모바일, threshold 100px)
|
||||||
|
- ESC 키
|
||||||
|
- 닫기 시 역재생 transition → 원래 그리드 위치로 복귀
|
||||||
|
|
||||||
|
### Slide Animation (이전/다음)
|
||||||
|
|
||||||
|
- 좌우 전환 시 현재 사진이 나가고 새 사진이 들어오는 slide 애니메이션
|
||||||
|
- 280ms, cubic-bezier(0.25, 0.46, 0.45, 0.94)
|
||||||
|
- 방향에 따라 왼쪽/오른쪽에서 진입
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Visual Design — Cinematic Travel Aesthetic
|
||||||
|
|
||||||
|
### Color System
|
||||||
|
|
||||||
|
- **베이스 배경**: `#0f0c09` (깊은 다크)
|
||||||
|
- **베이스 텍스트**: `#f5e6c8` (따뜻한 앰버)
|
||||||
|
- **뮤트 텍스트**: `rgba(245,230,200,0.5)`
|
||||||
|
- **라인/테두리**: `rgba(245,230,200,0.08)`
|
||||||
|
- **지역별 악센트**:
|
||||||
|
- 일본: `#c73e1d` (주홍)
|
||||||
|
- 유럽: `#2563eb` (코발트)
|
||||||
|
- 동남아: `#059669` (에메랄드)
|
||||||
|
- 국내: `#d97706` (호박)
|
||||||
|
- 기타: 기본 앰버 `#d4a574`
|
||||||
|
- 악센트 적용: 앨범 카드 호버 글로우, 미니맵 지역 하이라이트, 탭 활성 상태
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
- **제목/앨범명**: `Cormorant Garamond`, serif (기존 유지)
|
||||||
|
- **메타 정보/뱃지**: `Space Mono`, monospace (기존 유지)
|
||||||
|
- **앨범 카드 제목**: 데스크톱 24px, 모바일 18px
|
||||||
|
- **사진 수 뱃지**: 11px 모노, `rgba(15,12,9,0.7)` 배경 위 앰버 텍스트
|
||||||
|
|
||||||
|
### Album Card Visual
|
||||||
|
|
||||||
|
- 대표 사진 위 하단 30% 그라디언트: `linear-gradient(transparent, rgba(15,12,9,0.85))`
|
||||||
|
- 그라디언트 위에 앨범명 + 사진 수
|
||||||
|
- `border-radius: 12px`
|
||||||
|
- `border: 1px solid rgba(245,230,200,0.08)`
|
||||||
|
- 호버: `box-shadow: 0 0 20px rgba({accent}, 0.15)` + `transform: scale(1.03)`
|
||||||
|
|
||||||
|
### Masonry Photo Style
|
||||||
|
|
||||||
|
- `border-radius: 4px`
|
||||||
|
- 호버: `filter: brightness(1.08)` + `cursor: zoom-in`
|
||||||
|
- 스크롤 리빌: translateY(20px) + opacity(0) → translateY(0) + opacity(1), 사진마다 50ms 지연
|
||||||
|
|
||||||
|
### Lightbox Visual
|
||||||
|
|
||||||
|
- 배경: `#000`
|
||||||
|
- 메타 텍스트: 앰버 `#f5e6c8`, 세리프 폰트, 14px
|
||||||
|
- 썸네일 스트립: 활성 아이템에 앰버 2px 테두리
|
||||||
|
- 카운터: "3 / 156" 형태, 우상단, 모노스페이스
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Responsive Design
|
||||||
|
|
||||||
|
### Breakpoints
|
||||||
|
|
||||||
|
| 구간 | 앨범 카드 | Masonry 열 | 미니맵 높이 |
|
||||||
|
|------|----------|-----------|-----------|
|
||||||
|
| >1024px | 3열 | 4열 | 200px |
|
||||||
|
| 769-1024px | 2열 | 3열 | 200px |
|
||||||
|
| <=768px | 1열 | 2열 | 150px |
|
||||||
|
|
||||||
|
### Mobile Specifics
|
||||||
|
|
||||||
|
- 앨범 상세: `position: fixed; inset: 0` (풀스크린 오버레이)
|
||||||
|
- 라이트박스: 100dvh, 화살표 버튼 숨김 (스와이프로 대체)
|
||||||
|
- 미니맵: 기본 접힘 (모바일에서 공간 절약)
|
||||||
|
- 하단 네비게이션 고려: `padding-bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom))`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Reduced Motion
|
||||||
|
|
||||||
|
`prefers-reduced-motion: reduce` 적용 시:
|
||||||
|
|
||||||
|
- shared element transition (앨범 진입/이탈, 라이트박스 열기/닫기) → 즉시 fade (opacity 0→1, 150ms)
|
||||||
|
- 스크롤 리빌 애니메이션 → 즉시 표시 (opacity 1, transform none)
|
||||||
|
- 카드 호버 scale → 없음 (색상 변화만 유지)
|
||||||
|
- 슬라이드 전환 → 즉시 교체 (fade)
|
||||||
|
- 미니맵 접기/펼치기 → 즉시 전환
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
useTravelData hook
|
||||||
|
├── fetchRegions() → GET /api/travel/regions
|
||||||
|
├── fetchAlbums(region?) → GET /api/travel/photos?region={id}&page=1&size=1 (per region)
|
||||||
|
├── fetchPhotos(region, page) → GET /api/travel/photos?region={id}&page={n}&size=20
|
||||||
|
└── cache (Map, 10min TTL) → 기존 캐시 로직 재활용
|
||||||
|
|
||||||
|
State:
|
||||||
|
- regions: GeoJSON[]
|
||||||
|
- albums: { id, name, region, coverThumb, totalPhotos }[]
|
||||||
|
- selectedRegion: string | null
|
||||||
|
- selectedAlbum: string | null
|
||||||
|
- photos: Photo[]
|
||||||
|
- page, hasNext, loading, loadingMore
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Contract (기존 유지, 변경 없음)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/travel/regions
|
||||||
|
→ GeoJSON FeatureCollection
|
||||||
|
|
||||||
|
GET /api/travel/photos?region=japan&page=1&size=20
|
||||||
|
→ { region, page, size, total, has_next, items: [{ album, file, url, thumb, mtime }] }
|
||||||
|
|
||||||
|
POST /api/travel/reload
|
||||||
|
→ { status: "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Performance Considerations
|
||||||
|
|
||||||
|
- **앨범 카드 대표 사진**: page=1&size=1로 최소 데이터만 요청
|
||||||
|
- **Masonry 이미지**: 썸네일(480x480) 사용, 라이트박스에서만 원본 로드
|
||||||
|
- **무한 스크롤**: 20개씩 점진적 로드, rootMargin 300px 선제 로드
|
||||||
|
- **lazy loading**: 브라우저 네이티브 `loading="lazy"`
|
||||||
|
- **캐시**: 10분 TTL, 리전 단위
|
||||||
|
- **스크롤 리빌**: IntersectionObserver 단일 인스턴스로 배치 감시
|
||||||
|
- **shared element transition**: `will-change: transform` 적용, 합성 레이어로 GPU 가속
|
||||||
203
docs/superpowers/specs/2026-04-24-travel-proxy-perf-design.md
Normal file
203
docs/superpowers/specs/2026-04-24-travel-proxy-perf-design.md
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# Travel-Proxy 성능 개선 설계
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
travel-proxy의 파일 스캔 기반 아키텍처를 SQLite 인덱스 DB로 전환하여 수천 장의 사진을 무난하게 처리하고, 앨범 커버 지정 + 썸네일 사전 생성을 지원한다.
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
|
||||||
|
현재 travel-proxy는 `os.scandir`으로 NAS 폴더를 매번 스캔하고, 메모리 캐시(TTL 300초)로 결과를 보관한다. 사진 수백 장에서는 문제없지만, 수천 장이면:
|
||||||
|
- 캐시 만료 시 1~2초 스캔 지연
|
||||||
|
- 콜드 스타트(컨테이너 재시작) 시 첫 요청 느림
|
||||||
|
- 전체 리스트를 메모리에 상주
|
||||||
|
- 썸네일이 첫 요청 시 동기 생성되어 초기 로딩 지연
|
||||||
|
|
||||||
|
## 아키텍처
|
||||||
|
|
||||||
|
### 변경 전
|
||||||
|
|
||||||
|
```
|
||||||
|
API 요청 → os.scandir(폴더) → 메모리 캐시 → 슬라이싱 페이지네이션
|
||||||
|
↓
|
||||||
|
썸네일 온디맨드 생성 (Pillow)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 변경 후
|
||||||
|
|
||||||
|
```
|
||||||
|
수동 sync 버튼 → 폴더 스캔 → travel.db 동기화 + 썸네일 사전 생성
|
||||||
|
↓
|
||||||
|
API 요청 → SQLite 쿼리 (인덱스) → 페이지네이션
|
||||||
|
```
|
||||||
|
|
||||||
|
### 파일 구조
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `main.py` | FastAPI 라우트 (기존 + 신규) |
|
||||||
|
| `db.py` (신규) | SQLite 스키마 정의, 쿼리 헬퍼 |
|
||||||
|
| `indexer.py` (신규) | 폴더 스캔 → DB 동기화 + 썸네일 생성 |
|
||||||
|
|
||||||
|
기존 `main.py`의 `scan_album`, `ensure_thumb`, 메모리 캐시 로직이 `indexer.py`와 `db.py`로 이동하고, `main.py`는 라우트만 남는다.
|
||||||
|
|
||||||
|
## DB 스키마
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE photos (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
album TEXT NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
mtime REAL NOT NULL,
|
||||||
|
has_thumb INTEGER DEFAULT 0,
|
||||||
|
indexed_at TEXT NOT NULL,
|
||||||
|
UNIQUE(album, filename)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_photos_album ON photos(album);
|
||||||
|
|
||||||
|
CREATE TABLE album_covers (
|
||||||
|
album TEXT PRIMARY KEY,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 설계 포인트
|
||||||
|
|
||||||
|
- `photos` 테이블에 URL/thumb 경로를 저장하지 않음 — 런타임에 `MEDIA_BASE` + album + filename으로 조합 (환경변수 변경에 유연)
|
||||||
|
- `mtime`으로 변경 감지 — 동기화 시 파일이 삭제됐거나 mtime이 바뀌면 갱신
|
||||||
|
- `album_covers`가 비어있으면 해당 앨범의 첫 번째 사진이 자동 커버
|
||||||
|
|
||||||
|
## API 설계
|
||||||
|
|
||||||
|
### 기존 API 변경
|
||||||
|
|
||||||
|
| 엔드포인트 | 변경 내용 |
|
||||||
|
|-----------|----------|
|
||||||
|
| `GET /api/travel/photos` | 내부 로직만 변경 (os.scandir → DB 쿼리). 응답 형식 동일 |
|
||||||
|
| `GET /api/travel/regions` | 변경 없음 |
|
||||||
|
| `POST /api/travel/reload` | 제거 (sync로 대체) |
|
||||||
|
| `GET /media/travel/.thumb/{album}/{filename}` | 유지 — 동기화 시 이미 썸네일 생성되므로 Pillow 호출 빈도 대폭 감소. 미생성 분 폴백으로 온디맨드 생성 유지 |
|
||||||
|
|
||||||
|
### 신규 API
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `POST` | `/api/travel/sync` | 폴더 스캔 → DB 동기화 + 썸네일 생성 |
|
||||||
|
| `GET` | `/api/travel/albums` | 앨범 목록 + 사진 수 + 커버 정보 |
|
||||||
|
| `PUT` | `/api/travel/albums/{album}/cover` | 앨범 커버 지정 |
|
||||||
|
|
||||||
|
### POST /api/travel/sync
|
||||||
|
|
||||||
|
폴더를 스캔하여 DB와 동기화하고, 미생성 썸네일을 일괄 생성한다.
|
||||||
|
|
||||||
|
**요청**: 바디 없음
|
||||||
|
|
||||||
|
**응답**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"added": 42,
|
||||||
|
"removed": 3,
|
||||||
|
"thumbs_generated": 42,
|
||||||
|
"duration_sec": 12.5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**동기 실행** — 수동 트리거이므로 BackgroundTask 불필요, 응답에 결과 포함.
|
||||||
|
|
||||||
|
### GET /api/travel/albums
|
||||||
|
|
||||||
|
앨범 목록과 각 앨범의 사진 수, 커버 정보를 반환한다.
|
||||||
|
|
||||||
|
**응답**:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"album": "오사카",
|
||||||
|
"count": 342,
|
||||||
|
"cover_url": "/media/travel/오사카/IMG_3281.jpg",
|
||||||
|
"cover_thumb": "/media/travel/.thumb/오사카/IMG_3281.jpg"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
커버가 지정되지 않은 앨범은 첫 번째 사진(album + filename 정렬 기준)이 자동 커버.
|
||||||
|
|
||||||
|
### PUT /api/travel/albums/{album}/cover
|
||||||
|
|
||||||
|
특정 사진을 앨범 커버로 지정한다.
|
||||||
|
|
||||||
|
**요청**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filename": "IMG_3281.jpg"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**응답**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"album": "오사카",
|
||||||
|
"filename": "IMG_3281.jpg",
|
||||||
|
"cover_url": "/media/travel/오사카/IMG_3281.jpg",
|
||||||
|
"cover_thumb": "/media/travel/.thumb/오사카/IMG_3281.jpg"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**검증**: 해당 album + filename 조합이 photos 테이블에 존재하는지 확인. 없으면 404.
|
||||||
|
|
||||||
|
## 동기화 로직 (indexer.py)
|
||||||
|
|
||||||
|
### sync 프로세스
|
||||||
|
|
||||||
|
1. `region_map.json`에서 전체 앨범 폴더 목록 수집
|
||||||
|
2. 각 폴더 `os.scandir` → `{album, filename, mtime}` 세트 수집
|
||||||
|
3. DB와 비교:
|
||||||
|
- DB에 없는 파일 → INSERT (`added`)
|
||||||
|
- DB에 있지만 폴더에 없는 파일 → DELETE (`removed`)
|
||||||
|
- mtime이 다른 파일 → UPDATE + `has_thumb=0` (변경됨)
|
||||||
|
4. `has_thumb=0`인 파일 → 썸네일 생성 → `has_thumb=1`로 갱신
|
||||||
|
5. 결과 반환: `{added, removed, thumbs_generated, duration_sec}`
|
||||||
|
|
||||||
|
### 삭제된 커버 처리
|
||||||
|
|
||||||
|
커버로 지정된 사진이 폴더에서 삭제되면 `album_covers`에서도 제거 → 자동으로 첫 번째 사진 폴백.
|
||||||
|
|
||||||
|
### 성능
|
||||||
|
|
||||||
|
- NAS Celeron J4025 기준, 2,000장 최초 동기화 + 썸네일 생성 예상: 3~5분
|
||||||
|
- 이후 동기화는 변경분만 처리 → 수초 이내
|
||||||
|
|
||||||
|
## 앨범 커버 지정 UX
|
||||||
|
|
||||||
|
프론트엔드 앨범 상세 페이지에서 사진을 길게 누르거나 우클릭 → "커버로 설정" 메뉴. `PUT /api/travel/albums/{album}/cover` 호출.
|
||||||
|
|
||||||
|
프론트엔드 변경은 이 스펙 범위 밖 — 백엔드 API만 제공하고, 프론트 연동은 별도 작업.
|
||||||
|
|
||||||
|
## 기존 API 호환성
|
||||||
|
|
||||||
|
- `GET /api/travel/photos` 응답 형식 (`items`, `total`, `has_next`, `matched_albums`) 완전히 유지
|
||||||
|
- 프론트엔드 `useTravelData` 훅은 수정 없이 동작
|
||||||
|
- `GET /api/travel/albums`는 선택적 개선용 — 프론트가 앨범 카드 커버를 표시할 때 활용
|
||||||
|
|
||||||
|
## Docker 변경
|
||||||
|
|
||||||
|
- `travel.db` 저장 위치: 썸네일 볼륨 내 `/data/thumbs/travel.db` (추가 볼륨 불필요)
|
||||||
|
- `requirements.txt`에 `aiosqlite` 추가 불필요 — 동기 sqlite3 표준 라이브러리 사용
|
||||||
|
- Dockerfile 변경 없음
|
||||||
|
|
||||||
|
### docker-compose.yml 변경
|
||||||
|
|
||||||
|
기존 볼륨에 DB를 함께 저장하므로 추가 볼륨 불필요:
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ${PHOTO_PATH}:/data/travel:ro
|
||||||
|
- ${RUNTIME_PATH}\travel-thumbs:/data/thumbs:rw # travel.db도 여기에 저장
|
||||||
|
```
|
||||||
|
|
||||||
|
## 제거되는 코드
|
||||||
|
|
||||||
|
- `main.py`의 `CACHE`, `CACHE_TTL`, `META_MTIME_CACHE` 딕셔너리 및 관련 로직
|
||||||
|
- `main.py`의 `scan_album()` 함수 (indexer.py로 이동)
|
||||||
|
- `main.py`의 `ensure_thumb()` 함수 (indexer.py로 이동, 온디맨드 폴백은 유지)
|
||||||
|
- `POST /api/travel/reload` 엔드포인트 (sync로 대체)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user