Compare commits
275 Commits
v0.1.0
...
56fbe3fc4b
| Author | SHA1 | Date | |
|---|---|---|---|
| 56fbe3fc4b | |||
| 88b5ea9ce2 | |||
| 54d67f892c | |||
| 8411e2c73e | |||
| 86a6b75124 | |||
| 08a32e4357 | |||
| f6de95afb6 | |||
| caacb072a2 | |||
| f80683ce82 | |||
| 71f52e4d59 | |||
| 756f280bbc | |||
| a508a5633a | |||
| 1d6c1b4329 | |||
| 7b3ddd1b19 | |||
| 32e021cfc7 | |||
| 3749d79168 | |||
| 0de2d3cf93 | |||
| 55c37df703 | |||
| c2939459e7 | |||
| 7aa7ccc6d5 | |||
| d46d2cb30b | |||
| 20b51f706c | |||
| eb04b954a5 | |||
| a75ff069df | |||
| d39d9f26ac | |||
| 9dd517e82a | |||
| 496e3a6a73 | |||
| d6547edf0d | |||
| 5749d4d35d | |||
| 2477342272 | |||
| 62a9009fea | |||
| 0fadc774d8 | |||
| eef2e3967e | |||
| 2a8635e9ed | |||
| 6c46759848 | |||
| e3d5eaf6f3 | |||
| 6004bcf66d | |||
| a5a9337838 | |||
| 4d6296bce3 | |||
| c6366ad238 | |||
| b671d275eb | |||
| bb97aa3ec8 | |||
| 335ea012cc | |||
| c168656fe1 | |||
| 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 |
97
.env.example
97
.env.example
@@ -1,17 +1,90 @@
|
|||||||
# 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
|
||||||
|
|
||||||
|
# [REALESTATE LAB — agent-office push notify]
|
||||||
|
AGENT_OFFICE_URL=http://agent-office:8000
|
||||||
|
REALESTATE_LAB_URL=http://realestate-lab:8000
|
||||||
|
REALESTATE_DASHBOARD_URL=http://localhost:8080/realestate
|
||||||
|
REALESTATE_NOTIFY_TIMEOUT=15
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -63,3 +63,6 @@ uploads/
|
|||||||
################################
|
################################
|
||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
|
|
||||||
|
# Git worktrees
|
||||||
|
.worktrees/
|
||||||
|
|||||||
601
CLAUDE.md
Normal file
601
CLAUDE.md
Normal file
@@ -0,0 +1,601 @@
|
|||||||
|
# CLAUDE.md — web-backend 프로젝트 가이드
|
||||||
|
|
||||||
|
> Claude Code가 이 프로젝트를 작업할 때 참조하는 설정 및 구조 문서.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 프로젝트 개요
|
||||||
|
|
||||||
|
Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||||
|
- **서비스**: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, deployer (9개)
|
||||||
|
- **프론트엔드**: 별도 레포 (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 실행 위치)
|
||||||
|
│ ├── lotto/ # lotto 소스 (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` | 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 + 텔레그램 연동) |
|
||||||
|
| `personal` | 18850 | 개인 서비스 (포트폴리오·블로그·투두 통합) |
|
||||||
|
| `travel-proxy` | 19000 | 여행 사진 API + 썸네일 생성 |
|
||||||
|
| `frontend` (nginx) | 8080 | 정적 SPA 서빙 + API 리버스 프록시 |
|
||||||
|
| `webpage-deployer` | 19010 | Gitea Webhook 수신 → 자동 배포 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Nginx 라우팅 규칙
|
||||||
|
|
||||||
|
| 경로 | 프록시 대상 | 비고 |
|
||||||
|
|------|------------|------|
|
||||||
|
| `/api/` | `lotto: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/todos` | `personal:8000` | 투두 API |
|
||||||
|
| `/api/blog/` | `personal:8000` | 블로그 API |
|
||||||
|
| `/api/profile/` | `personal: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 (lotto/)
|
||||||
|
- 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) — personal 서비스로 이전됨, 레거시 테이블 유지 |
|
||||||
|
| `blog_posts` | 블로그 글 (tags: JSON 배열) — personal 서비스로 이전됨, 레거시 테이블 유지 |
|
||||||
|
|
||||||
|
**스케줄러 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/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 연동: 한국부동산원 청약홈 분양정보 조회 + 자치구 5티어 매칭 + agent-office push 알림
|
||||||
|
- DB: `/app/data/realestate.db` (announcements, announcement_models, user_profile, match_results, collect_log 테이블)
|
||||||
|
- 파일 구조: `main.py`, `db.py`, `collector.py`, `matcher.py`, `notifier.py`, `models.py`
|
||||||
|
|
||||||
|
**환경변수**
|
||||||
|
- `DATA_GO_KR_API_KEY`: 공공데이터포털 API 키 (미설정 시 수동 등록만 가능)
|
||||||
|
- `AGENT_OFFICE_URL`: agent-office 내부 URL (기본 `http://agent-office:8000`) — 신규 매칭 push 대상
|
||||||
|
- `REALESTATE_NOTIFY_TIMEOUT`: agent-office push timeout 초 (기본 15)
|
||||||
|
|
||||||
|
**스케줄러 job (`scheduled_collect` 4단계 흐름)**
|
||||||
|
- 09:00 매일 — `collect → cleanup → match → notify`
|
||||||
|
1. `collect_all()` — 모집공고일 30일 윈도우(`RCRIT_PBLANC_DE_FROM`) 사전 좁힘 + 자치구 추출 + status='완료' skip
|
||||||
|
2. `delete_old_completed_announcements(grace_days=90)` — `winner_date + 90일` 경과한 완료 공고 정리 (FK CASCADE로 match_results도 삭제)
|
||||||
|
3. `run_matching()` — 자치구 5티어 가중치 + 자격 곡선 적용
|
||||||
|
4. `notify_new_matches()` — `notified_at IS NULL AND match_score >= profile.min_match_score AND profile.notify_enabled`인 매칭을 agent-office로 push
|
||||||
|
- 00:00 매일 — 상태 갱신 + 재매칭 (`scheduled_status_update`, notifier 미호출)
|
||||||
|
|
||||||
|
**매칭 점수 모델 (총 100점)**
|
||||||
|
- 지역 35점 — 광역 매칭 시 10점 + 자치구 5티어 가중치(S=25 / A=20 / B=15 / C=10 / D=5)
|
||||||
|
- `preferred_districts`가 모든 티어 비어있으면 광역 매칭만으로 35점 풀 점수 (legacy 호환)
|
||||||
|
- 주택유형 10점 — `preferred_types`에 매칭 (binary)
|
||||||
|
- 면적 15점 — `[min_area, max_area]` 범위 안 모델 1개 이상 (binary)
|
||||||
|
- 가격 15점 — `max_price` 이하 모델 1개 이상 (binary)
|
||||||
|
- 자격 25점 — `_check_eligible_types()` 결과 1개 이상이면 15점 + 추가당 5점, 최대 +10
|
||||||
|
- reasons 텍스트 예시: `"자치구 S티어: 강남구 (+25)"`, `"광역 일치: 서울"`, `"선호 지역 일치: 서울"` (legacy)
|
||||||
|
|
||||||
|
**user_profile 신규 컬럼 (Task 2026-04-28 마이그레이션)**
|
||||||
|
- `preferred_districts` TEXT — JSON `{"S":[...], "A":[...], "B":[...], "C":[...], "D":[...]}`. default `'{}'`
|
||||||
|
- `min_match_score` INTEGER — 알림 임계값. default 70
|
||||||
|
- `notify_enabled` INTEGER — 알림 ON/OFF. default 1
|
||||||
|
|
||||||
|
**announcements / match_results 신규 컬럼**
|
||||||
|
- `announcements.district` TEXT + `idx_ann_district` 인덱스 — collector가 주소/region_name에서 정규식 파싱
|
||||||
|
- `match_results.notified_at` TEXT NULL — agent-office push 성공 시 timestamp 기록 (멱등 마킹)
|
||||||
|
|
||||||
|
**notifier.py 흐름**
|
||||||
|
1. `get_profile()` → `notify_enabled=False`면 skip, `min_match_score` 가져옴
|
||||||
|
2. `get_unnotified_matches(min_score)` — JOIN으로 announcements 정보 포함 (district, status, receipt 등)
|
||||||
|
3. `POST {AGENT_OFFICE_URL}/api/agent-office/realestate/notify` body=`{"matches": [...]}`
|
||||||
|
4. 응답 `{sent_ids: [...]}` → `mark_matches_notified(sent_ids)` (notified_at = now)
|
||||||
|
5. RequestException 시 마킹 안 함 → 다음 사이클 재시도
|
||||||
|
|
||||||
|
**realestate-lab API 목록**
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/realestate/announcements` | 공고 목록. 응답에 `district`, `match_score`, `match_reasons`, `eligible_types` 포함 |
|
||||||
|
| GET | `/api/realestate/announcements/{id}` | 공고 상세 (주택형별 + district 포함) |
|
||||||
|
| POST | `/api/realestate/announcements` | 수동 공고 등록 |
|
||||||
|
| PUT | `/api/realestate/announcements/{id}` | 공고 수정 |
|
||||||
|
| PATCH | `/api/realestate/announcements/{id}/bookmark` | 북마크 토글 (텔레그램 인라인 키보드 콜백 대상) |
|
||||||
|
| DELETE | `/api/realestate/announcements/{id}` | 공고 삭제 |
|
||||||
|
| DELETE | `/api/realestate/announcements/closed` | status='완료' 공고 일괄 삭제 |
|
||||||
|
| POST | `/api/realestate/collect` | 수동 수집 트리거 (collect → cleanup → match → notify 전체 흐름) |
|
||||||
|
| GET | `/api/realestate/collect/status` | 마지막 수집 결과 |
|
||||||
|
| GET | `/api/realestate/profile` | 내 프로필 조회 (`preferred_districts`, `min_match_score`, `notify_enabled` 포함) |
|
||||||
|
| PUT | `/api/realestate/profile` | 프로필 수정 (upsert). body에 `preferred_districts: {S:[],...}`, `min_match_score: 0~100`, `notify_enabled: bool` 수용 |
|
||||||
|
| GET | `/api/realestate/matches` | 매칭 결과 목록 (응답에 `district`, `status` 포함) |
|
||||||
|
| 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/realestate-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음)
|
||||||
|
- 실시간 상태 동기화: WebSocket (`/api/agent-office/ws`)
|
||||||
|
- 텔레그램 봇: 양방향 알림 + 승인 (인라인 키보드)
|
||||||
|
- 청약 매칭 알림: realestate-lab이 신규 매칭 발견 시 push → `RealestateAgent.on_new_matches()` → 텔레그램 1통(인라인 [🔖 북마크]/[📄 공고] 또는 [전체 보기] 버튼)
|
||||||
|
- 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`, `agents/realestate.py`, `telegram/realestate_message.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`)
|
||||||
|
- `REALESTATE_LAB_URL`: realestate-lab 내부 URL (기본 `http://realestate-lab:8000`) — 북마크 콜백 프록시 대상
|
||||||
|
- `REALESTATE_DASHBOARD_URL`: 텔레그램 [전체 보기] 버튼 URL (기본 `http://localhost:8080/realestate`)
|
||||||
|
- `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: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`)
|
||||||
|
- ~~09:15 매일 — 청약 매칭 데일리 리포트~~ (Task 2026-04-28에서 폐기. realestate-lab의 push 트리거로 전환)
|
||||||
|
|
||||||
|
**RealestateAgent (`agents/realestate.py`)**
|
||||||
|
- 진입점: `on_new_matches(matches: list[dict]) -> {sent, sent_ids, message_id}`
|
||||||
|
- realestate-lab의 push에서 트리거 → `format_realestate_matches()` + `build_match_keyboard()` → `messaging.send_raw()`
|
||||||
|
- 1~2건이면 풀 카드 + [🔖 북마크]/[📄 공고 보기] 행씩, 3건 이상이면 묶음 카드 + [📋 전체 보기] 단일 URL 버튼
|
||||||
|
- 인라인 키보드 콜백 `realestate_bookmark_{id}` → `webhook.py`의 `_handle_realestate_bookmark` → `service_proxy.realestate_bookmark_toggle()` → realestate-lab의 `PATCH /announcements/{id}/bookmark`
|
||||||
|
- 송신 성공 시 sent_ids 반환 → realestate-lab이 match_results.notified_at 마킹 (멱등)
|
||||||
|
- 실패 시 sent=0/sent_ids=[]/error 반환 → 마킹 안 됨 → 다음 사이클 재시도
|
||||||
|
- `on_command("fetch_matches")`: 수동 트리거 — service_proxy로 매치 가져와 `on_new_matches` 호출
|
||||||
|
- `on_schedule`: 폐기 (cron 등록 제거됨)
|
||||||
|
|
||||||
|
**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 수신 (realestate_bookmark_* 콜백 포함) |
|
||||||
|
| POST | `/api/agent-office/realestate/notify` | realestate-lab 전용 push 수신 → 텔레그램 송신 |
|
||||||
|
| GET | `/api/agent-office/states` | 전체 에이전트 상태 조회 |
|
||||||
|
| GET | `/api/agent-office/conversation/stats` | 텔레그램 자연어 대화 토큰·캐시 통계 (`days` 필터) |
|
||||||
|
|
||||||
|
### personal (personal/)
|
||||||
|
- 개인 서비스 (포트폴리오 + 블로그 + 투두 통합)
|
||||||
|
- DB: `/app/data/personal.db` (profile, careers, projects, skills, introductions, todos, blog_posts 테이블)
|
||||||
|
- 편집 인증: `PORTFOLIO_EDIT_PASSWORD` 환경변수, Bearer 토큰 (24시간 TTL)
|
||||||
|
- 파일 구조: `main.py`, `db.py`, `models.py`, `auth.py`
|
||||||
|
|
||||||
|
**환경변수**
|
||||||
|
- `PORTFOLIO_EDIT_PASSWORD`: 편집 모드 비밀번호 (미설정 시 편집 불가)
|
||||||
|
|
||||||
|
**personal API 목록**
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/profile/public` | 공개 데이터 일괄 조회 |
|
||||||
|
| POST | `/api/profile/auth` | 비밀번호 인증 → 토큰 |
|
||||||
|
| GET | `/api/profile/profile` | 프로필 조회 (인증) |
|
||||||
|
| PUT | `/api/profile/profile` | 프로필 수정 (인증) |
|
||||||
|
| GET | `/api/profile/careers` | 경력 목록 (인증) |
|
||||||
|
| POST | `/api/profile/careers` | 경력 추가 (인증) |
|
||||||
|
| PUT | `/api/profile/careers/{id}` | 경력 수정 (인증) |
|
||||||
|
| DELETE | `/api/profile/careers/{id}` | 경력 삭제 (인증) |
|
||||||
|
| GET | `/api/profile/projects` | 프로젝트 목록 (인증) |
|
||||||
|
| POST | `/api/profile/projects` | 프로젝트 추가 (인증) |
|
||||||
|
| PUT | `/api/profile/projects/{id}` | 프로젝트 수정 (인증) |
|
||||||
|
| DELETE | `/api/profile/projects/{id}` | 프로젝트 삭제 (인증) |
|
||||||
|
| GET | `/api/profile/skills` | 기술 목록 (인증) |
|
||||||
|
| POST | `/api/profile/skills` | 기술 추가 (인증) |
|
||||||
|
| PUT | `/api/profile/skills/{id}` | 기술 수정 (인증) |
|
||||||
|
| DELETE | `/api/profile/skills/{id}` | 기술 삭제 (인증) |
|
||||||
|
| GET | `/api/profile/introductions` | 자기소개 목록 (인증) |
|
||||||
|
| POST | `/api/profile/introductions` | 자기소개 추가 (인증) |
|
||||||
|
| PUT | `/api/profile/introductions/{id}` | 자기소개 수정 (인증) |
|
||||||
|
| DELETE | `/api/profile/introductions/{id}` | 자기소개 삭제 (인증) |
|
||||||
|
| PATCH | `/api/profile/introductions/{id}/main` | 메인 자기소개 지정 (인증) |
|
||||||
|
| GET | `/api/todos` | 투두 전체 목록 |
|
||||||
|
| POST | `/api/todos` | 투두 생성 |
|
||||||
|
| PUT | `/api/todos/{id}` | 투두 수정 |
|
||||||
|
| DELETE | `/api/todos/done` | 완료 항목 일괄 삭제 |
|
||||||
|
| DELETE | `/api/todos/{id}` | 투두 개별 삭제 |
|
||||||
|
| GET | `/api/blog/posts` | 블로그 글 목록 |
|
||||||
|
| POST | `/api/blog/posts` | 블로그 글 생성 |
|
||||||
|
| PUT | `/api/blog/posts/{id}` | 블로그 글 수정 |
|
||||||
|
| DELETE | `/api/blog/posts/{id}` | 블로그 글 삭제 |
|
||||||
|
|
||||||
|
### 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}` 보다 **반드시 먼저** 등록 (personal 서비스, 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}",
|
||||||
|
)
|
||||||
77
agent-office/app/agents/realestate.py
Normal file
77
agent-office/app/agents/realestate.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
from .base import BaseAgent
|
||||||
|
from ..db import create_task, update_task_status, add_log
|
||||||
|
from .. import service_proxy
|
||||||
|
from ..telegram import messaging
|
||||||
|
from ..telegram.realestate_message import format_realestate_matches, build_match_keyboard
|
||||||
|
|
||||||
|
|
||||||
|
class RealestateAgent(BaseAgent):
|
||||||
|
"""부동산 청약 에이전트.
|
||||||
|
|
||||||
|
realestate-lab이 신규 매칭 발견 시 /realestate/notify로 push해 트리거됨.
|
||||||
|
on_new_matches가 메인 진입점. on_schedule은 사용하지 않음(cron 폐기).
|
||||||
|
"""
|
||||||
|
|
||||||
|
agent_id = "realestate"
|
||||||
|
display_name = "청약 애널리스트"
|
||||||
|
|
||||||
|
async def on_new_matches(self, matches: list[dict]) -> dict:
|
||||||
|
"""신규 매칭 N건을 텔레그램 1통으로 푸시.
|
||||||
|
성공 시 sent_ids 반환 → realestate-lab이 notified_at 마킹.
|
||||||
|
실패 시 sent=0, sent_ids=[] 반환 → 다음 사이클 재시도.
|
||||||
|
"""
|
||||||
|
if not matches:
|
||||||
|
return {"sent": 0, "sent_ids": []}
|
||||||
|
|
||||||
|
task_id = create_task(self.agent_id, "notify_matches", {"count": len(matches)})
|
||||||
|
|
||||||
|
try:
|
||||||
|
text = format_realestate_matches(matches)
|
||||||
|
keyboard = build_match_keyboard(matches)
|
||||||
|
await self.transition("reporting", f"매칭 {len(matches)}건 알림", task_id)
|
||||||
|
|
||||||
|
tg = await messaging.send_raw(text, reply_markup=keyboard)
|
||||||
|
if not tg.get("ok"):
|
||||||
|
update_task_status(task_id, "failed", {"error": tg.get("description")})
|
||||||
|
await self.transition("idle", "알림 실패")
|
||||||
|
return {"sent": 0, "sent_ids": [], "error": tg.get("description")}
|
||||||
|
|
||||||
|
sent_ids = [m["id"] for m in matches if "id" in m]
|
||||||
|
update_task_status(task_id, "succeeded", {
|
||||||
|
"sent": len(matches),
|
||||||
|
"telegram_message_id": tg.get("message_id"),
|
||||||
|
})
|
||||||
|
await self.transition("idle", f"매칭 {len(matches)}건 알림 완료")
|
||||||
|
return {
|
||||||
|
"sent": len(matches),
|
||||||
|
"sent_ids": sent_ids,
|
||||||
|
"message_id": tg.get("message_id"),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
add_log(self.agent_id, f"on_new_matches failed: {e}", "error", task_id)
|
||||||
|
update_task_status(task_id, "failed", {"error": str(e)})
|
||||||
|
await self.transition("idle", f"오류: {e}")
|
||||||
|
return {"sent": 0, "sent_ids": [], "error": str(e)}
|
||||||
|
|
||||||
|
async def on_command(self, command: str, params: dict) -> dict:
|
||||||
|
if command == "fetch_matches":
|
||||||
|
try:
|
||||||
|
matches = await service_proxy.realestate_matches(limit=20)
|
||||||
|
if not matches:
|
||||||
|
return {"ok": True, "message": "매칭 없음"}
|
||||||
|
result = await self.on_new_matches(matches)
|
||||||
|
return {"ok": True, "result": result}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "message": str(e)}
|
||||||
|
|
||||||
|
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: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}
|
||||||
201
agent-office/app/main.py
Normal file
201
agent-office/app/main.py
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Realestate Agent Push Endpoint ---
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
|
|
||||||
|
class RealestateNotifyBody(BaseModel):
|
||||||
|
matches: List[Dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/agent-office/realestate/notify")
|
||||||
|
async def realestate_notify(body: RealestateNotifyBody):
|
||||||
|
agent = get_agent("realestate")
|
||||||
|
if agent is None:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(status_code=503, detail="RealestateAgent not initialized")
|
||||||
|
return await agent.on_new_matches(body.matches)
|
||||||
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
|
||||||
32
agent-office/app/scheduler.py
Normal file
32
agent-office/app/scheduler.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
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_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_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()
|
||||||
168
agent-office/app/service_proxy.py
Normal file
168
agent-office/app/service_proxy.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
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]]:
|
||||||
|
"""realestate-lab의 GET /api/realestate/matches 호출."""
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{REALESTATE_LAB_URL}/api/realestate/matches",
|
||||||
|
params={"size": limit},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
return data.get("items", [])
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
async def realestate_bookmark_toggle(announcement_id: int) -> Dict[str, Any]:
|
||||||
|
"""realestate-lab의 PATCH /api/realestate/announcements/{id}/bookmark 호출."""
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.patch(
|
||||||
|
f"{REALESTATE_LAB_URL}/api/realestate/announcements/{announcement_id}/bookmark"
|
||||||
|
)
|
||||||
|
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",
|
||||||
|
]
|
||||||
39
agent-office/app/telegram/agent_registry.py
Normal file
39
agent-office/app/telegram/agent_registry.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""에이전트 메타 등록소."""
|
||||||
|
|
||||||
|
AGENT_META = {
|
||||||
|
"stock": {
|
||||||
|
"display_name": "주식 트레이더",
|
||||||
|
"emoji": "📈",
|
||||||
|
"color": "#4488cc",
|
||||||
|
},
|
||||||
|
"music": {
|
||||||
|
"display_name": "음악 프로듀서",
|
||||||
|
"emoji": "🎵",
|
||||||
|
"color": "#44aa88",
|
||||||
|
},
|
||||||
|
"lotto": {
|
||||||
|
"emoji": "🎱",
|
||||||
|
"display_name": "로또 큐레이터",
|
||||||
|
},
|
||||||
|
"realestate": {
|
||||||
|
"display_name": "청약 애널리스트",
|
||||||
|
"emoji": "🏢",
|
||||||
|
"color": "#f43f5e",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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"},
|
||||||
|
],
|
||||||
|
)
|
||||||
93
agent-office/app/telegram/realestate_message.py
Normal file
93
agent-office/app/telegram/realestate_message.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""청약 매칭 알림 — 텔레그램 메시지 포맷터 + 인라인 키보드 빌더."""
|
||||||
|
import os
|
||||||
|
from html import escape as _h
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
DASHBOARD_URL = os.getenv("REALESTATE_DASHBOARD_URL", "https://example.com/realestate")
|
||||||
|
|
||||||
|
|
||||||
|
def _format_one_compact(m: dict) -> str:
|
||||||
|
score = m.get("match_score", 0)
|
||||||
|
name = _h(m.get("house_nm") or "(제목 없음)")
|
||||||
|
district = m.get("district") or ""
|
||||||
|
region = m.get("region_name") or ""
|
||||||
|
where = f"{region.split()[0] if region else ''} {district}".strip() or "위치 미상"
|
||||||
|
rstart = m.get("receipt_start") or ""
|
||||||
|
rend = m.get("receipt_end") or ""
|
||||||
|
return (
|
||||||
|
f"⭐ {score}점 — <b>{name}</b>\n"
|
||||||
|
f"📍 {_h(where)} 📅 {_h(rstart)} ~ {_h(rend)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_one_full(m: dict) -> str:
|
||||||
|
score = m.get("match_score", 0)
|
||||||
|
name = _h(m.get("house_nm") or "(제목 없음)")
|
||||||
|
district = m.get("district") or ""
|
||||||
|
region = m.get("region_name") or ""
|
||||||
|
flags = []
|
||||||
|
if m.get("is_speculative_area") == "Y":
|
||||||
|
flags.append("투기과열")
|
||||||
|
if m.get("is_price_cap") == "Y":
|
||||||
|
flags.append("분양가상한제")
|
||||||
|
flag_str = f" ({', '.join(flags)})" if flags else ""
|
||||||
|
|
||||||
|
rstart = m.get("receipt_start") or ""
|
||||||
|
rend = m.get("receipt_end") or ""
|
||||||
|
elig = m.get("eligible_types") or []
|
||||||
|
reasons = m.get("match_reasons") or []
|
||||||
|
|
||||||
|
where = f"{region.split()[0] if region else ''} {district}".strip() or "위치 미상"
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"⭐ {score}점 — <b>{name}</b>",
|
||||||
|
f"📍 {_h(where)}{_h(flag_str)}",
|
||||||
|
f"📅 청약 {_h(rstart)} ~ {_h(rend)}",
|
||||||
|
]
|
||||||
|
if elig:
|
||||||
|
lines.append(f"✓ 자격: {_h(', '.join(elig))}")
|
||||||
|
if reasons:
|
||||||
|
lines.append(f"💡 {_h(' / '.join(reasons[:4]))}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def format_realestate_matches(matches: list[dict]) -> str:
|
||||||
|
"""매칭 목록을 텔레그램 HTML 메시지로 변환.
|
||||||
|
1~2건은 풀 카드, 3건 이상은 묶음 카드(상위 5건).
|
||||||
|
"""
|
||||||
|
if not matches:
|
||||||
|
return "🏢 새 청약 매칭이 없습니다."
|
||||||
|
|
||||||
|
if len(matches) <= 2:
|
||||||
|
body = "\n\n".join(_format_one_full(m) for m in matches)
|
||||||
|
return f"🏢 <b>새 청약 매칭 {len(matches)}건</b>\n━━━━━━━━━━\n\n{body}"
|
||||||
|
|
||||||
|
top = matches[:5]
|
||||||
|
body = "\n\n".join(_format_one_compact(m) for m in top)
|
||||||
|
suffix = f"\n\n…외 {len(matches) - 5}건" if len(matches) > 5 else ""
|
||||||
|
return f"🏢 <b>새 청약 매칭 {len(matches)}건</b>\n━━━━━━━━━━\n\n{body}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_match_keyboard(matches: list[dict]) -> Optional[dict]:
|
||||||
|
"""1~2건: 매치별 [북마크][공고 보기] 행. 3건 이상: [전체 보기] 단일 행."""
|
||||||
|
if not matches:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(matches) <= 2:
|
||||||
|
rows = []
|
||||||
|
for m in matches:
|
||||||
|
buttons = [{
|
||||||
|
"text": "🔖 북마크",
|
||||||
|
"callback_data": f"realestate_bookmark_{m['id']}",
|
||||||
|
}]
|
||||||
|
url = m.get("pblanc_url")
|
||||||
|
if url:
|
||||||
|
buttons.append({"text": "📄 공고 보기", "url": url})
|
||||||
|
rows.append(buttons)
|
||||||
|
return {"inline_keyboard": rows}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"inline_keyboard": [[
|
||||||
|
{"text": "📋 전체 보기", "url": DASHBOARD_URL},
|
||||||
|
]],
|
||||||
|
}
|
||||||
95
agent-office/app/telegram/router.py
Normal file
95
agent-office/app/telegram/router.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""텔레그램 메시지 명령 → 에이전트 라우팅.
|
||||||
|
새 명령을 추가하려면 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는 인자 필요 — 아래 특수 케이스에서 처리
|
||||||
|
},
|
||||||
|
"realestate": {
|
||||||
|
"matches": ("fetch_matches", {}),
|
||||||
|
"dashboard": ("dashboard", {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 <프롬프트> — 작곡 시작
|
||||||
|
|
||||||
|
<b>🏢 청약 애널리스트</b>
|
||||||
|
/realestate matches — 신규 매칭 조회 후 알림 전송
|
||||||
|
/realestate dashboard — 청약 현황 요약
|
||||||
|
"""
|
||||||
199
agent-office/app/telegram/webhook.py
Normal file
199
agent-office/app/telegram/webhook.py
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
"""텔레그램 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]:
|
||||||
|
"""승인/거절 및 realestate 북마크 콜백 처리."""
|
||||||
|
callback_id = callback_query.get("data", "")
|
||||||
|
|
||||||
|
# realestate 북마크 토글 콜백 — DB 조회 없이 직접 처리
|
||||||
|
if callback_id.startswith("realestate_bookmark_"):
|
||||||
|
return await _handle_realestate_bookmark(callback_query, callback_id)
|
||||||
|
|
||||||
|
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_realestate_bookmark(callback_query: dict, callback_id: str) -> dict:
|
||||||
|
"""realestate_bookmark_{announcement_id} 콜백 처리."""
|
||||||
|
from .. import service_proxy
|
||||||
|
from .messaging import send_raw
|
||||||
|
|
||||||
|
# answerCallbackQuery 먼저 — 텔레그램 로딩 스피너 해제
|
||||||
|
await api_call(
|
||||||
|
"answerCallbackQuery",
|
||||||
|
{"callback_query_id": callback_query["id"], "text": "처리 중..."},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ann_id = int(callback_id.removeprefix("realestate_bookmark_"))
|
||||||
|
except ValueError:
|
||||||
|
await send_raw("⚠️ 잘못된 북마크 콜백 데이터")
|
||||||
|
return {"ok": False, "error": "invalid_callback_data"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await service_proxy.realestate_bookmark_toggle(ann_id)
|
||||||
|
is_on = result.get("is_bookmarked")
|
||||||
|
if is_on == 1:
|
||||||
|
await send_raw(f"🔖 북마크 추가 완료 (#{ann_id})")
|
||||||
|
elif is_on == 0:
|
||||||
|
await send_raw(f"🔖 북마크 해제 완료 (#{ann_id})")
|
||||||
|
else:
|
||||||
|
await send_raw(f"🔖 북마크 토글 완료 (#{ann_id})")
|
||||||
|
return {"ok": True, "announcement_id": ann_id}
|
||||||
|
except Exception as e:
|
||||||
|
await send_raw(f"⚠️ 북마크 처리 실패: {e}")
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
99
agent-office/tests/test_realestate_agent.py
Normal file
99
agent-office/tests/test_realestate_agent.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
_fd, _TMP = tempfile.mkstemp(suffix=".db")
|
||||||
|
os.close(_fd)
|
||||||
|
os.unlink(_TMP)
|
||||||
|
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _init_db():
|
||||||
|
import gc
|
||||||
|
gc.collect()
|
||||||
|
if os.path.exists(_TMP):
|
||||||
|
os.remove(_TMP)
|
||||||
|
from app.db import init_db
|
||||||
|
init_db()
|
||||||
|
yield
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
|
||||||
|
def test_on_new_matches_returns_empty_when_no_matches():
|
||||||
|
from app.agents.realestate import RealestateAgent
|
||||||
|
|
||||||
|
agent = RealestateAgent()
|
||||||
|
result = asyncio.run(agent.on_new_matches([]))
|
||||||
|
assert result == {"sent": 0, "sent_ids": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_on_new_matches_sends_telegram_and_returns_ids():
|
||||||
|
from app.agents.realestate import RealestateAgent
|
||||||
|
from app.telegram import messaging
|
||||||
|
|
||||||
|
matches = [{
|
||||||
|
"id": 7, "match_score": 80, "house_nm": "단지A",
|
||||||
|
"region_name": "서울특별시", "district": "강남구",
|
||||||
|
"receipt_start": "2026-05-01", "receipt_end": "2026-05-05",
|
||||||
|
"match_reasons": [], "eligible_types": [], "pblanc_url": "https://x.test/7",
|
||||||
|
}]
|
||||||
|
|
||||||
|
fake_send = AsyncMock(return_value={"ok": True, "message_id": 123})
|
||||||
|
with patch.object(messaging, "send_raw", fake_send):
|
||||||
|
agent = RealestateAgent()
|
||||||
|
result = asyncio.run(agent.on_new_matches(matches))
|
||||||
|
|
||||||
|
assert result["sent"] == 1
|
||||||
|
assert result["sent_ids"] == [7]
|
||||||
|
assert result["message_id"] == 123
|
||||||
|
fake_send.assert_awaited_once()
|
||||||
|
args, kwargs = fake_send.call_args
|
||||||
|
text = args[0]
|
||||||
|
assert "단지A" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_on_new_matches_telegram_failure_returns_zero():
|
||||||
|
from app.agents.realestate import RealestateAgent
|
||||||
|
from app.telegram import messaging
|
||||||
|
|
||||||
|
matches = [{
|
||||||
|
"id": 8, "match_score": 80, "house_nm": "단지B",
|
||||||
|
"region_name": "서울", "district": "송파구",
|
||||||
|
"receipt_start": "", "receipt_end": "",
|
||||||
|
"match_reasons": [], "eligible_types": [], "pblanc_url": "",
|
||||||
|
}]
|
||||||
|
|
||||||
|
fake_send = AsyncMock(return_value={"ok": False, "description": "401"})
|
||||||
|
with patch.object(messaging, "send_raw", fake_send):
|
||||||
|
agent = RealestateAgent()
|
||||||
|
result = asyncio.run(agent.on_new_matches(matches))
|
||||||
|
|
||||||
|
assert result["sent"] == 0
|
||||||
|
assert result["sent_ids"] == []
|
||||||
|
assert "error" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_endpoint_calls_agent_on_new_matches():
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app.main import app
|
||||||
|
from app.agents.realestate import RealestateAgent
|
||||||
|
|
||||||
|
fake = AsyncMock(return_value={"sent": 1, "sent_ids": [99], "message_id": 1})
|
||||||
|
with patch.object(RealestateAgent, "on_new_matches", fake):
|
||||||
|
with TestClient(app) as client:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/agent-office/realestate/notify",
|
||||||
|
json={"matches": [{"id": 99, "match_score": 80}]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["sent"] == 1
|
||||||
|
assert body["sent_ids"] == [99]
|
||||||
133
agent-office/tests/test_realestate_callback.py
Normal file
133
agent-office/tests/test_realestate_callback.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import gc
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
_fd, _TMP = tempfile.mkstemp(suffix=".db")
|
||||||
|
os.close(_fd)
|
||||||
|
os.unlink(_TMP)
|
||||||
|
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _init_db():
|
||||||
|
gc.collect()
|
||||||
|
if os.path.exists(_TMP):
|
||||||
|
try:
|
||||||
|
os.remove(_TMP)
|
||||||
|
except PermissionError:
|
||||||
|
pass
|
||||||
|
from app.db import init_db
|
||||||
|
init_db()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
def test_callback_realestate_bookmark_calls_proxy():
|
||||||
|
"""callback_data 'realestate_bookmark_42' 가 service_proxy.realestate_bookmark_toggle(42) 를 호출하고
|
||||||
|
is_bookmarked=1 이면 '추가 완료' 메시지를 전송한다."""
|
||||||
|
from app import service_proxy
|
||||||
|
from app.telegram import webhook
|
||||||
|
|
||||||
|
fake_toggle = AsyncMock(return_value={"is_bookmarked": 1})
|
||||||
|
fake_send = AsyncMock(return_value={"ok": True})
|
||||||
|
fake_api_call = AsyncMock(return_value={"ok": True})
|
||||||
|
|
||||||
|
update = {
|
||||||
|
"callback_query": {
|
||||||
|
"id": "cb1",
|
||||||
|
"from": {"id": 1},
|
||||||
|
"data": "realestate_bookmark_42",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(service_proxy, "realestate_bookmark_toggle", fake_toggle), \
|
||||||
|
patch("app.telegram.messaging.send_raw", fake_send), \
|
||||||
|
patch("app.telegram.webhook.api_call", fake_api_call):
|
||||||
|
result = asyncio.run(webhook.handle_webhook(update))
|
||||||
|
|
||||||
|
fake_toggle.assert_awaited_once_with(42)
|
||||||
|
assert result == {"ok": True, "announcement_id": 42}
|
||||||
|
args, _ = fake_send.call_args
|
||||||
|
assert "추가" in args[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_callback_realestate_bookmark_invalid_id():
|
||||||
|
"""callback_data 'realestate_bookmark_abc' 는 ValueError를 처리하고 에러 응답 반환."""
|
||||||
|
from app import service_proxy
|
||||||
|
from app.telegram import webhook
|
||||||
|
|
||||||
|
fake_toggle = AsyncMock(return_value={"bookmarked": True})
|
||||||
|
fake_send = AsyncMock(return_value={"ok": True})
|
||||||
|
fake_api_call = AsyncMock(return_value={"ok": True})
|
||||||
|
|
||||||
|
update = {
|
||||||
|
"callback_query": {
|
||||||
|
"id": "cb2",
|
||||||
|
"from": {"id": 1},
|
||||||
|
"data": "realestate_bookmark_abc",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(service_proxy, "realestate_bookmark_toggle", fake_toggle), \
|
||||||
|
patch("app.telegram.messaging.send_raw", fake_send), \
|
||||||
|
patch("app.telegram.webhook.api_call", fake_api_call):
|
||||||
|
result = asyncio.run(webhook.handle_webhook(update))
|
||||||
|
|
||||||
|
fake_toggle.assert_not_awaited()
|
||||||
|
assert result is not None
|
||||||
|
assert result.get("ok") is False
|
||||||
|
assert result.get("error") == "invalid_callback_data"
|
||||||
|
|
||||||
|
|
||||||
|
def test_callback_realestate_bookmark_proxy_error():
|
||||||
|
"""service_proxy 가 예외를 던질 때 에러 응답 반환."""
|
||||||
|
from app import service_proxy
|
||||||
|
from app.telegram import webhook
|
||||||
|
|
||||||
|
fake_toggle = AsyncMock(side_effect=Exception("connection refused"))
|
||||||
|
fake_send = AsyncMock(return_value={"ok": True})
|
||||||
|
fake_api_call = AsyncMock(return_value={"ok": True})
|
||||||
|
|
||||||
|
update = {
|
||||||
|
"callback_query": {
|
||||||
|
"id": "cb3",
|
||||||
|
"from": {"id": 1},
|
||||||
|
"data": "realestate_bookmark_99",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(service_proxy, "realestate_bookmark_toggle", fake_toggle), \
|
||||||
|
patch("app.telegram.messaging.send_raw", fake_send), \
|
||||||
|
patch("app.telegram.webhook.api_call", fake_api_call):
|
||||||
|
result = asyncio.run(webhook.handle_webhook(update))
|
||||||
|
|
||||||
|
fake_toggle.assert_awaited_once_with(99)
|
||||||
|
assert result is not None
|
||||||
|
assert result.get("ok") is False
|
||||||
|
assert "connection refused" in result.get("error", "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_realestate_callback_uses_db_path():
|
||||||
|
"""approve_*/reject_* 콜백은 기존 DB 조회 경로를 사용 (realestate 분기를 타지 않음)."""
|
||||||
|
from app.telegram import webhook
|
||||||
|
|
||||||
|
fake_api_call = AsyncMock(return_value={"ok": True})
|
||||||
|
|
||||||
|
update = {
|
||||||
|
"callback_query": {
|
||||||
|
"id": "cb4",
|
||||||
|
"from": {"id": 1},
|
||||||
|
"data": "approve_abcd1234",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# DB에 등록되지 않은 콜백이므로 None 반환 — 기존 로직 진입 확인
|
||||||
|
with patch("app.telegram.webhook.api_call", fake_api_call):
|
||||||
|
result = asyncio.run(webhook.handle_webhook(update))
|
||||||
|
|
||||||
|
assert result is None # DB에 없으면 None 반환 (기존 동작 유지)
|
||||||
59
agent-office/tests/test_realestate_message.py
Normal file
59
agent-office/tests/test_realestate_message.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
def test_format_realestate_match_full_card_single():
|
||||||
|
from app.telegram.realestate_message import format_realestate_matches
|
||||||
|
matches = [{
|
||||||
|
"id": 1,
|
||||||
|
"match_score": 90,
|
||||||
|
"house_nm": "디에이치 강남",
|
||||||
|
"region_name": "서울특별시",
|
||||||
|
"district": "강남구",
|
||||||
|
"is_speculative_area": "Y",
|
||||||
|
"is_price_cap": "Y",
|
||||||
|
"receipt_start": "2026-05-15",
|
||||||
|
"receipt_end": "2026-05-19",
|
||||||
|
"match_reasons": ["광역 일치", "자치구 S티어: 강남구 (+25)", "예산 범위"],
|
||||||
|
"eligible_types": ["일반1순위", "특별-신혼부부"],
|
||||||
|
"pblanc_url": "https://example.com/p/1",
|
||||||
|
}]
|
||||||
|
text = format_realestate_matches(matches)
|
||||||
|
assert "디에이치 강남" in text
|
||||||
|
assert "90점" in text
|
||||||
|
assert "강남구" in text
|
||||||
|
assert "2026-05-15" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_realestate_match_compact_when_three_or_more():
|
||||||
|
from app.telegram.realestate_message import format_realestate_matches
|
||||||
|
matches = [
|
||||||
|
{"id": i, "match_score": 90 - i, "house_nm": f"단지{i}", "district": "강남구",
|
||||||
|
"region_name": "서울특별시", "receipt_start": "2026-05-15", "receipt_end": "2026-05-19",
|
||||||
|
"match_reasons": [], "eligible_types": [], "pblanc_url": ""}
|
||||||
|
for i in range(3)
|
||||||
|
]
|
||||||
|
text = format_realestate_matches(matches)
|
||||||
|
assert "3건" in text or "3" in text
|
||||||
|
for i in range(3):
|
||||||
|
assert f"단지{i}" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_keyboard_single_match_has_bookmark_and_url():
|
||||||
|
from app.telegram.realestate_message import build_match_keyboard
|
||||||
|
matches = [{"id": 42, "pblanc_url": "https://example.com/p/42"}]
|
||||||
|
kb = build_match_keyboard(matches)
|
||||||
|
rows = kb["inline_keyboard"]
|
||||||
|
flat = [b for row in rows for b in row]
|
||||||
|
assert any(b.get("callback_data", "").startswith("realestate_bookmark_42") for b in flat)
|
||||||
|
assert any(b.get("url") == "https://example.com/p/42" for b in flat)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_keyboard_multi_matches_uses_dashboard_link():
|
||||||
|
from app.telegram.realestate_message import build_match_keyboard
|
||||||
|
matches = [{"id": i, "pblanc_url": ""} for i in range(3)]
|
||||||
|
kb = build_match_keyboard(matches)
|
||||||
|
flat = [b for row in kb["inline_keyboard"] for b in row]
|
||||||
|
# 3건 이상이면 [전체 보기] 단일 URL 버튼
|
||||||
|
assert any("전체" in b.get("text", "") for b in flat)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_keyboard_empty_returns_none():
|
||||||
|
from app.telegram.realestate_message import build_match_keyboard
|
||||||
|
assert build_match_keyboard([]) is None
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import requests
|
|
||||||
from typing import Dict, Any
|
|
||||||
|
|
||||||
from .db import get_draw, upsert_draw
|
|
||||||
|
|
||||||
def _normalize_item(item: dict) -> dict:
|
|
||||||
# smok95 all.json / latest.json 구조
|
|
||||||
# - draw_no: int
|
|
||||||
# - numbers: [n1..n6]
|
|
||||||
# - bonus_no: int
|
|
||||||
# - date: "YYYY-MM-DD ..."
|
|
||||||
numbers = item["numbers"]
|
|
||||||
return {
|
|
||||||
"drw_no": int(item["draw_no"]),
|
|
||||||
"drw_date": (item.get("date") or "")[:10],
|
|
||||||
"n1": int(numbers[0]),
|
|
||||||
"n2": int(numbers[1]),
|
|
||||||
"n3": int(numbers[2]),
|
|
||||||
"n4": int(numbers[3]),
|
|
||||||
"n5": int(numbers[4]),
|
|
||||||
"n6": int(numbers[5]),
|
|
||||||
"bonus": int(item["bonus_no"]),
|
|
||||||
}
|
|
||||||
|
|
||||||
def sync_all_from_json(all_url: str) -> Dict[str, Any]:
|
|
||||||
r = requests.get(all_url, timeout=60)
|
|
||||||
r.raise_for_status()
|
|
||||||
data = r.json() # list[dict]
|
|
||||||
|
|
||||||
inserted = 0
|
|
||||||
skipped = 0
|
|
||||||
|
|
||||||
for item in data:
|
|
||||||
row = _normalize_item(item)
|
|
||||||
|
|
||||||
if get_draw(row["drw_no"]):
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
upsert_draw(row)
|
|
||||||
inserted += 1
|
|
||||||
|
|
||||||
return {"mode": "all_json", "url": all_url, "inserted": inserted, "skipped": skipped, "total": len(data)}
|
|
||||||
|
|
||||||
def sync_latest(latest_url: str) -> Dict[str, Any]:
|
|
||||||
r = requests.get(latest_url, timeout=30)
|
|
||||||
r.raise_for_status()
|
|
||||||
item = r.json()
|
|
||||||
|
|
||||||
row = _normalize_item(item)
|
|
||||||
before = get_draw(row["drw_no"])
|
|
||||||
upsert_draw(row)
|
|
||||||
|
|
||||||
return {"mode": "latest_json", "url": latest_url, "was_new": (before is None), "drawNo": row["drw_no"]}
|
|
||||||
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
# backend/app/db.py
|
|
||||||
import os
|
|
||||||
import sqlite3
|
|
||||||
import json
|
|
||||||
import hashlib
|
|
||||||
from typing import Any, Dict, Optional, List
|
|
||||||
|
|
||||||
DB_PATH = "/app/data/lotto.db"
|
|
||||||
|
|
||||||
def _conn() -> sqlite3.Connection:
|
|
||||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
|
||||||
conn = sqlite3.connect(DB_PATH)
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
return conn
|
|
||||||
|
|
||||||
def _ensure_column(conn: sqlite3.Connection, table: str, col: str, ddl: str) -> None:
|
|
||||||
cols = {r["name"] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
|
||||||
if col not in cols:
|
|
||||||
conn.execute(ddl)
|
|
||||||
|
|
||||||
def init_db() -> None:
|
|
||||||
with _conn() as conn:
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS draws (
|
|
||||||
drw_no INTEGER PRIMARY KEY,
|
|
||||||
drw_date TEXT NOT NULL,
|
|
||||||
n1 INTEGER NOT NULL,
|
|
||||||
n2 INTEGER NOT NULL,
|
|
||||||
n3 INTEGER NOT NULL,
|
|
||||||
n4 INTEGER NOT NULL,
|
|
||||||
n5 INTEGER NOT NULL,
|
|
||||||
n6 INTEGER NOT NULL,
|
|
||||||
bonus INTEGER NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_draws_date ON draws(drw_date);")
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS recommendations (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
based_on_draw INTEGER,
|
|
||||||
numbers TEXT NOT NULL,
|
|
||||||
params TEXT NOT NULL
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_reco_created ON recommendations(created_at DESC);")
|
|
||||||
|
|
||||||
# ✅ 확장 컬럼들(기존 DB에도 자동 추가)
|
|
||||||
_ensure_column(conn, "recommendations", "numbers_sorted",
|
|
||||||
"ALTER TABLE recommendations ADD COLUMN numbers_sorted TEXT;")
|
|
||||||
_ensure_column(conn, "recommendations", "dedup_hash",
|
|
||||||
"ALTER TABLE recommendations ADD COLUMN dedup_hash TEXT;")
|
|
||||||
_ensure_column(conn, "recommendations", "favorite",
|
|
||||||
"ALTER TABLE recommendations ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0;")
|
|
||||||
_ensure_column(conn, "recommendations", "note",
|
|
||||||
"ALTER TABLE recommendations ADD COLUMN note TEXT NOT NULL DEFAULT '';")
|
|
||||||
_ensure_column(conn, "recommendations", "tags",
|
|
||||||
"ALTER TABLE recommendations ADD COLUMN tags TEXT NOT NULL DEFAULT '[]';")
|
|
||||||
|
|
||||||
# ✅ UNIQUE 인덱스(중복 저장 방지)
|
|
||||||
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS uq_reco_dedup ON recommendations(dedup_hash);")
|
|
||||||
|
|
||||||
def upsert_draw(row: Dict[str, Any]) -> None:
|
|
||||||
with _conn() as conn:
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO draws (drw_no, drw_date, n1, n2, n3, n4, n5, n6, bonus)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(drw_no) DO UPDATE SET
|
|
||||||
drw_date=excluded.drw_date,
|
|
||||||
n1=excluded.n1, n2=excluded.n2, n3=excluded.n3,
|
|
||||||
n4=excluded.n4, n5=excluded.n5, n6=excluded.n6,
|
|
||||||
bonus=excluded.bonus,
|
|
||||||
updated_at=datetime('now')
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
int(row["drw_no"]),
|
|
||||||
str(row["drw_date"]),
|
|
||||||
int(row["n1"]), int(row["n2"]), int(row["n3"]),
|
|
||||||
int(row["n4"]), int(row["n5"]), int(row["n6"]),
|
|
||||||
int(row["bonus"]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_latest_draw() -> Optional[Dict[str, Any]]:
|
|
||||||
with _conn() as conn:
|
|
||||||
r = conn.execute("SELECT * FROM draws ORDER BY drw_no DESC LIMIT 1").fetchone()
|
|
||||||
return dict(r) if r else None
|
|
||||||
|
|
||||||
def get_draw(drw_no: int) -> Optional[Dict[str, Any]]:
|
|
||||||
with _conn() as conn:
|
|
||||||
r = conn.execute("SELECT * FROM draws WHERE drw_no = ?", (drw_no,)).fetchone()
|
|
||||||
return dict(r) if r else None
|
|
||||||
|
|
||||||
def count_draws() -> int:
|
|
||||||
with _conn() as conn:
|
|
||||||
r = conn.execute("SELECT COUNT(*) AS c FROM draws").fetchone()
|
|
||||||
return int(r["c"])
|
|
||||||
|
|
||||||
def get_all_draw_numbers():
|
|
||||||
with _conn() as conn:
|
|
||||||
rows = conn.execute(
|
|
||||||
"SELECT drw_no, n1, n2, n3, n4, n5, n6 FROM draws ORDER BY drw_no ASC"
|
|
||||||
).fetchall()
|
|
||||||
return [(int(r["drw_no"]), [int(r["n1"]), int(r["n2"]), int(r["n3"]), int(r["n4"]), int(r["n5"]), int(r["n6"])]) for r in rows]
|
|
||||||
|
|
||||||
# ---------- ✅ recommendation helpers ----------
|
|
||||||
|
|
||||||
def _canonical_params(params: dict) -> str:
|
|
||||||
return json.dumps(params, sort_keys=True, separators=(",", ":"))
|
|
||||||
|
|
||||||
def _numbers_sorted_str(numbers: List[int]) -> str:
|
|
||||||
return ",".join(str(x) for x in sorted(numbers))
|
|
||||||
|
|
||||||
def _dedup_hash(based_on_draw: Optional[int], numbers: List[int], params: dict) -> str:
|
|
||||||
s = f"{based_on_draw or ''}|{_numbers_sorted_str(numbers)}|{_canonical_params(params)}"
|
|
||||||
return hashlib.sha1(s.encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
def save_recommendation_dedup(based_on_draw: Optional[int], numbers: List[int], params: dict) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
✅ 동일 추천(번호+params+based_on_draw)이면 중복 저장 없이 기존 id 반환
|
|
||||||
"""
|
|
||||||
ns = _numbers_sorted_str(numbers)
|
|
||||||
h = _dedup_hash(based_on_draw, numbers, params)
|
|
||||||
|
|
||||||
with _conn() as conn:
|
|
||||||
# 이미 있으면 반환
|
|
||||||
r = conn.execute("SELECT id FROM recommendations WHERE dedup_hash = ?", (h,)).fetchone()
|
|
||||||
if r:
|
|
||||||
return {"id": int(r["id"]), "saved": False, "deduped": True}
|
|
||||||
|
|
||||||
cur = conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO recommendations (based_on_draw, numbers, params, numbers_sorted, dedup_hash)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(based_on_draw, json.dumps(numbers), json.dumps(params), ns, h),
|
|
||||||
)
|
|
||||||
return {"id": int(cur.lastrowid), "saved": True, "deduped": False}
|
|
||||||
|
|
||||||
def list_recommendations_ex(
|
|
||||||
limit: int = 30,
|
|
||||||
offset: int = 0,
|
|
||||||
favorite: Optional[bool] = None,
|
|
||||||
tag: Optional[str] = None,
|
|
||||||
q: Optional[str] = None,
|
|
||||||
sort: str = "id_desc", # id_desc|created_desc|favorite_desc
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
import json
|
|
||||||
|
|
||||||
where = []
|
|
||||||
args: list[Any] = []
|
|
||||||
|
|
||||||
if favorite is not None:
|
|
||||||
where.append("favorite = ?")
|
|
||||||
args.append(1 if favorite else 0)
|
|
||||||
|
|
||||||
if q:
|
|
||||||
where.append("note LIKE ?")
|
|
||||||
args.append(f"%{q}%")
|
|
||||||
|
|
||||||
# tags는 JSON 문자열이므로 단순 LIKE로 처리(가볍게 시작)
|
|
||||||
if tag:
|
|
||||||
where.append("tags LIKE ?")
|
|
||||||
args.append(f"%{tag}%")
|
|
||||||
|
|
||||||
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
|
|
||||||
|
|
||||||
if sort == "created_desc":
|
|
||||||
order = "created_at DESC"
|
|
||||||
elif sort == "favorite_desc":
|
|
||||||
# favorite(1)이 먼저, 그 다음 최신
|
|
||||||
order = "favorite DESC, id DESC"
|
|
||||||
else:
|
|
||||||
order = "id DESC"
|
|
||||||
|
|
||||||
sql = f"""
|
|
||||||
SELECT id, created_at, based_on_draw, numbers, params, favorite, note, tags
|
|
||||||
FROM recommendations
|
|
||||||
{where_sql}
|
|
||||||
ORDER BY {order}
|
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
"""
|
|
||||||
args.extend([int(limit), int(offset)])
|
|
||||||
|
|
||||||
with _conn() as conn:
|
|
||||||
rows = conn.execute(sql, args).fetchall()
|
|
||||||
|
|
||||||
out = []
|
|
||||||
for r in rows:
|
|
||||||
out.append({
|
|
||||||
"id": int(r["id"]),
|
|
||||||
"created_at": r["created_at"],
|
|
||||||
"based_on_draw": r["based_on_draw"],
|
|
||||||
"numbers": json.loads(r["numbers"]),
|
|
||||||
"params": json.loads(r["params"]),
|
|
||||||
"favorite": bool(r["favorite"]) if r["favorite"] is not None else False,
|
|
||||||
"note": r["note"],
|
|
||||||
"tags": json.loads(r["tags"]) if r["tags"] else [],
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
|
|
||||||
def update_recommendation(rec_id: int, favorite: Optional[bool] = None, note: Optional[str] = None, tags: Optional[List[str]] = None) -> bool:
|
|
||||||
fields = []
|
|
||||||
args: list[Any] = []
|
|
||||||
|
|
||||||
if favorite is not None:
|
|
||||||
fields.append("favorite = ?")
|
|
||||||
args.append(1 if favorite else 0)
|
|
||||||
if note is not None:
|
|
||||||
fields.append("note = ?")
|
|
||||||
args.append(note)
|
|
||||||
if tags is not None:
|
|
||||||
fields.append("tags = ?")
|
|
||||||
args.append(json.dumps(tags))
|
|
||||||
|
|
||||||
if not fields:
|
|
||||||
return False
|
|
||||||
|
|
||||||
args.append(rec_id)
|
|
||||||
|
|
||||||
with _conn() as conn:
|
|
||||||
cur = conn.execute(
|
|
||||||
f"UPDATE recommendations SET {', '.join(fields)} WHERE id = ?",
|
|
||||||
args,
|
|
||||||
)
|
|
||||||
return cur.rowcount > 0
|
|
||||||
|
|
||||||
def delete_recommendation(rec_id: int) -> bool:
|
|
||||||
with _conn() as conn:
|
|
||||||
cur = conn.execute("DELETE FROM recommendations WHERE id = ?", (rec_id,))
|
|
||||||
return cur.rowcount > 0
|
|
||||||
|
|
||||||
@@ -1,344 +0,0 @@
|
|||||||
import os
|
|
||||||
from typing import Optional, List, Dict, Any, Tuple
|
|
||||||
from fastapi import FastAPI, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
|
||||||
|
|
||||||
from .db import (
|
|
||||||
init_db, get_draw, get_latest_draw, get_all_draw_numbers,
|
|
||||||
save_recommendation_dedup, list_recommendations_ex, delete_recommendation,
|
|
||||||
update_recommendation,
|
|
||||||
)
|
|
||||||
from .recommender import recommend_numbers
|
|
||||||
from .collector import sync_latest
|
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
|
||||||
|
|
||||||
ALL_URL = os.getenv("LOTTO_ALL_URL", "https://smok95.github.io/lotto/results/all.json")
|
|
||||||
LATEST_URL = os.getenv("LOTTO_LATEST_URL", "https://smok95.github.io/lotto/results/latest.json")
|
|
||||||
|
|
||||||
def calc_metrics(numbers: List[int]) -> Dict[str, Any]:
|
|
||||||
nums = sorted(numbers)
|
|
||||||
s = sum(nums)
|
|
||||||
odd = sum(1 for x in nums if x % 2 == 1)
|
|
||||||
even = len(nums) - odd
|
|
||||||
mn, mx = nums[0], nums[-1]
|
|
||||||
rng = mx - mn
|
|
||||||
|
|
||||||
# 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,
|
|
||||||
}
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
|
||||||
def on_startup():
|
|
||||||
init_db()
|
|
||||||
scheduler.add_job(lambda: sync_latest(LATEST_URL), "cron", hour="9,21", minute=10)
|
|
||||||
scheduler.start()
|
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
def health():
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
@app.get("/api/lotto/latest")
|
|
||||||
def api_latest():
|
|
||||||
row = get_latest_draw()
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(status_code=404, detail="No data yet")
|
|
||||||
return {
|
|
||||||
"drawNo": row["drw_no"],
|
|
||||||
"date": row["drw_date"],
|
|
||||||
"numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]],
|
|
||||||
"bonus": row["bonus"],
|
|
||||||
}
|
|
||||||
|
|
||||||
@app.get("/api/lotto/{drw_no:int}")
|
|
||||||
def api_draw(drw_no: int):
|
|
||||||
row = get_draw(drw_no)
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(status_code=404, detail="Not found")
|
|
||||||
return {
|
|
||||||
"drwNo": row["drw_no"],
|
|
||||||
"date": row["drw_date"],
|
|
||||||
"numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]],
|
|
||||||
"bonus": row["bonus"],
|
|
||||||
}
|
|
||||||
|
|
||||||
@app.post("/api/admin/sync_latest")
|
|
||||||
def admin_sync_latest():
|
|
||||||
return sync_latest(LATEST_URL)
|
|
||||||
|
|
||||||
# ---------- ✅ recommend (dedup save) ----------
|
|
||||||
@app.get("/api/lotto/recommend")
|
|
||||||
def api_recommend(
|
|
||||||
recent_window: int = 200,
|
|
||||||
recent_weight: float = 2.0,
|
|
||||||
avoid_recent_k: int = 5,
|
|
||||||
|
|
||||||
# ---- optional constraints (Lotto Lab) ----
|
|
||||||
sum_min: Optional[int] = None,
|
|
||||||
sum_max: Optional[int] = None,
|
|
||||||
odd_min: Optional[int] = None,
|
|
||||||
odd_max: Optional[int] = None,
|
|
||||||
range_min: Optional[int] = None,
|
|
||||||
range_max: Optional[int] = None,
|
|
||||||
max_overlap_latest: Optional[int] = None, # 최근 avoid_recent_k 회차와 중복 허용 개수
|
|
||||||
max_try: int = 200, # 조건 맞는 조합 찾기 재시도
|
|
||||||
):
|
|
||||||
draws = get_all_draw_numbers()
|
|
||||||
if not draws:
|
|
||||||
raise HTTPException(status_code=404, detail="No data yet")
|
|
||||||
|
|
||||||
latest = get_latest_draw()
|
|
||||||
|
|
||||||
params = {
|
|
||||||
"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_numbers(
|
|
||||||
draws,
|
|
||||||
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}. "
|
|
||||||
f"Try relaxing sum/odd/range/overlap constraints.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ✅ dedup save
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------- ✅ history list (filter/paging) ----------
|
|
||||||
@app.get("/api/history")
|
|
||||||
def api_history(
|
|
||||||
limit: int = 30,
|
|
||||||
offset: int = 0,
|
|
||||||
favorite: Optional[bool] = None,
|
|
||||||
tag: Optional[str] = None,
|
|
||||||
q: Optional[str] = None,
|
|
||||||
sort: str = "id_desc",
|
|
||||||
):
|
|
||||||
items = list_recommendations_ex(
|
|
||||||
limit=limit,
|
|
||||||
offset=offset,
|
|
||||||
favorite=favorite,
|
|
||||||
tag=tag,
|
|
||||||
q=q,
|
|
||||||
sort=sort,
|
|
||||||
)
|
|
||||||
|
|
||||||
draws = get_all_draw_numbers()
|
|
||||||
|
|
||||||
out = []
|
|
||||||
for it in items:
|
|
||||||
nums = it["numbers"]
|
|
||||||
out.append({
|
|
||||||
**it,
|
|
||||||
"metrics": calc_metrics(nums),
|
|
||||||
"recent_overlap": calc_recent_overlap(
|
|
||||||
nums, draws, last_k=int(it["params"].get("avoid_recent_k", 0) or 0)
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"items": out,
|
|
||||||
"limit": limit,
|
|
||||||
"offset": offset,
|
|
||||||
"filters": {"favorite": favorite, "tag": tag, "q": q, "sort": sort},
|
|
||||||
}
|
|
||||||
|
|
||||||
@app.delete("/api/history/{rec_id:int}")
|
|
||||||
def api_history_delete(rec_id: int):
|
|
||||||
ok = delete_recommendation(rec_id)
|
|
||||||
if not ok:
|
|
||||||
raise HTTPException(status_code=404, detail="Not found")
|
|
||||||
return {"deleted": True, "id": rec_id}
|
|
||||||
|
|
||||||
# ---------- ✅ history update (favorite/note/tags) ----------
|
|
||||||
class HistoryUpdate(BaseModel):
|
|
||||||
favorite: Optional[bool] = None
|
|
||||||
note: Optional[str] = None
|
|
||||||
tags: Optional[List[str]] = None
|
|
||||||
|
|
||||||
@app.patch("/api/history/{rec_id:int}")
|
|
||||||
def api_history_patch(rec_id: int, body: HistoryUpdate):
|
|
||||||
ok = update_recommendation(rec_id, favorite=body.favorite, note=body.note, tags=body.tags)
|
|
||||||
if not ok:
|
|
||||||
raise HTTPException(status_code=404, detail="Not found or no changes")
|
|
||||||
return {"updated": True, "id": rec_id}
|
|
||||||
|
|
||||||
# ---------- ✅ batch recommend ----------
|
|
||||||
def _batch_unique(draws, count: int, recent_window: int, recent_weight: float, avoid_recent_k: int, max_try: int = 200):
|
|
||||||
items = []
|
|
||||||
seen = set()
|
|
||||||
|
|
||||||
tries = 0
|
|
||||||
while len(items) < count and tries < max_try:
|
|
||||||
tries += 1
|
|
||||||
r = recommend_numbers(draws, recent_window=recent_window, recent_weight=recent_weight, avoid_recent_k=avoid_recent_k)
|
|
||||||
key = tuple(sorted(r["numbers"]))
|
|
||||||
if key in seen:
|
|
||||||
continue
|
|
||||||
seen.add(key)
|
|
||||||
items.append(r)
|
|
||||||
|
|
||||||
return items
|
|
||||||
|
|
||||||
@app.get("/api/lotto/recommend/batch")
|
|
||||||
def api_recommend_batch(
|
|
||||||
count: int = 5,
|
|
||||||
recent_window: int = 200,
|
|
||||||
recent_weight: float = 2.0,
|
|
||||||
avoid_recent_k: int = 5,
|
|
||||||
):
|
|
||||||
count = max(1, min(count, 20))
|
|
||||||
draws = get_all_draw_numbers()
|
|
||||||
if not draws:
|
|
||||||
raise HTTPException(status_code=404, detail="No data yet")
|
|
||||||
|
|
||||||
latest = get_latest_draw()
|
|
||||||
params = {
|
|
||||||
"recent_window": recent_window,
|
|
||||||
"recent_weight": float(recent_weight),
|
|
||||||
"avoid_recent_k": avoid_recent_k,
|
|
||||||
"count": count,
|
|
||||||
}
|
|
||||||
|
|
||||||
items = _batch_unique(draws, count, recent_window, float(recent_weight), avoid_recent_k)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"based_on_latest_draw": latest["drw_no"] if latest else None,
|
|
||||||
"count": count,
|
|
||||||
"items": [{"numbers": it["numbers"], "explain": it["explain"]} for it in items],
|
|
||||||
"params": params,
|
|
||||||
}
|
|
||||||
|
|
||||||
class BatchSave(BaseModel):
|
|
||||||
items: List[List[int]]
|
|
||||||
params: dict
|
|
||||||
|
|
||||||
@app.post("/api/lotto/recommend/batch")
|
|
||||||
def api_recommend_batch_save(body: BatchSave):
|
|
||||||
latest = get_latest_draw()
|
|
||||||
based = latest["drw_no"] if latest else None
|
|
||||||
|
|
||||||
created, deduped = [], []
|
|
||||||
for nums in body.items:
|
|
||||||
saved = save_recommendation_dedup(based, nums, body.params)
|
|
||||||
(created if saved["saved"] else deduped).append(saved["id"])
|
|
||||||
|
|
||||||
return {"saved": True, "created_ids": created, "deduped_ids": deduped}
|
|
||||||
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import random
|
|
||||||
from collections import Counter
|
|
||||||
from typing import Dict, Any, List, Tuple
|
|
||||||
|
|
||||||
def recommend_numbers(
|
|
||||||
draws: List[Tuple[int, List[int]]],
|
|
||||||
*,
|
|
||||||
recent_window: int = 200,
|
|
||||||
recent_weight: float = 2.0,
|
|
||||||
avoid_recent_k: int = 5,
|
|
||||||
seed: int | None = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
가벼운 통계 기반 추천:
|
|
||||||
- 전체 빈도 + 최근(recent_window) 빈도에 가중치를 더한 가중 샘플링
|
|
||||||
- 최근 avoid_recent_k 회차에 나온 번호는 확률을 낮춤(완전 제외는 아님)
|
|
||||||
"""
|
|
||||||
if seed is not None:
|
|
||||||
random.seed(seed)
|
|
||||||
|
|
||||||
# 전체 빈도
|
|
||||||
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)
|
|
||||||
|
|
||||||
# 최근 k회차 번호(패널티)
|
|
||||||
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)
|
|
||||||
|
|
||||||
# 가중치 구성
|
|
||||||
weights = {}
|
|
||||||
for n in range(1, 46):
|
|
||||||
w = freq_all[n] + recent_weight * freq_recent[n]
|
|
||||||
if n in last_k_nums:
|
|
||||||
w *= 0.6 # 최근에 너무 방금 나온 건 살짝 덜 뽑히게
|
|
||||||
weights[n] = max(w, 0.1)
|
|
||||||
|
|
||||||
# 중복 없이 6개 뽑기(가중 샘플링)
|
|
||||||
chosen = []
|
|
||||||
pool = list(range(1, 46))
|
|
||||||
for _ in range(6):
|
|
||||||
total = sum(weights[n] for n in pool)
|
|
||||||
r = random.random() * total
|
|
||||||
acc = 0.0
|
|
||||||
for n in pool:
|
|
||||||
acc += weights[n]
|
|
||||||
if acc >= r:
|
|
||||||
chosen.append(n)
|
|
||||||
pool.remove(n)
|
|
||||||
break
|
|
||||||
|
|
||||||
chosen_sorted = sorted(chosen)
|
|
||||||
|
|
||||||
explain = {
|
|
||||||
"recent_window": recent_window,
|
|
||||||
"recent_weight": recent_weight,
|
|
||||||
"avoid_recent_k": avoid_recent_k,
|
|
||||||
"top_all": [n for n, _ in freq_all.most_common(10)],
|
|
||||||
"top_recent": [n for n, _ in freq_recent.most_common(10)],
|
|
||||||
"last_k_draws": [d for d, _ in last_k],
|
|
||||||
}
|
|
||||||
|
|
||||||
return {"numbers": chosen_sorted, "explain": explain}
|
|
||||||
|
|
||||||
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,9 +1,12 @@
|
|||||||
version: "3.8"
|
name: webpage
|
||||||
|
|
||||||
services:
|
services:
|
||||||
backend:
|
lotto:
|
||||||
build: ./backend
|
build:
|
||||||
container_name: lotto-backend
|
context: ./lotto
|
||||||
|
args:
|
||||||
|
APP_VERSION: ${APP_VERSION:-dev}
|
||||||
|
container_name: lotto
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "18000:8000"
|
- "18000:8000"
|
||||||
@@ -12,36 +15,221 @@ 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}
|
||||||
|
- AGENT_OFFICE_URL=${AGENT_OFFICE_URL:-http://agent-office:8000}
|
||||||
|
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
|
||||||
|
- REALESTATE_DASHBOARD_URL=${REALESTATE_DASHBOARD_URL:-http://localhost:8080/realestate}
|
||||||
|
- 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: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
|
||||||
|
|
||||||
|
personal:
|
||||||
|
build:
|
||||||
|
context: ./personal
|
||||||
|
container_name: personal
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "18850:8000"
|
||||||
|
environment:
|
||||||
|
- TZ=${TZ:-Asia/Seoul}
|
||||||
|
- PORTFOLIO_EDIT_PASSWORD=${PORTFOLIO_EDIT_PASSWORD:-}
|
||||||
|
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||||
|
volumes:
|
||||||
|
- ${RUNTIME_PATH:-.}/data/personal:/app/data
|
||||||
|
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: 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 업데이트"
|
||||||
|
```
|
||||||
3163
docs/superpowers/plans/2026-04-27-agent-office-v2.md
Normal file
3163
docs/superpowers/plans/2026-04-27-agent-office-v2.md
Normal file
File diff suppressed because it is too large
Load Diff
2129
docs/superpowers/plans/2026-04-27-portfolio.md
Normal file
2129
docs/superpowers/plans/2026-04-27-portfolio.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,971 @@
|
|||||||
|
# 청약 타겟팅 프론트엔드 구현 계획
|
||||||
|
|
||||||
|
> **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:** 백엔드에서 추가된 자치구 5티어 매칭 기능을 `web-ui`의 청약(Subscription) 페이지에 노출 — 프로필 편집 UI(드래그&드롭 + 슬라이더 + 토글) + 카드/상세에 district·5티어·reasons 표시.
|
||||||
|
|
||||||
|
**Architecture:** `Subscription.jsx`(1354줄, 단일 파일)의 ProfileTab에 신규 컴포넌트 2개(`DistrictTierEditor`, `NotificationSettings`)를 추가하고, `AnnouncementCard`/`AnnouncementDetail`/`MatchesTab` 3 곳에 district + 5티어 뱃지 + reasons 표시를 추가한다. 백엔드 응답은 이미 모든 필요 데이터를 포함하므로 API 변경 없음.
|
||||||
|
|
||||||
|
**Tech Stack:** React 18 + Vite + JavaScript / Native HTML5 drag-and-drop / `window.matchMedia` 분기 / ESLint / 단위 테스트 인프라 없음(빌드 + lint + 수동 시각 검증)
|
||||||
|
|
||||||
|
**스펙 참조:** `web-backend/docs/superpowers/specs/2026-04-28-realestate-frontend-targeting-design.md`
|
||||||
|
|
||||||
|
**작업 디렉토리:** `C:\Users\jaeoh\Desktop\workspace\web-ui` (web-backend와 별도 git repo. commit/push도 web-ui repo에서 처리.)
|
||||||
|
|
||||||
|
**검증 방식:**
|
||||||
|
- 단위 테스트 인프라 없음 → 각 task는 `npm run build` 통과 + `npm run lint` 통과로 1차 검증
|
||||||
|
- 마지막 task에서 `npm run dev` + 브라우저로 수동 시각 검증 시나리오 일괄 실행
|
||||||
|
- 백엔드는 NAS에 이미 배포됨(2a8635e..a508a56) → 실제 응답으로 동작 확인 가능
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: 모듈 상단 변경 — DEFAULT_PROFILE 확장 + extractTier 헬퍼
|
||||||
|
|
||||||
|
`Subscription.jsx` 모듈 상단에 신규 3 필드 default와 reasons → tier 추출 헬퍼를 추가. 이후 task들이 이 두 가지를 의존.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-ui/src/pages/subscription/Subscription.jsx` (모듈 상단 — `DEFAULT_PROFILE` 상수 + 새 헬퍼)
|
||||||
|
|
||||||
|
- [ ] **Step 1: DEFAULT_PROFILE에 신규 3 필드 default 추가**
|
||||||
|
|
||||||
|
`Subscription.jsx`에서 `DEFAULT_PROFILE` 상수 정의를 찾는다 (grep `DEFAULT_PROFILE =`). 끝부분에 3 필드 추가:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const DEFAULT_PROFILE = {
|
||||||
|
// ... 기존 필드 그대로 유지
|
||||||
|
preferred_regions: '',
|
||||||
|
preferred_types: '',
|
||||||
|
min_area: '',
|
||||||
|
max_area: '',
|
||||||
|
max_price: '',
|
||||||
|
// 신규 (자치구 5티어 + 알림 설정)
|
||||||
|
preferred_districts: {},
|
||||||
|
min_match_score: 70,
|
||||||
|
notify_enabled: true,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
(주의: 기존 마지막 필드 뒤에 콤마가 있는지 확인 후 일관성 유지)
|
||||||
|
|
||||||
|
- [ ] **Step 2: `extractTier` 헬퍼 함수 추가**
|
||||||
|
|
||||||
|
`DEFAULT_PROFILE` 정의 위(또는 fmt 헬퍼들 근처, 모듈 최상단 영역) 어딘가에 추가:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 매칭 reasons에서 자치구 티어를 추출 ("자치구 S티어: 강남구 (+25)" → "S")
|
||||||
|
function extractTier(reasons) {
|
||||||
|
for (const r of reasons || []) {
|
||||||
|
const m = r.match(/자치구 ([SABCD])티어/);
|
||||||
|
if (m) return m[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 빌드 + 린트 검증**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||||
|
npm run build
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: build 성공, lint 0 errors / 0 warnings.
|
||||||
|
|
||||||
|
- [ ] **Step 4: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||||
|
git add src/pages/subscription/Subscription.jsx
|
||||||
|
git commit -m "feat(subscription): DEFAULT_PROFILE 신규 3필드 + extractTier 헬퍼"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: DistrictTierEditor 컴포넌트 신규
|
||||||
|
|
||||||
|
자치구 5티어 분류 UI. 데스크톱 드래그&드롭 + 모바일 read-only.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web-ui/src/pages/subscription/components/DistrictTierEditor.jsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 컴포넌트 파일 생성**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const SEOUL_DISTRICTS = [
|
||||||
|
"강남구","강동구","강북구","강서구","관악구",
|
||||||
|
"광진구","구로구","금천구","노원구","도봉구",
|
||||||
|
"동대문구","동작구","마포구","서대문구","서초구",
|
||||||
|
"성동구","성북구","송파구","양천구","영등포구",
|
||||||
|
"용산구","은평구","종로구","중구","중랑구",
|
||||||
|
];
|
||||||
|
|
||||||
|
const TIERS = [
|
||||||
|
{ key: "S", label: "S", weight: "100%" },
|
||||||
|
{ key: "A", label: "A", weight: "80%" },
|
||||||
|
{ key: "B", label: "B", weight: "60%" },
|
||||||
|
{ key: "C", label: "C", weight: "40%" },
|
||||||
|
{ key: "D", label: "D", weight: "20%" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EMPTY_TIERS = { S: [], A: [], B: [], C: [], D: [] };
|
||||||
|
|
||||||
|
function useIsDesktop() {
|
||||||
|
const [isDesktop, setIsDesktop] = useState(
|
||||||
|
typeof window !== "undefined" && window.matchMedia("(min-width: 768px)").matches
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const mq = window.matchMedia("(min-width: 768px)");
|
||||||
|
const handler = (e) => setIsDesktop(e.matches);
|
||||||
|
mq.addEventListener("change", handler);
|
||||||
|
return () => mq.removeEventListener("change", handler);
|
||||||
|
}, []);
|
||||||
|
return isDesktop;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DistrictTierEditor({ value, onChange }) {
|
||||||
|
const isDesktop = useIsDesktop();
|
||||||
|
const [dragOver, setDragOver] = useState(null); // 현재 hover 중인 zone key
|
||||||
|
|
||||||
|
const current = value && Object.keys(value).length > 0 ? value : EMPTY_TIERS;
|
||||||
|
|
||||||
|
const unassigned = SEOUL_DISTRICTS.filter(
|
||||||
|
d => !TIERS.some(t => (current[t.key] || []).includes(d))
|
||||||
|
);
|
||||||
|
|
||||||
|
const moveDistrict = (district, targetTier /* null = 미할당 */) => {
|
||||||
|
const next = { S: [], A: [], B: [], C: [], D: [] };
|
||||||
|
for (const t of Object.keys(next)) {
|
||||||
|
next[t] = (current[t] || []).filter(d => d !== district);
|
||||||
|
}
|
||||||
|
if (targetTier) {
|
||||||
|
next[targetTier] = [...next[targetTier], district];
|
||||||
|
}
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragStart = (e, district) => {
|
||||||
|
e.dataTransfer.setData("text/district", district);
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
};
|
||||||
|
const onDragOver = (e, key) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
if (dragOver !== key) setDragOver(key);
|
||||||
|
};
|
||||||
|
const onDragLeave = () => setDragOver(null);
|
||||||
|
const onDrop = (e, targetTier /* null = 미할당 */) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const district = e.dataTransfer.getData("text/district");
|
||||||
|
setDragOver(null);
|
||||||
|
if (district) moveDistrict(district, targetTier);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isDesktop) {
|
||||||
|
return (
|
||||||
|
<div className="sub-panel">
|
||||||
|
<div className="sub-panel__head">
|
||||||
|
<p className="sub-panel__eyebrow">자치구 우선순위</p>
|
||||||
|
<h3>지역 5티어</h3>
|
||||||
|
</div>
|
||||||
|
<div className="sub-panel__body" style={{ display: "grid", gap: 10 }}>
|
||||||
|
{TIERS.map(t => (
|
||||||
|
<div key={t.key} className="dte-row dte-row--readonly">
|
||||||
|
<span className={`sub-chip sub-chip--tier sub-chip--tier-${t.key}`}>
|
||||||
|
{t.label} {t.weight}
|
||||||
|
</span>
|
||||||
|
<span className="dte-row__list">
|
||||||
|
{(current[t.key] || []).length === 0
|
||||||
|
? <span className="dte-empty">(없음)</span>
|
||||||
|
: (current[t.key] || []).join(", ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="dte-mobile-hint">✏️ 자치구 분류는 PC에서 편집할 수 있어요</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sub-panel">
|
||||||
|
<div className="sub-panel__head">
|
||||||
|
<p className="sub-panel__eyebrow">자치구 우선순위</p>
|
||||||
|
<h3>지역 5티어 (드래그해서 분류)</h3>
|
||||||
|
</div>
|
||||||
|
<div className="sub-panel__body" style={{ display: "grid", gap: 12 }}>
|
||||||
|
{/* 미할당 풀 */}
|
||||||
|
<div
|
||||||
|
className={`dte-pool ${dragOver === "_unassigned" ? "dte-pool--over" : ""}`}
|
||||||
|
onDragOver={(e) => onDragOver(e, "_unassigned")}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDrop={(e) => onDrop(e, null)}
|
||||||
|
>
|
||||||
|
<p className="dte-pool__title">미할당 ({unassigned.length})</p>
|
||||||
|
<div className="dte-chips">
|
||||||
|
{unassigned.map(d => (
|
||||||
|
<span
|
||||||
|
key={d}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => onDragStart(e, d)}
|
||||||
|
className="sub-chip sub-chip--district dte-chip"
|
||||||
|
>
|
||||||
|
{d}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 5티어 그리드 */}
|
||||||
|
<div className="dte-grid">
|
||||||
|
{TIERS.map(t => (
|
||||||
|
<div
|
||||||
|
key={t.key}
|
||||||
|
className={`dte-zone ${dragOver === t.key ? "dte-zone--over" : ""}`}
|
||||||
|
onDragOver={(e) => onDragOver(e, t.key)}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDrop={(e) => onDrop(e, t.key)}
|
||||||
|
>
|
||||||
|
<div className={`dte-zone__head sub-chip--tier-${t.key}`}>
|
||||||
|
{t.label} <span className="dte-zone__weight">{t.weight}</span>
|
||||||
|
</div>
|
||||||
|
<div className="dte-zone__chips">
|
||||||
|
{(current[t.key] || []).map(d => (
|
||||||
|
<span
|
||||||
|
key={d}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => onDragStart(e, d)}
|
||||||
|
className="sub-chip sub-chip--district dte-chip"
|
||||||
|
>
|
||||||
|
{d}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="dte-chip__remove"
|
||||||
|
onClick={() => moveDistrict(d, null)}
|
||||||
|
aria-label={`${d} 미할당으로`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 빌드 + 린트 검증**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||||
|
npm run build
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: build 성공, lint 0 errors. 컴포넌트가 아직 사용되지 않으므로 dead code warning은 무시.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||||
|
git add src/pages/subscription/components/DistrictTierEditor.jsx
|
||||||
|
git commit -m "feat(subscription): DistrictTierEditor — 자치구 5티어 드래그앤드롭"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: NotificationSettings 컴포넌트 신규
|
||||||
|
|
||||||
|
임계값 슬라이더 + 알림 토글 + 미리보기.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web-ui/src/pages/subscription/components/NotificationSettings.jsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 컴포넌트 파일 생성**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
export default function NotificationSettings({ minScore, notifyEnabled, onChange }) {
|
||||||
|
const score = minScore ?? 70;
|
||||||
|
const enabled = notifyEnabled ?? true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sub-panel">
|
||||||
|
<div className="sub-panel__head">
|
||||||
|
<p className="sub-panel__eyebrow">알림 설정</p>
|
||||||
|
<h3>🔔 텔레그램 알림</h3>
|
||||||
|
</div>
|
||||||
|
<div className="sub-panel__body" style={{ display: "grid", gap: 16 }}>
|
||||||
|
<label className="ns-row">
|
||||||
|
<span className="ns-row__label">텔레그램 알림</span>
|
||||||
|
<span className="ns-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="sub-toggle"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(e) => onChange({ notify_enabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span className="sub-toggle__label">{enabled ? "ON" : "OFF"}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="ns-row ns-row--column">
|
||||||
|
<span className="ns-row__label">매칭 임계값 — {score}점</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="5"
|
||||||
|
value={score}
|
||||||
|
onChange={(e) => onChange({ min_match_score: Number(e.target.value) })}
|
||||||
|
className="ns-slider"
|
||||||
|
disabled={!enabled}
|
||||||
|
/>
|
||||||
|
<div className="ns-scale">
|
||||||
|
<span>0</span>
|
||||||
|
<span>50</span>
|
||||||
|
<span>100</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p className="ns-hint">
|
||||||
|
{enabled
|
||||||
|
? `💡 ${score}점 이상 매치 시 텔레그램에 자동 알림합니다.`
|
||||||
|
: "⚠️ 알림 OFF — 임계값을 통과한 매칭이 있어도 메시지가 발송되지 않습니다."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 빌드 + 린트 검증**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||||
|
npm run build
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: build 성공, lint 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||||
|
git add src/pages/subscription/components/NotificationSettings.jsx
|
||||||
|
git commit -m "feat(subscription): NotificationSettings — 임계값 슬라이더 + 알림 토글"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: ProfileTab에 두 컴포넌트 통합 + handleSave 변경
|
||||||
|
|
||||||
|
신규 컴포넌트 2개를 ProfileTab에 import·렌더하고, handleSave가 신규 3 필드를 PUT body에 포함하도록 변경.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-ui/src/pages/subscription/Subscription.jsx` ProfileTab 함수 (956~1299줄 부근)
|
||||||
|
|
||||||
|
- [ ] **Step 1: import 추가 (파일 상단의 다른 import들 근처)**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import DistrictTierEditor from "./components/DistrictTierEditor";
|
||||||
|
import NotificationSettings from "./components/NotificationSettings";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: handleSave 안 신규 3 필드 처리 추가**
|
||||||
|
|
||||||
|
`handleSave` 함수 안에서 payload 변환 부분을 찾아 다음을 추가. 기존 `preferred_regions` / `preferred_types` 변환 직후에:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 신규: preferred_districts (객체), min_match_score, notify_enabled
|
||||||
|
payload.preferred_districts = profile.preferred_districts && typeof profile.preferred_districts === "object"
|
||||||
|
? profile.preferred_districts
|
||||||
|
: {};
|
||||||
|
payload.min_match_score = profile.min_match_score ?? null;
|
||||||
|
payload.notify_enabled = profile.notify_enabled ?? null;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: ProfileTab의 GET 응답 처리에 신규 3 필드 매핑 보강**
|
||||||
|
|
||||||
|
`useEffect` 안의 `apiGet('/api/realestate/profile')` 응답 처리에서 `display = { ...DEFAULT_PROFILE, ...data }` 라인이 이미 있어 자동으로 spread 됨. 별도 수정 불필요. (백엔드가 항상 응답에 포함시키므로 fallback도 자연스러움.)
|
||||||
|
|
||||||
|
확인 차원에서 `min_match_score`/`notify_enabled`/`preferred_districts`가 응답에 없을 경우 DEFAULT 값이 사용되는지 검증.
|
||||||
|
|
||||||
|
- [ ] **Step 4: ProfileTab 렌더 — DistrictTierEditor / NotificationSettings 추가**
|
||||||
|
|
||||||
|
`return ()` 안에서 기존 "선호 조건" 패널과 저장 버튼 사이에 두 컴포넌트 삽입. 정확한 위치는 기존 코드의 마지막 `<div className="sub-panel">` (선호 조건 패널) 다음 + 저장 버튼 직전:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
{/* 자치구 5티어 */}
|
||||||
|
<DistrictTierEditor
|
||||||
|
value={profile.preferred_districts}
|
||||||
|
onChange={(next) => setProfile(prev => ({ ...prev, preferred_districts: next }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 알림 설정 */}
|
||||||
|
<NotificationSettings
|
||||||
|
minScore={profile.min_match_score ?? 70}
|
||||||
|
notifyEnabled={profile.notify_enabled ?? true}
|
||||||
|
onChange={(patch) => setProfile(prev => ({ ...prev, ...patch }))}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 빌드 + 린트 검증**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||||
|
npm run build
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: build 성공, lint 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 6: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||||
|
git add src/pages/subscription/Subscription.jsx
|
||||||
|
git commit -m "feat(subscription): ProfileTab에 5티어/알림 설정 통합"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Subscription.css — 5티어 + 드래그영역 + 토글 + 슬라이더 + 매칭분석 스타일
|
||||||
|
|
||||||
|
신규 컴포넌트와 카드 표시 변경에 필요한 모든 CSS를 한 번에 추가.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-ui/src/pages/subscription/Subscription.css` (파일 끝에 신규 섹션 추가)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 5티어 + district 뱃지 색상**
|
||||||
|
|
||||||
|
`Subscription.css` 파일 끝에 추가:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* === 신규: 자치구 5티어 + district 뱃지 ============================== */
|
||||||
|
.sub-chip--district {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
border-color: #d1d5db;
|
||||||
|
}
|
||||||
|
.sub-chip--tier {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.sub-chip--tier-S { background: #fee2e2; color: #dc2626; border-color: #fca5a5; }
|
||||||
|
.sub-chip--tier-A { background: #fef3c7; color: #d97706; border-color: #fcd34d; }
|
||||||
|
.sub-chip--tier-B { background: #d1fae5; color: #059669; border-color: #6ee7b7; }
|
||||||
|
.sub-chip--tier-C { background: #dbeafe; color: #2563eb; border-color: #93c5fd; }
|
||||||
|
.sub-chip--tier-D { background: #ede9fe; color: #7c3aed; border-color: #c4b5fd; }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: DistrictTierEditor 드래그&드롭 영역**
|
||||||
|
|
||||||
|
같은 파일에 이어서 추가:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* === 신규: DistrictTierEditor ====================================== */
|
||||||
|
.dte-pool {
|
||||||
|
border: 1px dashed var(--border-soft, #e5e7eb);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.dte-pool--over {
|
||||||
|
background: #f0f9ff;
|
||||||
|
border-color: #38bdf8;
|
||||||
|
}
|
||||||
|
.dte-pool__title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted, #6b7280);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.dte-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.dte-chip {
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.dte-chip:active { cursor: grabbing; }
|
||||||
|
.dte-chip__remove {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: inherit;
|
||||||
|
margin-left: 4px;
|
||||||
|
padding: 0 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.dte-chip__remove:hover { opacity: 1; }
|
||||||
|
|
||||||
|
.dte-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.dte-zone {
|
||||||
|
border: 1px solid var(--border-soft, #e5e7eb);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
min-height: 120px;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.dte-zone--over {
|
||||||
|
background: #f0f9ff;
|
||||||
|
border-color: #38bdf8;
|
||||||
|
}
|
||||||
|
.dte-zone__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.dte-zone__weight {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.dte-zone__chips {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 모바일 read-only 뷰 */
|
||||||
|
.dte-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 80px 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--border-soft, #e5e7eb);
|
||||||
|
}
|
||||||
|
.dte-row:last-of-type { border-bottom: 0; }
|
||||||
|
.dte-row__list {
|
||||||
|
color: var(--text, #1f2937);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.dte-empty {
|
||||||
|
color: var(--text-muted, #6b7280);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.dte-mobile-hint {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--text-muted, #6b7280);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: NotificationSettings — 토글 + 슬라이더**
|
||||||
|
|
||||||
|
같은 파일에 이어서 추가:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* === 신규: NotificationSettings ==================================== */
|
||||||
|
.ns-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.ns-row--column {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.ns-row__label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text, #1f2937);
|
||||||
|
}
|
||||||
|
.ns-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.sub-toggle {
|
||||||
|
appearance: none;
|
||||||
|
width: 40px;
|
||||||
|
height: 22px;
|
||||||
|
background: #d1d5db;
|
||||||
|
border-radius: 11px;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.sub-toggle::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.sub-toggle:checked {
|
||||||
|
background: #10b981;
|
||||||
|
}
|
||||||
|
.sub-toggle:checked::before {
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
|
.sub-toggle__label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted, #6b7280);
|
||||||
|
}
|
||||||
|
.ns-slider {
|
||||||
|
width: 100%;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.ns-slider:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.ns-scale {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted, #6b7280);
|
||||||
|
}
|
||||||
|
.ns-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted, #6b7280);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 매칭 분석 섹션 + 모바일 그리드 fallback**
|
||||||
|
|
||||||
|
같은 파일에 이어서 추가:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* === 신규: 매칭 분석 섹션 ========================================== */
|
||||||
|
.sub-match-analysis {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--surface-soft, #f9fafb);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.sub-match-analysis__score {
|
||||||
|
font-family: var(--font-display, system-ui);
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent, #3b82f6);
|
||||||
|
}
|
||||||
|
.sub-match-analysis__reasons {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
color: var(--text, #1f2937);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.sub-match-analysis__reasons li {
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
.sub-match-analysis__elig {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 모바일 dte-grid → 1칼럼 */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.dte-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 빌드 검증**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: build 성공 (CSS 추가는 lint 영향 없음).
|
||||||
|
|
||||||
|
- [ ] **Step 6: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||||
|
git add src/pages/subscription/Subscription.css
|
||||||
|
git commit -m "feat(subscription): 5티어 뱃지 + 드래그영역 + 토글 + 슬라이더 스타일"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: AnnouncementCard에 district + 5티어 뱃지
|
||||||
|
|
||||||
|
매칭 결과 데이터가 있는 경우만 뱃지 표시.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-ui/src/pages/subscription/Subscription.jsx` AnnouncementCard 함수 (315~389줄 부근)
|
||||||
|
|
||||||
|
- [ ] **Step 1: AnnouncementCard 안 메타 라인에 뱃지 추가**
|
||||||
|
|
||||||
|
`AnnouncementCard` 컴포넌트의 JSX에서 단지명·지역 라인 직후 또는 메타 라인에 다음 뱃지를 추가:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
{item.district && (
|
||||||
|
<span className="sub-chip sub-chip--district">{item.district}</span>
|
||||||
|
)}
|
||||||
|
{(() => {
|
||||||
|
const tier = extractTier(item.match_reasons);
|
||||||
|
return tier ? (
|
||||||
|
<span className={`sub-chip sub-chip--tier sub-chip--tier-${tier}`}>
|
||||||
|
{tier}티어
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
```
|
||||||
|
|
||||||
|
(`extractTier`는 Task 1에서 모듈 상단에 정의됨. JSX 안에서 직접 호출 가능.)
|
||||||
|
|
||||||
|
정확한 삽입 위치: 카드 헤더의 region/area 라인 옆, 또는 status 뱃지 옆에 자연스럽게 배치. 기존 카드 구조에 맞춰 조정.
|
||||||
|
|
||||||
|
- [ ] **Step 2: 빌드 + 린트 검증**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||||
|
npm run build
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: build 성공, lint 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||||
|
git add src/pages/subscription/Subscription.jsx
|
||||||
|
git commit -m "feat(subscription): AnnouncementCard에 district + 5티어 뱃지"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: AnnouncementDetail에 매칭 분석 섹션
|
||||||
|
|
||||||
|
매칭 결과가 있는 공고만 매칭 분석 섹션을 노출.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-ui/src/pages/subscription/Subscription.jsx` AnnouncementDetail 함수 (390~595줄 부근)
|
||||||
|
|
||||||
|
- [ ] **Step 1: AnnouncementDetail 안 매칭 분석 섹션 추가**
|
||||||
|
|
||||||
|
`AnnouncementDetail` 컴포넌트의 JSX 마지막 부분(다른 모든 섹션 다음)에 추가:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
{item.match_score !== undefined && item.match_score !== null && (
|
||||||
|
<div className="sub-match-analysis">
|
||||||
|
<div>
|
||||||
|
<p className="sub-panel__eyebrow">매칭 분석</p>
|
||||||
|
<span className="sub-match-analysis__score">
|
||||||
|
⭐ {item.match_score}<span style={{ fontSize: 14, color: "var(--text-muted)" }}> / 100</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.match_reasons && item.match_reasons.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="sub-panel__eyebrow" style={{ marginTop: 8 }}>💡 매칭 사유</p>
|
||||||
|
<ul className="sub-match-analysis__reasons">
|
||||||
|
{item.match_reasons.map((r, idx) => (
|
||||||
|
<li key={idx}>{r}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.eligible_types && item.eligible_types.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="sub-panel__eyebrow" style={{ marginTop: 8 }}>✓ 신청 자격</p>
|
||||||
|
<div className="sub-match-analysis__elig">
|
||||||
|
{item.eligible_types.map(t => (
|
||||||
|
<span key={t} className="sub-chip">{t}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 빌드 + 린트 검증**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||||
|
npm run build
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: build 성공, lint 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||||
|
git add src/pages/subscription/Subscription.jsx
|
||||||
|
git commit -m "feat(subscription): AnnouncementDetail에 매칭 분석 섹션"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: MatchesTab 매치 카드에 district + 5티어 뱃지
|
||||||
|
|
||||||
|
`MatchesTab`은 별도의 매치 카드 마크업을 가지고 있을 가능성이 높다. `AnnouncementCard`와 동일한 helper(`extractTier`) + 뱃지 패턴을 적용.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-ui/src/pages/subscription/Subscription.jsx` MatchesTab 함수 (763~955줄 부근)
|
||||||
|
|
||||||
|
- [ ] **Step 1: MatchesTab의 매치 카드 마크업을 찾아 district + 5티어 뱃지 삽입**
|
||||||
|
|
||||||
|
`MatchesTab` 함수 안에서 매치 한 건당 렌더하는 영역(보통 `match.house_nm` / `match.region_name` 등을 표시하는 곳)을 찾는다. 거기에 다음 뱃지를 추가:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
{match.district && (
|
||||||
|
<span className="sub-chip sub-chip--district">{match.district}</span>
|
||||||
|
)}
|
||||||
|
{(() => {
|
||||||
|
const tier = extractTier(match.match_reasons);
|
||||||
|
return tier ? (
|
||||||
|
<span className={`sub-chip sub-chip--tier sub-chip--tier-${tier}`}>
|
||||||
|
{tier}티어
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
```
|
||||||
|
|
||||||
|
(`match` 변수명은 실제 코드의 변수명에 맞춰 조정. 보통 `match`, `m`, 또는 `item`)
|
||||||
|
|
||||||
|
- [ ] **Step 2: 빌드 + 린트 검증**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||||
|
npm run build
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: build 성공, lint 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||||
|
git add src/pages/subscription/Subscription.jsx
|
||||||
|
git commit -m "feat(subscription): MatchesTab 카드에 district + 5티어 뱃지"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: CLAUDE.md 업데이트 + 수동 시각 검증
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-ui/CLAUDE.md` (페이지/엔드포인트 표 업데이트)
|
||||||
|
|
||||||
|
- [ ] **Step 1: CLAUDE.md 업데이트**
|
||||||
|
|
||||||
|
`web-ui/CLAUDE.md`를 열고:
|
||||||
|
1. Subscription 페이지 설명 섹션에 신규 기능 한 줄 추가:
|
||||||
|
```
|
||||||
|
- 프로필: 자치구 5티어 분류(드래그&드롭), 알림 임계값/토글 (백엔드 2026-04-28-realestate-targeting-enhancement-design 참조)
|
||||||
|
- 카드/상세: district + 5티어 뱃지 + 매칭 사유 텍스트
|
||||||
|
```
|
||||||
|
2. API 엔드포인트 매핑 표에 `/api/realestate/profile` PUT body가 `preferred_districts` (object), `min_match_score` (int), `notify_enabled` (bool)을 받는다는 한 줄 추가.
|
||||||
|
|
||||||
|
- [ ] **Step 2: 수동 시각 검증 (dev server)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
브라우저에서 `http://localhost:3007` 접속 후 청약 페이지(Subscription) 진입.
|
||||||
|
|
||||||
|
검증 시나리오 (모두 통과해야 함):
|
||||||
|
|
||||||
|
| # | 시나리오 | 기대 결과 |
|
||||||
|
|---|---------|----------|
|
||||||
|
| 1 | 데스크톱 뷰포트(>=768px) → 프로필 탭 → 자치구 영역 표시 | 미할당 풀 + 5티어 그리드(S/A/B/C/D) 노출, 25개 자치구가 미할당 풀에 보임 |
|
||||||
|
| 2 | "강남구"를 S 슬롯으로 드래그 | S 슬롯에 들어가고 미할당에서 사라짐 |
|
||||||
|
| 3 | "송파구"를 A 슬롯으로, "강남구"를 다시 S에서 A로 드래그 | A 슬롯에 둘 다, S는 비워짐 |
|
||||||
|
| 4 | A 슬롯의 "송파구" 칩의 × 버튼 클릭 | 미할당 풀로 복귀 |
|
||||||
|
| 5 | 알림 토글 OFF → 슬라이더 disabled, 안내 텍스트가 "알림 OFF" 톤 |
|
||||||
|
| 6 | 슬라이더 80으로 변경 → "80점 이상 매치 시…" 텍스트 즉시 갱신 |
|
||||||
|
| 7 | "저장" 버튼 클릭 → 새로고침 → 자치구/임계값/토글 값 유지 |
|
||||||
|
| 8 | 모바일 뷰포트(<768px) | 자치구 영역이 read-only 리스트로 변경, 편집 영역 숨김, "PC에서 편집해주세요" 안내 표시 |
|
||||||
|
| 9 | 공고 탭 → 매칭 결과 있는 공고 카드 | district 뱃지 + 5티어 뱃지 표시 (매칭 데이터 있는 경우만) |
|
||||||
|
| 10 | 공고 카드 클릭 → 상세 모달 | 매칭 분석 섹션에 점수 + reasons + 자격 표시 |
|
||||||
|
| 11 | 매칭 탭 → 카드들 | district + 5티어 뱃지 표시 |
|
||||||
|
| 12 | 회귀 — 기존 프로필 필드(나이/청약통장/특공) 입력·저장 | 정상 동작 |
|
||||||
|
|
||||||
|
문제 발견 시 해당 task로 돌아가 수정.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 빌드 최종 검증**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||||
|
npm run build
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 에러·경고 없음.
|
||||||
|
|
||||||
|
- [ ] **Step 4: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||||
|
git add CLAUDE.md
|
||||||
|
git commit -m "docs(web-ui): 청약 5티어 + 알림 설정 문서 업데이트"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 완료 기준
|
||||||
|
|
||||||
|
- 9개 task 모두 commit 완료
|
||||||
|
- `npm run build` warning/error 없이 통과
|
||||||
|
- `npm run lint` 0 errors / 0 warnings
|
||||||
|
- 12개 수동 시각 검증 시나리오 모두 통과
|
||||||
|
- 매칭 점수 70점 이상 + notify_enabled=true + 자치구 S 티어에 강남구 설정 시, 백엔드(이미 NAS에 배포됨)가 신규 매칭 발견 시 텔레그램 알림 송신 (end-to-end)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 배포
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||||
|
npm run release:nas
|
||||||
|
```
|
||||||
|
|
||||||
|
NAS에 robocopy로 빌드 산출물 업로드. NAS Z 드라이브 연결이 전제(`\\gahusb.synology.me\docker`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 참고 — 후속 별도 plan
|
||||||
|
|
||||||
|
- Subscription.jsx 자체 분할 (1354줄 → 별도 리팩토링)
|
||||||
|
- 5축 점수 분해 progress bar (백엔드 응답에 5축 점수 추가 필요)
|
||||||
|
- 임계값 통과 매치 카운트 미리보기 (`dashboard.pass_count` 백엔드 신설 필요)
|
||||||
|
- 자치구 5티어 자동 추천 (사용자 가점·예산 기반)
|
||||||
|
- 알림 채널 추가 (이메일/Slack)
|
||||||
|
- 모바일 자치구 편집 지원 (touch backend 도입 시)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,402 @@
|
|||||||
|
# Lotto 구매 연동 + 전략 진화 시스템 설계
|
||||||
|
|
||||||
|
> 작성일: 2026-04-05
|
||||||
|
> 상태: 승인 대기
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 목표
|
||||||
|
|
||||||
|
로또 번호 추천 기능을 고도화하여:
|
||||||
|
1. **동행복권 실 구매 연동** — 추천 번호를 클립보드 복사 + 동행복권 바로가기로 실제 구매 지원
|
||||||
|
2. **가상 구매 모드** — 돈을 쓰지 않고 "이 번호로 구매한다"를 등록, 결과 발표 후 자동 가상 수익률 계산
|
||||||
|
3. **전략 진화 시스템** — 구매 이력 기반으로 각 추천 전략(combined, simulation, heatmap, manual, custom)의 성과를 추적하고, EMA + Softmax로 가중치를 자동 조정하는 메타 전략
|
||||||
|
4. **통합 구매 이력** — 실제/가상 구매를 하나의 리스트에서 관리하되, 실 구매는 시각적으로 강조
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 접근 방식
|
||||||
|
|
||||||
|
**방식 1 (단일 확장) 채택**: 기존 `lotto-backend`(backend/) 서비스 내부에 모듈 추가.
|
||||||
|
- NAS Celeron J4025 환경에서 새 컨테이너 추가는 리소스 부담
|
||||||
|
- 기존 checker/recommender/DB와 자연스러운 연동 가능
|
||||||
|
- 파일 수준 모듈 분리로 유지보수성 확보
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 데이터 모델
|
||||||
|
|
||||||
|
### 3.1 기존 `purchase_history` 테이블 마이그레이션
|
||||||
|
|
||||||
|
현재 스키마:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE purchase_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
draw_no INTEGER NOT NULL,
|
||||||
|
amount INTEGER NOT NULL,
|
||||||
|
sets INTEGER NOT NULL DEFAULT 1,
|
||||||
|
prize INTEGER NOT NULL DEFAULT 0,
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
마이그레이션 전략: **ALTER TABLE로 컬럼 추가** (기존 데이터 보존)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE purchase_history ADD COLUMN numbers TEXT NOT NULL DEFAULT '[]';
|
||||||
|
ALTER TABLE purchase_history ADD COLUMN is_real INTEGER NOT NULL DEFAULT 1;
|
||||||
|
ALTER TABLE purchase_history ADD COLUMN source_strategy TEXT NOT NULL DEFAULT 'manual';
|
||||||
|
ALTER TABLE purchase_history ADD COLUMN source_detail TEXT NOT NULL DEFAULT '{}';
|
||||||
|
ALTER TABLE purchase_history ADD COLUMN checked INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE purchase_history ADD COLUMN results TEXT NOT NULL DEFAULT '[]';
|
||||||
|
ALTER TABLE purchase_history ADD COLUMN total_prize INTEGER NOT NULL DEFAULT 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
- 기존 레코드: `is_real=1`, `source_strategy='manual'`, `checked=0` (기본값)
|
||||||
|
- 기존 `prize` 컬럼은 하위호환용으로 유지. 신규 로직은 `total_prize` + `results` 사용
|
||||||
|
- 기존 `sets` 컬럼은 하위호환용으로 유지. 신규 로직은 `numbers` JSON 배열 길이로 세트 수 산출
|
||||||
|
|
||||||
|
### 3.2 신규 `strategy_performance` 테이블
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS strategy_performance (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
strategy TEXT NOT NULL,
|
||||||
|
draw_no INTEGER NOT NULL,
|
||||||
|
sets_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_correct INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_correct INTEGER NOT NULL DEFAULT 0,
|
||||||
|
prize_total INTEGER NOT NULL DEFAULT 0,
|
||||||
|
avg_score REAL NOT NULL DEFAULT 0.0,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
UNIQUE(strategy, draw_no)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 신규 `strategy_weights` 테이블
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS strategy_weights (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
strategy TEXT NOT NULL UNIQUE,
|
||||||
|
weight REAL NOT NULL DEFAULT 0.2,
|
||||||
|
ema_score REAL NOT NULL DEFAULT 0.15,
|
||||||
|
total_sets INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_hits_3plus INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
초기 가중치 (첫 실행 시 seed):
|
||||||
|
|
||||||
|
| strategy | weight | ema_score |
|
||||||
|
|-----------|--------|-----------|
|
||||||
|
| combined | 0.30 | 0.15 |
|
||||||
|
| simulation | 0.25 | 0.15 |
|
||||||
|
| heatmap | 0.20 | 0.15 |
|
||||||
|
| manual | 0.15 | 0.15 |
|
||||||
|
| custom | 0.10 | 0.15 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API 설계
|
||||||
|
|
||||||
|
### 4.1 구매 API (기존 경로 확장)
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 변경 사항 |
|
||||||
|
|--------|------|----------|
|
||||||
|
| `POST` | `/api/lotto/purchase` | 요청 바디 확장 (numbers, is_real, source_strategy, source_detail 추가) |
|
||||||
|
| `GET` | `/api/lotto/purchase` | 필터 추가: `is_real`, `strategy`, `checked` |
|
||||||
|
| `GET` | `/api/lotto/purchase/stats` | 응답 확장: total/real/virtual + by_strategy 섹션 |
|
||||||
|
| `PUT` | `/api/lotto/purchase/{id}` | 기존 그대로 (allowed 필드 확장) |
|
||||||
|
| `DELETE` | `/api/lotto/purchase/{id}` | 기존 그대로 |
|
||||||
|
|
||||||
|
**POST 요청 바디:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"draw_no": 1125,
|
||||||
|
"numbers": [[3, 12, 23, 34, 38, 45], [7, 14, 21, 29, 36, 42]],
|
||||||
|
"is_real": true,
|
||||||
|
"amount": 2000,
|
||||||
|
"source_strategy": "combined",
|
||||||
|
"source_detail": {"recommendation_ids": [451, 452]},
|
||||||
|
"note": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
하위호환: `numbers`가 빈 배열이면 기존 방식(sets + amount만)으로 동작. `is_real` 미지정 시 기본값 `true`.
|
||||||
|
|
||||||
|
**GET /purchase/stats 응답:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total": {"sets": 48, "invested": 48000, "prize": 15000, "roi": -68.75, "win_rate": 12.5},
|
||||||
|
"real": {"sets": 20, "invested": 20000, "prize": 10000, "roi": -50.0, "win_rate": 15.0},
|
||||||
|
"virtual": {"sets": 28, "invested": 28000, "prize": 5000, "roi": -82.14, "win_rate": 10.7},
|
||||||
|
"by_strategy": {
|
||||||
|
"combined": {"sets": 15, "avg_correct": 1.8, "hits_3plus": 3, "roi": -45.0},
|
||||||
|
"simulation": {"sets": 12, "avg_correct": 2.1, "hits_3plus": 4, "roi": -30.0}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 전략 진화 API (신규)
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `GET` | `/api/lotto/strategy/weights` | 현재 전략별 가중치 + 성과 요약 + trend |
|
||||||
|
| `GET` | `/api/lotto/strategy/performance` | 전략별 회차 성과 이력 (차트용, `days` 파라미터) |
|
||||||
|
| `POST` | `/api/lotto/strategy/evolve` | 수동 가중치 재계산 트리거 |
|
||||||
|
|
||||||
|
**GET /strategy/weights 응답:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"weights": [
|
||||||
|
{"strategy": "combined", "weight": 0.32, "ema_score": 0.285, "total_sets": 15, "hits_3plus": 3, "trend": "up"},
|
||||||
|
{"strategy": "simulation", "weight": 0.28, "ema_score": 0.312, "total_sets": 12, "hits_3plus": 4, "trend": "up"},
|
||||||
|
{"strategy": "heatmap", "weight": 0.18, "ema_score": 0.195, "total_sets": 10, "hits_3plus": 1, "trend": "down"},
|
||||||
|
{"strategy": "manual", "weight": 0.14, "ema_score": 0.160, "total_sets": 8, "hits_3plus": 1, "trend": "stable"},
|
||||||
|
{"strategy": "custom", "weight": 0.08, "ema_score": 0.105, "total_sets": 3, "hits_3plus": 0, "trend": "stable"}
|
||||||
|
],
|
||||||
|
"last_evolved": "2026-04-05T09:10:00",
|
||||||
|
"min_data_draws": 10,
|
||||||
|
"current_data_draws": 32,
|
||||||
|
"status": "active"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 스마트 추천 API (신규)
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `GET` | `/api/lotto/recommend/smart` | 전략 가중치 기반 메타 전략 추천. `sets` 파라미터 (기본 5) |
|
||||||
|
|
||||||
|
**응답:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sets": [
|
||||||
|
{
|
||||||
|
"numbers": [3, 12, 23, 34, 38, 45],
|
||||||
|
"meta_score": 0.847,
|
||||||
|
"source_strategy": "simulation",
|
||||||
|
"contribution": {"simulation": 0.42, "combined": 0.31, "heatmap": 0.27},
|
||||||
|
"individual_scores": {"frequency": 0.82, "fingerprint": 0.91, "gap": 0.78, "cooccur": 0.85, "diversity": 0.73}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"strategy_weights_used": {"combined": 0.32, "simulation": 0.28, "heatmap": 0.18, "manual": 0.14, "custom": 0.08},
|
||||||
|
"learning_status": {"draws_learned": 32, "status": "active", "message": ""}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 전략 진화 알고리즘
|
||||||
|
|
||||||
|
### 5.1 성과 점수 산출 (회차별, 세트별)
|
||||||
|
|
||||||
|
```python
|
||||||
|
set_score = correct_count / 6.0
|
||||||
|
|
||||||
|
# 당첨 등수별 보너스
|
||||||
|
RANK_BONUS = {5: 0.1, 4: 0.3, 3: 0.6, 2: 0.8, 1: 1.0}
|
||||||
|
set_score += RANK_BONUS.get(rank, 0)
|
||||||
|
|
||||||
|
# 한 구매 건의 draw_score = avg(set_scores)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 EMA 갱신
|
||||||
|
|
||||||
|
```python
|
||||||
|
ALPHA = 0.3 # 최근 3~4회차가 EMA의 ~65% 차지
|
||||||
|
new_ema = ALPHA * draw_score + (1 - ALPHA) * old_ema
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 가중치 변환 (Softmax)
|
||||||
|
|
||||||
|
```python
|
||||||
|
TEMPERATURE = 2.0
|
||||||
|
MIN_WEIGHT = 0.05
|
||||||
|
|
||||||
|
raw = {s: exp(ema / TEMPERATURE) for s, ema in ema_scores.items()}
|
||||||
|
total = sum(raw.values())
|
||||||
|
weights = {s: max(v / total, MIN_WEIGHT) for s, v in raw.items()}
|
||||||
|
# 재정규화하여 합 = 1.0
|
||||||
|
remainder = 1.0 - sum(weights.values())
|
||||||
|
# ... 비례 배분으로 조정
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 재계산 타이밍
|
||||||
|
|
||||||
|
- **자동**: `check_results_for_draw()` → purchases 체크 → strategy_performance 갱신 → weights 재계산
|
||||||
|
- **수동**: `POST /api/lotto/strategy/evolve`
|
||||||
|
|
||||||
|
### 5.5 스마트 추천 흐름
|
||||||
|
|
||||||
|
1. `strategy_weights` 로드
|
||||||
|
2. 각 전략에서 후보 10세트 생성:
|
||||||
|
- `combined`: `generate_combined_recommendation()` x 10
|
||||||
|
- `simulation`: `get_best_picks()` 상위 10개
|
||||||
|
- `heatmap`: `recommend_with_heatmap()` x 10
|
||||||
|
- `manual`: `recommend_numbers()` x 10
|
||||||
|
- `custom`: 데이터 없으면 skip
|
||||||
|
3. `meta_score = original_score x strategy_weight`
|
||||||
|
4. 전체 풀에서 중복 제거 후 상위 N세트 선출
|
||||||
|
5. 각 세트에 출처 전략 + 기여도 breakdown 첨부
|
||||||
|
|
||||||
|
### 5.6 콜드 스타트
|
||||||
|
|
||||||
|
- 구매 이력 0건: 초기 가중치 그대로 사용
|
||||||
|
- 특정 전략 구매 0건: 해당 전략 EMA 초기값(0.15) 유지
|
||||||
|
- 10회차 미만: 스마트 추천 응답에 `status: "learning"` + 기존 combined 추천 병행
|
||||||
|
|
||||||
|
### 5.7 Trend 판정
|
||||||
|
|
||||||
|
```python
|
||||||
|
recent_delta = current_ema - ema_5_draws_ago
|
||||||
|
if recent_delta > 0.02: trend = "up"
|
||||||
|
elif recent_delta < -0.02: trend = "down"
|
||||||
|
else: trend = "stable"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 체커 연동 (자동 파이프라인)
|
||||||
|
|
||||||
|
기존 흐름에 purchase 체크를 연결:
|
||||||
|
|
||||||
|
```
|
||||||
|
Scheduler (09:10 / 21:10)
|
||||||
|
→ sync_latest()
|
||||||
|
→ 새 회차 감지 시:
|
||||||
|
→ check_results_for_draw() # 기존: recommendations 체크
|
||||||
|
→ check_purchases_for_draw() # 신규: purchases 체크
|
||||||
|
→ 각 세트별 rank/correct/bonus 계산 (checker._calc_rank 재사용)
|
||||||
|
→ purchases.results, total_prize, checked=1 갱신
|
||||||
|
→ strategy_performance upsert
|
||||||
|
→ strategy_evolver.recalculate_weights()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 백엔드 모듈 구조
|
||||||
|
|
||||||
|
### 7.1 신규 파일
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `purchase_manager.py` | 구매 이력 관리 + 결과 체크 |
|
||||||
|
| `strategy_evolver.py` | EMA 계산 + 가중치 진화 + 스마트 추천 |
|
||||||
|
|
||||||
|
### 7.2 수정 파일
|
||||||
|
|
||||||
|
| 파일 | 변경 내용 |
|
||||||
|
|------|----------|
|
||||||
|
| `db.py` | purchase_history ALTER + 신규 테이블 2개 + CRUD 함수 추가 |
|
||||||
|
| `main.py` | 신규 엔드포인트 9개 + Pydantic 모델 + import |
|
||||||
|
| `checker.py` | `check_results_for_draw()` 끝에 purchase 체크 호출 추가 |
|
||||||
|
|
||||||
|
### 7.3 기존 유지 파일 (변경 없음)
|
||||||
|
|
||||||
|
`recommender.py`, `generator.py`, `analyzer.py`, `collector.py`, `utils.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 프론트엔드 변경
|
||||||
|
|
||||||
|
### 8.1 신규 컴포넌트
|
||||||
|
|
||||||
|
| 컴포넌트 | 역할 |
|
||||||
|
|----------|------|
|
||||||
|
| `SmartRecommendPanel.jsx` | 전략 진화 기반 메타 추천 + 구매 버튼 |
|
||||||
|
| `PurchaseHub.jsx` | 통합 구매 이력 (기존 PurchasePanel 대체) |
|
||||||
|
| `StrategyDashboard.jsx` | 전략 가중치 시각화 + 성과 추이 차트 |
|
||||||
|
| `PurchaseButton.jsx` | 공통 구매 버튼 (실구매/가상구매) |
|
||||||
|
|
||||||
|
### 8.2 수정 컴포넌트
|
||||||
|
|
||||||
|
| 컴포넌트 | 변경 내용 |
|
||||||
|
|----------|----------|
|
||||||
|
| `CombinedRecommendPanel.jsx` | 구매 버튼(PurchaseButton) 추가 |
|
||||||
|
| `Functions.jsx` | 신규 패널 3개 추가 + import |
|
||||||
|
|
||||||
|
### 8.3 신규 훅
|
||||||
|
|
||||||
|
| 훅 | 역할 |
|
||||||
|
|----|------|
|
||||||
|
| `useStrategyWeights.js` | 전략 가중치/성과 데이터 fetch |
|
||||||
|
|
||||||
|
### 8.4 수정 훅
|
||||||
|
|
||||||
|
| 훅 | 변경 내용 |
|
||||||
|
|----|----------|
|
||||||
|
| `usePurchases.js` | 새 API 스키마 연동 (numbers, is_real, source_strategy 등) |
|
||||||
|
|
||||||
|
### 8.5 API 헬퍼 추가 (`api.js`)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 전략
|
||||||
|
getStrategyWeights() // GET /api/lotto/strategy/weights
|
||||||
|
getStrategyPerformance(days) // GET /api/lotto/strategy/performance
|
||||||
|
triggerStrategyEvolve() // POST /api/lotto/strategy/evolve
|
||||||
|
|
||||||
|
// 스마트 추천
|
||||||
|
getSmartRecommend(sets) // GET /api/lotto/recommend/smart
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.6 동행복권 바로가기
|
||||||
|
|
||||||
|
별도 API 없음. 프론트엔드 PurchaseButton에서:
|
||||||
|
1. 번호를 클립보드에 복사
|
||||||
|
2. `window.open('https://dhlottery.co.kr/gameResult.do?method=byWin')` — 새 탭
|
||||||
|
3. 확인 다이얼로그 "구매 완료했나요?" → 예 → `POST /api/lotto/purchase (is_real=1)`
|
||||||
|
|
||||||
|
### 8.7 UI 시각 구분
|
||||||
|
|
||||||
|
- 실 구매: 금색/강조 배경 + 지갑 아이콘
|
||||||
|
- 가상 구매: 기본 배경 + 게임패드 아이콘
|
||||||
|
- 미확인: 시계 아이콘
|
||||||
|
- 당첨: 초록 하이라이트 + 체크 아이콘
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 전체 데이터 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
추천(기존) ──[구매 버튼]──→ POST /purchase
|
||||||
|
│
|
||||||
|
스마트 추천(신규) ──[구매 버튼]──┘
|
||||||
|
↓
|
||||||
|
purchase_history 테이블
|
||||||
|
│
|
||||||
|
매주 토요일 추첨 결과 ──→ sync_latest()
|
||||||
|
↓
|
||||||
|
check_results_for_draw()
|
||||||
|
├── recommendations 체크 (기존)
|
||||||
|
└── check_purchases_for_draw() (신규)
|
||||||
|
↓
|
||||||
|
strategy_performance 갱신
|
||||||
|
↓
|
||||||
|
recalculate_weights()
|
||||||
|
↓
|
||||||
|
strategy_weights 갱신
|
||||||
|
↓
|
||||||
|
다음 스마트 추천에 반영 ──→ 순환
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 비기능 요구사항
|
||||||
|
|
||||||
|
- **하위호환**: 기존 purchase API 사용자(프론트 PurchasePanel)는 마이그레이션 중에도 동작해야 함
|
||||||
|
- **성능**: 스마트 추천은 각 전략 10세트 생성 → 총 50세트 중 상위 N개 선출. 1-2초 내 응답 목표
|
||||||
|
- **데이터 안전**: ALTER TABLE은 SQLite 트랜잭션으로 안전하게 실행. 기존 데이터 유실 없음
|
||||||
|
- **콜드 스타트**: 구매 데이터 없어도 스마트 추천 동작 (초기 가중치 사용)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 범위 외 (추후 고려)
|
||||||
|
|
||||||
|
- 동행복권 자동 로그인/자동 구매 (CAPTCHA + 보안 정책으로 불가)
|
||||||
|
- 번호 자동 입력 브라우저 확장 프로그램
|
||||||
|
- 푸시 알림 (당첨 결과 통보)
|
||||||
|
- 다중 사용자 지원
|
||||||
342
docs/superpowers/specs/2026-04-05-realestate-lab-design.md
Normal file
342
docs/superpowers/specs/2026-04-05-realestate-lab-design.md
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
# realestate-lab 설계 스펙
|
||||||
|
|
||||||
|
> 부동산 청약 공고 자동 수집 + 프로필 기반 자격 매칭 서비스
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
|
||||||
|
공공데이터포털(한국부동산원 청약홈 분양정보 API)에서 청약 공고를 자동 수집하고, 사용자 프로필 기반으로 지원 가능 여부를 자동 판별하는 독립 서비스.
|
||||||
|
|
||||||
|
**핵심 목표:**
|
||||||
|
- 수동 공고 등록 없이 자동 수집 → DB 저장
|
||||||
|
- 프로필 기반 자격 매칭 → 지원 가능한 청약만 필터링
|
||||||
|
- 프론트에서 "새 공고 N건" 확인 → 향후 텔레그램 알림 확장
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 서비스 아키텍처
|
||||||
|
|
||||||
|
### 독립 서비스 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
realestate-lab/ # 포트 18800
|
||||||
|
├── app/
|
||||||
|
│ ├── main.py # FastAPI 앱 + APScheduler
|
||||||
|
│ ├── db.py # SQLite CRUD (realestate.db)
|
||||||
|
│ ├── collector.py # 공공데이터포털 API 수집기
|
||||||
|
│ ├── matcher.py # 프로필 기반 자격 매칭 엔진
|
||||||
|
│ └── models.py # Pydantic 요청/응답 모델
|
||||||
|
├── Dockerfile
|
||||||
|
└── requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 수집 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
APScheduler (매일 09:00)
|
||||||
|
→ collector.py: 청약홈 API 5개 엔드포인트 호출
|
||||||
|
→ DB에 신규 공고 upsert (HOUSE_MANAGE_NO + PBLANC_NO 기준)
|
||||||
|
→ matcher.py: 프로필 매칭 → 적격 공고에 match_status 부여
|
||||||
|
→ 신규 매칭 공고 카운트 → (향후) 텔레그램 알림
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 데이터 소스
|
||||||
|
|
||||||
|
### 공공데이터포털 — 한국부동산원_청약홈 분양정보 조회 서비스
|
||||||
|
|
||||||
|
- **Base URL**: `https://api.odcloud.kr/api`
|
||||||
|
- **서비스 키**: `DATA_GO_KR_API_KEY` 환경변수
|
||||||
|
- **일 호출 제한**: 40,000건
|
||||||
|
- **데이터 포맷**: JSON
|
||||||
|
|
||||||
|
### 수집 대상 API 엔드포인트
|
||||||
|
|
||||||
|
| 엔드포인트 | 설명 |
|
||||||
|
|-----------|------|
|
||||||
|
| `/ApplyhomeInfoDetailSvc/v1/getAPTLttotPblancDetail` | APT 분양정보 상세 |
|
||||||
|
| `/ApplyhomeInfoDetailSvc/v1/getUrbtyOfctlLttotPblancDetail` | 오피스텔/도시형/민간임대 상세 |
|
||||||
|
| `/ApplyhomeInfoDetailSvc/v1/getRemndrLttotPblancDetail` | 잔여세대 상세 |
|
||||||
|
| `/ApplyhomeInfoDetailSvc/v1/getPblPvtRentLttotPblancDetail` | 공공지원 민간임대 상세 |
|
||||||
|
| `/ApplyhomeInfoDetailSvc/v1/getOPTLttotPblancDetail` | 임의공급 상세 |
|
||||||
|
|
||||||
|
### 주택형별 상세 API (모델별 세대수·분양가)
|
||||||
|
|
||||||
|
| 엔드포인트 | 설명 |
|
||||||
|
|-----------|------|
|
||||||
|
| `/ApplyhomeInfoDetailSvc/v1/getAPTLttotPblancMdl` | APT 주택형별 |
|
||||||
|
| `/ApplyhomeInfoDetailSvc/v1/getUrbtyOfctlLttotPblancMdl` | 오피스텔 주택형별 |
|
||||||
|
| `/ApplyhomeInfoDetailSvc/v1/getRemndrLttotPblancMdl` | 잔여세대 주택형별 |
|
||||||
|
| `/ApplyhomeInfoDetailSvc/v1/getPblPvtRentLttotPblancMdl` | 공공지원 민간임대 주택형별 |
|
||||||
|
| `/ApplyhomeInfoDetailSvc/v1/getOPTLttotPblancMdl` | 임의공급 주택형별 |
|
||||||
|
|
||||||
|
### 공통 쿼리 파라미터
|
||||||
|
|
||||||
|
- `page` (기본: 1), `perPage` (기본: 100)
|
||||||
|
- `serviceKey` — 인코딩된 API 키
|
||||||
|
- `cond[RCRIT_PBLANC_DE::GTE]` / `cond[RCRIT_PBLANC_DE::LTE]` — 모집공고일 범위 필터
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. DB 스키마 (realestate.db)
|
||||||
|
|
||||||
|
### announcements (청약 공고)
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | INTEGER PK | 자동 증가 |
|
||||||
|
| house_manage_no | TEXT NOT NULL | 주택관리번호 |
|
||||||
|
| pblanc_no | TEXT NOT NULL | 공고번호 |
|
||||||
|
| house_nm | TEXT | 주택명 |
|
||||||
|
| house_secd | TEXT | 주택구분코드 (01:APT, 02:오피스텔, 04:무순위 등) |
|
||||||
|
| house_dtl_secd | TEXT | 주택상세구분코드 (01:민영, 03:국민 등) |
|
||||||
|
| rent_secd | TEXT | 분양구분 (0:분양, 1:임대) |
|
||||||
|
| region_code | TEXT | 공급지역코드 |
|
||||||
|
| region_name | TEXT | 공급지역명 |
|
||||||
|
| address | TEXT | 공급위치 |
|
||||||
|
| total_units | INTEGER | 공급규모 |
|
||||||
|
| rcrit_date | TEXT | 모집공고일 |
|
||||||
|
| receipt_start | TEXT | 청약접수시작일 |
|
||||||
|
| receipt_end | TEXT | 청약접수종료일 |
|
||||||
|
| spsply_start | TEXT | 특별공급 접수시작일 |
|
||||||
|
| spsply_end | TEXT | 특별공급 접수종료일 |
|
||||||
|
| gnrl_rank1_start | TEXT | 1순위 접수시작일 |
|
||||||
|
| gnrl_rank1_end | TEXT | 1순위 접수종료일 |
|
||||||
|
| winner_date | TEXT | 당첨자발표일 |
|
||||||
|
| contract_start | TEXT | 계약시작일 |
|
||||||
|
| contract_end | TEXT | 계약종료일 |
|
||||||
|
| homepage_url | TEXT | 홈페이지 |
|
||||||
|
| pblanc_url | TEXT | 공고 URL |
|
||||||
|
| constructor | TEXT | 시공사 |
|
||||||
|
| developer | TEXT | 시행사 |
|
||||||
|
| move_in_month | TEXT | 입주예정월 |
|
||||||
|
| is_speculative_area | TEXT | 투기과열지구 |
|
||||||
|
| is_price_cap | TEXT | 분양가상한제 |
|
||||||
|
| contact | TEXT | 문의처 |
|
||||||
|
| status | TEXT | 청약예정/청약중/결과발표/완료 (자동 계산) |
|
||||||
|
| source | TEXT | auto/manual |
|
||||||
|
| created_at | TEXT | |
|
||||||
|
| updated_at | TEXT | |
|
||||||
|
|
||||||
|
- UNIQUE 제약: `(house_manage_no, pblanc_no)`
|
||||||
|
- INDEX: `idx_realestate_status` on `status`
|
||||||
|
- INDEX: `idx_realestate_region` on `region_name`
|
||||||
|
|
||||||
|
### announcement_models (주택형별 상세)
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | INTEGER PK | |
|
||||||
|
| house_manage_no | TEXT | FK → announcements |
|
||||||
|
| pblanc_no | TEXT | FK → announcements |
|
||||||
|
| model_no | TEXT | 모델번호 |
|
||||||
|
| house_ty | TEXT | 주택형 (84A 등) |
|
||||||
|
| supply_area | REAL | 공급면적(㎡) |
|
||||||
|
| general_units | INTEGER | 일반공급 세대수 |
|
||||||
|
| special_units | INTEGER | 특별공급 세대수 |
|
||||||
|
| multi_child_units | INTEGER | 다자녀 |
|
||||||
|
| newlywed_units | INTEGER | 신혼부부 |
|
||||||
|
| first_life_units | INTEGER | 생애최초 |
|
||||||
|
| old_parent_units | INTEGER | 노부모부양 |
|
||||||
|
| institution_units | INTEGER | 기관추천 |
|
||||||
|
| youth_units | INTEGER | 청년 |
|
||||||
|
| newborn_units | INTEGER | 신생아 |
|
||||||
|
| top_amount | INTEGER | 분양최고금액(만원) |
|
||||||
|
|
||||||
|
- UNIQUE: `(house_manage_no, pblanc_no, model_no)`
|
||||||
|
|
||||||
|
### user_profile (사용자 청약 프로필)
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | INTEGER PK | 항상 1 (단일 사용자) |
|
||||||
|
| name | TEXT | 이름 |
|
||||||
|
| age | INTEGER | 나이 |
|
||||||
|
| is_homeless | BOOLEAN | 무주택 여부 |
|
||||||
|
| is_householder | BOOLEAN | 세대주 여부 |
|
||||||
|
| subscription_months | INTEGER | 청약통장 가입개월수 |
|
||||||
|
| subscription_amount | INTEGER | 청약통장 납입총액(만원) |
|
||||||
|
| family_members | INTEGER | 세대원 수 |
|
||||||
|
| has_dependents | BOOLEAN | 부양가족 유무 |
|
||||||
|
| children_count | INTEGER | 미성년 자녀수 |
|
||||||
|
| is_newlywed | BOOLEAN | 신혼부부 여부 |
|
||||||
|
| marriage_months | INTEGER | 혼인기간(개월) |
|
||||||
|
| has_newborn | BOOLEAN | 2세 이하 자녀 유무 |
|
||||||
|
| is_first_home | BOOLEAN | 생애최초 해당 여부 |
|
||||||
|
| income_level | TEXT | 소득수준 (100%이하/100~130%/130~160%) |
|
||||||
|
| preferred_regions | TEXT | 관심지역 JSON 배열 |
|
||||||
|
| preferred_types | TEXT | 관심주택유형 JSON 배열 |
|
||||||
|
| min_area | REAL | 최소 희망면적(㎡) |
|
||||||
|
| max_area | REAL | 최대 희망면적(㎡) |
|
||||||
|
| max_price | INTEGER | 최대 분양가(만원) |
|
||||||
|
| updated_at | TEXT | |
|
||||||
|
|
||||||
|
### match_results (매칭 결과)
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | INTEGER PK | |
|
||||||
|
| announcement_id | INTEGER | FK → announcements |
|
||||||
|
| model_id | INTEGER | FK → announcement_models (nullable) |
|
||||||
|
| match_score | INTEGER | 매칭 점수 (0~100) |
|
||||||
|
| match_reasons | TEXT | 매칭 사유 JSON 배열 |
|
||||||
|
| eligible_types | TEXT | 지원 가능 유형 JSON 배열 |
|
||||||
|
| is_new | BOOLEAN | 신규 매칭 여부 (알림용) |
|
||||||
|
| created_at | TEXT | |
|
||||||
|
|
||||||
|
- UNIQUE: `(announcement_id, model_id)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API 엔드포인트
|
||||||
|
|
||||||
|
### 청약 공고
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/realestate/announcements` | 공고 목록 (필터: region, status, house_type, matched_only, sort, page, size) |
|
||||||
|
| GET | `/api/realestate/announcements/{id}` | 공고 상세 (주택형별 포함) |
|
||||||
|
| POST | `/api/realestate/announcements` | 수동 공고 등록 |
|
||||||
|
| PUT | `/api/realestate/announcements/{id}` | 공고 수정 |
|
||||||
|
| DELETE | `/api/realestate/announcements/{id}` | 공고 삭제 |
|
||||||
|
|
||||||
|
### 수집 관리
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| POST | `/api/realestate/collect` | 수동 수집 트리거 |
|
||||||
|
| GET | `/api/realestate/collect/status` | 마지막 수집 결과 (수집일시, 신규건수, 에러) |
|
||||||
|
|
||||||
|
### 프로필
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/realestate/profile` | 내 프로필 조회 |
|
||||||
|
| PUT | `/api/realestate/profile` | 프로필 수정 (upsert) |
|
||||||
|
|
||||||
|
### 매칭
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/realestate/matches` | 매칭 결과 목록 (점수순, 신규 우선) |
|
||||||
|
| POST | `/api/realestate/matches/refresh` | 매칭 재계산 |
|
||||||
|
| PATCH | `/api/realestate/matches/{id}/read` | 신규 알림 읽음 처리 |
|
||||||
|
|
||||||
|
### 대시보드
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/realestate/dashboard` | 요약 (진행중 공고수, 신규 매칭수, 다가오는 일정) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 매칭 엔진
|
||||||
|
|
||||||
|
### 점수 산출 (0~100)
|
||||||
|
|
||||||
|
| 기준 | 가중치 | 로직 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 지역 매칭 | 30 | preferred_regions에 포함 → 30점 |
|
||||||
|
| 주택유형 매칭 | 10 | preferred_types에 포함 → 10점 |
|
||||||
|
| 면적 매칭 | 15 | min_area~max_area 범위 내 주택형 존재 → 15점 |
|
||||||
|
| 가격 매칭 | 15 | max_price 이하 주택형 존재 → 15점 |
|
||||||
|
| 자격 매칭 | 30 | 지원 가능 공급유형 수에 비례 |
|
||||||
|
|
||||||
|
### 자격 매칭 세부
|
||||||
|
|
||||||
|
| 공급유형 | 판별 조건 |
|
||||||
|
|----------|----------|
|
||||||
|
| 일반 1순위 | 무주택 + 세대주 + 청약통장 가입기간 충족 (투기과열 24개월, 그 외 12개월) |
|
||||||
|
| 일반 2순위 | 1순위 미충족 시 |
|
||||||
|
| 특별-신혼부부 | is_newlywed + 무주택 + 소득기준 |
|
||||||
|
| 특별-생애최초 | is_first_home + 무주택 + 소득기준 |
|
||||||
|
| 특별-다자녀 | children_count >= 2 + 무주택 |
|
||||||
|
| 특별-노부모부양 | has_dependents + 무주택 |
|
||||||
|
| 특별-청년 | age 19~39 + 무주택 |
|
||||||
|
| 특별-신생아 | has_newborn + 무주택 |
|
||||||
|
|
||||||
|
- 1개 유형 → 10점, 2개 → 20점, 3개 이상 → 30점
|
||||||
|
- `eligible_types`: 지원 가능 유형 목록 저장
|
||||||
|
- `match_reasons`: 각 판별 사유 저장
|
||||||
|
|
||||||
|
### 상태 자동 계산
|
||||||
|
|
||||||
|
```
|
||||||
|
오늘 < receipt_start → 청약예정
|
||||||
|
receipt_start ≤ 오늘 ≤ receipt_end → 청약중
|
||||||
|
receipt_end < 오늘 ≤ winner_date → 결과발표
|
||||||
|
오늘 > winner_date → 완료
|
||||||
|
```
|
||||||
|
|
||||||
|
### 매칭 실행 시점
|
||||||
|
|
||||||
|
- 신규 공고 수집 후 자동 실행
|
||||||
|
- 프로필 변경 시 `POST /matches/refresh`로 재계산
|
||||||
|
- 매일 00:00 상태 갱신 시 재매칭
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 인프라 통합
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
realestate-lab:
|
||||||
|
build: ./realestate-lab
|
||||||
|
container_name: realestate-lab
|
||||||
|
ports:
|
||||||
|
- "18800:8000"
|
||||||
|
volumes:
|
||||||
|
- ${RUNTIME_PATH:-.}/data:/app/data
|
||||||
|
environment:
|
||||||
|
- DATA_GO_KR_API_KEY=${DATA_GO_KR_API_KEY}
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nginx
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /api/realestate/ {
|
||||||
|
proxy_pass http://realestate-lab:8000;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### APScheduler
|
||||||
|
|
||||||
|
| 시간 | Job | 설명 |
|
||||||
|
|------|-----|------|
|
||||||
|
| 매일 09:00 | `run_collection` | 5개 API 수집 → 매칭 |
|
||||||
|
| 매일 00:00 | `update_statuses` | 날짜 기반 상태 갱신 |
|
||||||
|
|
||||||
|
### 배포
|
||||||
|
|
||||||
|
- `scripts/deploy-nas.sh`에 `realestate-lab/` rsync 대상 추가
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. lotto-backend 제거 대상
|
||||||
|
|
||||||
|
| 파일 | 제거 항목 |
|
||||||
|
|------|----------|
|
||||||
|
| `backend/app/db.py` | `realestate_complexes` 테이블 생성, CRUD 함수 5개 |
|
||||||
|
| `backend/app/main.py` | `ComplexCreate`/`ComplexUpdate` 모델, `/api/realestate/complexes` 라우트 4개 |
|
||||||
|
|
||||||
|
기존 `realestate_complexes` 테이블 데이터는 마이그레이션 불필요 (스키마 완전 상이).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 환경변수
|
||||||
|
|
||||||
|
| 변수 | 설명 | 필수 |
|
||||||
|
|------|------|------|
|
||||||
|
| `DATA_GO_KR_API_KEY` | 공공데이터포털 API 키 | 선택 (미설정 시 수동 등록만 가능) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 향후 확장
|
||||||
|
|
||||||
|
- **텔레그램 알림**: 신규 매칭 공고 발생 시 텔레그램 봇으로 push (알림 모듈 분리 구조 대비)
|
||||||
|
- **경쟁률 조회**: 청약 접수 기간 중 경쟁률 실시간 수집
|
||||||
|
- **실거래가 비교**: 주변 시세와 분양가 비교 분석
|
||||||
163
docs/superpowers/specs/2026-04-07-pet-lab-design.md
Normal file
163
docs/superpowers/specs/2026-04-07-pet-lab-design.md
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
# Pet Lab - Desktop Pet Application Design
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Windows PC 바탕화면에 항상 떠있는 데스크톱 펫 애플리케이션. 캐릭터(박뚱냥)가 화면 하단에 고정되어 마우스 방향으로 시선을 추적하고, 클릭/우클릭으로 상호작용할 수 있다.
|
||||||
|
|
||||||
|
**프로젝트 위치**: `C:\Users\jaeoh\Desktop\workspace\pet-lab` (독립 프로젝트, web-backend 모노레포 외부)
|
||||||
|
|
||||||
|
**기술 스택**: Python 3.12 + PyQt5
|
||||||
|
|
||||||
|
**배포**: 로컬 Windows PC 실행 전용 (NAS 배포 불필요). 추후 PyInstaller로 .exe 패킹.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
pet-lab/
|
||||||
|
├── app/
|
||||||
|
│ ├── main.py # 엔트리포인트 (QApplication 초기화, 시스템 트레이)
|
||||||
|
│ ├── pet_widget.py # 메인 위젯 (투명 윈도우 + 캐릭터 렌더링)
|
||||||
|
│ ├── eye_tracker.py # 마우스 위치 기반 시선/기울기 계산
|
||||||
|
│ ├── interaction.py # 클릭 반응 애니메이션 + 우클릭 컨텍스트 메뉴
|
||||||
|
│ └── config.py # 설정값 (크기, 위치, 속도 상수)
|
||||||
|
├── assets/
|
||||||
|
│ └── characters/
|
||||||
|
│ └── 박뚱냥.png # 캐릭터 이미지 (투명 배경 PNG)
|
||||||
|
├── requirements.txt # PyQt5
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Responsibilities
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `main.py` | QApplication 생성, PetWidget 인스턴스화, 이벤트 루프 시작 |
|
||||||
|
| `pet_widget.py` | 투명 프레임리스 윈도우, 캐릭터 이미지 표시, QTimer 루프로 시선 업데이트 |
|
||||||
|
| `eye_tracker.py` | 마우스 좌표 → 기울기 각도/좌우 반전 여부 계산 (순수 계산 모듈) |
|
||||||
|
| `interaction.py` | 좌클릭(점프), 더블클릭(흔들기) 애니메이션, 우클릭 메뉴 생성/처리 |
|
||||||
|
| `config.py` | 상수 정의: 캐릭터 크기(소/중/대), 틸트 범위, 타이머 간격 등 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Behavior
|
||||||
|
|
||||||
|
### 투명 윈도우
|
||||||
|
|
||||||
|
PyQt5 윈도우 플래그 조합:
|
||||||
|
- `Qt.FramelessWindowHint`: 타이틀바 제거
|
||||||
|
- `Qt.WindowStaysOnTopHint`: 항상 위 (토글 가능)
|
||||||
|
- `Qt.Tool`: 태스크바에 표시 안 함
|
||||||
|
- `WA_TranslucentBackground`: 배경 투명
|
||||||
|
|
||||||
|
캐릭터 이미지 영역만 클릭 이벤트 수신. 투명 영역은 `WA_TransparentForMouseEvents`가 아닌, 위젯 크기를 캐릭터 이미지 크기에 맞춰서 처리.
|
||||||
|
|
||||||
|
### 바닥 고정 위치
|
||||||
|
|
||||||
|
- Y = 화면 높이 - 태스크바 높이(기본 48px) - 캐릭터 높이
|
||||||
|
- X = 수평 위치 프리셋: 좌(화면 10%), 중앙(50%), 우(90%)
|
||||||
|
- 기본 위치: 화면 우측(90%)
|
||||||
|
- 태스크바 높이는 Windows API 없이 기본값 48px 사용 (충분히 실용적)
|
||||||
|
|
||||||
|
### 시선 추적
|
||||||
|
|
||||||
|
QTimer(30ms 간격, 약 33fps)로 글로벌 마우스 좌표 폴링:
|
||||||
|
|
||||||
|
1. `QCursor.pos()`로 마우스 절대 좌표 획득
|
||||||
|
2. 캐릭터 중심점과 마우스 사이의 각도 계산 (`math.atan2`)
|
||||||
|
3. 각도를 기울기로 변환:
|
||||||
|
- 마우스가 캐릭터 왼쪽 → 이미지 좌측 기울기 (음수 각도)
|
||||||
|
- 마우스가 캐릭터 오른쪽 → 이미지 우측 기울기 (양수 각도)
|
||||||
|
- 기울기 범위: -15도 ~ +15도
|
||||||
|
4. 마우스가 캐릭터 왼쪽이면 이미지 좌우 반전 (`QTransform.scale(-1, 1)`)
|
||||||
|
5. `QTransform.rotate(angle)`로 기울기 적용
|
||||||
|
6. 마우스 좌표 변화 없으면 렌더링 스킵 (성능 최적화)
|
||||||
|
|
||||||
|
### 클릭 반응
|
||||||
|
|
||||||
|
**좌클릭 — 점프**:
|
||||||
|
- `QPropertyAnimation`으로 위젯 Y좌표를 위로 30px 이동 후 복귀
|
||||||
|
- duration: 300ms, easing: `QEasingCurve.OutBounce`
|
||||||
|
|
||||||
|
**더블클릭 — 흔들기**:
|
||||||
|
- `QPropertyAnimation`으로 X좌표를 좌우 진동
|
||||||
|
- duration: 400ms, 좌(-10) → 우(+10) → 원위치
|
||||||
|
|
||||||
|
### 우클릭 컨텍스트 메뉴
|
||||||
|
|
||||||
|
| 메뉴 항목 | 동작 |
|
||||||
|
|-----------|------|
|
||||||
|
| 위치: 좌/중앙/우 | 캐릭터 수평 위치 변경 |
|
||||||
|
| 크기: 소/중/대 | 캐릭터 크기 변경 (100/150/200px) |
|
||||||
|
| 항상 위 | `WindowStaysOnTopHint` 토글 |
|
||||||
|
| 종료 | 애플리케이션 종료 |
|
||||||
|
|
||||||
|
`QMenu`로 구현. 서브메뉴 사용하여 위치/크기를 그룹화.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Constants (`config.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 캐릭터 크기 (높이 기준, 너비는 비율 유지)
|
||||||
|
SIZES = {"small": 100, "medium": 150, "large": 200}
|
||||||
|
DEFAULT_SIZE = "medium"
|
||||||
|
|
||||||
|
# 수평 위치 프리셋 (화면 너비 비율)
|
||||||
|
POSITIONS = {"left": 0.1, "center": 0.5, "right": 0.9}
|
||||||
|
DEFAULT_POSITION = "right"
|
||||||
|
|
||||||
|
# 시선 추적
|
||||||
|
TIMER_INTERVAL_MS = 30 # 약 33fps
|
||||||
|
MAX_TILT_ANGLE = 15.0 # 최대 기울기 (도)
|
||||||
|
|
||||||
|
# 태스크바
|
||||||
|
TASKBAR_HEIGHT = 48 # Windows 기본 태스크바 높이
|
||||||
|
|
||||||
|
# 애니메이션
|
||||||
|
JUMP_HEIGHT = 30 # 점프 높이 (px)
|
||||||
|
JUMP_DURATION_MS = 300
|
||||||
|
SHAKE_OFFSET = 10 # 흔들기 좌우 폭 (px)
|
||||||
|
SHAKE_DURATION_MS = 400
|
||||||
|
|
||||||
|
# 에셋 경로
|
||||||
|
CHARACTER_DIR = "assets/characters"
|
||||||
|
DEFAULT_CHARACTER = "박뚱냥.png"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
PyQt5>=5.15,<6.0
|
||||||
|
```
|
||||||
|
|
||||||
|
개발 시 추가:
|
||||||
|
```
|
||||||
|
pyinstaller>=6.0 # .exe 패킹용 (나중에)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- **Windows 전용**: PyQt5 투명 윈도우는 Windows에서 가장 안정적. macOS/Linux는 고려하지 않음.
|
||||||
|
- **이미지 1장으로 시작**: 현재 박뚱냥.png 정면 포즈 1장. 시선은 이미지 기울기 + 좌우 반전으로 표현.
|
||||||
|
- **NAS 배포 불필요**: Docker, docker-compose.yml, deploy.sh 수정 없음.
|
||||||
|
- **독립 프로젝트**: `C:\Users\jaeoh\Desktop\workspace\pet-lab`에 별도 Git 저장소.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Extensions
|
||||||
|
|
||||||
|
- 스프라이트 시트 추가: idle, walk, sit, sleep 등 포즈별 이미지 → 상태 머신 기반 애니메이션
|
||||||
|
- 자율 행동: 일정 시간 마우스 비활동 시 졸기/잠자기 상태 전환
|
||||||
|
- 시스템 트레이 아이콘: 종료/설정 접근
|
||||||
|
- 설정 파일 저장/로드: JSON으로 크기/위치/캐릭터 선택 영속화
|
||||||
|
- 다중 캐릭터: `assets/characters/` 디렉토리에 여러 캐릭터 추가, 우클릭 메뉴에서 선택
|
||||||
|
- PyInstaller .exe 패킹: 단독 배포용 실행파일 생성
|
||||||
|
- 웹 서비스 연동: pet-lab API 서버 → 캐릭터 다운로드/공유
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
# Music Lab Suno API 전체 기능 확장 설계
|
||||||
|
|
||||||
|
> 작성일: 2026-04-08
|
||||||
|
> 범위: 백엔드 (web-backend/music-lab) + 프론트엔드 (web-ui/src/pages/music)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 목표
|
||||||
|
|
||||||
|
Suno API의 미사용 기능을 전부 활용하여 music-lab을 완전한 AI 음악 프로덕션 스튜디오로 업그레이드한다. 실용도 기준 3단계로 점진 확장.
|
||||||
|
|
||||||
|
## 2. 단계별 기능 목록
|
||||||
|
|
||||||
|
### Phase 1: 핵심 생성 강화
|
||||||
|
|
||||||
|
| # | 기능 | 설명 |
|
||||||
|
|---|------|------|
|
||||||
|
| 1-1 | 크레딧 잔액 상시 표시 | 헤더에 실시간 크레딧 배지, 10 이하 경고 |
|
||||||
|
| 1-2 | 보컬 성별 선택 | Male / Female / Auto 3버튼 |
|
||||||
|
| 1-3 | negativeTags | 제외 스타일 텍스트 + 프리셋 칩 |
|
||||||
|
| 1-4 | V5_5 모델 추가 | SUNO_MODELS 딕셔너리에 추가 |
|
||||||
|
| 1-5 | styleWeight / audioWeight | 0~1.0 슬라이더 2개 |
|
||||||
|
| 1-6 | 커버 이미지 생성 | 라이브러리 카드에서 앨범아트 2장 생성 |
|
||||||
|
|
||||||
|
### Phase 2: 후처리 파워업
|
||||||
|
|
||||||
|
| # | 기능 | 설명 |
|
||||||
|
|---|------|------|
|
||||||
|
| 2-1 | WAV 고음질 변환 | MP3→WAV 변환 다운로드 |
|
||||||
|
| 2-2 | 12스템 분리 | 드럼/베이스/기타 등 12개 개별 추출 |
|
||||||
|
| 2-3 | 타임스탬프 가사 | 가라오케 스타일 싱크 재생 |
|
||||||
|
| 2-4 | 스타일 부스트 | AI로 최적 스타일 프롬프트 자동 생성 |
|
||||||
|
|
||||||
|
### Phase 3: 고급 크리에이티브
|
||||||
|
|
||||||
|
| # | 기능 | 설명 |
|
||||||
|
|---|------|------|
|
||||||
|
| 3-1 | 오디오 업로드 + 커버 | 외부 음원을 Suno 스타일로 리메이크 |
|
||||||
|
| 3-2 | 오디오 업로드 + 확장 | 외부 음원 이어서 만들기 |
|
||||||
|
| 3-3 | 보컬 추가 | 인스트루멘탈에 AI 보컬 입히기 |
|
||||||
|
| 3-4 | 인스트루멘탈 추가 | 보컬에 AI 반주 입히기 |
|
||||||
|
| 3-5 | 뮤직비디오 생성 | MP4 자동 생성 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 백엔드 API 설계
|
||||||
|
|
||||||
|
### 3.1 기존 엔드포인트 수정
|
||||||
|
|
||||||
|
#### GenerateRequest 스키마 확장 (main.py)
|
||||||
|
|
||||||
|
```python
|
||||||
|
class GenerateRequest(BaseModel):
|
||||||
|
# 기존 필드 유지
|
||||||
|
provider: str = "suno"
|
||||||
|
model: str = "V4"
|
||||||
|
title: str = ""
|
||||||
|
genre: str = ""
|
||||||
|
moods: list[str] = []
|
||||||
|
instruments: list[str] = []
|
||||||
|
duration_sec: int | None = None
|
||||||
|
bpm: int | None = None
|
||||||
|
key: str = ""
|
||||||
|
scale: str = ""
|
||||||
|
prompt: str = ""
|
||||||
|
lyrics: str = ""
|
||||||
|
instrumental: bool = False
|
||||||
|
|
||||||
|
# Phase 1 추가
|
||||||
|
vocal_gender: str | None = None # "m" | "f" | None(auto)
|
||||||
|
negative_tags: str | None = None # 제외 스타일
|
||||||
|
style_weight: float | None = None # 0.0~1.0
|
||||||
|
audio_weight: float | None = None # 0.0~1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
#### SUNO_MODELS 확장 (suno_provider.py)
|
||||||
|
|
||||||
|
```python
|
||||||
|
SUNO_MODELS = {
|
||||||
|
"V4": {"name": "V4", "max_duration": 240},
|
||||||
|
"V4_5": {"name": "V4.5", "max_duration": 480},
|
||||||
|
"V4_5PLUS": {"name": "V4.5+", "max_duration": 480},
|
||||||
|
"V4_5ALL": {"name": "V4.5 All","max_duration": 480},
|
||||||
|
"V5": {"name": "V5", "max_duration": 480},
|
||||||
|
"V5_5": {"name": "V5.5", "max_duration": 480}, # 추가
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### _build_suno_payload 확장
|
||||||
|
|
||||||
|
새 파라미터를 Suno API 페이로드에 매핑:
|
||||||
|
- `vocal_gender` → `vocalGender`
|
||||||
|
- `negative_tags` → `negativeTags`
|
||||||
|
- `style_weight` → `styleWeight`
|
||||||
|
- `audio_weight` → `audioWeight`
|
||||||
|
|
||||||
|
None이 아닌 경우에만 페이로드에 포함.
|
||||||
|
|
||||||
|
### 3.2 신규 엔드포인트
|
||||||
|
|
||||||
|
#### Phase 1
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/music/cover-image
|
||||||
|
Request: { "task_id": str, "suno_id": str }
|
||||||
|
Response: { "task_id": str } → 폴링 → { "images": [url1, url2] }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Phase 2
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/music/wav
|
||||||
|
Request: { "task_id": str, "suno_id": str }
|
||||||
|
Response: { "task_id": str } → 폴링 → { "wav_url": str }
|
||||||
|
|
||||||
|
POST /api/music/stem-split
|
||||||
|
Request: { "task_id": str, "suno_id": str }
|
||||||
|
Response: { "task_id": str } → 폴링 → { "stems": { vocal: url, drums: url, ... } }
|
||||||
|
|
||||||
|
GET /api/music/timestamped-lyrics?task_id=...&suno_id=...
|
||||||
|
Response: { "aligned_words": [...], "waveform_data": [...] }
|
||||||
|
|
||||||
|
POST /api/music/style-boost
|
||||||
|
Request: { "content": str }
|
||||||
|
Response: { "result": str, "credits_consumed": float }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Phase 3
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/music/upload-cover
|
||||||
|
Request: { "upload_url": str, "model": str, "custom_mode": bool,
|
||||||
|
"instrumental": bool, "prompt"?: str, "style"?: str, "title"?: str,
|
||||||
|
"vocal_gender"?: str, "negative_tags"?: str,
|
||||||
|
"style_weight"?: float, "audio_weight"?: float }
|
||||||
|
Response: { "task_id": str }
|
||||||
|
|
||||||
|
POST /api/music/upload-extend
|
||||||
|
Request: { "upload_url": str, "model": str, "continue_at"?: float,
|
||||||
|
"default_param_flag": bool, "prompt"?: str, "style"?: str, "title"?: str,
|
||||||
|
"vocal_gender"?: str, "negative_tags"?: str }
|
||||||
|
Response: { "task_id": str }
|
||||||
|
|
||||||
|
POST /api/music/add-vocals
|
||||||
|
Request: { "upload_url": str, "prompt": str, "title": str, "style": str,
|
||||||
|
"negative_tags": str, "vocal_gender"?: str, "model"?: str,
|
||||||
|
"style_weight"?: float, "audio_weight"?: float }
|
||||||
|
Response: { "task_id": str }
|
||||||
|
|
||||||
|
POST /api/music/add-instrumental
|
||||||
|
Request: { "upload_url": str, "title": str, "tags": str, "negative_tags": str,
|
||||||
|
"vocal_gender"?: str, "model"?: str,
|
||||||
|
"style_weight"?: float, "audio_weight"?: float }
|
||||||
|
Response: { "task_id": str }
|
||||||
|
|
||||||
|
POST /api/music/video
|
||||||
|
Request: { "task_id": str, "suno_id": str, "author"?: str, "domain_name"?: str }
|
||||||
|
Response: { "task_id": str } → 폴링 → { "video_url": str }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 suno_provider.py 리팩토링
|
||||||
|
|
||||||
|
**공통 폴링 헬퍼 추출:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _poll_suno_task(
|
||||||
|
record_info_url: str,
|
||||||
|
task_id: str,
|
||||||
|
max_attempts: int = 40,
|
||||||
|
interval: int = 8,
|
||||||
|
success_extractor: Callable[[dict], Any] = None
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
범용 Suno 작업 폴링.
|
||||||
|
record_info_url: 예) "/api/v1/generate/record-info"
|
||||||
|
success_extractor: SUCCESS 상태일 때 결과 추출 함수
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
기존 `run_suno_generation`, `run_suno_extend`, `run_vocal_removal`도 이 헬퍼를 사용하도록 리팩토링.
|
||||||
|
|
||||||
|
**신규 함수 목록:**
|
||||||
|
|
||||||
|
| 함수 | Phase | Suno 엔드포인트 | 비동기 |
|
||||||
|
|------|-------|----------------|--------|
|
||||||
|
| `run_cover_image` | 1 | `POST /api/v1/suno/cover/generate` | 폴링 |
|
||||||
|
| `run_wav_convert` | 2 | `POST /api/v1/wav/generate` | 폴링 |
|
||||||
|
| `run_stem_split` | 2 | `POST /api/v1/vocal-removal/generate` (type=split_stem) | 폴링 |
|
||||||
|
| `get_timestamped_lyrics` | 2 | `POST /api/v1/generate/get-timestamped-lyrics` | 동기 |
|
||||||
|
| `generate_style_boost` | 2 | `POST /api/v1/style/generate` | 동기 |
|
||||||
|
| `run_upload_cover` | 3 | `POST /api/v1/generate/upload-cover` | 폴링 |
|
||||||
|
| `run_upload_extend` | 3 | `POST /api/v1/generate/upload-extend` | 폴링 |
|
||||||
|
| `run_add_vocals` | 3 | `POST /api/v1/generate/add-vocals` | 폴링 |
|
||||||
|
| `run_add_instrumental` | 3 | `POST /api/v1/generate/add-instrumental` | 폴링 |
|
||||||
|
| `run_video_generate` | 3 | `POST /api/v1/mp4/generate` | 폴링 |
|
||||||
|
|
||||||
|
### 3.4 DB 스키마 변경
|
||||||
|
|
||||||
|
**music_library 테이블 컬럼 추가 (ALTER TABLE 마이그레이션):**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE music_library ADD COLUMN cover_images TEXT NOT NULL DEFAULT '[]';
|
||||||
|
ALTER TABLE music_library ADD COLUMN wav_url TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE music_library ADD COLUMN video_url TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE music_library ADD COLUMN stem_urls TEXT NOT NULL DEFAULT '{}';
|
||||||
|
```
|
||||||
|
|
||||||
|
**db.py 함수 추가:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def update_track_cover_images(track_id: int, images: list[str])
|
||||||
|
def update_track_wav_url(track_id: int, wav_url: str)
|
||||||
|
def update_track_video_url(track_id: int, video_url: str)
|
||||||
|
def update_track_stem_urls(track_id: int, stems: dict)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 프론트엔드 UI/UX 설계
|
||||||
|
|
||||||
|
### 4.1 파일 구조 (컴포넌트 분할)
|
||||||
|
|
||||||
|
```
|
||||||
|
web-ui/src/pages/music/
|
||||||
|
├── MusicStudio.jsx -- 메인 (탭 라우팅 + 공유 상태)
|
||||||
|
├── MusicStudio.css -- 전체 스타일
|
||||||
|
├── components/
|
||||||
|
│ ├── CreateTab.jsx -- 생성 폼 (6단계 + Phase 1 확장)
|
||||||
|
│ ├── LyricsTab.jsx -- 가사 관리
|
||||||
|
│ ├── LibraryTab.jsx -- 라이브러리 + 카드
|
||||||
|
│ ├── RemixTab.jsx -- Phase 3: 업로드/리믹스
|
||||||
|
│ ├── AudioPlayer.jsx -- 오디오 플레이어
|
||||||
|
│ ├── LibraryCard.jsx -- 트랙 카드 + 액션 메뉴
|
||||||
|
│ ├── StemModal.jsx -- 12스템 결과 모달
|
||||||
|
│ ├── CoverArtModal.jsx -- 커버 이미지 선택 모달
|
||||||
|
│ ├── SyncedLyricsPlayer.jsx -- 타임스탬프 가사 오버레이
|
||||||
|
│ └── CreditsBadge.jsx -- 크레딧 잔액 배지
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Phase 1 UI 변경
|
||||||
|
|
||||||
|
#### 크레딧 배지 (CreditsBadge)
|
||||||
|
- 위치: 헤더 우측 상단, 탭 옆
|
||||||
|
- 표시: `⚡ 127 credits`
|
||||||
|
- 10 이하: 빨간색 + pulse 애니메이션
|
||||||
|
- 갱신 시점: 페이지 로드, 생성 완료 후, 30초 자동 갱신
|
||||||
|
|
||||||
|
#### Create 탭 Step 4 확장
|
||||||
|
|
||||||
|
**Vocal Gender (Suno 전용):**
|
||||||
|
- 3버튼 토글: `♂ Male` | `♀ Female` | `Auto`
|
||||||
|
- 기본값: Auto
|
||||||
|
- 스타일: `.ms-gender-toggle` (기존 duration-rail과 유사)
|
||||||
|
|
||||||
|
**Negative Tags:**
|
||||||
|
- 텍스트 입력 필드 + 프리셋 칩
|
||||||
|
- 프리셋: screaming, autotune, distortion, whisper, falsetto, rap
|
||||||
|
- 칩 클릭 시 텍스트에 추가/제거
|
||||||
|
- 스타일: `.ms-negative-tags` (기존 mood-rack과 유사)
|
||||||
|
|
||||||
|
**Style Weight / Audio Weight:**
|
||||||
|
- range 슬라이더 2개 (기존 BPM 슬라이더와 동일 스타일)
|
||||||
|
- 레이블: "Prompt ↔ Style Balance" / "Original ↔ AI Balance"
|
||||||
|
- 0~100 표시, API 전송 시 0.0~1.0 변환
|
||||||
|
- 기본값: 미설정 (슬라이더 중앙, 값 전송 안 함)
|
||||||
|
|
||||||
|
#### Library 카드 액션 메뉴 확장
|
||||||
|
|
||||||
|
기존 5개 버튼 → 6개 (Cover Art 추가)
|
||||||
|
4개 초과 시 `•••` 더보기 드롭다운 메뉴로 분기:
|
||||||
|
- 기본 노출: Play, Download, Delete
|
||||||
|
- 더보기: Extend, Vocal Split, Cover Art (+ Phase 2/3 추가분)
|
||||||
|
|
||||||
|
#### CoverArtModal
|
||||||
|
- 2장 이미지 좌우 비교 표시
|
||||||
|
- 각 이미지 아래 "이 이미지 사용" 버튼
|
||||||
|
- 선택 시 라이브러리 카드 썸네일 업데이트
|
||||||
|
|
||||||
|
### 4.3 Phase 2 UI 변경
|
||||||
|
|
||||||
|
#### Library 카드 더보기 메뉴 추가
|
||||||
|
- WAV 다운로드
|
||||||
|
- Stem Split (12스템)
|
||||||
|
- Synced Lyrics
|
||||||
|
- Style Boost (Create 탭 프롬프트로 전달)
|
||||||
|
|
||||||
|
#### StemModal
|
||||||
|
- 3×4 그리드 카드 레이아웃
|
||||||
|
- 각 스템: 이름 아이콘 + 미니 재생 버튼 + 다운로드 버튼
|
||||||
|
- 12개 스템: vocal, backing_vocals, drums, bass, guitar, keyboard, strings, brass, woodwinds, percussion, synth, fx
|
||||||
|
- 스타일: 기존 라이브러리 카드의 축소 버전
|
||||||
|
|
||||||
|
#### SyncedLyricsPlayer
|
||||||
|
- AudioPlayer 교체/오버레이 모드
|
||||||
|
- 재생 중 현재 단어를 accent 컬러로 하이라이트
|
||||||
|
- 하단에 waveformData 기반 파형 바
|
||||||
|
- 닫기 버튼으로 일반 플레이어 복귀
|
||||||
|
|
||||||
|
#### Style Boost 버튼
|
||||||
|
- Create 탭 장르 선택 영역에 `✨ Style Boost` 버튼
|
||||||
|
- 클릭 시: 현재 genre + moods 조합 → API 호출 → 결과를 프롬프트에 삽입
|
||||||
|
- 로딩 중 버튼 스피너
|
||||||
|
|
||||||
|
### 4.4 Phase 3 UI 변경
|
||||||
|
|
||||||
|
#### Remix 탭 (신규 4번째 탭)
|
||||||
|
- 탭 레이블: `REMIX`
|
||||||
|
- 상단: 오디오 URL 입력 필드 (또는 라이브러리에서 선택)
|
||||||
|
- 4개 액션 카드 그리드 (2×2):
|
||||||
|
- **AI Cover**: 아이콘 + 설명 + 파라미터 폼 (펼침)
|
||||||
|
- **Extend**: 아이콘 + 설명 + continue_at 입력
|
||||||
|
- **Add Vocals**: 아이콘 + 설명 + prompt/style 입력
|
||||||
|
- **Add Instrumental**: 아이콘 + 설명 + tags 입력
|
||||||
|
- 선택한 카드만 펼쳐서 세부 옵션 표시
|
||||||
|
- 하단: 뮤직비디오 생성 버튼 (라이브러리에서 선택한 곡 대상)
|
||||||
|
|
||||||
|
### 4.5 디자인 토큰 추가
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Phase 1 추가 토큰 */
|
||||||
|
--ms-danger: #e74c3c; /* 크레딧 경고 빨간색 */
|
||||||
|
--ms-male: #4a9eff; /* 남성 보컬 파란색 */
|
||||||
|
--ms-female: #ff6b9d; /* 여성 보컬 분홍색 */
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. api.js 추가 함수
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Phase 1
|
||||||
|
export const generateCoverImage = (payload) => apiPost('/api/music/cover-image', payload);
|
||||||
|
|
||||||
|
// Phase 2
|
||||||
|
export const convertToWav = (payload) => apiPost('/api/music/wav', payload);
|
||||||
|
export const splitStems = (payload) => apiPost('/api/music/stem-split', payload);
|
||||||
|
export const getTimestampedLyrics = (taskId, sunoId) =>
|
||||||
|
apiGet(`/api/music/timestamped-lyrics?task_id=${taskId}&suno_id=${sunoId}`);
|
||||||
|
export const generateStyleBoost = (content) => apiPost('/api/music/style-boost', { content });
|
||||||
|
|
||||||
|
// Phase 3
|
||||||
|
export const uploadAndCover = (payload) => apiPost('/api/music/upload-cover', payload);
|
||||||
|
export const uploadAndExtend = (payload) => apiPost('/api/music/upload-extend', payload);
|
||||||
|
export const addVocals = (payload) => apiPost('/api/music/add-vocals', payload);
|
||||||
|
export const addInstrumental = (payload) => apiPost('/api/music/add-instrumental', payload);
|
||||||
|
export const generateVideo = (payload) => apiPost('/api/music/video', payload);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 폴링 패턴
|
||||||
|
|
||||||
|
모든 비동기 작업은 기존 음악 생성과 동일한 폴링 패턴:
|
||||||
|
|
||||||
|
1. POST 요청 → `{ task_id }` 반환
|
||||||
|
2. 프론트: 3초 간격 `GET /api/music/status/{task_id}` 폴링
|
||||||
|
3. 백엔드: BackgroundTask에서 Suno 폴링 → music_tasks 상태 업데이트
|
||||||
|
4. `status: succeeded` → 결과 반환 + 라이브러리 자동 갱신
|
||||||
|
|
||||||
|
동기 API (타임스탬프 가사, 스타일 부스트)는 즉시 응답.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 구현 순서
|
||||||
|
|
||||||
|
### Phase 1 (핵심 생성 강화)
|
||||||
|
1. 백엔드: SUNO_MODELS에 V5_5 추가 + 공통 폴링 헬퍼 추출
|
||||||
|
2. 백엔드: GenerateRequest 스키마 확장 + _build_suno_payload 매핑
|
||||||
|
3. 백엔드: 커버 이미지 생성 엔드포인트 + suno_provider 함수
|
||||||
|
4. 백엔드: DB 마이그레이션 (cover_images, wav_url, video_url, stem_urls)
|
||||||
|
5. 프론트: 컴포넌트 분할 (MusicStudio → CreateTab, LyricsTab, LibraryTab, etc.)
|
||||||
|
6. 프론트: CreditsBadge 구현
|
||||||
|
7. 프론트: Create 탭 Step 4 확장 (vocal gender, negative tags, weight 슬라이더)
|
||||||
|
8. 프론트: LibraryCard 더보기 메뉴 + CoverArtModal
|
||||||
|
9. 프론트: api.js 함수 추가
|
||||||
|
|
||||||
|
### Phase 2 (후처리 파워업)
|
||||||
|
10. 백엔드: WAV/스템/타임스탬프/스타일부스트 엔드포인트
|
||||||
|
11. 프론트: StemModal + SyncedLyricsPlayer + Style Boost 버튼
|
||||||
|
12. 프론트: Library 카드 Phase 2 액션 추가
|
||||||
|
|
||||||
|
### Phase 3 (고급 크리에이티브)
|
||||||
|
13. 백엔드: upload-cover/upload-extend/add-vocals/add-instrumental/video 엔드포인트
|
||||||
|
14. 프론트: RemixTab 구현
|
||||||
|
15. 프론트: Library 카드 Phase 3 액션 (Video)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 제약사항 및 주의점
|
||||||
|
|
||||||
|
- **Suno 파일 보관**: 14~15일 후 자동 삭제 → 로컬 다운로드 필수 (기존 패턴 유지)
|
||||||
|
- **동시 요청 제한**: 10초당 20건 → 배치 작업 시 rate limiting 고려
|
||||||
|
- **12스템 분리 비용**: 50크레딧 → UI에 비용 경고 표시
|
||||||
|
- **WAV 중복 변환**: 409 에러 → 이미 변환된 경우 기존 URL 반환
|
||||||
|
- **뮤직비디오**: taskId + audioId 필요 → 라이브러리에 task_id 저장 필수
|
||||||
|
- **V5_5 모델**: 커스텀 모델 → 문서상 제한사항 추가 확인 필요
|
||||||
|
- **크레딧 조회 엔드포인트**: 기존 `/api/v1/get-credits` vs 문서 `/api/v1/generate/credit` → 두 엔드포인트 폴백 시도
|
||||||
|
- **upload 계열 API**: upload_url은 외부 접근 가능한 URL이어야 함 → 로컬 파일은 NAS nginx URL로 변환
|
||||||
444
docs/superpowers/specs/2026-04-11-agent-office-design.md
Normal file
444
docs/superpowers/specs/2026-04-11-agent-office-design.md
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
# Agent Office - AI 에이전트 사무실 시각화 설계
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
Lab 하위에 2D 픽셀아트 스타일의 가상 사무실을 구현하여, AI 에이전트들이 실시간으로 작업하는 모습을 게임처럼 시각화하고 상호작용하는 페이지.
|
||||||
|
|
||||||
|
### 핵심 컨셉
|
||||||
|
- **게임 같은 사무실**: 2D 픽셀아트 오픈 오피스에 에이전트 캐릭터들이 배치
|
||||||
|
- **실제 작업 수행**: 에이전트들이 기존 백엔드 서비스 API를 호출하여 실제 결과물 생성
|
||||||
|
- **직접 지시**: 에이전트 클릭 → 채팅/명령 패널로 지시, 승인 요청 시 알림 표시
|
||||||
|
- **텔레그램 양방향**: 알림 발송 + 인라인 버튼으로 승인/거절/수정
|
||||||
|
- **아이들 행동**: 장시간 명령 없으면 휴게실에서 커피, 졸기, 동료 잡담 등
|
||||||
|
|
||||||
|
### MVP 범위
|
||||||
|
- **에이전트 2개**: StockAgent (주식 뉴스/주가 알람), MusicAgent (작곡 파이프라인)
|
||||||
|
- **사무실**: 단일 오픈 오피스 (향후 방/층 확장 가능)
|
||||||
|
- **텔레그램**: 양방향 (알림 + 인라인 버튼 승인)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ Frontend (React) │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────┐ ┌─────────────────────────┐ │
|
||||||
|
│ │ OfficeCanvas │ │ React Overlay │ │
|
||||||
|
│ │ (Canvas 2D) │ │ - ChatPanel │ │
|
||||||
|
│ │ - 타일맵 렌더 │ │ - AgentStatus │ │
|
||||||
|
│ │ - 스프라이트 │ │ - TaskHistory │ │
|
||||||
|
│ │ - 클릭 히트맵 │ │ - ApprovalDialog │ │
|
||||||
|
│ └──────────────┘ └─────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────────┐ │
|
||||||
|
│ │ useAgentManager (상태 + WebSocket) │ │
|
||||||
|
│ └──────────────────────────────────────────┘ │
|
||||||
|
└──────────────────┬──────────────────────────────┘
|
||||||
|
│ WebSocket + REST
|
||||||
|
┌──────────────────▼──────────────────────────────┐
|
||||||
|
│ Backend: agent-office (새 서비스, 포트 18900) │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────┐ ┌────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ Scheduler │ │ Agent FSM │ │ Telegram Bot │ │
|
||||||
|
│ │(APScheduler)│ │ (상태머신) │ │ (양방향) │ │
|
||||||
|
│ └────────────┘ └────────────┘ └──────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────────┐ │
|
||||||
|
│ │ Service Proxy (기존 서비스 API 호출) │ │
|
||||||
|
│ │ stock-lab / music-lab 등 │ │
|
||||||
|
│ └──────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 핵심 결정
|
||||||
|
- **agent-office**: 새 백엔드 서비스 (포트 18900). 기존 서비스는 수정하지 않음
|
||||||
|
- **Service Proxy 패턴**: agent-office가 기존 서비스 API를 HTTP 호출
|
||||||
|
- **WebSocket**: 에이전트 상태 변화를 실시간 전달
|
||||||
|
- **Canvas + React 오버레이 하이브리드**: 게임 렌더링은 Canvas, UI 패널은 React DOM
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 에이전트 상태 머신 (FSM)
|
||||||
|
|
||||||
|
### 상태 전이
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────┐ 스케줄/지시 ┌──────────┐ 완료 ┌──────────┐
|
||||||
|
│ idle │ ──────────────→ │ working │ ───────→ │ reporting│
|
||||||
|
└──┬───┘ └────┬─────┘ └────┬─────┘
|
||||||
|
│ │ 승인 필요 │
|
||||||
|
│ 장시간 idle ▼ │ 결과 전달 후
|
||||||
|
│ ┌───────────┐ │
|
||||||
|
▼ │ waiting │ │
|
||||||
|
┌──────┐ │ (승인대기) │ │
|
||||||
|
│ break│ └───────────┘ │
|
||||||
|
│ (휴식)│ │
|
||||||
|
└──┬───┘◄───────────────────────────────────────────┘
|
||||||
|
│ 새 작업 발생
|
||||||
|
└──────────→ idle
|
||||||
|
```
|
||||||
|
|
||||||
|
### 상태별 시각화
|
||||||
|
|
||||||
|
| 상태 | 캐릭터 행동 | 위치 | 오버레이 |
|
||||||
|
|------|------------|------|---------|
|
||||||
|
| `idle` | 모니터 보며 대기 애니메이션 | 자기 데스크 | 없음 |
|
||||||
|
| `working` | 타이핑 애니메이션, 모니터에 진행 표시 | 자기 데스크 | 작업명 말풍선 |
|
||||||
|
| `waiting` | 살짝 좌우 흔들림 | 자기 데스크 | `❗` 아이콘 (클릭 유도) |
|
||||||
|
| `reporting` | 결과물 들고 걸어감 | 데스크 → 회의 테이블 | 결과 요약 말풍선 |
|
||||||
|
| `break` | 커피 마시기/졸기/산책/잡담 | 휴게실/복도 | `☕`/`💤` 아이콘 |
|
||||||
|
|
||||||
|
### 아이들 행동 규칙
|
||||||
|
- idle 상태 5분 경과 → 50% 확률로 break 전환
|
||||||
|
- break 지속: 1~3분 랜덤 → idle 복귀
|
||||||
|
- break 중 에이전트끼리 근처에 있으면 잡담 애니메이션
|
||||||
|
- 새 작업 발생 시 즉시 break 종료 → idle → working
|
||||||
|
|
||||||
|
### 승인 흐름별 분류
|
||||||
|
|
||||||
|
| 에이전트 | 자동 실행 | 승인 필요 |
|
||||||
|
|---------|----------|----------|
|
||||||
|
| Stock | 뉴스 요약, 주가 알람 | - |
|
||||||
|
| Music | - | 작곡 (프롬프트 확인 후) |
|
||||||
|
| Lotto (향후) | 통계 분석, 추천번호 | 구매 관련 |
|
||||||
|
| Blog (향후) | - | 키워드 제시 후 글 생성 |
|
||||||
|
| Realestate (향후) | 공고 수집, 매칭 | - |
|
||||||
|
| Claude AI (향후) | - | 직접 지시 + 승인 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 사무실 맵 & 렌더링
|
||||||
|
|
||||||
|
### 타일맵 구조 (MVP: 단일 오픈 오피스)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
|
||||||
|
│ │Stock│ │Music│ │Claude│ │ (빈) │ │
|
||||||
|
│ │Desk │ │Desk │ │Desk │ │향후용│ │
|
||||||
|
│ └─────┘ └─────┘ └─────┘ └─────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌───────────┐ │
|
||||||
|
│ │ 회의 테이블 │ │
|
||||||
|
│ │ (보고구역) │ │
|
||||||
|
│ └───────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────┐ ┌─────────────────┐ │
|
||||||
|
│ │ 휴게실 │ │ CEO 데스크 (나) │ │
|
||||||
|
│ │ coffee │ │ │ │
|
||||||
|
│ └──────────┘ └─────────────────┘ │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 렌더링 계층 (아래→위)
|
||||||
|
1. **바닥 타일**: 카펫, 나무 바닥
|
||||||
|
2. **가구**: 데스크, 의자, 소파, 화분, 커피머신
|
||||||
|
3. **캐릭터**: 에이전트 스프라이트 (상태별 애니메이션)
|
||||||
|
4. **오버레이**: 말풍선, 상태 아이콘, 이름표
|
||||||
|
|
||||||
|
### 스프라이트 에셋
|
||||||
|
- 무료 픽셀아트 에셋팩 활용 (타일셋, 가구)
|
||||||
|
- 에이전트 캐릭터: 기본 인물 스프라이트 + 액세서리로 구분
|
||||||
|
- Stock: 넥타이 + 차트 모니터
|
||||||
|
- Music: 헤드폰 + 음표 이펙트
|
||||||
|
- Claude: 보라색 톤 + AI 아이콘
|
||||||
|
- 스프라이트시트: 4방향 × 4프레임 (idle, walk, work, break)
|
||||||
|
|
||||||
|
### Canvas 렌더링 엔진
|
||||||
|
- **게임 루프**: `requestAnimationFrame` 기반, 60fps 타겟
|
||||||
|
- **카메라**: 고정 뷰 (MVP), 향후 줌/팬 추가 가능
|
||||||
|
- **클릭 히트맵**: 캐릭터 바운딩 박스 체크 → 클릭 시 React 이벤트 발생
|
||||||
|
- **이동**: 웨이포인트 기반 lerp (데스크↔회의실↔휴게실)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 백엔드: agent-office 서비스
|
||||||
|
|
||||||
|
### 파일 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
agent-office/
|
||||||
|
├── app/
|
||||||
|
│ ├── main.py # FastAPI + WebSocket + lifespan
|
||||||
|
│ ├── db.py # SQLite (agent_tasks, agent_logs, agent_config)
|
||||||
|
│ ├── config.py # 환경변수, 서비스 URL 설정
|
||||||
|
│ ├── scheduler.py # APScheduler 스케줄 관리
|
||||||
|
│ ├── telegram_bot.py # Telegram Bot API 양방향
|
||||||
|
│ ├── websocket_manager.py # WebSocket 연결 관리 + 브로드캐스트
|
||||||
|
│ ├── service_proxy.py # 기존 서비스 API 호출 래퍼
|
||||||
|
│ ├── agents/
|
||||||
|
│ │ ├── base.py # BaseAgent (FSM, 공통 로직)
|
||||||
|
│ │ ├── stock.py # StockAgent
|
||||||
|
│ │ └── music.py # MusicAgent
|
||||||
|
│ └── models.py # Pydantic 모델
|
||||||
|
├── Dockerfile
|
||||||
|
└── requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### DB 테이블 (agent_office.db)
|
||||||
|
|
||||||
|
**agent_config**
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| agent_id | TEXT PK | 에이전트 식별자 (stock, music, ...) |
|
||||||
|
| display_name | TEXT | 표시명 ("주식 트레이더") |
|
||||||
|
| enabled | BOOLEAN | 활성 상태 |
|
||||||
|
| schedule_config | TEXT (JSON) | 스케줄 설정 |
|
||||||
|
| custom_config | TEXT (JSON) | 에이전트별 커스텀 설정 (감시 종목 등) |
|
||||||
|
| created_at | TEXT | 생성 시각 |
|
||||||
|
| updated_at | TEXT | 수정 시각 |
|
||||||
|
|
||||||
|
**agent_tasks**
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | TEXT PK (UUID) | 작업 ID |
|
||||||
|
| agent_id | TEXT FK | 에이전트 |
|
||||||
|
| task_type | TEXT | 작업 유형 (news_summary, price_alert, compose, ...) |
|
||||||
|
| status | TEXT | pending / approved / working / succeeded / failed |
|
||||||
|
| input_data | TEXT (JSON) | 입력 파라미터 |
|
||||||
|
| result_data | TEXT (JSON) | 결과 데이터 |
|
||||||
|
| requires_approval | BOOLEAN | 승인 필요 여부 |
|
||||||
|
| approved_at | TEXT | 승인 시각 |
|
||||||
|
| approved_via | TEXT | 승인 경로 (web / telegram) |
|
||||||
|
| created_at | TEXT | 생성 시각 |
|
||||||
|
| completed_at | TEXT | 완료 시각 |
|
||||||
|
|
||||||
|
**agent_logs**
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | INTEGER PK | 자동 증가 |
|
||||||
|
| agent_id | TEXT FK | 에이전트 |
|
||||||
|
| task_id | TEXT FK | 관련 작업 (nullable) |
|
||||||
|
| level | TEXT | info / warn / error |
|
||||||
|
| message | TEXT | 로그 메시지 |
|
||||||
|
| created_at | TEXT | 시각 |
|
||||||
|
|
||||||
|
**telegram_state**
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| callback_id | TEXT PK | 텔레그램 콜백 ID |
|
||||||
|
| task_id | TEXT FK | 매핑된 작업 |
|
||||||
|
| agent_id | TEXT FK | 매핑된 에이전트 |
|
||||||
|
| action | TEXT | approve / reject / modify |
|
||||||
|
| responded | BOOLEAN | 응답 완료 여부 |
|
||||||
|
| created_at | TEXT | 생성 시각 |
|
||||||
|
|
||||||
|
### BaseAgent 인터페이스
|
||||||
|
|
||||||
|
```python
|
||||||
|
class BaseAgent:
|
||||||
|
agent_id: str
|
||||||
|
state: str # idle, working, waiting, reporting, break
|
||||||
|
|
||||||
|
async def on_schedule(self) -> None:
|
||||||
|
"""스케줄러에 의해 호출. 자동 작업 실행."""
|
||||||
|
|
||||||
|
async def on_command(self, command: str, params: dict) -> dict:
|
||||||
|
"""사용자 직접 지시 처리."""
|
||||||
|
|
||||||
|
async def on_approval(self, task_id: str, approved: bool, feedback: str) -> None:
|
||||||
|
"""승인/거절 콜백."""
|
||||||
|
|
||||||
|
async def get_status(self) -> dict:
|
||||||
|
"""현재 상태 + 최근 작업 요약."""
|
||||||
|
```
|
||||||
|
|
||||||
|
### MVP 에이전트 상세
|
||||||
|
|
||||||
|
**StockAgent:**
|
||||||
|
- 스케줄: 매일 08:00 `on_schedule()` → `stock-lab GET /api/stock/news` 호출
|
||||||
|
- AI 요약: 뉴스 데이터를 Ollama(192.168.45.59)로 요약 생성
|
||||||
|
- 텔레그램 전송: 요약 결과를 포맷팅하여 발송 (자동, 승인 불필요)
|
||||||
|
- 주가 알람: `agent_config.custom_config`에 감시 종목/조건 저장, 주기적 체크
|
||||||
|
- 상태 전이: idle → working(뉴스 수집) → reporting(텔레그램 전송) → idle
|
||||||
|
|
||||||
|
**MusicAgent:**
|
||||||
|
- 트리거: 사용자 웹/텔레그램 지시 → `on_command()`
|
||||||
|
- 프롬프트 확인: 사용자 입력 프롬프트를 텔레그램으로 전송 + 인라인 버튼
|
||||||
|
- 승인 시: `music-lab POST /api/music/generate` 호출
|
||||||
|
- 상태 폴링: `music-lab GET /api/music/status/{task_id}` → 완료까지 반복
|
||||||
|
- 결과 알림: 생성된 음악 URL을 텔레그램 + 웹에 전달
|
||||||
|
- 상태 전이: idle → waiting(프롬프트 승인 대기) → working(생성 중) → reporting(결과 전달) → idle
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 텔레그램 봇
|
||||||
|
|
||||||
|
### 구성
|
||||||
|
- **Telegram Bot API** + **Webhook 수신** (NAS에서)
|
||||||
|
- agent-office 서비스 내부에 통합 (별도 프로세스 아님)
|
||||||
|
- Nginx: `/api/agent-office/telegram/webhook` → `agent-office:8000`
|
||||||
|
|
||||||
|
### 환경변수
|
||||||
|
- `TELEGRAM_BOT_TOKEN`: Bot Father에서 발급
|
||||||
|
- `TELEGRAM_CHAT_ID`: 사용자 채팅 ID (1:1 봇)
|
||||||
|
- `TELEGRAM_WEBHOOK_URL`: Webhook 수신 URL (NAS 외부 접근 가능 URL)
|
||||||
|
|
||||||
|
### 메시지 포맷
|
||||||
|
|
||||||
|
**자동 알림 (뉴스 요약):**
|
||||||
|
```
|
||||||
|
📈 [주식 에이전트] 아침 뉴스 요약
|
||||||
|
━━━━━━━━━━━━━━━━
|
||||||
|
• 삼성전자: 반도체 수출 호조...
|
||||||
|
• 코스피: 외인 순매수 전환...
|
||||||
|
• 미국 CPI 발표 예정...
|
||||||
|
|
||||||
|
📊 관심종목 현황
|
||||||
|
삼성전자 82,500원 (+2.1%)
|
||||||
|
AAPL $185.20 (+1.2%)
|
||||||
|
```
|
||||||
|
|
||||||
|
**승인 요청 (작곡):**
|
||||||
|
```
|
||||||
|
🎵 [음악 에이전트] 작곡 요청
|
||||||
|
━━━━━━━━━━━━━━━━
|
||||||
|
프롬프트: "Lo-fi hip hop, rainy day, piano"
|
||||||
|
스타일: Chill, Ambient
|
||||||
|
모델: V5.5
|
||||||
|
|
||||||
|
[✅ 승인] [❌ 거절] [✏️ 수정]
|
||||||
|
```
|
||||||
|
|
||||||
|
**주가 알람:**
|
||||||
|
```
|
||||||
|
🚨 [주식 에이전트] 주가 알림
|
||||||
|
━━━━━━━━━━━━━━━━
|
||||||
|
삼성전자 82,500원
|
||||||
|
조건: 82,000원 이상 → 도달!
|
||||||
|
현재 등락: +2.1%
|
||||||
|
```
|
||||||
|
|
||||||
|
### 양방향 흐름
|
||||||
|
1. 에이전트 → `telegram_bot.send_message()` → 텔레그램
|
||||||
|
2. 사용자 → 인라인 버튼 클릭 or 텍스트 입력
|
||||||
|
3. 텔레그램 → Webhook POST → `telegram_bot.handle_webhook()`
|
||||||
|
4. `handle_webhook()` → `telegram_state` 조회 → 에이전트 `on_approval()` 호출
|
||||||
|
5. 에이전트 FSM 상태 전이 → WebSocket 브로드캐스트 → 프론트엔드 반영
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 프론트엔드 구조
|
||||||
|
|
||||||
|
### 파일 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
src/pages/agent-office/
|
||||||
|
├── AgentOffice.jsx # 메인 페이지 (Canvas + Overlay 컨테이너)
|
||||||
|
├── AgentOffice.css # 스타일
|
||||||
|
├── canvas/
|
||||||
|
│ ├── OfficeRenderer.js # Canvas 렌더링 엔진 (게임루프)
|
||||||
|
│ ├── SpriteSheet.js # 스프라이트시트 로더 + 프레임 애니메이션
|
||||||
|
│ ├── TileMap.js # 타일맵 데이터 + 렌더링
|
||||||
|
│ └── AgentSprite.js # 에이전트 캐릭터 (위치, 상태, 이동, 애니메이션)
|
||||||
|
├── components/
|
||||||
|
│ ├── ChatPanel.jsx # 에이전트 채팅/명령 패널
|
||||||
|
│ ├── AgentBubble.jsx # 말풍선/상태 아이콘 오버레이
|
||||||
|
│ ├── TaskHistory.jsx # 작업 이력 사이드패널
|
||||||
|
│ └── ApprovalDialog.jsx # 승인 요청 다이얼로그
|
||||||
|
├── hooks/
|
||||||
|
│ ├── useAgentManager.js # WebSocket + 에이전트 상태 관리
|
||||||
|
│ └── useOfficeCanvas.js # Canvas 초기화 + 이벤트 바인딩
|
||||||
|
└── assets/
|
||||||
|
├── tileset.png # 사무실 타일셋 (16x16 or 32x32)
|
||||||
|
├── agents.png # 에이전트 스프라이트시트
|
||||||
|
└── office-map.json # 타일맵 데이터
|
||||||
|
```
|
||||||
|
|
||||||
|
### WebSocket 프로토콜
|
||||||
|
|
||||||
|
**서버 → 클라이언트:**
|
||||||
|
```json
|
||||||
|
{"type": "agent_state", "agent": "stock", "state": "working", "detail": "뉴스 수집 중..."}
|
||||||
|
{"type": "agent_state", "agent": "music", "state": "waiting", "detail": "프롬프트 승인 대기", "task_id": "abc-123"}
|
||||||
|
{"type": "task_complete", "agent": "stock", "task_id": "...", "result": {"summary": "..."}}
|
||||||
|
{"type": "agent_move", "agent": "stock", "target": "break_room"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**클라이언트 → 서버:**
|
||||||
|
```json
|
||||||
|
{"type": "command", "agent": "music", "action": "compose", "params": {"prompt": "...", "style": "..."}}
|
||||||
|
{"type": "approval", "agent": "music", "task_id": "abc-123", "approved": true}
|
||||||
|
{"type": "query", "agent": "stock", "action": "status"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ChatPanel 기능
|
||||||
|
- 에이전트별 채팅 히스토리 표시
|
||||||
|
- 텍스트 입력 + 빠른 액션 버튼
|
||||||
|
- 승인 대기 중인 작업 강조 표시
|
||||||
|
- 최근 작업 결과 인라인 표시
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 인프라 변경
|
||||||
|
|
||||||
|
### Docker Compose 추가
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
agent-office:
|
||||||
|
build: ./agent-office
|
||||||
|
container_name: agent-office
|
||||||
|
ports:
|
||||||
|
- "18900:8000"
|
||||||
|
volumes:
|
||||||
|
- ${RUNTIME_PATH}/data:/app/data
|
||||||
|
environment:
|
||||||
|
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
||||||
|
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID}
|
||||||
|
- TELEGRAM_WEBHOOK_URL=${TELEGRAM_WEBHOOK_URL}
|
||||||
|
- STOCK_LAB_URL=http://stock-lab:8000
|
||||||
|
- MUSIC_LAB_URL=http://music-lab:8000
|
||||||
|
depends_on:
|
||||||
|
- stock-lab
|
||||||
|
- music-lab
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nginx 라우팅 추가
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /api/agent-office/ {
|
||||||
|
proxy_pass http://agent-office:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade"; # WebSocket 지원
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 라우팅 (React Router)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// routes.jsx
|
||||||
|
{ path: 'agent-office', lazy: () => import('./pages/agent-office/AgentOffice') }
|
||||||
|
```
|
||||||
|
|
||||||
|
Lab 페이지(EffectLab.jsx)의 LAB_ITEMS에 Agent Office 항목 추가.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 향후 확장 (Phase 2+)
|
||||||
|
|
||||||
|
| 단계 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| Phase 2 | LottoAgent, BlogAgent, RealestateAgent 추가 |
|
||||||
|
| Phase 3 | Claude AI Agent (자연어 복합 지시) |
|
||||||
|
| Phase 4 | 방/층 확장 (부서별 공간 분리) |
|
||||||
|
| Phase 5 | 에이전트 간 협업 시각화 (회의 테이블에서 토론) |
|
||||||
|
| Phase 6 | 에이전트 커스텀 (이름, 외형, 성격 설정) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 기술 스택 요약
|
||||||
|
|
||||||
|
| 레이어 | 기술 |
|
||||||
|
|--------|------|
|
||||||
|
| 사무실 렌더링 | HTML5 Canvas 2D (커스텀 엔진) |
|
||||||
|
| 프론트엔드 | React 18 + Vite |
|
||||||
|
| 실시간 통신 | WebSocket (FastAPI) |
|
||||||
|
| 백엔드 | FastAPI (Python 3.12) |
|
||||||
|
| DB | SQLite (agent_office.db) |
|
||||||
|
| 스케줄러 | APScheduler |
|
||||||
|
| 메시징 | Telegram Bot API (Webhook) |
|
||||||
|
| 서비스 연동 | HTTP Proxy (기존 서비스 API 호출) |
|
||||||
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로 대체)
|
||||||
497
docs/superpowers/specs/2026-04-27-agent-office-v2-design.md
Normal file
497
docs/superpowers/specs/2026-04-27-agent-office-v2-design.md
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
# Agent Office v2 — Pixel Office UX 대규모 업데이트 설계
|
||||||
|
|
||||||
|
> 참고 프로젝트: `pixel-agents` (VS Code 확장, React 19 + Canvas 2D)
|
||||||
|
> 대상: `web-ui/src/pages/agent-office/` (프론트엔드) + `web-backend/agent-office/` (백엔드)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 목표
|
||||||
|
|
||||||
|
기존 대시보드 칼럼 중심 UI를 **전체 화면 픽셀 오피스** 중심으로 전환하여, "가상 오피스를 사용한다"는 몰입감을 제공한다.
|
||||||
|
|
||||||
|
### 핵심 변경
|
||||||
|
|
||||||
|
- 캔버스가 메인 화면을 차지하고, 에이전트 클릭 시 사이드 패널로 상세 정보 표시
|
||||||
|
- BFS 경로 탐색 + 풀 배회 시스템으로 에이전트에 생동감 부여
|
||||||
|
- 3가지 오피스 테마 프리셋 (Modern / Retro / Minimal)
|
||||||
|
- 캐릭터 프로시저럴 고도화 + 스프라이트 로더 설계 (점진적 전환)
|
||||||
|
|
||||||
|
### 변경하지 않는 것
|
||||||
|
|
||||||
|
- 백엔드 FSM 5상태 (`idle`, `working`, `waiting`, `reporting`, `break`)
|
||||||
|
- WebSocket 프로토콜 메시지 타입 (init, agent_state, task_complete, agent_move, notification, command_result)
|
||||||
|
- REST API 엔드포인트
|
||||||
|
- 텔레그램 봇 연동
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 화면 구성
|
||||||
|
|
||||||
|
### 2.1 데스크톱 레이아웃
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────┬──────────────┐
|
||||||
|
│ [Agent Office] ● Connected [Theme ▾] [Zoom] │ │
|
||||||
|
├──────────────────────────────────────────────────┤ Side Panel │
|
||||||
|
│ │ 320px │
|
||||||
|
│ │ │
|
||||||
|
│ Pixel Office Canvas │ [Agent hdr] │
|
||||||
|
│ (flex: 1, 전체 높이) │ [Tabs····] │
|
||||||
|
│ │ [Content ] │
|
||||||
|
│ - 에이전트 클릭 → 패널 열림 │ [·········] │
|
||||||
|
│ - 빈 공간 클릭 → 패널 닫힘 │ │
|
||||||
|
│ │ │
|
||||||
|
└──────────────────────────────────────────────────┴──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **상단 바**: 타이틀, WebSocket 연결 상태(●), 테마 드롭다운, 줌 컨트롤 (1x~4x)
|
||||||
|
- **캔버스**: `flex: 1`로 남은 공간 전체 차지, `imageSmoothingEnabled = false`
|
||||||
|
- **사이드 패널**: 320px 고정폭, 에이전트 클릭 시 슬라이드 인, X 버튼 또는 빈 공간 클릭으로 닫힘
|
||||||
|
- **패널 닫힘 시**: 캔버스가 전체 너비로 확장
|
||||||
|
|
||||||
|
### 2.2 모바일 레이아웃 (< 768px)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────┐
|
||||||
|
│ [≡] Agent Office ● Conn │
|
||||||
|
├──────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Pixel Office Canvas │
|
||||||
|
│ (전체 화면) │
|
||||||
|
│ 핀치 줌 + 패닝 │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
├──────────────────────────┤ ← 바텀 시트 (드래그)
|
||||||
|
│ [Agent Header] │
|
||||||
|
│ [Tabs: Cmd|Task|Tok|Log]│
|
||||||
|
│ [Content area] │
|
||||||
|
└──────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- 캔버스: 전체 화면, 터치 핀치 줌/패닝
|
||||||
|
- 사이드 패널 → 바텀 시트 (에이전트 탭 시 올라옴, 아래로 드래그 시 닫힘)
|
||||||
|
- 상단 바: 햄버거 메뉴로 테마/줌 접기
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 사이드 패널 구조
|
||||||
|
|
||||||
|
### 3.1 헤더
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ [🎵 32x32] 음악 프로듀서 │
|
||||||
|
│ ● working - ... │
|
||||||
|
└─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- 에이전트 아이콘 (emoji 기반, 32x32 색상 배경)
|
||||||
|
- display_name + 현재 상태 + state_detail
|
||||||
|
|
||||||
|
### 3.2 탭 구성
|
||||||
|
|
||||||
|
| 탭 | 내용 |
|
||||||
|
|----|------|
|
||||||
|
| **Commands** (기본) | Quick Action 버튼 (에이전트별 고유), Custom Command 입력, Approval UI (waiting 상태 시) |
|
||||||
|
| **Tasks** | 최근 작업 이력 (상태 배지, 타임스탬프, 결과 펼치기) |
|
||||||
|
| **Tokens** | 일간/주간 토큰 사용량 차트, 캐시 히트율 |
|
||||||
|
| **Logs** | 에이전트 로그 스트림 (level별 색상, 자동 스크롤) |
|
||||||
|
|
||||||
|
### 3.3 에이전트별 Quick Actions
|
||||||
|
|
||||||
|
| 에이전트 | 버튼 |
|
||||||
|
|---------|------|
|
||||||
|
| Stock | Fetch News, Add Alert, Test Telegram |
|
||||||
|
| Music | Compose, Check Credits |
|
||||||
|
| Blog | Research, Add Keyword, List Keywords |
|
||||||
|
| Realestate | Fetch Matches, Dashboard |
|
||||||
|
| Lotto | Curate Now, Status |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 캔버스 엔진
|
||||||
|
|
||||||
|
### 4.1 타일맵
|
||||||
|
|
||||||
|
- **그리드**: 32 × 20 타일 (기존 20×14에서 확장)
|
||||||
|
- **타일 크기**: 32px × 32px (기본), 줌에 따라 스케일
|
||||||
|
- **타일 타입**: VOID(0), FLOOR(1), WALL(2), FURNITURE(3)
|
||||||
|
- **렌더링 순서**: 바닥 → 벽 → 가구 → 에이전트 (Y좌표 Z-sorting) → 오버레이
|
||||||
|
|
||||||
|
### 4.2 오피스 레이아웃 (고정)
|
||||||
|
|
||||||
|
```
|
||||||
|
WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW (W=Wall)
|
||||||
|
W..............................W
|
||||||
|
W...[Stock]...[Music]..........W
|
||||||
|
W...desk+mon..desk+inst........W
|
||||||
|
W..............................W
|
||||||
|
W...[Blog]....[RE]....[Lotto]..W
|
||||||
|
W...desk+mon..desk+mon.desk+monW
|
||||||
|
W..............................W
|
||||||
|
W..............................W
|
||||||
|
W..........[Meeting]...........W
|
||||||
|
W..........table 4x2...........W
|
||||||
|
W..............................W
|
||||||
|
W..............................W
|
||||||
|
W....[Coffee]...[Sofa]........W
|
||||||
|
W....machine....couch.........W
|
||||||
|
W..............................W
|
||||||
|
W...[Plants]......[Bookshelf]..W
|
||||||
|
W..............................W
|
||||||
|
W..............................W
|
||||||
|
WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW
|
||||||
|
```
|
||||||
|
|
||||||
|
- 각 에이전트 구역에 테마별 소품 (Stock: 모니터 3대, Music: 악기, Blog: 서류 등)
|
||||||
|
- 중앙: 회의 테이블 (4x2 타일)
|
||||||
|
- 하단: 휴게실 구역 (커피 머신 + 소파)
|
||||||
|
- waypoint 정의: `desk_stock`, `desk_music`, `desk_blog`, `desk_realestate`, `desk_lotto`, `meeting`, `break_room`, `coffee`
|
||||||
|
|
||||||
|
### 4.3 줌 & 패닝
|
||||||
|
|
||||||
|
- 줌 레벨: 1x, 2x, 3x, 4x (정수 배율만, 픽셀 선명도 유지)
|
||||||
|
- 데스크톱: 마우스 휠 줌, 드래그 패닝
|
||||||
|
- 모바일: 핀치 줌, 터치 패닝
|
||||||
|
- 기본값: 캔버스 크기에 맞춰 자동 fit
|
||||||
|
|
||||||
|
### 4.4 게임 루프
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function gameLoop(timestamp) {
|
||||||
|
const dt = (timestamp - lastTime) / 1000;
|
||||||
|
lastTime = timestamp;
|
||||||
|
|
||||||
|
update(dt); // 에이전트 이동, 애니메이션 프레임 업데이트
|
||||||
|
render(); // 타일맵 → 가구 → 에이전트(Y-sort) → 오버레이
|
||||||
|
|
||||||
|
requestAnimationFrame(gameLoop);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 60fps requestAnimationFrame
|
||||||
|
- `imageSmoothingEnabled = false` (픽셀 선명도)
|
||||||
|
- devicePixelRatio 반영
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 에이전트 캐릭터 시스템
|
||||||
|
|
||||||
|
### 5.1 프로시저럴 렌더링 (Phase 1)
|
||||||
|
|
||||||
|
- 해상도: 16 × 32px (기존 8×16에서 2배 확대)
|
||||||
|
- 에이전트별 고유 색상 (기존 유지)
|
||||||
|
- 애니메이션 프레임:
|
||||||
|
|
||||||
|
| 상태 | 프레임 수 | 속도 | 설명 |
|
||||||
|
|------|----------|------|------|
|
||||||
|
| idle | 2 | 0.8s/frame | 미세 움직임 (숨쉬기) |
|
||||||
|
| walk | 4 | 0.15s/frame | 걷기 사이클 [0,1,2,1] |
|
||||||
|
| type | 2 | 0.3s/frame | 타이핑 (팔 움직임) |
|
||||||
|
| wait | 2 | 0.5s/frame | 좌우 흔들림 (wobble) |
|
||||||
|
| break | 2 | 1.0s/frame | 커피 마시기 / 졸기 |
|
||||||
|
|
||||||
|
- 4방향 스프라이트: DOWN, UP, RIGHT, LEFT (LEFT = RIGHT 좌우반전)
|
||||||
|
|
||||||
|
### 5.2 스프라이트 로더 (Phase 2 준비)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
class SpriteLoader {
|
||||||
|
constructor() {
|
||||||
|
this.sprites = new Map(); // agent_id → spritesheet Image
|
||||||
|
this.fallback = 'procedural';
|
||||||
|
}
|
||||||
|
|
||||||
|
async load(agentId, sheetUrl) { /* PNG 로드 */ }
|
||||||
|
|
||||||
|
draw(ctx, agentId, state, direction, frame, x, y) {
|
||||||
|
if (this.sprites.has(agentId)) {
|
||||||
|
// 스프라이트시트에서 프레임 추출하여 그리기
|
||||||
|
} else {
|
||||||
|
// 프로시저럴 폴백
|
||||||
|
ProceduralSprite.draw(ctx, agentId, state, direction, frame, x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 스프라이트시트 규격: 각 프레임 16×32px, 가로로 프레임 나열
|
||||||
|
- 행: 방향 (DOWN/UP/RIGHT), 열: 상태별 프레임
|
||||||
|
- PNG 없으면 프로시저럴 폴백 → 에셋 제작 전에도 완전 동작
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 이동 시스템
|
||||||
|
|
||||||
|
### 6.1 BFS 경로 탐색
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function findPath(grid, start, goal) {
|
||||||
|
// 4방향 BFS (상하좌우, 대각선 없음)
|
||||||
|
// blocked 타일(가구, 벽) 회피
|
||||||
|
// 반환: [{col, row}, ...] 경로 배열
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 가구 footprint → `blocked[]` 배열로 타일 마킹
|
||||||
|
- 의자/책상 뒤 타일은 walkable (backgroundTiles 개념)
|
||||||
|
- 경로 없으면 제자리 유지
|
||||||
|
|
||||||
|
### 6.2 이동 파라미터
|
||||||
|
|
||||||
|
| 파라미터 | 값 | 설명 |
|
||||||
|
|---------|-----|------|
|
||||||
|
| WALK_SPEED | 48 px/sec | pixel-agents 참고 |
|
||||||
|
| moveProgress | 0~1 | 현재 타일 → 다음 타일 선형 보간 |
|
||||||
|
| direction | DOWN/UP/RIGHT/LEFT | 이동 방향 → 스프라이트 방향 결정 |
|
||||||
|
|
||||||
|
### 6.3 배회 로직 (idle 상태)
|
||||||
|
|
||||||
|
```
|
||||||
|
idle 진입
|
||||||
|
→ 3~8초 대기 (seatTimer)
|
||||||
|
→ 자리에서 일어남
|
||||||
|
→ 인접 floor 타일로 랜덤 이동
|
||||||
|
→ 3~6회 반복 (wanderCount)
|
||||||
|
→ 자리로 BFS 복귀
|
||||||
|
→ 2~20초 자리에서 휴식 (restTimer)
|
||||||
|
→ 반복
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 상태 전환 시 이동 시퀀스
|
||||||
|
|
||||||
|
| 전환 | 동작 |
|
||||||
|
|------|------|
|
||||||
|
| `* → working` | 배회 중단, 자기 책상으로 BFS 이동 → 도착 후 type 애니메이션 |
|
||||||
|
| `* → waiting` | 자기 책상에서 wobble 애니메이션 + 말풍선 |
|
||||||
|
| `* → reporting` | 자기 책상에서 빠른 type 애니메이션 |
|
||||||
|
| `idle (배회 중)` | 랜덤 floor 타일로 이동, wanderCount 소진 시 복귀 |
|
||||||
|
| `* → break` | 휴게실(break_room/coffee) waypoint로 BFS 이동 → break 애니메이션 |
|
||||||
|
| `break → idle` | 자기 책상으로 BFS 이동 → idle 루프 시작 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 오버레이 시스템
|
||||||
|
|
||||||
|
캔버스 위에 HTML이 아닌 Canvas 2D로 직접 렌더링.
|
||||||
|
|
||||||
|
### 7.1 항상 표시
|
||||||
|
|
||||||
|
- **이름 라벨**: 에이전트 아래, 에이전트 색상 텍스트, 12px
|
||||||
|
- **상태 배지**: 이름 아래, 배경색 + 텍스트 ("working", "idle", "break")
|
||||||
|
|
||||||
|
### 7.2 조건부 표시
|
||||||
|
|
||||||
|
- **말풍선**: `waiting` 상태에서만, 에이전트 위에 "승인 대기!" 텍스트
|
||||||
|
- 둥근 사각형 배경 (#fbbf24), 아래 삼각형 꼬리
|
||||||
|
- 2초 페이드인, 상태 변경 시 즉시 사라짐
|
||||||
|
- **알림 배지**: 미확인 notification 있을 때, 에이전트 우상단에 빨간 원 + 숫자
|
||||||
|
|
||||||
|
### 7.3 렌더링 순서
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 타일맵 (바닥 + 벽)
|
||||||
|
2. 가구 (Y-sort)
|
||||||
|
3. 에이전트 (Y-sort, 가구와 혼합)
|
||||||
|
4. 오버레이 (말풍선, 이름, 배지) — 항상 최상위
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 테마 시스템
|
||||||
|
|
||||||
|
### 8.1 테마 데이터 구조
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const THEMES = {
|
||||||
|
modern: {
|
||||||
|
name: 'Modern',
|
||||||
|
wall: { color: '#1a1a2e', border: '#333', accent: '#8b5cf6' },
|
||||||
|
floor: { color1: '#2a2a3e', color2: '#323248' },
|
||||||
|
furniture: { desk: '#3a3a5a', chair: '#4c1d95', monitor: '#5555aa', shelf: '#2a2a4e' },
|
||||||
|
decor: { plant: '#22c55e', pot: '#4a3a2a', lamp: '#fbbf24', ledStrip: '#8b5cf6' },
|
||||||
|
lighting: { ambient: 'rgba(139,92,246,0.05)', glow: 'rgba(139,92,246,0.15)' }
|
||||||
|
},
|
||||||
|
retro: {
|
||||||
|
name: 'Retro',
|
||||||
|
wall: { color: '#2a1a0a', border: '#6a4a2a', accent: '#cc8844' },
|
||||||
|
floor: { color1: '#4a3a1a', color2: '#3a2a10' },
|
||||||
|
furniture: { desk: '#6a4a1a', chair: '#8a5a2a', monitor: '#555555', shelf: '#5a3a1a' },
|
||||||
|
decor: { plant: '#44aa44', pot: '#6a4a2a', lamp: '#ffcc66', brick: '#8a5a2a' },
|
||||||
|
lighting: { ambient: 'rgba(255,200,100,0.05)', glow: 'rgba(255,200,100,0.2)' }
|
||||||
|
},
|
||||||
|
minimal: {
|
||||||
|
name: 'Minimal',
|
||||||
|
wall: { color: '#fafafa', border: '#ddd', accent: '#3b82f6' },
|
||||||
|
floor: { color1: '#e8e8e8', color2: '#f0f0f0' },
|
||||||
|
furniture: { desk: '#ffffff', chair: '#e0e0e0', monitor: '#333333', shelf: '#f5f5f5' },
|
||||||
|
decor: { plant: '#86efac', pot: '#ffffff', lamp: '#fbbf24', window: '#e0f0ff' },
|
||||||
|
lighting: { ambient: 'rgba(59,130,246,0.03)', glow: 'rgba(255,255,255,0.1)' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 테마 적용 방식
|
||||||
|
|
||||||
|
- `TileMap.render(theme)` — 바닥/벽 색상을 theme에서 읽어 렌더링
|
||||||
|
- `FurnitureRenderer.draw(type, theme)` — 가구별 프로시저럴 렌더링에 theme 팔레트 적용
|
||||||
|
- 테마 전환 시 전체 캔버스 리렌더 (레이아웃 변경 없음)
|
||||||
|
- 사용자 선택은 `localStorage`에 저장, 기본값: `modern`
|
||||||
|
|
||||||
|
### 8.3 테마별 고유 데코
|
||||||
|
|
||||||
|
| 테마 | 고유 요소 |
|
||||||
|
|------|----------|
|
||||||
|
| Modern | LED 스트립 (벽 하단), 네온 글로우, 미니멀 화분 |
|
||||||
|
| Retro | 벽돌 텍스처, CRT 모니터, 책장(컬러풀 책), 탁상 램프 |
|
||||||
|
| Minimal | 창문(자연광), 다육이, 깔끔한 화이트 선반 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 히트 테스팅 & 인터랙션
|
||||||
|
|
||||||
|
### 9.1 클릭 처리
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
canvas.onclick = (e) => {
|
||||||
|
const {col, row} = screenToTile(e.offsetX, e.offsetY, zoom, pan);
|
||||||
|
|
||||||
|
// 1. 에이전트 히트 테스트 (역순, 최상위 우선)
|
||||||
|
const agent = agents.findLast(a =>
|
||||||
|
Math.abs(a.x - col) < 1 && Math.abs(a.y - row) < 1.5
|
||||||
|
);
|
||||||
|
|
||||||
|
if (agent) {
|
||||||
|
openSidePanel(agent.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 빈 공간 → 패널 닫기
|
||||||
|
closeSidePanel();
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 호버 (데스크톱만)
|
||||||
|
|
||||||
|
- 에이전트 위 호버 시 커서 `pointer`로 변경
|
||||||
|
- 툴팁 불필요 (이름+배지가 항상 표시되므로)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. WebSocket 연동
|
||||||
|
|
||||||
|
기존 프로토콜 100% 유지. 프론트엔드에서 메시지 수신 시 캔버스 상태만 추가 업데이트.
|
||||||
|
|
||||||
|
| 메시지 타입 | 캔버스 반응 |
|
||||||
|
|------------|-----------|
|
||||||
|
| `agent_state` | 해당 에이전트 FSM 상태 전환 → 애니메이션/위치 변경 트리거 |
|
||||||
|
| `agent_move` | target에 따라 BFS 경로 계산 → 이동 시작 |
|
||||||
|
| `task_complete` | 에이전트 상태를 idle로 전환 |
|
||||||
|
| `notification` | 에이전트 위 알림 배지 카운트 증가 |
|
||||||
|
| `init` | 모든 에이전트 초기 위치/상태 설정 |
|
||||||
|
|
||||||
|
### agent_state 수신 시 이동 로직
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function onAgentState(agentId, newState) {
|
||||||
|
const agent = agents.get(agentId);
|
||||||
|
|
||||||
|
switch (newState) {
|
||||||
|
case 'working':
|
||||||
|
case 'waiting':
|
||||||
|
case 'reporting':
|
||||||
|
// 자리에 있지 않으면 자리로 이동
|
||||||
|
if (!agent.isAtDesk()) agent.moveTo(agent.deskWaypoint);
|
||||||
|
break;
|
||||||
|
case 'break':
|
||||||
|
agent.moveTo('break_room');
|
||||||
|
break;
|
||||||
|
case 'idle':
|
||||||
|
// 배회 루프 시작
|
||||||
|
agent.startWandering();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
agent.setState(newState);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 파일 구조 (프론트엔드)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/pages/agent-office/
|
||||||
|
├── AgentOffice.jsx # 루트 컴포넌트 (재작성)
|
||||||
|
├── AgentOffice.css # 스타일 (재작성)
|
||||||
|
├── hooks/
|
||||||
|
│ ├── useAgentManager.js # WebSocket + 상태 (기존 확장)
|
||||||
|
│ └── useOfficeCanvas.js # 캔버스 셋업 (재작성)
|
||||||
|
├── components/
|
||||||
|
│ ├── TopBar.jsx # 상단 바 (신규)
|
||||||
|
│ ├── SidePanel.jsx # 사이드 패널 컨테이너 (신규)
|
||||||
|
│ ├── CommandTab.jsx # Commands 탭 (AgentColumn 리팩토링)
|
||||||
|
│ ├── TaskTab.jsx # Tasks 탭 (AgentColumn에서 분리)
|
||||||
|
│ ├── TokenTab.jsx # Tokens 탭 (신규)
|
||||||
|
│ ├── LogTab.jsx # Logs 탭 (신규)
|
||||||
|
│ ├── ApprovalCard.jsx # 승인 UI 카드 (신규)
|
||||||
|
│ └── MobileBottomSheet.jsx # 모바일 바텀 시트 (신규)
|
||||||
|
├── canvas/
|
||||||
|
│ ├── OfficeRenderer.js # 게임 루프 + 렌더 파이프라인 (재작성)
|
||||||
|
│ ├── TileMap.js # 타일맵 렌더링 + 테마 적용 (재작성)
|
||||||
|
│ ├── FurnitureRenderer.js # 가구 프로시저럴 렌더링 (신규)
|
||||||
|
│ ├── AgentSprite.js # 에이전트 이동 + 애니메이션 (재작성)
|
||||||
|
│ ├── ProceduralSprite.js # 프로시저럴 캐릭터 렌더링 (SpriteSheet 리팩토링)
|
||||||
|
│ ├── SpriteLoader.js # 스프라이트시트 로더 + 폴백 (신규)
|
||||||
|
│ ├── Pathfinder.js # BFS 경로 탐색 (신규)
|
||||||
|
│ ├── OverlayRenderer.js # 이름, 배지, 말풍선 (신규)
|
||||||
|
│ └── themes.js # 테마 데이터 (신규)
|
||||||
|
├── assets/
|
||||||
|
│ ├── office-map.json # 32x20 맵 데이터 (재작성)
|
||||||
|
│ └── sprites/ # Phase 2 스프라이트시트 PNG (빈 디렉토리)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 삭제 대상
|
||||||
|
|
||||||
|
- `components/AgentColumn.jsx` → CommandTab + TaskTab으로 분리
|
||||||
|
- `components/CommandColumn.jsx` → SidePanel 내 CommandTab으로 통합
|
||||||
|
- `components/ChatPanel.jsx` → 미사용, 삭제
|
||||||
|
- `components/DocumentPanel.jsx` → LogTab으로 대체
|
||||||
|
- `canvas/SpriteSheet.js` → ProceduralSprite.js로 리팩토링
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 백엔드 변경사항
|
||||||
|
|
||||||
|
**없음.** 기존 WebSocket 프로토콜과 REST API를 그대로 사용한다.
|
||||||
|
|
||||||
|
단, `agent_move` 메시지가 break 전환 시에도 정확히 발송되는지 확인 필요:
|
||||||
|
- `base.py`의 `check_idle_break()` → `transition('break')` → WebSocket broadcast에 `agent_move` 포함 여부 확인
|
||||||
|
- 필요 시 `transition()` 메서드에서 break 상태 전환 시 `agent_move` 메시지 추가
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 구현 순서 (Phase 개요)
|
||||||
|
|
||||||
|
| Phase | 내용 | 의존성 |
|
||||||
|
|-------|------|--------|
|
||||||
|
| **1. 캔버스 엔진** | 게임 루프, 타일맵, 줌/팬, 테마 시스템 | 없음 |
|
||||||
|
| **2. 에이전트 시스템** | 프로시저럴 캐릭터, BFS 경로 탐색, 상태별 애니메이션, 배회 로직 | Phase 1 |
|
||||||
|
| **3. 오버레이** | 이름 라벨, 상태 배지, 말풍선, 알림 배지 | Phase 2 |
|
||||||
|
| **4. 사이드 패널** | 4탭 구성, Quick Actions, Approval UI | Phase 1 |
|
||||||
|
| **5. 페이지 통합** | AgentOffice.jsx 재작성, WebSocket 연동, 히트 테스팅 | Phase 1-4 |
|
||||||
|
| **6. 모바일 대응** | 바텀 시트, 핀치 줌, 터치 이벤트, 반응형 | Phase 5 |
|
||||||
|
| **7. 스프라이트 로더** | SpriteLoader 구현, 폴백 연결 | Phase 2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 성공 기준
|
||||||
|
|
||||||
|
- [ ] 전체 화면 캔버스에서 5명의 에이전트가 상태에 맞게 애니메이션
|
||||||
|
- [ ] idle 에이전트가 사무실을 배회하다 자리로 복귀
|
||||||
|
- [ ] break 에이전트가 휴게실로 이동하여 휴식
|
||||||
|
- [ ] 에이전트 클릭 시 사이드 패널 열림, 4탭 모두 동작
|
||||||
|
- [ ] Commands 탭에서 명령 전송 + 승인/거부 동작
|
||||||
|
- [ ] 3가지 테마 전환 동작, localStorage에 저장
|
||||||
|
- [ ] 모바일에서 바텀 시트 + 핀치 줌 동작
|
||||||
|
- [ ] 기존 WebSocket 프로토콜과 100% 호환
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
# Personal 서비스 마이그레이션 설계
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
기존 `portfolio` 서비스를 `personal`로 리네이밍하고, lotto-backend에 있던 Blog/Todo 기능을 personal 서비스로 통합한다.
|
||||||
|
|
||||||
|
**목표**: 신규 컨테이너 없이, 개인 콘텐츠(포트폴리오 + 블로그 + 투두)를 하나의 서비스로 통합
|
||||||
|
|
||||||
|
**제약**: 기존 데이터 무손실 이전 필수
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 아키텍처
|
||||||
|
|
||||||
|
### 변경 전
|
||||||
|
|
||||||
|
```
|
||||||
|
lotto-backend (lotto.db)
|
||||||
|
├── 로또 API (/api/lotto/*)
|
||||||
|
├── 블로그 API (/api/blog/posts) ← 이전 대상
|
||||||
|
└── 투두 API (/api/todos) ← 이전 대상
|
||||||
|
|
||||||
|
portfolio (portfolio.db)
|
||||||
|
└── 포트폴리오 API (/api/profile/*)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 변경 후
|
||||||
|
|
||||||
|
```
|
||||||
|
lotto-backend (lotto.db)
|
||||||
|
└── 로또 API (/api/lotto/*) ← Blog/Todo 라우트 제거
|
||||||
|
|
||||||
|
personal (personal.db)
|
||||||
|
├── 포트폴리오 API (/api/profile/*)
|
||||||
|
├── 블로그 API (/api/blog/posts) ← 통합
|
||||||
|
└── 투두 API (/api/todos) ← 통합
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 서비스 속성
|
||||||
|
|
||||||
|
| 항목 | 현재 (portfolio) | 변경 후 (personal) |
|
||||||
|
|------|-----------------|-------------------|
|
||||||
|
| 디렉토리 | `portfolio/` | `personal/` |
|
||||||
|
| 컨테이너명 | `portfolio` | `personal` |
|
||||||
|
| 포트 | 18850 | 18850 (유지) |
|
||||||
|
| DB 파일 | `data/portfolio/portfolio.db` | `data/personal/personal.db` |
|
||||||
|
| API prefix | `/api/profile/` | `/api/profile/` + `/api/todos` + `/api/blog/` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DB 스키마
|
||||||
|
|
||||||
|
personal.db에 기존 5테이블 + 신규 2테이블:
|
||||||
|
|
||||||
|
### 기존 테이블 (portfolio에서 이관)
|
||||||
|
- `profile` — 프로필 (id=1 싱글턴)
|
||||||
|
- `careers` — 경력
|
||||||
|
- `projects` — 프로젝트
|
||||||
|
- `skills` — 기술스택
|
||||||
|
- `introductions` — 자기소개
|
||||||
|
|
||||||
|
### 신규 추가 테이블 (lotto-backend에서 이관)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS todos (
|
||||||
|
id TEXT PRIMARY KEY
|
||||||
|
DEFAULT (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2)))),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'todo'
|
||||||
|
CHECK(status IN ('todo','in_progress','done')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_todos_created ON todos(created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS blog_posts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL DEFAULT '',
|
||||||
|
excerpt TEXT NOT NULL DEFAULT '',
|
||||||
|
tags TEXT NOT NULL DEFAULT '[]',
|
||||||
|
date TEXT NOT NULL DEFAULT (date('now','localtime')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_blog_date ON blog_posts(date DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API 엔드포인트 (personal 서비스 전체)
|
||||||
|
|
||||||
|
### 포트폴리오 (기존 유지)
|
||||||
|
| 메서드 | 경로 | 인증 | 설명 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| GET | `/api/profile/public` | - | 공개 데이터 일괄 조회 |
|
||||||
|
| POST | `/api/profile/auth` | - | 비밀번호 인증 → 토큰 |
|
||||||
|
| GET/PUT | `/api/profile/profile` | Bearer | 프로필 조회/수정 |
|
||||||
|
| GET/POST | `/api/profile/careers` | Bearer | 경력 목록/추가 |
|
||||||
|
| PUT/DELETE | `/api/profile/careers/{id}` | Bearer | 경력 수정/삭제 |
|
||||||
|
| GET/POST | `/api/profile/projects` | Bearer | 프로젝트 목록/추가 |
|
||||||
|
| PUT/DELETE | `/api/profile/projects/{id}` | Bearer | 프로젝트 수정/삭제 |
|
||||||
|
| GET/POST | `/api/profile/skills` | Bearer | 기술 목록/추가 |
|
||||||
|
| PUT/DELETE | `/api/profile/skills/{id}` | Bearer | 기술 수정/삭제 |
|
||||||
|
| GET/POST | `/api/profile/introductions` | Bearer | 자기소개 목록/추가 |
|
||||||
|
| PUT/DELETE | `/api/profile/introductions/{id}` | Bearer | 자기소개 수정/삭제 |
|
||||||
|
| PATCH | `/api/profile/introductions/{id}/main` | Bearer | 메인 자기소개 지정 |
|
||||||
|
|
||||||
|
### 투두 (lotto-backend에서 이전, 인증 없음)
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/todos` | 전체 목록 |
|
||||||
|
| POST | `/api/todos` | 생성 |
|
||||||
|
| DELETE | `/api/todos/done` | 완료 일괄 삭제 |
|
||||||
|
| PUT | `/api/todos/{id}` | 수정 |
|
||||||
|
| DELETE | `/api/todos/{id}` | 삭제 |
|
||||||
|
|
||||||
|
### 블로그 (lotto-backend에서 이전, 인증 없음)
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/blog/posts` | 목록 (`{"posts": [...]}`) |
|
||||||
|
| POST | `/api/blog/posts` | 생성 |
|
||||||
|
| PUT | `/api/blog/posts/{id}` | 수정 |
|
||||||
|
| DELETE | `/api/blog/posts/{id}` | 삭제 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nginx 라우팅 변경
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# 추가: /api/todos → personal
|
||||||
|
location /api/todos {
|
||||||
|
resolver 127.0.0.11 valid=10s;
|
||||||
|
set $personal_backend personal:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_pass http://$personal_backend$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 추가: /api/blog/ → personal
|
||||||
|
location /api/blog/ {
|
||||||
|
resolver 127.0.0.11 valid=10s;
|
||||||
|
set $personal_backend personal:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_pass http://$personal_backend$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 변경: portfolio → personal
|
||||||
|
location /api/profile/ {
|
||||||
|
resolver 127.0.0.11 valid=10s;
|
||||||
|
set $personal_backend personal:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_pass http://$personal_backend$request_uri;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
기존 `/api/` catch-all은 lotto-backend로 유지 (todos/blog 요청은 위의 더 구체적인 location에서 먼저 매칭).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 인프라 변경
|
||||||
|
|
||||||
|
### docker-compose.yml
|
||||||
|
- `portfolio` 서비스 → `personal`로 리네이밍
|
||||||
|
- 볼륨: `${RUNTIME_PATH}/data/personal:/app/data`
|
||||||
|
- 환경변수 동일 (PORTFOLIO_EDIT_PASSWORD 등)
|
||||||
|
|
||||||
|
### deploy.sh / deploy-nas.sh
|
||||||
|
- SERVICES, BUILD_TARGETS, CONTAINER_NAMES 등에서 `portfolio` → `personal` 변경
|
||||||
|
- DATA_DIRS에서 `portfolio` → `personal` 변경
|
||||||
|
|
||||||
|
### lotto-backend 정리
|
||||||
|
- `main.py`에서 Blog/Todo 라우트 + Pydantic 모델 제거 (약 100줄)
|
||||||
|
- `db.py`에서 Blog/Todo CRUD 함수 제거 (약 130줄)
|
||||||
|
- `db.py`의 `init_db()`에서 todos/blog_posts 테이블 생성 코드는 유지 (기존 DB 호환)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 배포 순서 (안전 우선)
|
||||||
|
|
||||||
|
1. **코드 개발** — personal 서비스 + lotto-backend 정리 + 인프라 변경
|
||||||
|
2. **git push** — 자동 배포 트리거
|
||||||
|
3. **NAS에서 데이터 디렉토리 준비** — `mkdir -p data/personal`
|
||||||
|
4. **기존 portfolio.db 이동** — `cp data/portfolio/portfolio.db data/personal/personal.db`
|
||||||
|
5. **lotto.db에서 Blog/Todo 데이터 복사**:
|
||||||
|
```bash
|
||||||
|
sqlite3 data/lotto.db ".dump todos" | sqlite3 data/personal/personal.db
|
||||||
|
sqlite3 data/lotto.db ".dump blog_posts" | sqlite3 data/personal/personal.db
|
||||||
|
```
|
||||||
|
6. **컨테이너 재시작** — `docker compose restart personal`
|
||||||
|
7. **검증** — API 호출로 데이터 건수 대조
|
||||||
|
8. **lotto.db 원본 테이블** — 삭제하지 않고 당분간 유지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 프론트엔드
|
||||||
|
|
||||||
|
변경 없음. 모든 API 호출이 상대경로(`/api/todos`, `/api/blog/posts`, `/api/profile/`)이므로 nginx 라우팅 변경만으로 자동 적용.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 리스크
|
||||||
|
|
||||||
|
- **낮음**: Blog/Todo는 lotto 테이블과 FK/공유 쿼리 없음
|
||||||
|
- **롤백**: lotto.db 원본 테이블 유지 + nginx 라우팅 원복으로 즉시 롤백 가능
|
||||||
|
- **다운타임**: nginx reload 순간 (~1초)
|
||||||
355
docs/superpowers/specs/2026-04-27-portfolio-design.md
Normal file
355
docs/superpowers/specs/2026-04-27-portfolio-design.md
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
# Portfolio Service Design Spec
|
||||||
|
|
||||||
|
> 개인 포트폴리오 정식 서비<EC849C><EBB984>. 취업/이직용 이력서 + 개인 브랜딩 쇼케이스 겸용.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 서비스 개요
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|-----|
|
||||||
|
| 서비스명 | portfolio |
|
||||||
|
| 경로 | `web-backend/portfolio/` |
|
||||||
|
| 컨테이너 | `portfolio` |
|
||||||
|
| 내부 포트 | 8000 |
|
||||||
|
| 외부 포트 | 18850 |
|
||||||
|
| DB | `/app/data/portfolio.db` (SQLite) |
|
||||||
|
| Nginx 프록시 | `/api/portfolio/` → `portfolio:8000` |
|
||||||
|
| 프레임워크 | FastAPI (Python 3.12) |
|
||||||
|
| 프론트 경로 | `/portfolio` |
|
||||||
|
|
||||||
|
### 목적
|
||||||
|
|
||||||
|
- 프로필, 경력, 프로젝트, 기술스택을 웹에서 관리하고 공개 전시
|
||||||
|
- 자기소개 글을 다중 버전으로 관리 (메인 1개 지정, 클립보드 복사)
|
||||||
|
- 이력서 PDF 내보내기
|
||||||
|
- 홈 페이지에 요약 카드로 연동
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. DB 스키마
|
||||||
|
|
||||||
|
### `profile` (1행, upsert)
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | INTEGER PK | 항상 1 |
|
||||||
|
| name | TEXT | 이름 (한글) |
|
||||||
|
| name_en | TEXT | 이름 (영문) |
|
||||||
|
| role | TEXT | 직함 (한글) |
|
||||||
|
| role_en | TEXT | 직함 (영문) |
|
||||||
|
| email | TEXT | 이메일 |
|
||||||
|
| phone | TEXT | 전화번호 |
|
||||||
|
| github_url | TEXT | GitHub URL |
|
||||||
|
| blog_url | TEXT | 블로그 URL |
|
||||||
|
| photo_url | TEXT | 프로필 사진 URL |
|
||||||
|
| bio | TEXT | 간단 소개 (3줄 정도) |
|
||||||
|
| updated_at | TEXT | ISO8601 |
|
||||||
|
|
||||||
|
### `careers` (경력 이력)
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | INTEGER PK AUTOINCREMENT | |
|
||||||
|
| category | TEXT | `company` \| `education` \| `etc` |
|
||||||
|
| organization | TEXT | 회사/기관명 |
|
||||||
|
| role | TEXT | 직함/전공 |
|
||||||
|
| description | TEXT | 설명 |
|
||||||
|
| start_date | TEXT | YYYY-MM |
|
||||||
|
| end_date | TEXT | YYYY-MM 또는 빈 문자열(현재) |
|
||||||
|
| sort_order | INTEGER | 정렬 순서 (낮을수록 위) |
|
||||||
|
| created_at | TEXT | ISO8601 |
|
||||||
|
| updated_at | TEXT | ISO8601 |
|
||||||
|
|
||||||
|
### `projects` (프로젝트)
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | INTEGER PK AUTOINCREMENT | |
|
||||||
|
| category | TEXT | `company` \| `personal` \| `academy` |
|
||||||
|
| title | TEXT | 프로젝트명 |
|
||||||
|
| description | TEXT | 설명 |
|
||||||
|
| tech_stack | TEXT | JSON 배열 `["Python", "FastAPI", ...]` |
|
||||||
|
| role | TEXT | 담당 역할 |
|
||||||
|
| start_date | TEXT | YYYY-MM |
|
||||||
|
| end_date | TEXT | YYYY-MM 또는 빈 문자열 |
|
||||||
|
| url | TEXT | 프로젝트 URL (선택) |
|
||||||
|
| image_url | TEXT | 대표 이미지 URL (선택) |
|
||||||
|
| sort_order | INTEGER | 정렬 순서 |
|
||||||
|
| created_at | TEXT | ISO8601 |
|
||||||
|
| updated_at | TEXT | ISO8601 |
|
||||||
|
|
||||||
|
### `skills` (기술 스택)
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | INTEGER PK AUTOINCREMENT | |
|
||||||
|
| category | TEXT | `language` \| `framework` \| `infra` \| `tool` |
|
||||||
|
| name | TEXT | 기술명 |
|
||||||
|
| level | INTEGER | 숙련도 1~5 |
|
||||||
|
| sort_order | INTEGER | 정렬 순서 |
|
||||||
|
|
||||||
|
### `introductions` (자기소개 글)
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | INTEGER PK AUTOINCREMENT | |
|
||||||
|
| title | TEXT | 버전명 (예: "이직용 짧은 버전") |
|
||||||
|
| content | TEXT | 본문 |
|
||||||
|
| is_main | INTEGER | 0 \| 1 (메인 자기소개 지정, 항상 1개만 1) |
|
||||||
|
| created_at | TEXT | ISO8601 |
|
||||||
|
| updated_at | TEXT | ISO8601 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. API 설계
|
||||||
|
|
||||||
|
### 공개 API (인증 불필요)
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/portfolio/public` | 전체 공개 데이터 일괄 조회 (profile + careers + projects + skills + 메인 자기소개) |
|
||||||
|
|
||||||
|
응답 형태:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"profile": { ... },
|
||||||
|
"careers": [ ... ],
|
||||||
|
"projects": [ ... ],
|
||||||
|
"skills": [ ... ],
|
||||||
|
"main_introduction": { "id": 1, "title": "...", "content": "..." }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 인증 API
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| POST | `/api/portfolio/auth` | 비밀번호 검증 → 세션 토큰 반환 |
|
||||||
|
|
||||||
|
- 요청: `{ "password": "..." }`
|
||||||
|
- 응답: `{ "token": "uuid-string", "expires_in": 86400 }`
|
||||||
|
- 환경변수: `PORTFOLIO_EDIT_PASSWORD`
|
||||||
|
- 토큰: UUID, 서버 메모리 딕셔너리 저장, 24시간 TTL
|
||||||
|
- 실패: 401
|
||||||
|
|
||||||
|
### 편집 API (Authorization: Bearer {token} 필요)
|
||||||
|
|
||||||
|
**Profile:**
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/portfolio/profile` | 프로필 조회 |
|
||||||
|
| PUT | `/api/portfolio/profile` | 프로필 수정 (upsert) |
|
||||||
|
|
||||||
|
**Careers:**
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/portfolio/careers` | 경력 목록 |
|
||||||
|
| POST | `/api/portfolio/careers` | 경력 추가 |
|
||||||
|
| PUT | `/api/portfolio/careers/{id}` | 경력 수정 |
|
||||||
|
| DELETE | `/api/portfolio/careers/{id}` | 경력 삭제 |
|
||||||
|
|
||||||
|
**Projects:**
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/portfolio/projects` | 프로젝트 목록 |
|
||||||
|
| POST | `/api/portfolio/projects` | 프로젝트 추가 |
|
||||||
|
| PUT | `/api/portfolio/projects/{id}` | 프로젝트 수정 |
|
||||||
|
| DELETE | `/api/portfolio/projects/{id}` | 프로젝트 삭제 |
|
||||||
|
|
||||||
|
**Skills:**
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/portfolio/skills` | 기술 목록 |
|
||||||
|
| POST | `/api/portfolio/skills` | 기술 추가 |
|
||||||
|
| PUT | `/api/portfolio/skills/{id}` | 기술 수정 |
|
||||||
|
| DELETE | `/api/portfolio/skills/{id}` | 기술 삭제 |
|
||||||
|
|
||||||
|
**Introductions:**
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/portfolio/introductions` | 자기소개 전체 목록 |
|
||||||
|
| POST | `/api/portfolio/introductions` | 자기소개 추가 |
|
||||||
|
| PUT | `/api/portfolio/introductions/{id}` | 자기소개 수정 |
|
||||||
|
| DELETE | `/api/portfolio/introductions/{id}` | 자기소개 삭제 |
|
||||||
|
| PATCH | `/api/portfolio/introductions/{id}/main` | 메인 자기소개 지정 (기존 is_main=1 → 0 리셋) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 인증 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
편집 버튼 클릭
|
||||||
|
→ 토큰 없음 → 비밀번호 모달 표시
|
||||||
|
→ POST /api/portfolio/auth { password }
|
||||||
|
→ 성공: 토큰을 React state에 저장 (새로고침 시 재인증)
|
||||||
|
→ 이후 편집 API 호출에 Authorization: Bearer {token} 포함
|
||||||
|
→ 토큰 만료/불일치 시 401 → 재인증 모달
|
||||||
|
```
|
||||||
|
|
||||||
|
서버 측:
|
||||||
|
- `_auth_tokens: dict[str, float]` 메모리 딕셔너리 (token → expiry timestamp)
|
||||||
|
- FastAPI Depends로 토큰 검증 미들웨어
|
||||||
|
- 서버 재시작 시 토큰 소멸 (재인증 필요, 보안상 적절)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 프론트엔드 구조
|
||||||
|
|
||||||
|
### 라우팅
|
||||||
|
|
||||||
|
`routes.jsx`에 추가:
|
||||||
|
- navLink: `{ id: 'portfolio', label: 'Portfolio', path: '/portfolio', subtitle: 'RESUME', accent: '#06b6d4' }`
|
||||||
|
- appRoute: `{ path: 'portfolio', element: <Portfolio /> }`
|
||||||
|
|
||||||
|
### 파일 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
src/pages/portfolio/
|
||||||
|
Portfolio.jsx — 메인 페이지 (3탭 컨테이너)
|
||||||
|
Portfolio.css — 스타일
|
||||||
|
ProfileTab.jsx — 탭 1: 프로필 & 이력 & 기술스택
|
||||||
|
ProjectTab.jsx — 탭 2: 프로젝트
|
||||||
|
IntroTab.jsx — 탭 3: 자기소개 관리
|
||||||
|
usePortfolio.js — API 호출 + 인증 상태 관리 훅
|
||||||
|
PasswordModal.jsx — 비밀번호 입력 모달
|
||||||
|
ResumeView.jsx — PDF 출력 전용 레이아웃 (print CSS)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 탭 1: 프로필 & 이력
|
||||||
|
|
||||||
|
**보기 모드:**
|
||||||
|
- 프로필 카드 (사진, 이름, 역할, 바이오, 연락처 아이콘 링크)
|
||||||
|
- 경력 타임라인 (category별 그룹: 회사 → 교육 → 기타, sort_order 순)
|
||||||
|
- 기술 스택 (category별 그룹, level 바 표시)
|
||||||
|
- "이력서 PDF 내보내기" 버튼
|
||||||
|
|
||||||
|
**편집 모드:**
|
||||||
|
- 프로필: 인라인 편집 (input/textarea)
|
||||||
|
- 경력: 추가/편집/삭제/순서 변경
|
||||||
|
- 기술: 추가/편집/삭제/순서 변경
|
||||||
|
|
||||||
|
### 탭 2: 프로젝트
|
||||||
|
|
||||||
|
**보기 모드:**
|
||||||
|
- 카테고리 필터 버튼 (전체 / 회사 / 개인 / 아카데미)
|
||||||
|
- 프로젝트 카드 그리드: 제목, 설명(2줄 clamp), 기술스택 태그, 기간, 링크 아이콘
|
||||||
|
|
||||||
|
**편집 모드:**
|
||||||
|
- 프로젝트 추가/편집/삭제 폼
|
||||||
|
- tech_stack: 태그 입력 UI (쉼표 또는 엔터로 추가)
|
||||||
|
|
||||||
|
### 탭 3: 자기소개 관리
|
||||||
|
|
||||||
|
- 자기소개 글 리스트 (메인 표시: 별 배지)
|
||||||
|
- 각 항목: 제목, 미리보기(3줄), 수정일
|
||||||
|
- 액션 버튼: 복사(클립보드) / 편집 / 메인 지정 / 삭제
|
||||||
|
- 상단: "새 글 작성" 버튼 → 인라인 폼 또는 MobileSheet
|
||||||
|
- 복사 버튼: `navigator.clipboard.writeText()` → "복사됨!" 피드백 1.5초
|
||||||
|
|
||||||
|
### 편집 모드 진입
|
||||||
|
|
||||||
|
- 각 탭 우상단 "편집" 토글 버튼
|
||||||
|
- 첫 클릭 시 PasswordModal 표시 → 인증 성공 → 편집 UI 노출
|
||||||
|
- 인증 토큰은 usePortfolio 훅에서 관리 (React state, 새로고침 시 소멸)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 홈 페이지 연동
|
||||||
|
|
||||||
|
### 변경 내용
|
||||||
|
|
||||||
|
현재 Home.jsx Profile 섹션(하드코딩)을 요약 카드로 교체:
|
||||||
|
|
||||||
|
- `GET /api/portfolio/public` fetch
|
||||||
|
- 성공 시: 이름, 역할, 바이오, 기술태그 상위 8개, 대표 프로젝트 3개 카드
|
||||||
|
- "포트폴리오 보기 →" 링크 버튼
|
||||||
|
- 실패 시: 기존 하드코딩 프로필 폴백 (서비스 미가동 대응)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. PDF 내보내기
|
||||||
|
|
||||||
|
### 방식
|
||||||
|
|
||||||
|
`window.print()` + `@media print` 전용 CSS
|
||||||
|
|
||||||
|
- ResumeView.jsx: 이력서 레이아웃 전용 컴포넌트
|
||||||
|
- "PDF 내보내기" 버튼 → ResumeView를 화면에 렌더링 → `window.print()` → 숨김
|
||||||
|
- 프린트 CSS: 네비/탭/편집버튼 숨기고, A4 1~2페이지 레이아웃 렌더링
|
||||||
|
|
||||||
|
### 이력서 레이아웃 (A4)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ [사진] 박재오 │
|
||||||
|
│ Server Developer │
|
||||||
|
│ email | github │
|
||||||
|
├──────────────────────────────┤
|
||||||
|
│ ABOUT │
|
||||||
|
│ (메인 자기소개 또는 bio) │
|
||||||
|
├──────────────────────────────┤
|
||||||
|
│ EXPERIENCE │
|
||||||
|
│ - 현대오토에버 (2023~현재) │
|
||||||
|
│ - 롯데정보통신 (2020~2023) │
|
||||||
|
│ - SSAFY 1기 (2019) │
|
||||||
|
├──────────────────────────────┤
|
||||||
|
│ PROJECTS │
|
||||||
|
│ - 프로젝트 카드 목록 │
|
||||||
|
├──────────────────────────────┤
|
||||||
|
│ SKILLS │
|
||||||
|
│ [태그 나열] │
|
||||||
|
└──────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Docker / Nginx 변경
|
||||||
|
|
||||||
|
### docker-compose.yml 추가
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
portfolio:
|
||||||
|
build: ./portfolio
|
||||||
|
container_name: portfolio
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ${RUNTIME_PATH:-.}/data:/app/data
|
||||||
|
environment:
|
||||||
|
- PORTFOLIO_EDIT_PASSWORD=${PORTFOLIO_EDIT_PASSWORD}
|
||||||
|
ports:
|
||||||
|
- "18850:8000"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nginx 추가
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /api/portfolio/ {
|
||||||
|
proxy_pass http://portfolio:8000/api/portfolio/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Backlog (향후)
|
||||||
|
|
||||||
|
- Blog CRUD (`/api/blog/posts`) → portfolio 서비스로 이전
|
||||||
|
- Todo CRUD (`/api/todos`) → portfolio 서비스로 이전
|
||||||
|
- 이전 완료 후 lotto-backend에서 해당 테이블/라우트 제거
|
||||||
|
- Nginx 라우팅 변경 (`/api/blog/`, `/api/todos` → portfolio)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 모바일 대응
|
||||||
|
|
||||||
|
- 기존 프로젝트 패턴 그대로: `useIsMobile()` + SwipeableView 3탭
|
||||||
|
- 편집 모드: MobileSheet 활용
|
||||||
|
- 자기소개 복사: 모바일에서도 `navigator.clipboard` 동작
|
||||||
|
- PDF: 모바일에서는 "PDF 내보내기" 대신 "공유" 또는 브라우저 인쇄 기능 활용
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
# 청약 타겟팅 프론트엔드 설계 — 자치구 5티어 + 알림 설정
|
||||||
|
|
||||||
|
> 대상: `web-ui/src/pages/subscription/`
|
||||||
|
> 백엔드 의존: 2026-04-28-realestate-targeting-enhancement-design.md (이미 배포됨)
|
||||||
|
> 후속 별도 스펙: Subscription.jsx 분할 리팩토링, 5축 progress bar, 추가 알림 채널
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 목표
|
||||||
|
|
||||||
|
백엔드 청약 타겟팅 고도화로 추가된 3 프로필 필드(`preferred_districts`, `min_match_score`, `notify_enabled`)를 프론트 UI에 노출한다. 매칭 결과·공고 카드에는 자치구 + 5티어 뱃지를, 상세 모달에는 매칭 사유 텍스트를 추가해 사용자가 점수의 근거를 즉시 이해할 수 있게 한다.
|
||||||
|
|
||||||
|
### 핵심 변경
|
||||||
|
|
||||||
|
- **ProfileTab**: 자치구 5티어 분류(드래그&드롭, PC 전용) + 임계값 슬라이더 + 알림 토글
|
||||||
|
- **모바일**: 자치구 분류는 read-only — "PC에서 편집해주세요" 안내
|
||||||
|
- **카드 표시**: AnnouncementCard / 매칭 카드에 district 뱃지 + 5티어 뱃지(reasons에서 derive)
|
||||||
|
- **상세 모달**: AnnouncementDetail에 "매칭 분석" 섹션 (점수 + reasons 텍스트 + 자격)
|
||||||
|
|
||||||
|
### 변경하지 않는 것
|
||||||
|
|
||||||
|
- Subscription.jsx 자체 분할 — 본 스코프 외(별도 리팩토링)
|
||||||
|
- 백엔드 응답 형태 — 모든 필요 데이터는 이미 응답에 포함됨
|
||||||
|
- 5축 점수 분해 시각화 — 백엔드 응답 변경 필요(별도)
|
||||||
|
- 알림 채널 추가 — 텔레그램 외 이메일/Slack은 별도
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 컴포넌트 분할
|
||||||
|
|
||||||
|
### 2.1 신규 컴포넌트 2개
|
||||||
|
|
||||||
|
| 파일 | 책임 | 추정 크기 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `web-ui/src/pages/subscription/components/DistrictTierEditor.jsx` | 자치구 5티어 드래그&드롭 + 모바일 read-only | ~180줄 |
|
||||||
|
| `web-ui/src/pages/subscription/components/NotificationSettings.jsx` | 임계값 슬라이더 + 알림 토글 + 미리보기 | ~80줄 |
|
||||||
|
|
||||||
|
ProfileTab(현재 343줄)에 그대로 추가하면 단일 함수가 거대화되어 가독성·유지보수가 떨어진다. 의미 단위로 분할.
|
||||||
|
|
||||||
|
### 2.2 변경 받는 기존 컴포넌트
|
||||||
|
|
||||||
|
| 컴포넌트 (파일: Subscription.jsx) | 변경 |
|
||||||
|
|----|------|
|
||||||
|
| ProfileTab (956~1299줄) | 신규 컴포넌트 2개 import + 자치구 섹션 / 알림 설정 섹션 렌더 + handleSave에서 신규 3필드 송신 |
|
||||||
|
| AnnouncementCard (315~389줄) | district 뱃지 + 5티어 뱃지(`extractTier(reasons)`) |
|
||||||
|
| AnnouncementDetail (390~595줄) | "매칭 분석" 섹션 추가 (점수 + reasons + eligible_types) |
|
||||||
|
| MatchesTab (763~955줄) | 매치 카드에 district + 5티어 뱃지 + reasons 표시 |
|
||||||
|
| 모듈 상단 | `DEFAULT_PROFILE`에 신규 3필드 기본값 추가, `extractTier` 헬퍼 함수 |
|
||||||
|
|
||||||
|
### 2.3 스타일
|
||||||
|
|
||||||
|
- `Subscription.css`: 5티어 뱃지 5 클래스(`.sub-chip--tier-S`~`D`), 드래그&드롭 hover/dragover, 슬라이더, 토글, district 뱃지
|
||||||
|
|
||||||
|
### 2.4 기각된 대안
|
||||||
|
|
||||||
|
| 대안 | 기각 사유 |
|
||||||
|
|------|-----------|
|
||||||
|
| 단일 파일에 모든 신규 UI | ProfileTab이 500줄+ 거대화, 디버깅 어려움 |
|
||||||
|
| Subscription.jsx 자체 분할 | 본 작업 스코프 외, 별도 리팩토링이 적절 |
|
||||||
|
| `react-dnd` 도입 | 의존성 +50KB, 모바일 어차피 사용 안 함. YAGNI |
|
||||||
|
| 5칼럼 체크박스 그리드 | 모바일/데스크톱 둘 다 무난하지만 드래그&드롭이 더 직관적이라 채택 안 함 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. DistrictTierEditor 컴포넌트
|
||||||
|
|
||||||
|
### 3.1 인터페이스
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<DistrictTierEditor
|
||||||
|
value={preferredDistricts} // {"S":["강남구",...], "A":[...], "B":[...], "C":[...], "D":[...]}
|
||||||
|
onChange={(next) => setProfile({...profile, preferred_districts: next})}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
`value`가 비어있거나 누락되면 빈 객체 fallback. `onChange`는 새 객체를 항상 한 번 호출(부모는 setState만 처리).
|
||||||
|
|
||||||
|
### 3.2 상수
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const SEOUL_DISTRICTS = [
|
||||||
|
"강남구","강동구","강북구","강서구","관악구",
|
||||||
|
"광진구","구로구","금천구","노원구","도봉구",
|
||||||
|
"동대문구","동작구","마포구","서대문구","서초구",
|
||||||
|
"성동구","성북구","송파구","양천구","영등포구",
|
||||||
|
"용산구","은평구","종로구","중구","중랑구",
|
||||||
|
];
|
||||||
|
|
||||||
|
const TIERS = [
|
||||||
|
{ key: "S", label: "S", weight: "100%" },
|
||||||
|
{ key: "A", label: "A", weight: "80%" },
|
||||||
|
{ key: "B", label: "B", weight: "60%" },
|
||||||
|
{ key: "C", label: "C", weight: "40%" },
|
||||||
|
{ key: "D", label: "D", weight: "20%" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EMPTY_TIERS = { S:[], A:[], B:[], C:[], D:[] };
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 데스크톱 레이아웃 (≥768px)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ 자치구 우선순위 ─────────────────────────────────────────┐
|
||||||
|
│ 미할당 (드래그해서 분류) │
|
||||||
|
│ [강서구] [노원구] [도봉구] [중랑구] [관악구] ... │
|
||||||
|
│ │
|
||||||
|
│ ┌─ S 100% ─┐ ┌─ A 80% ─┐ ┌─ B 60% ─┐ ┌─ C 40% ─┐ ┌─ D 20% ─┐│
|
||||||
|
│ │[강남구]× │ │[송파구]× │ │ │ │ │ │ ││
|
||||||
|
│ │[서초구]× │ │[마포구]× │ │ │ │ │ │ ││
|
||||||
|
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘│
|
||||||
|
└────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- 5티어는 가로 5칼럼 그리드(`grid-template-columns: repeat(5, 1fr)`)
|
||||||
|
- 미할당 풀은 그리드 위, 가로 wrap
|
||||||
|
- 자치구 칩은 `<span draggable="true">` + `<button>×</button>` (`×` 클릭 시 미할당으로 복귀)
|
||||||
|
- 각 티어 슬롯은 dropzone(`onDragOver` + `onDrop`)
|
||||||
|
- 미할당 풀도 dropzone(드래그해서 떨어뜨리면 해당 티어에서 제거)
|
||||||
|
|
||||||
|
### 3.4 모바일 레이아웃 (<768px) — read-only
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ 자치구 우선순위 ──────────────┐
|
||||||
|
│ S 100% 강남구, 서초구 │
|
||||||
|
│ A 80% 송파구, 마포구 │
|
||||||
|
│ B 60% (없음) │
|
||||||
|
│ C 40% (없음) │
|
||||||
|
│ D 20% (없음) │
|
||||||
|
│ │
|
||||||
|
│ ✏️ 자치구 분류는 PC에서 편집 │
|
||||||
|
└──────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
분기 로직:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const [isDesktop, setIsDesktop] = useState(
|
||||||
|
typeof window !== "undefined" && window.matchMedia("(min-width: 768px)").matches
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia("(min-width: 768px)");
|
||||||
|
const handler = (e) => setIsDesktop(e.matches);
|
||||||
|
mq.addEventListener("change", handler);
|
||||||
|
return () => mq.removeEventListener("change", handler);
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
`isDesktop=false`면 read-only 뷰만 렌더, 드래그 핸들러는 등록하지 않음.
|
||||||
|
|
||||||
|
### 3.5 핵심 로직
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const handleDrop = (district, targetTier /* null = 미할당 */) => {
|
||||||
|
const current = value || EMPTY_TIERS;
|
||||||
|
const next = { ...EMPTY_TIERS };
|
||||||
|
for (const t of Object.keys(EMPTY_TIERS)) {
|
||||||
|
next[t] = (current[t] || []).filter(d => d !== district);
|
||||||
|
}
|
||||||
|
if (targetTier) {
|
||||||
|
next[targetTier] = [...next[targetTier], district];
|
||||||
|
}
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unassigned = SEOUL_DISTRICTS.filter(d =>
|
||||||
|
!TIERS.some(t => (value?.[t.key] || []).includes(d))
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
`onChange`는 새 객체를 통째로 전달(immutable update).
|
||||||
|
|
||||||
|
### 3.6 드래그&드롭 이벤트 (HTML5 native)
|
||||||
|
|
||||||
|
| 이벤트 | 핸들러 |
|
||||||
|
|--------|--------|
|
||||||
|
| `onDragStart` (chip) | `e.dataTransfer.setData("district", districtName)` |
|
||||||
|
| `onDragOver` (zone) | `e.preventDefault()` (drop 허용) |
|
||||||
|
| `onDrop` (zone) | `e.preventDefault()` + `handleDrop(e.dataTransfer.getData("district"), tierKey)` |
|
||||||
|
|
||||||
|
외부 라이브러리 없음.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. NotificationSettings 컴포넌트
|
||||||
|
|
||||||
|
### 4.1 인터페이스
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<NotificationSettings
|
||||||
|
minScore={profile.min_match_score} // number 0~100
|
||||||
|
notifyEnabled={profile.notify_enabled} // bool
|
||||||
|
onChange={(patch) => setProfile({...profile, ...patch})}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
`onChange` 호출 예시: 슬라이더 변경 시 `onChange({ min_match_score: 75 })`, 토글 시 `onChange({ notify_enabled: false })`.
|
||||||
|
|
||||||
|
### 4.2 레이아웃
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ 🔔 알림 설정 ────────────────────────────────┐
|
||||||
|
│ 텔레그램 알림 ●━━━○ ON │
|
||||||
|
│ 매칭 임계값 ▬▬▬▬▬▬●▬▬▬ 70점 │
|
||||||
|
│ 0 50 100 │
|
||||||
|
│ │
|
||||||
|
│ 💡 70점 이상 매치 시 텔레그램에 자동 알림합니다│
|
||||||
|
└────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 컨트롤
|
||||||
|
|
||||||
|
- 토글: `<input type="checkbox" className="sub-toggle">` + 사용자 정의 CSS (Subscription.css에 `.sub-toggle` 신설)
|
||||||
|
- 슬라이더: `<input type="range" min="0" max="100" step="5">` + 우측 숫자 라벨
|
||||||
|
- 미리보기: `notify_enabled === false` 일 때 경고 톤 메시지("알림 OFF — 메시지가 발송되지 않습니다")
|
||||||
|
|
||||||
|
### 4.4 저장 동작
|
||||||
|
|
||||||
|
각 컨트롤 변경 시 `onChange`로 부모 state만 업데이트. 실제 PUT 요청은 ProfileTab 기존 "저장" 버튼이 일괄 처리(다른 모든 필드와 동일 패턴).
|
||||||
|
|
||||||
|
### 4.5 카운트 미리보기 (스코프 외)
|
||||||
|
|
||||||
|
"현재 임계값 통과 매치 N건" 같은 카운트 미리보기는 본 스펙에서 다루지 않는다. `dashboard.new_match_count`는 "미확인 매칭"이라 임계값 통과와 의미가 다르고, 정확한 카운트를 위해서는 백엔드에 `dashboard.pass_count` 필드 신설이 필요하다. 후속 스펙으로 분리.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 카드 표시 변경
|
||||||
|
|
||||||
|
### 5.1 헬퍼 함수 (Subscription.jsx 모듈 상단)
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
function extractTier(reasons) {
|
||||||
|
for (const r of reasons || []) {
|
||||||
|
const m = r.match(/자치구 ([SABCD])티어/);
|
||||||
|
if (m) return m[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 백엔드 응답 변경 없이 reasons 배열에서 티어 도출
|
||||||
|
- reasons 형식 예시: `"자치구 S티어: 강남구 (+25)"` (백엔드 matcher.py의 fmt와 일치)
|
||||||
|
- 광역만 매칭(legacy 모드)이면 티어 없음 → `null`
|
||||||
|
|
||||||
|
### 5.2 AnnouncementCard
|
||||||
|
|
||||||
|
기존 카드 제목/지역 라인 옆 또는 메타 라인에 추가:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
{item.district && (
|
||||||
|
<span className="sub-chip sub-chip--district">{item.district}</span>
|
||||||
|
)}
|
||||||
|
{(() => {
|
||||||
|
const tier = extractTier(item.match_reasons);
|
||||||
|
return tier ? (
|
||||||
|
<span className={`sub-chip sub-chip--tier sub-chip--tier-${tier}`}>
|
||||||
|
{tier}티어
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
```
|
||||||
|
|
||||||
|
`item.match_reasons`는 매칭 결과가 있는 경우만 존재. 없으면 뱃지 미표시(공고 목록 탭에서 매칭 결과 없는 카드).
|
||||||
|
|
||||||
|
### 5.3 AnnouncementDetail
|
||||||
|
|
||||||
|
상세 모달 하단에 새 섹션:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ 매칭 분석 ─────────────────────────────────┐
|
||||||
|
│ ⭐ 점수: 90점 / 100점 │
|
||||||
|
│ │
|
||||||
|
│ 💡 매칭 사유 │
|
||||||
|
│ • 광역 일치: 서울특별시 │
|
||||||
|
│ • 자치구 S티어: 강남구 (+25) │
|
||||||
|
│ • 예산 범위 내 모델 존재 (최고가 7.2억원) │
|
||||||
|
│ • 자격 유형 2개: 일반1순위, 특별-신혼부부 │
|
||||||
|
│ │
|
||||||
|
│ ✓ 신청 자격 │
|
||||||
|
│ [일반1순위] [특별-신혼부부] │
|
||||||
|
└──────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
`item.match_score`, `item.match_reasons`, `item.eligible_types`는 이미 응답에 포함됨(get_unnotified_matches는 물론 get_matches/get_announcement도 enrich_items 거침). 매칭 결과가 없는 공고에는 이 섹션 자체를 렌더하지 않음(`item.match_score` 존재 여부로 분기).
|
||||||
|
|
||||||
|
### 5.4 MatchesTab
|
||||||
|
|
||||||
|
매치 카드는 이미 매칭 데이터를 받지만 district + 5티어 뱃지 표시가 부족할 가능성 높음. AnnouncementCard와 동일한 helper(`extractTier`)로 일관 표시. 카드 클릭 시 AnnouncementDetail 모달이 reasons 노출.
|
||||||
|
|
||||||
|
### 5.5 5티어 뱃지 색상 (Subscription.css 신설)
|
||||||
|
|
||||||
|
```css
|
||||||
|
.sub-chip--tier-S { background:#fee2e2; color:#dc2626; border-color:#fca5a5; }
|
||||||
|
.sub-chip--tier-A { background:#fef3c7; color:#d97706; border-color:#fcd34d; }
|
||||||
|
.sub-chip--tier-B { background:#d1fae5; color:#059669; border-color:#6ee7b7; }
|
||||||
|
.sub-chip--tier-C { background:#dbeafe; color:#2563eb; border-color:#93c5fd; }
|
||||||
|
.sub-chip--tier-D { background:#ede9fe; color:#7c3aed; border-color:#c4b5fd; }
|
||||||
|
.sub-chip--district { background:#f3f4f6; color:#374151; border-color:#d1d5db; }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. ProfileTab 통합
|
||||||
|
|
||||||
|
### 6.1 DEFAULT_PROFILE 갱신
|
||||||
|
|
||||||
|
Subscription.jsx 모듈 상단의 `DEFAULT_PROFILE` 상수에 3 필드 default 추가:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const DEFAULT_PROFILE = {
|
||||||
|
// ... 기존 필드
|
||||||
|
preferred_regions: '',
|
||||||
|
preferred_types: '',
|
||||||
|
min_area: '',
|
||||||
|
max_area: '',
|
||||||
|
max_price: '',
|
||||||
|
// 신규
|
||||||
|
preferred_districts: {},
|
||||||
|
min_match_score: 70,
|
||||||
|
notify_enabled: true,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 ProfileTab 렌더 추가 위치
|
||||||
|
|
||||||
|
자치구 섹션은 기존 "선호 조건" 섹션 다음, 알림 설정 섹션은 그 다음에 배치(저장 버튼 위):
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<DistrictTierEditor
|
||||||
|
value={profile.preferred_districts}
|
||||||
|
onChange={(next) => handleChange("preferred_districts", next)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NotificationSettings
|
||||||
|
minScore={profile.min_match_score ?? 70}
|
||||||
|
notifyEnabled={profile.notify_enabled ?? true}
|
||||||
|
onChange={(patch) => setProfile(prev => ({ ...prev, ...patch }))}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 handleSave 변경
|
||||||
|
|
||||||
|
신규 3 필드는 변환 없이 그대로 PUT body에 포함:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// 기존 변환 로직 다음에
|
||||||
|
payload.preferred_districts = profile.preferred_districts || {};
|
||||||
|
payload.min_match_score = profile.min_match_score ?? null;
|
||||||
|
payload.notify_enabled = profile.notify_enabled ?? null;
|
||||||
|
```
|
||||||
|
|
||||||
|
JSON 형태(객체)는 백엔드 ProfileUpdate 모델에서 `Dict[str, List[str]]`로 받음(이미 구현됨).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 테스트 전략
|
||||||
|
|
||||||
|
`web-ui` 레포는 단위 테스트 인프라가 빈약(컨벤션 확인 필요). 본 작업의 검증:
|
||||||
|
|
||||||
|
| 영역 | 검증 방식 |
|
||||||
|
|------|-----------|
|
||||||
|
| 빌드 | `npm run build` warning/error 없음 |
|
||||||
|
| 데스크톱 자치구 편집 | 미할당 풀 → S 슬롯 드래그 → 저장 → 새로고침 → 유지 확인 |
|
||||||
|
| 자치구 티어 이동 | S → A로 드래그 → S에서 사라지고 A에 등장 |
|
||||||
|
| 자치구 해제 | × 버튼 또는 미할당 풀로 드래그 → 미할당 풀에 복귀 |
|
||||||
|
| 모바일 read-only | 개발자 도구 < 768px → 편집 영역 숨김 + 안내 메시지 표시 |
|
||||||
|
| 임계값 슬라이더 | 0→100 조절, 즉시 미리보기 텍스트 갱신, 저장·새로고침 후 유지 |
|
||||||
|
| 알림 토글 | OFF 시 경고 톤 안내 표시 |
|
||||||
|
| 매칭 카드 | district 뱃지 + 5티어 뱃지 표시 (해당 데이터 있는 경우) |
|
||||||
|
| 상세 모달 | 매칭 분석 섹션의 점수 + reasons + 자격 표시 |
|
||||||
|
| 회귀 | 기존 프로필 필드(나이/청약통장/특공 등) 입력·저장 정상 |
|
||||||
|
|
||||||
|
`scripts/dev.bat` 또는 `cd web-ui && npm run dev`로 dev server 실행 후 브라우저에서 수동 검증.
|
||||||
|
|
||||||
|
배포는 frontend 별도 절차: `cd web-ui && npm run release:nas` (NAS Z 드라이브에 robocopy).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 스코프
|
||||||
|
|
||||||
|
### 본 스펙 범위
|
||||||
|
|
||||||
|
- ✅ DistrictTierEditor 신규 컴포넌트
|
||||||
|
- ✅ NotificationSettings 신규 컴포넌트
|
||||||
|
- ✅ ProfileTab 신규 3 필드 통합 + 저장
|
||||||
|
- ✅ AnnouncementCard / MatchesTab district + 5티어 뱃지
|
||||||
|
- ✅ AnnouncementDetail 매칭 분석 섹션
|
||||||
|
- ✅ Subscription.css 5티어 뱃지 + 드래그 영역 + 토글 + 슬라이더 스타일
|
||||||
|
- ✅ 모바일 read-only fallback
|
||||||
|
|
||||||
|
### 후속 별도 스펙
|
||||||
|
|
||||||
|
- ❌ Subscription.jsx 자체 분할 (1354줄 → 별도 리팩토링)
|
||||||
|
- ❌ 5축 점수 분해 progress bar (백엔드 응답에 5축 점수 추가 필요)
|
||||||
|
- ❌ 임계값 통과 매치 카운트 미리보기 (`dashboard.pass_count` 백엔드 신설 필요)
|
||||||
|
- ❌ 자치구 5티어 자동 추천 (사용자 가점·예산 기반)
|
||||||
|
- ❌ 알림 채널 추가 (이메일/Slack)
|
||||||
|
- ❌ 모바일 자치구 편집 지원 (touch backend 필요 시)
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
# 청약 서비스 타겟팅 고도화 설계
|
||||||
|
|
||||||
|
> 대상: `web-backend/realestate-lab/` + `web-backend/agent-office/`
|
||||||
|
> 후속 별도 스펙: 프론트 자치구 입력 UI(`web-ui`), 청약 가점 vs 커트라인 비교, 서울 외 광역 자치구 파싱
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 목표
|
||||||
|
|
||||||
|
현재 청약 서비스가 1) 완료된 공고까지 무차별 수집하고, 2) 매칭이 binary라 단지별 의미 있는 점수 차이가 없으며, 3) 데일리 리포트라 "발견 즉시"의 가치를 못 살리는 문제를 해결한다.
|
||||||
|
|
||||||
|
### 핵심 변경
|
||||||
|
|
||||||
|
- **수집**: 모집공고 30일 이전 + 이미 `완료` 상태인 공고는 저장하지 않음. 90일 경과 완료 공고 자동 정리.
|
||||||
|
- **단일 SoT**: `user_profile.preferred_regions`를 수집·조회·매칭의 단일 기준점으로 사용 (서울 default).
|
||||||
|
- **매칭**: 자치구 5티어 가중치(S=100% / A=80% / B=60% / C=40% / D=20%) 도입. 자격 점수 미세 조정.
|
||||||
|
- **알림**: 데일리 리포트 폐기. "신규 매칭 + 임계값 통과" 즉시 텔레그램 푸시. realestate-lab → agent-office HTTP push.
|
||||||
|
|
||||||
|
### 변경하지 않는 것
|
||||||
|
|
||||||
|
- 공공데이터 API 엔드포인트 5종 구성
|
||||||
|
- 매칭 총점 100점 체계
|
||||||
|
- 텔레그램 봇 토큰·formatter는 agent-office에 단일 보관
|
||||||
|
- realestate-lab의 09:00 / 00:00 cron 스케줄(기존 그대로 유지, 트리거 로직만 변경)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 아키텍처 변경 개요
|
||||||
|
|
||||||
|
### 2.1 변경 포인트
|
||||||
|
|
||||||
|
| # | 위치 | 변경 |
|
||||||
|
|---|------|------|
|
||||||
|
| 1 | `realestate-lab/collector.py` | API 호출 시 모집공고일 윈도우 사전 적용. 응답 시 `완료` 상태 skip. 자치구 파싱. 90일 경과 완료 공고 정리. |
|
||||||
|
| 2 | `realestate-lab/db.py` | `user_profile`에 3컬럼, `announcements`에 `district`, `match_results`에 `notified_at` 추가. `delete_old_completed_announcements()` 신규. |
|
||||||
|
| 3 | `realestate-lab/matcher.py` | 자치구 5티어 가중치 + 자격 점수 재배분. binary → 자치구 그라디언트. |
|
||||||
|
| 4 | `realestate-lab` 신규 모듈 | `notifier.py`: 임계값 통과 신규 매칭 추출 + agent-office push. `notified_at` 멱등 마킹. |
|
||||||
|
| 5 | `agent-office/agents/realestate.py` | 데일리 cron 폐기. `on_new_matches(matches)` 신규. 메시지 fmt + 인라인 키보드. |
|
||||||
|
| 6 | `agent-office/main.py` | `POST /api/agent-office/realestate/notify` 신규 엔드포인트. |
|
||||||
|
|
||||||
|
### 2.2 데이터 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
[09:00 cron] realestate-lab.scheduled_collect()
|
||||||
|
├─ collect_all()
|
||||||
|
│ ├─ API 호출 (RCRIT_PBLANC_DE_FROM = today − 30일)
|
||||||
|
│ ├─ 응답 파싱 + district 추출
|
||||||
|
│ ├─ status='완료' skip → upsert
|
||||||
|
│ └─ delete_old_completed_announcements(grace_days=90)
|
||||||
|
├─ run_matching() // 5티어 가중치 적용
|
||||||
|
└─ notify_new_matches()
|
||||||
|
├─ SELECT match_results WHERE notified_at IS NULL
|
||||||
|
│ AND match_score >= profile.min_match_score
|
||||||
|
│ AND profile.notify_enabled = 1
|
||||||
|
├─ POST agent-office /api/agent-office/realestate/notify
|
||||||
|
└─ 성공 → UPDATE notified_at = now()
|
||||||
|
|
||||||
|
[agent-office] POST /api/agent-office/realestate/notify
|
||||||
|
└─ RealestateAgent.on_new_matches(matches)
|
||||||
|
├─ formatter로 텔레그램 텍스트 + 인라인 키보드 빌드
|
||||||
|
└─ telegram_bot.send_message()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 기각된 대안
|
||||||
|
|
||||||
|
| 대안 | 기각 사유 |
|
||||||
|
|------|-----------|
|
||||||
|
| 매칭 로직을 agent-office에 이식 | 두 서비스에 매칭 코드 복제 → 동기화 부담 |
|
||||||
|
| 완료 공고 즉시 삭제 | 사용자가 회고 못 함. 90일 grace 채택 |
|
||||||
|
| agent-office가 realestate-lab을 폴링 | 트래픽 + 지연 |
|
||||||
|
| realestate-lab이 직접 텔레그램 호출 | 토큰·formatter 분산. 봇 단일 책임 위반 |
|
||||||
|
| 가격·면적 그라디언트 곡선 | 점수 해석 어려움. binary 유지 (자치구 1축에만 곡선 적용) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. DB 스키마 변경
|
||||||
|
|
||||||
|
### 3.1 `user_profile` — 3컬럼 추가
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE user_profile ADD COLUMN preferred_districts TEXT NOT NULL DEFAULT '{}';
|
||||||
|
ALTER TABLE user_profile ADD COLUMN min_match_score INTEGER NOT NULL DEFAULT 70;
|
||||||
|
ALTER TABLE user_profile ADD COLUMN notify_enabled INTEGER NOT NULL DEFAULT 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`preferred_districts`**: JSON. 5티어 분류.
|
||||||
|
```json
|
||||||
|
{"S": ["강남구", "서초구"], "A": ["송파구", "마포구"], "B": [], "C": [], "D": []}
|
||||||
|
```
|
||||||
|
모든 티어 비어있으면 자치구 기준 미설정으로 간주 (기존 호환 동작).
|
||||||
|
- **`min_match_score`**: 알림 트리거 임계값(0~100). 기본 70.
|
||||||
|
- **`notify_enabled`**: 텔레그램 푸시 ON/OFF. 0이면 알림 전체 차단.
|
||||||
|
|
||||||
|
### 3.2 `announcements` — `district` 컬럼 추가
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE announcements ADD COLUMN district TEXT;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ann_district ON announcements(district);
|
||||||
|
```
|
||||||
|
|
||||||
|
- collector가 응답의 `HSSPLY_ADRES` / `region_name`을 정규식 파싱하여 채움.
|
||||||
|
- 서울 외 지역, 파싱 실패 → NULL.
|
||||||
|
|
||||||
|
### 3.3 `match_results` — `notified_at` 컬럼 추가
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE match_results ADD COLUMN notified_at TEXT;
|
||||||
|
```
|
||||||
|
|
||||||
|
- NULL이면 미알림. 알림 송신 후 `strftime('%Y-%m-%dT%H:%M:%fZ','now')` 기록.
|
||||||
|
- 기존 `is_new`(사용자가 UI에서 봤는지)와 의미 분리.
|
||||||
|
|
||||||
|
### 3.4 신규 함수
|
||||||
|
|
||||||
|
```python
|
||||||
|
def delete_old_completed_announcements(grace_days: int = 90) -> int:
|
||||||
|
"""winner_date + grace_days 경과한 status='완료' 공고를 삭제.
|
||||||
|
winner_date가 NULL인 행은 안전하게 보존(수동 검토 대상).
|
||||||
|
match_results는 FK CASCADE로 자동 삭제. 삭제된 건수 반환.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_unnotified_matches(min_score: int) -> list[dict]:
|
||||||
|
"""notified_at IS NULL AND match_score >= min_score 인 매칭 + 공고 정보 조인 반환."""
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
def mark_matches_notified(match_ids: list[int]) -> None:
|
||||||
|
"""notified_at = now() 일괄 업데이트."""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 마이그레이션 패턴
|
||||||
|
|
||||||
|
기존 db.py의 `init_db()` 안에서 try/except로 컬럼 존재 여부 검사 후 ALTER (운영 DB 무중단).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. collector 변경
|
||||||
|
|
||||||
|
### 4.1 모집공고일 윈도우 사전 좁힘
|
||||||
|
|
||||||
|
```python
|
||||||
|
def collect_all() -> dict:
|
||||||
|
today = date.today()
|
||||||
|
date_from = (today - timedelta(days=30)).strftime("%Y%m%d")
|
||||||
|
|
||||||
|
for detail_ep, model_ep in DETAIL_ENDPOINTS:
|
||||||
|
rows = _api_call(detail_ep, params={
|
||||||
|
# 공공데이터 API 파라미터명은 엔드포인트별로 다를 수 있음.
|
||||||
|
# 구현 시 한국부동산원 API 스펙 확인 후 정확한 키 적용.
|
||||||
|
"RCRIT_PBLANC_DE_FROM": date_from,
|
||||||
|
})
|
||||||
|
# ...
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **구현 시 검증 필요**: `ApplyhomeInfoDetailSvc`의 5개 엔드포인트가 모두 모집공고일 필터 파라미터를 지원하지 않을 수 있음. 미지원 시 응답 수신 후 클라이언트 측에서 `parsed["rcrit_date"] < date_from` skip하는 fallback을 적용.
|
||||||
|
|
||||||
|
### 4.2 `완료` 상태 skip
|
||||||
|
|
||||||
|
```python
|
||||||
|
parsed = _parse_apt_detail(raw)
|
||||||
|
parsed["district"] = _extract_district(parsed)
|
||||||
|
|
||||||
|
status = compute_status(
|
||||||
|
parsed.get("receipt_start", ""),
|
||||||
|
parsed.get("receipt_end", ""),
|
||||||
|
parsed.get("winner_date", ""),
|
||||||
|
)
|
||||||
|
if status == "완료":
|
||||||
|
continue # DB 자원 절감
|
||||||
|
|
||||||
|
# 일정 정보 없는 공고 skip (기존 로직 유지)
|
||||||
|
has_dates = any(parsed.get(f) for f in (...))
|
||||||
|
if not has_dates:
|
||||||
|
continue
|
||||||
|
|
||||||
|
upsert_announcement(parsed)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 자치구 추출
|
||||||
|
|
||||||
|
```python
|
||||||
|
DISTRICT_PATTERN = re.compile(r"(?:서울특별시|서울시|서울)\s+(\S+?(?:구|군))")
|
||||||
|
|
||||||
|
def _extract_district(parsed: dict) -> str | None:
|
||||||
|
for src in (parsed.get("address"), parsed.get("region_name")):
|
||||||
|
if not src:
|
||||||
|
continue
|
||||||
|
m = DISTRICT_PATTERN.search(src)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
return None
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 정리 + 매칭 + 알림 트리거
|
||||||
|
|
||||||
|
```python
|
||||||
|
def collect_all() -> dict:
|
||||||
|
# ... 위 수집 로직
|
||||||
|
save_collect_log(new_count, total_count)
|
||||||
|
return {"new_count": new_count, "total_count": total_count}
|
||||||
|
|
||||||
|
|
||||||
|
def scheduled_collect():
|
||||||
|
"""09:00 cron — 수집 + 정리 + 매칭 + 알림"""
|
||||||
|
collect_all()
|
||||||
|
deleted = delete_old_completed_announcements(grace_days=90)
|
||||||
|
logger.info("정리: %d건 삭제", deleted)
|
||||||
|
run_matching()
|
||||||
|
notify_new_matches() # NEW
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. matcher 변경
|
||||||
|
|
||||||
|
### 5.1 가중치 재배분 (총 100점 유지)
|
||||||
|
|
||||||
|
| 축 | 기존 | 신규 |
|
||||||
|
|----|------|------|
|
||||||
|
| 지역 | 30 | **35** (광역 10 + 자치구 가중 0~25) |
|
||||||
|
| 주택유형 | 10 | 10 |
|
||||||
|
| 면적 | 15 | 15 |
|
||||||
|
| 가격 | 15 | 15 |
|
||||||
|
| 자격 | 30 | **25** |
|
||||||
|
|
||||||
|
### 5.2 지역 점수 (35점)
|
||||||
|
|
||||||
|
```python
|
||||||
|
TIER_WEIGHTS = {"S": 1.00, "A": 0.80, "B": 0.60, "C": 0.40, "D": 0.20}
|
||||||
|
|
||||||
|
def _region_score(profile: dict, ann: dict) -> tuple[int, list[str]]:
|
||||||
|
region_name = ann.get("region_name") or ""
|
||||||
|
district = ann.get("district") or ""
|
||||||
|
preferred_regions = profile.get("preferred_regions") or []
|
||||||
|
preferred_districts = profile.get("preferred_districts") or {}
|
||||||
|
|
||||||
|
region_match = bool(region_name and any(r in region_name for r in preferred_regions))
|
||||||
|
if not region_match:
|
||||||
|
return 0, []
|
||||||
|
|
||||||
|
# 자치구 기준 미설정 → 광역만으로 풀 점수 (기존 호환)
|
||||||
|
has_districts = any(preferred_districts.get(t) for t in TIER_WEIGHTS)
|
||||||
|
if not has_districts:
|
||||||
|
return 35, [f"선호 지역 일치: {region_name}"]
|
||||||
|
|
||||||
|
score = 10
|
||||||
|
reasons = [f"광역 일치: {region_name}"]
|
||||||
|
|
||||||
|
for tier, weight in TIER_WEIGHTS.items():
|
||||||
|
if district in (preferred_districts.get(tier) or []):
|
||||||
|
tier_score = round(25 * weight)
|
||||||
|
score += tier_score
|
||||||
|
reasons.append(f"자치구 {tier}티어: {district} (+{tier_score})")
|
||||||
|
break
|
||||||
|
|
||||||
|
return score, reasons
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 자격 점수 (25점)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _eligibility_score(eligible_types: list[str]) -> int:
|
||||||
|
if not eligible_types:
|
||||||
|
return 0
|
||||||
|
score = 15 # 첫 자격
|
||||||
|
score += min((len(eligible_types) - 1) * 5, 10) # 추가 자격당 +5, 최대 +10
|
||||||
|
return score
|
||||||
|
```
|
||||||
|
|
||||||
|
다른 축(주택유형 10, 면적 15, 가격 15)은 기존 binary 로직 유지.
|
||||||
|
|
||||||
|
### 5.4 매칭 결과 저장
|
||||||
|
|
||||||
|
`run_matching()`은 기존 흐름 유지. `match_results.notified_at`은 손대지 않음 (notifier가 관리).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 알림 흐름
|
||||||
|
|
||||||
|
### 6.1 realestate-lab 측 — `notifier.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
from .db import get_unnotified_matches, mark_matches_notified, get_profile
|
||||||
|
|
||||||
|
AGENT_OFFICE_URL = os.getenv("AGENT_OFFICE_URL", "http://agent-office:8000")
|
||||||
|
|
||||||
|
|
||||||
|
def notify_new_matches() -> dict:
|
||||||
|
profile = get_profile()
|
||||||
|
if not profile or not profile.get("notify_enabled"):
|
||||||
|
return {"sent": 0, "skipped": "notify_disabled"}
|
||||||
|
|
||||||
|
threshold = profile.get("min_match_score", 70)
|
||||||
|
matches = get_unnotified_matches(threshold)
|
||||||
|
if not matches:
|
||||||
|
return {"sent": 0}
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
f"{AGENT_OFFICE_URL}/api/agent-office/realestate/notify",
|
||||||
|
json={"matches": matches},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
body = resp.json()
|
||||||
|
sent_ids = body.get("sent_ids", [])
|
||||||
|
if sent_ids:
|
||||||
|
mark_matches_notified(sent_ids)
|
||||||
|
return body
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error("알림 push 실패: %s", e)
|
||||||
|
return {"sent": 0, "error": str(e)}
|
||||||
|
```
|
||||||
|
|
||||||
|
알림 push 실패 시 `notified_at`을 채우지 않아 다음 사이클에서 재시도된다.
|
||||||
|
|
||||||
|
### 6.2 agent-office 측 — 신규 엔드포인트
|
||||||
|
|
||||||
|
```python
|
||||||
|
# agent-office/main.py
|
||||||
|
@app.post("/api/agent-office/realestate/notify")
|
||||||
|
async def realestate_notify(body: dict):
|
||||||
|
matches = body.get("matches", [])
|
||||||
|
agent = registry.get("realestate")
|
||||||
|
result = await agent.on_new_matches(matches)
|
||||||
|
return result
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# agents/realestate.py
|
||||||
|
async def on_new_matches(self, matches: list[dict]) -> dict:
|
||||||
|
if not matches:
|
||||||
|
return {"sent": 0, "sent_ids": []}
|
||||||
|
|
||||||
|
text = telegram_formatter.format_realestate_matches(matches)
|
||||||
|
keyboard = telegram_formatter.build_match_keyboard(matches)
|
||||||
|
tg = await telegram_bot.send_message(text, reply_markup=keyboard)
|
||||||
|
|
||||||
|
if not tg.get("ok"):
|
||||||
|
return {"sent": 0, "sent_ids": [], "error": tg.get("error")}
|
||||||
|
|
||||||
|
sent_ids = [m["id"] for m in matches]
|
||||||
|
return {"sent": len(matches), "sent_ids": sent_ids, "message_id": tg.get("message_id")}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 텔레그램 메시지 포맷
|
||||||
|
|
||||||
|
**3건 이상 — 묶음 카드**
|
||||||
|
|
||||||
|
```
|
||||||
|
🏢 새 청약 매칭 3건
|
||||||
|
|
||||||
|
⭐ 92점 — 디에이치 강남 [S]
|
||||||
|
📍 서울 강남구 (분양가상한제) · 32~45㎡ · 6.2~9.8억
|
||||||
|
📅 청약 05/15(수) ~ 05/19(일)
|
||||||
|
|
||||||
|
⭐ 78점 — 마포 푸르지오 [A]
|
||||||
|
📍 서울 마포구 · 59~84㎡ · 8.0~11.5억
|
||||||
|
📅 청약 05/22(수) ~ 05/26(일)
|
||||||
|
|
||||||
|
⭐ 72점 — 송파 데시앙 [A]
|
||||||
|
📍 서울 송파구 · 39~59㎡ · 5.8~7.9억
|
||||||
|
📅 청약 05/27(월) ~ 05/30(목)
|
||||||
|
|
||||||
|
[전체 보기]
|
||||||
|
```
|
||||||
|
|
||||||
|
**1~2건 — 풀 카드**
|
||||||
|
|
||||||
|
```
|
||||||
|
⭐ 90점 — 디에이치 강남 [S]
|
||||||
|
📍 서울 강남구 (분양가상한제)
|
||||||
|
🏠 32~45㎡ · 6.2~9.8억
|
||||||
|
📅 청약 05/15(수) ~ 05/19(일)
|
||||||
|
✓ 자격: 일반1순위, 특별-신혼부부
|
||||||
|
💡 광역 일치 / 자치구 S티어 / 예산 범위 / 자격 2개
|
||||||
|
|
||||||
|
[🔖 북마크] [📄 공고 보기]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 인라인 키보드 콜백
|
||||||
|
|
||||||
|
| 버튼 | 콜백 동작 |
|
||||||
|
|------|-----------|
|
||||||
|
| `[🔖 북마크]` | `PATCH /api/realestate/announcements/{id}/bookmark` (기존 endpoint) |
|
||||||
|
| `[📄 공고 보기]` | `pblanc_url` (텔레그램 URL 버튼) |
|
||||||
|
| `[전체 보기]` | 대시보드 deep link (`/realestate?tab=matches`) |
|
||||||
|
|
||||||
|
agent-office의 텔레그램 webhook(`/api/agent-office/telegram/webhook`)이 callback_query를 받아 service_proxy로 realestate-lab API 호출.
|
||||||
|
|
||||||
|
### 6.5 기존 RealestateAgent 동작 정리
|
||||||
|
|
||||||
|
```python
|
||||||
|
# agent-office/scheduler.py — 09:15 데일리 cron 제거
|
||||||
|
# scheduler.add_job(realestate_agent.on_schedule, ...) ← REMOVE
|
||||||
|
```
|
||||||
|
|
||||||
|
`RealestateAgent.on_schedule()`은 호출 지점이 사라지므로 제거. `on_command("fetch_matches")`는 수동 트리거(텔레그램 슬래시 명령)용으로 보존하되 `on_new_matches()`를 직접 호출하도록 단순화.
|
||||||
|
|
||||||
|
### 6.6 환경변수
|
||||||
|
|
||||||
|
| 변수 | 위치 | 기본값 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `AGENT_OFFICE_URL` | realestate-lab `.env` | `http://agent-office:8000` |
|
||||||
|
| `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID` | agent-office (기존) | (기존) |
|
||||||
|
|
||||||
|
docker-compose의 사내 네트워크로 호출되므로 외부 노출 없음.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. API 변경 요약
|
||||||
|
|
||||||
|
### 7.1 realestate-lab
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 변경 |
|
||||||
|
|--------|------|------|
|
||||||
|
| PUT | `/api/realestate/profile` | body에 `preferred_districts`, `min_match_score`, `notify_enabled` 수용 |
|
||||||
|
| GET | `/api/realestate/profile` | 응답에 위 3필드 포함 |
|
||||||
|
| GET | `/api/realestate/announcements` | 응답 item에 `district` 포함 |
|
||||||
|
| GET | `/api/realestate/announcements/{id}` | 응답에 `district` 포함 |
|
||||||
|
| GET | `/api/realestate/matches` | 응답 item에 `notified_at` 포함 (디버깅용) |
|
||||||
|
|
||||||
|
### 7.2 agent-office
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 변경 |
|
||||||
|
|--------|------|------|
|
||||||
|
| POST | `/api/agent-office/realestate/notify` | **신규** — realestate-lab 전용 push 수신 |
|
||||||
|
|
||||||
|
### 7.3 Pydantic 모델 확장
|
||||||
|
|
||||||
|
```python
|
||||||
|
# realestate-lab/app/models.py
|
||||||
|
class ProfileUpdate(BaseModel):
|
||||||
|
# ... 기존 필드
|
||||||
|
preferred_districts: Optional[Dict[str, List[str]]] = None
|
||||||
|
min_match_score: Optional[int] = Field(default=None, ge=0, le=100)
|
||||||
|
notify_enabled: Optional[bool] = None
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 테스트 전략
|
||||||
|
|
||||||
|
| 영역 | 테스트 항목 |
|
||||||
|
|------|-------------|
|
||||||
|
| `_extract_district` | "서울특별시 강남구 도곡동" → `"강남구"`, "서울 송파구" → `"송파구"`, "부산 해운대구" → NULL, "" → NULL |
|
||||||
|
| `compute_status` | 변경 없음. 기존 테스트 유지 |
|
||||||
|
| `_region_score` | 광역 미매칭 / 광역만 매칭 + 자치구 미설정 / S~D 티어별 / 광역 매칭 + 비선호 자치구 — 5케이스 |
|
||||||
|
| `_eligibility_score` | 자격 0개 / 1개 / 3개 / 5개 — 점수 단조 증가 + 25 상한 |
|
||||||
|
| `delete_old_completed_announcements` | winner_date 91일 전 → 삭제, 89일 전 → 보존, status≠'완료' → 보존 |
|
||||||
|
| collector 사전 좁힘 | mock API 응답으로 30일 윈도우 외 데이터 skip 확인. `완료` skip 확인 |
|
||||||
|
| `notify_new_matches` 멱등성 | `notified_at` 채워진 매치는 push 후보 제외, push 실패 시 `notified_at` 미기록 → 다음 사이클 재시도 |
|
||||||
|
| agent-office push endpoint | mock telegram client로 `format_realestate_matches` 호출 + send 검증 |
|
||||||
|
| 알림 임계값 필터 | min_match_score=70, score=69 → push 대상 외 / score=70 → 포함 |
|
||||||
|
| `notify_enabled=0` | push 자체 skip |
|
||||||
|
|
||||||
|
NAS Docker는 git push 자동 배포이므로 별도 절차 없음. ALTER TABLE은 init_db에서 try/except 패턴으로 운영 DB 무중단 적용.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 스코프
|
||||||
|
|
||||||
|
### 본 스펙 범위
|
||||||
|
|
||||||
|
- ✅ realestate-lab: collector, matcher, db 변경, notifier 신규
|
||||||
|
- ✅ agent-office: `/realestate/notify` 엔드포인트, `on_new_matches` 메소드, 메시지 formatter
|
||||||
|
- ✅ 기존 데일리 RealestateAgent cron 폐기
|
||||||
|
|
||||||
|
### 후속 별도 스펙
|
||||||
|
|
||||||
|
- ❌ 프론트(`web-ui`) 자치구 5티어 입력 UI (별도 frontend 스펙)
|
||||||
|
- ❌ 청약 가점 vs 공고별 예상 커트라인 비교 (외부 데이터 의존성, 별도 연구)
|
||||||
|
- ❌ 서울 외 광역(부산 해운대구 등) 자치구 파싱 확장
|
||||||
|
- ❌ 매칭 임계값 변경 후 재발송 트리거 (`POST /notifications/resend`)
|
||||||
|
- ❌ 자치구별 매칭 분포 대시보드 위젯
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
# music-lab YouTube 수익화 고도화 설계
|
||||||
|
|
||||||
|
> 작성일: 2026-05-01
|
||||||
|
> 범위: music-lab + agent-office 확장
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
|
||||||
|
Suno API로 생성한 음악을 YouTube 업로드 가능한 완성 영상으로 만들고, 시장 수요 분석을 통해 수익이 나는 콘텐츠를 정기적으로 생산하는 파이프라인 구축.
|
||||||
|
|
||||||
|
**핵심 목표:**
|
||||||
|
- 시장 조사 자동화 → 만들 만한 장르/스타일 추천
|
||||||
|
- 음악 + 영상 합성 → YouTube 업로드 패키지(MP4 + 메타데이터) 자동 생성
|
||||||
|
- 수익 추적 → 채널별·장르별·국가별 RPM 분석
|
||||||
|
- **Phase 1**: 파일 내보내기(수동 업로드) → **Phase 3**: YouTube API 자동 업로드
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 결정 사항 요약
|
||||||
|
|
||||||
|
| 항목 | 결정 |
|
||||||
|
|------|------|
|
||||||
|
| 자동화 수준 | 반자동 — 수집·추천 자동, 생성·업로드 수동 트리거 |
|
||||||
|
| 업로드 방식 | Phase 1: 파일 내보내기, Phase 3: YouTube API |
|
||||||
|
| 영상 포맷 | 오디오 비주얼라이저 + AI 이미지 슬라이드쇼 |
|
||||||
|
| 시장 조사 데이터 | YouTube 트렌드 + Google Trends + Billboard (해외 시장 포함) |
|
||||||
|
| 음악 언어 전략 | 인스트루멘탈 + 영어 가사 혼합 |
|
||||||
|
| 이미지 소스 | Suno 커버이미지 + Pexels/Unsplash (추후 Stable Diffusion) |
|
||||||
|
| 주력 해외 시장 | 브라질, 인도네시아, 멕시코, 글로벌 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
[외부 데이터 소스]
|
||||||
|
YouTube Data API v3 · Google Trends · Billboard · Pexels/Unsplash
|
||||||
|
↓ 매일 09:00 스케줄
|
||||||
|
[agent-office :18900]
|
||||||
|
YouTubeResearchAgent (신규)
|
||||||
|
- 국가별 트렌딩 수집·분석
|
||||||
|
- POST /api/music/market/ingest → music-lab push
|
||||||
|
- 매주 월요일 08:00 텔레그램 인사이트 리포트
|
||||||
|
↓
|
||||||
|
[music-lab :18600]
|
||||||
|
기존: 음악 생성 · 라이브러리
|
||||||
|
신규: 시장 데이터 저장 · 영상 제작 파이프라인 · 수익화 추적
|
||||||
|
↓
|
||||||
|
[내보내기 패키지]
|
||||||
|
output.mp4 + thumbnail.jpg + metadata.json
|
||||||
|
(Phase 3: YouTube API 자동 업로드)
|
||||||
|
```
|
||||||
|
|
||||||
|
**변경 없는 것:** 컨테이너 수, 포트 배정, Nginx 라우팅 (경로 1개 추가 제외)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. DB 스키마 (신규)
|
||||||
|
|
||||||
|
### 4-1. music.db 신규 테이블
|
||||||
|
|
||||||
|
#### `market_trends`
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | INTEGER PK | |
|
||||||
|
| source | TEXT | `'youtube'` \| `'google_trends'` \| `'billboard'` |
|
||||||
|
| country | TEXT | `'BR'` \| `'ID'` \| `'MX'` \| `'US'` \| `'KR'` … |
|
||||||
|
| genre | TEXT | 장르 문자열 |
|
||||||
|
| keyword | TEXT | 검색 키워드 |
|
||||||
|
| score | REAL | 정규화 인기도 (0.0~1.0) |
|
||||||
|
| rank | INTEGER | 차트 순위 (nullable) |
|
||||||
|
| metadata | TEXT | JSON — 추가 원본 데이터 |
|
||||||
|
| collected_at | TEXT | ISO8601 |
|
||||||
|
|
||||||
|
인덱스: `(country, source, collected_at DESC)`
|
||||||
|
|
||||||
|
#### `trend_reports`
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | INTEGER PK | |
|
||||||
|
| report_date | TEXT UNIQUE | YYYY-MM-DD |
|
||||||
|
| top_genres | TEXT | JSON 배열 `[{genre, score, countries}]` |
|
||||||
|
| top_keywords | TEXT | JSON 배열 |
|
||||||
|
| recommended_styles | TEXT | JSON `[{genre, prompt, countries, reason}]` |
|
||||||
|
| insights | TEXT | AI 분석 텍스트 |
|
||||||
|
| created_at | TEXT | ISO8601 |
|
||||||
|
|
||||||
|
#### `video_projects`
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | INTEGER PK | |
|
||||||
|
| track_id | INTEGER FK | → music_library.id |
|
||||||
|
| format | TEXT | `'visualizer'` \| `'slideshow'` |
|
||||||
|
| status | TEXT | `'pending'` \| `'rendering'` \| `'done'` \| `'failed'` |
|
||||||
|
| output_path | TEXT | MP4 로컬 경로 |
|
||||||
|
| output_url | TEXT | `/media/videos/…` 서빙 URL |
|
||||||
|
| thumbnail_path | TEXT | JPG 로컬 경로 |
|
||||||
|
| target_countries | TEXT | JSON 배열 `['BR', 'ID']` |
|
||||||
|
| yt_title | TEXT | Claude API 생성 제목 (최대 100자) |
|
||||||
|
| yt_description | TEXT | Claude API 생성 설명 (해시태그 포함) |
|
||||||
|
| yt_tags | TEXT | JSON 배열 (10-15개, 국가별 현지화) |
|
||||||
|
| render_params | TEXT | JSON — 렌더링 파라미터 (색상, 전환 효과 등) |
|
||||||
|
| error | TEXT | 실패 시 에러 메시지 |
|
||||||
|
| created_at | TEXT | ISO8601 |
|
||||||
|
| completed_at | TEXT | ISO8601 (nullable) |
|
||||||
|
|
||||||
|
#### `revenue_records`
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | INTEGER PK | |
|
||||||
|
| video_project_id | INTEGER FK | → video_projects.id (nullable) |
|
||||||
|
| yt_video_id | TEXT | YouTube 영상 ID |
|
||||||
|
| record_month | TEXT | YYYY-MM |
|
||||||
|
| views | INTEGER | 조회수 |
|
||||||
|
| watch_hours | REAL | 시청 시간 (시간 단위) |
|
||||||
|
| revenue_usd | REAL | 수익 (USD) |
|
||||||
|
| rpm_usd | REAL | revenue / views * 1000 |
|
||||||
|
| country | TEXT | 국가별 분석용 (nullable) |
|
||||||
|
| source | TEXT | `'manual'` \| `'youtube_api'` |
|
||||||
|
| created_at | TEXT | ISO8601 |
|
||||||
|
|
||||||
|
### 4-2. agent_office.db 신규 테이블
|
||||||
|
|
||||||
|
#### `youtube_research_jobs`
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | INTEGER PK | |
|
||||||
|
| status | TEXT | `'running'` \| `'completed'` \| `'failed'` |
|
||||||
|
| countries | TEXT | JSON 배열 — 수집 대상 국가 |
|
||||||
|
| trends_collected | INTEGER | 수집된 트렌드 건수 |
|
||||||
|
| report_id | INTEGER | 생성된 trend_reports.id (nullable) |
|
||||||
|
| error | TEXT | 실패 시 에러 |
|
||||||
|
| started_at | TEXT | ISO8601 |
|
||||||
|
| completed_at | TEXT | ISO8601 (nullable) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 신규 API 엔드포인트
|
||||||
|
|
||||||
|
### 5-1. music-lab — 시장 조사
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/music/market/trends` | 트렌드 목록 (`country`, `genre`, `source`, `days` 필터) |
|
||||||
|
| GET | `/api/music/market/report/latest` | 최신 분석 리포트 + 추천 스타일 |
|
||||||
|
| GET | `/api/music/market/report` | 리포트 이력 |
|
||||||
|
| POST | `/api/music/market/ingest` | agent-office → 트렌드 데이터 수신 |
|
||||||
|
| GET | `/api/music/market/suggest` | 트렌드 기반 제작 아이디어 추천 |
|
||||||
|
|
||||||
|
### 5-2. music-lab — 영상 제작
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| POST | `/api/music/video-project` | 프로젝트 생성 (`track_id`, `format`, `target_countries`) |
|
||||||
|
| GET | `/api/music/video-projects` | 프로젝트 목록 |
|
||||||
|
| GET | `/api/music/video-project/{id}` | 프로젝트 상세 + 렌더링 상태 |
|
||||||
|
| POST | `/api/music/video-project/{id}/render` | 렌더링 시작 (BackgroundTask) |
|
||||||
|
| GET | `/api/music/video-project/{id}/export` | 내보내기 패키지 (MP4 URL + metadata JSON) |
|
||||||
|
| DELETE | `/api/music/video-project/{id}` | 프로젝트 삭제 |
|
||||||
|
|
||||||
|
### 5-3. music-lab — 수익화 추적
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/music/revenue` | 수익 기록 (`yt_video_id`, `year_month` 필터) |
|
||||||
|
| POST | `/api/music/revenue` | 수익 기록 추가 |
|
||||||
|
| PUT | `/api/music/revenue/{id}` | 수익 기록 수정 |
|
||||||
|
| DELETE | `/api/music/revenue/{id}` | 수익 기록 삭제 |
|
||||||
|
| GET | `/api/music/revenue/dashboard` | 총수익·RPM·장르별·국가별 집계 |
|
||||||
|
|
||||||
|
### 5-4. agent-office — YouTube 리서치
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| POST | `/api/agent-office/youtube/research` | 수동 리서치 트리거 (`countries` 지정 가능) |
|
||||||
|
| GET | `/api/agent-office/youtube/research/status` | 마지막 실행 상태 + 수집 건수 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 영상 제작 파이프라인
|
||||||
|
|
||||||
|
### 6-1. 오디오 비주얼라이저 (`format: 'visualizer'`)
|
||||||
|
|
||||||
|
```
|
||||||
|
MP3 (file_path) + 배경 이미지 (cover_images[0] 우선, 없으면 장르별 그라디언트 기본 배경)
|
||||||
|
→ FFmpeg showwaves 필터 (1920×1080, 음파 오버레이)
|
||||||
|
→ H.264 + AAC MP4
|
||||||
|
→ 썸네일 추출 (5초 지점 프레임)
|
||||||
|
→ Claude API 메타데이터 생성
|
||||||
|
```
|
||||||
|
|
||||||
|
핵심 FFmpeg 명령:
|
||||||
|
```bash
|
||||||
|
ffmpeg -loop 1 -i cover.jpg -i audio.mp3 \
|
||||||
|
-filter_complex \
|
||||||
|
"[1:a]showwaves=s=1920x200:mode=cline:colors=0xFF4444[wave]; \
|
||||||
|
[0:v][wave]overlay=0:880[out]" \
|
||||||
|
-map "[out]" -map 1:a \
|
||||||
|
-c:v libx264 -c:a aac -shortest output.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
적합 장르: Lo-fi, Ambient, Study Music, Phonk
|
||||||
|
|
||||||
|
### 6-2. AI 이미지 슬라이드쇼 (`format: 'slideshow'`)
|
||||||
|
|
||||||
|
```
|
||||||
|
① 키워드 추출 (genre + moods + prompt → 검색어)
|
||||||
|
② 이미지 수집
|
||||||
|
- Pexels API: 키워드 검색 4-6장 (무료 200req/시간)
|
||||||
|
- Suno 커버이미지: cover_images 필드에서 1-2장
|
||||||
|
③ 이미지당 표시 시간 = track.duration_sec / 이미지 수
|
||||||
|
④ FFmpeg xfade 전환 (fade, 1초)
|
||||||
|
⑤ H.264 + AAC MP4 출력
|
||||||
|
⑥ 썸네일 추출 + Claude API 메타데이터 생성
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6-3. 공통 후처리
|
||||||
|
|
||||||
|
**Claude API 메타데이터 생성:**
|
||||||
|
- 입력: `genre`, `moods`, `lyrics`, `target_countries`
|
||||||
|
- 출력:
|
||||||
|
- `yt_title`: 최대 100자, SEO 최적화, 국가 감안
|
||||||
|
- `yt_description`: 해시태그 + 타임스탬프 + 링크 플레이스홀더
|
||||||
|
- `yt_tags`: 10-15개, 현지어 포함 (예: 브라질 타겟 → `"música relaxante"`, `"estudo música"`)
|
||||||
|
|
||||||
|
**내보내기 패키지:**
|
||||||
|
```
|
||||||
|
/data/videos/{project_id}/
|
||||||
|
output.mp4 ← 최종 영상
|
||||||
|
thumbnail.jpg ← 썸네일
|
||||||
|
metadata.json ← {title, description, tags, target_countries, category}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. YouTubeResearchAgent (agent-office)
|
||||||
|
|
||||||
|
**파일:** `agents/youtube.py`
|
||||||
|
|
||||||
|
**데이터 수집 (매일 09:00):**
|
||||||
|
1. YouTube Data API v3 — 국가별 (`BR`, `ID`, `MX`, `US`, `KR`) 트렌딩 음악 카테고리 50개
|
||||||
|
2. pytrends — 장르별 Google Trends 점수 (최근 7일)
|
||||||
|
3. Billboard Hot 100 스크래핑 — 글로벌 차트 상위 20
|
||||||
|
|
||||||
|
**분석 → trend_reports 생성:**
|
||||||
|
- 소스별 score 정규화 후 장르 클러스터링
|
||||||
|
- `recommended_styles` 생성: `{genre, suno_prompt, target_countries, reason}`
|
||||||
|
- Claude API로 `insights` 텍스트 생성
|
||||||
|
|
||||||
|
**push → music-lab:**
|
||||||
|
```
|
||||||
|
POST http://music-lab:8000/api/music/market/ingest
|
||||||
|
body: {trends: [...], report: {...}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**스케줄러:**
|
||||||
|
- 매일 09:00 — `youtube_research_job`
|
||||||
|
- 매주 월요일 08:00 — 주간 인사이트 텔레그램 발송
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 인프라 변경사항
|
||||||
|
|
||||||
|
| 대상 | 변경 내용 |
|
||||||
|
|------|-----------|
|
||||||
|
| `music-lab/Dockerfile` | `RUN apt-get install -y ffmpeg` 추가 |
|
||||||
|
| `nginx/default.conf` | `/media/videos/` → `/data/videos/` 경로 추가 |
|
||||||
|
| `music-lab/requirements.txt` | `anthropic`, `Pillow` 추가 |
|
||||||
|
| `agent-office/requirements.txt` | `google-api-python-client`, `pytrends` 추가 |
|
||||||
|
| `.env` | `PEXELS_API_KEY`, `YOUTUBE_DATA_API_KEY` 추가 |
|
||||||
|
| `docker-compose.yml` | music-lab volume에 `/data/videos` 마운트 추가 |
|
||||||
|
|
||||||
|
**CLAUDE.md 업데이트 필요:**
|
||||||
|
- Nginx: `/media/videos/` 경로 추가
|
||||||
|
- music-lab API 목록에 신규 16개 추가 (시장조사 5 + 영상제작 6 + 수익화 5), agent-office 2개 추가
|
||||||
|
- agent-office 스케줄러에 youtube_research_job 추가
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 수익화 전략
|
||||||
|
|
||||||
|
### 9-1. YouTube 광고 수익 (CPM 기준)
|
||||||
|
|
||||||
|
| 국가 | CPM 범위 |
|
||||||
|
|------|---------|
|
||||||
|
| 브라질 | $1.5 ~ $4 |
|
||||||
|
| 인도네시아 | $1.0 ~ $2.5 |
|
||||||
|
| 미국 | $3.0 ~ $8.0 |
|
||||||
|
| 한국 | $2.0 ~ $5.0 |
|
||||||
|
|
||||||
|
Lo-fi / Ambient은 긴 시청 시간 유도 → RPM 유리. 인스트루멘탈은 언어 장벽 없음.
|
||||||
|
|
||||||
|
### 9-2. 국가별 장르 전략
|
||||||
|
|
||||||
|
| 국가 | 주력 장르 |
|
||||||
|
|------|-----------|
|
||||||
|
| 브라질 | Funk, Phonk, Lo-fi |
|
||||||
|
| 인도네시아 | Pop, Study Music, Lo-fi |
|
||||||
|
| 멕시코 | Latin Pop, Reggaeton |
|
||||||
|
| 글로벌 | Ambient, Cinematic |
|
||||||
|
|
||||||
|
### 9-3. 업로드 목표
|
||||||
|
|
||||||
|
- **주 3-5개** 영상 업로드 (시스템 안정화 후 일 1개 목표)
|
||||||
|
- 영상 **50개** 누적 → 수익 활성화 (구독자 1,000 + 시청 4,000시간)
|
||||||
|
- 영상 **200개** 누적 → 월 $100+ 수동 수익 목표
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 구현 로드맵
|
||||||
|
|
||||||
|
### Phase 1 — 영상 제작 파이프라인 (약 2-3주)
|
||||||
|
|
||||||
|
**music-lab 백엔드:**
|
||||||
|
- `video_producer.py` — FFmpeg 래퍼 (비주얼라이저 + 슬라이드쇼)
|
||||||
|
- `market.py` — 트렌드 데이터 수신·저장·조회·추천
|
||||||
|
- `monetization.py` — 수익화 추적 CRUD
|
||||||
|
- DB 마이그레이션: `video_projects`, `revenue_records`
|
||||||
|
- 신규 API 12개 (영상 제작 6 + 수익화 5 + market ingest 1)
|
||||||
|
- Dockerfile `ffmpeg` 추가
|
||||||
|
- Nginx `/media/videos/` 경로 추가
|
||||||
|
|
||||||
|
### Phase 2 — 시장 조사 자동화 (약 1-2주)
|
||||||
|
|
||||||
|
**agent-office:**
|
||||||
|
- `agents/youtube.py` (YouTubeResearchAgent)
|
||||||
|
- YouTube Data API v3 연동
|
||||||
|
- pytrends 연동
|
||||||
|
- Billboard 스크래핑
|
||||||
|
- 스케줄러 등록 (매일 09:00, 매주 월요일 08:00)
|
||||||
|
- `youtube_research_jobs` DB 테이블
|
||||||
|
- 신규 API 2개 + agent-office API 2개
|
||||||
|
|
||||||
|
**music-lab:**
|
||||||
|
- DB 마이그레이션: `market_trends`, `trend_reports`
|
||||||
|
- 신규 API 4개 (트렌드 조회 3 + 추천 1)
|
||||||
|
|
||||||
|
### Phase 3 — YouTube API 자동 업로드 (채널 안정화 후)
|
||||||
|
|
||||||
|
- YouTube Data API OAuth 2.0 인증
|
||||||
|
- 동영상 업로드·썸네일 설정 자동화
|
||||||
|
- YouTube Studio 수익 데이터 자동 수집 (`source: 'youtube_api'`)
|
||||||
|
- 텔레그램 업로드 완료 알림
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 신규 파일 목록
|
||||||
|
|
||||||
|
### music-lab/app/
|
||||||
|
- `video_producer.py` — FFmpeg 비주얼라이저·슬라이드쇼 렌더링
|
||||||
|
- `market.py` — 시장 트렌드 수신·저장·조회·추천
|
||||||
|
- `monetization.py` — 수익 기록 CRUD·대시보드
|
||||||
|
|
||||||
|
### agent-office/app/agents/
|
||||||
|
- `youtube.py` — YouTubeResearchAgent
|
||||||
|
|
||||||
|
### agent-office/app/
|
||||||
|
- `youtube_researcher.py` — YouTube/Trends/Billboard 데이터 수집 로직
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user