Compare commits
331 Commits
v0.1.0
...
4f67cd02fa
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f67cd02fa | |||
| 868906b8c6 | |||
| bd97cc1e97 | |||
| 7552ce4263 | |||
| 17034ea6ea | |||
| fe60c8d330 | |||
| 4755e34c14 | |||
| ad1c721ba8 | |||
| 1c705b0ef3 | |||
| 68dec2e53d | |||
| e33a2310af | |||
| fceca88db4 | |||
| d66a321982 | |||
| e03d074222 | |||
| 2eeb98a723 | |||
| 657ffdc55f | |||
| f54da7d46a | |||
| dc92c3d42d | |||
| 24a57f2b69 | |||
| b9d3242341 | |||
| 5e9a51c9e8 | |||
| 5844567048 | |||
| 0906c3ba35 | |||
| ff4ef299ad | |||
| 5ebcbae8b5 | |||
| 1cd3cf8830 | |||
| c18fd8e52b | |||
| dc482b32e4 | |||
| ef026e7ac6 | |||
| 80a54d056e | |||
| 83192eb66c | |||
| 9a0bbeccd5 | |||
| 7a9690526a | |||
| 7a7e3d1ce0 | |||
| eb547a0367 | |||
| 096e291ed8 | |||
| 7c8d079f74 | |||
| 85e5f96379 | |||
| 47a4b1e231 | |||
| be0094b83f | |||
| e948393906 | |||
| 0beceefeef | |||
| 355667cf9c | |||
| 26b9eea0dc | |||
| 3b9dcfe0dd | |||
| 1d4354e402 | |||
| 8604c6292d | |||
| 21666f4372 | |||
| f83b900320 | |||
| a7b2fc0d9d | |||
| 327d0b4e81 | |||
| 8e7a3806c5 | |||
| abf475433b | |||
| 7336fd090e | |||
| 62d79b2669 | |||
| 56fbe3fc4b | |||
| a5495aeaa4 | |||
| 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 |
41
.claude/settings.json
Normal file
41
.claude/settings.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(git status:*)",
|
||||||
|
"Bash(git diff:*)",
|
||||||
|
"Bash(git log:*)",
|
||||||
|
"Bash(git show:*)",
|
||||||
|
"Bash(git branch:*)",
|
||||||
|
"Bash(git stash list:*)",
|
||||||
|
"Bash(git remote -v)",
|
||||||
|
"Bash(docker ps:*)",
|
||||||
|
"Bash(docker logs:*)",
|
||||||
|
"Bash(docker compose ps:*)",
|
||||||
|
"Bash(docker compose logs:*)",
|
||||||
|
"Bash(docker compose config:*)",
|
||||||
|
"Bash(docker images:*)",
|
||||||
|
"Bash(pytest:*)",
|
||||||
|
"Bash(python -m pytest:*)",
|
||||||
|
"Bash(python -V)",
|
||||||
|
"Bash(python -c:*)",
|
||||||
|
"Bash(pip list:*)",
|
||||||
|
"Bash(pip show:*)",
|
||||||
|
"Bash(pip freeze:*)",
|
||||||
|
"Bash(uvicorn --version)",
|
||||||
|
"Bash(ls:*)",
|
||||||
|
"Bash(cat docker-compose.yml)"
|
||||||
|
],
|
||||||
|
"deny": [
|
||||||
|
"Read(.env)",
|
||||||
|
"Read(.env.*)",
|
||||||
|
"Read(**/.env)",
|
||||||
|
"Read(**/.env.*)",
|
||||||
|
"Read(**/credentials*)",
|
||||||
|
"Read(**/secrets*)",
|
||||||
|
"Read(**/*.pem)",
|
||||||
|
"Read(**/*.key)",
|
||||||
|
"Read(**/lotto.db)",
|
||||||
|
"Read(**/stock.db)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
124
.env.example
124
.env.example
@@ -1,17 +1,117 @@
|
|||||||
# 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
|
||||||
|
|
||||||
|
# [MUSIC LAB — YouTube Video Generation]
|
||||||
|
PEXELS_API_KEY=
|
||||||
|
YOUTUBE_DATA_API_KEY=
|
||||||
|
# VIDEO_DATA_DIR=/app/data/videos # 기본값, 재정의 필요 시만 설정
|
||||||
|
|
||||||
|
# ─── packs-lab — NAS 자료 다운로드 자동화 ────────────────────────────
|
||||||
|
# Synology DSM 7.x 인증 (공유 링크 발급용)
|
||||||
|
DSM_HOST=https://gahusb.synology.me:5001
|
||||||
|
DSM_USER=
|
||||||
|
DSM_PASS=
|
||||||
|
|
||||||
|
# Vercel SaaS ↔ backend HMAC 시크릿 (양쪽 동일 값)
|
||||||
|
BACKEND_HMAC_SECRET=
|
||||||
|
|
||||||
|
# Supabase pack_files 테이블 접근 (service_role 키, RLS 우회)
|
||||||
|
SUPABASE_URL=https://<project>.supabase.co
|
||||||
|
SUPABASE_SERVICE_KEY=
|
||||||
|
|
||||||
|
# admin upload 토큰 TTL (초). default 1800 = 30분
|
||||||
|
UPLOAD_TOKEN_TTL_SEC=1800
|
||||||
|
|
||||||
|
# 호스트 마운트 경로 (로컬 ./data/packs, NAS /volume1/docker/webpage/media/packs)
|
||||||
|
PACK_DATA_PATH=./data/packs
|
||||||
|
|
||||||
|
# 컨테이너 내부 PACK_BASE_DIR (routes.py가 파일 저장 시 사용. docker-compose volume의 컨테이너 측 경로와 반드시 일치)
|
||||||
|
PACK_BASE_DIR=/app/data/packs
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -63,3 +63,6 @@ uploads/
|
|||||||
################################
|
################################
|
||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
|
|
||||||
|
# Git worktrees
|
||||||
|
.worktrees/
|
||||||
|
|||||||
689
CLAUDE.md
Normal file
689
CLAUDE.md
Normal file
@@ -0,0 +1,689 @@
|
|||||||
|
# CLAUDE.md — web-backend 프로젝트 가이드
|
||||||
|
|
||||||
|
> Claude Code가 이 프로젝트를 작업할 때 참조하는 설정 및 구조 문서.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 프로젝트 개요
|
||||||
|
|
||||||
|
Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||||
|
- **서비스**: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, packs-lab, deployer (10개)
|
||||||
|
- **프론트엔드**: 별도 레포 (React + Vite SPA), 빌드 산출물만 NAS에 배포
|
||||||
|
- **인프라**: Docker Compose (10컨테이너) + 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 + 텔레그램 연동) |
|
||||||
|
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 업로드, Vercel SaaS와 HMAC 통신) |
|
||||||
|
| `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 |
|
||||||
|
| `/api/packs/` | `packs-lab:8000` | 5GB 업로드 대응 (`client_max_body_size 5G`, `proxy_request_buffering off`, 1800s timeout) |
|
||||||
|
| `/webhook`, `/webhook/` | `deployer:9000` | Gitea Webhook |
|
||||||
|
| `/media/music/` | `/data/music/` (파일 직접 서빙) | 생성된 오디오 파일 |
|
||||||
|
| `/media/videos/` | `/data/videos/` (파일 직접 서빙) | YouTube 영상 MP4 |
|
||||||
|
| `/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 |
|
||||||
|
| Packs Lab | http://localhost:18950 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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) + YouTube 영상 제작 + 시장 조사 트렌드
|
||||||
|
- 생성된 오디오 파일: `/app/data/music/` (Nginx가 `/media/music/`로 직접 서빙)
|
||||||
|
- 생성된 영상 파일: `/app/data/videos/` (Nginx가 `/media/videos/`로 직접 서빙)
|
||||||
|
- DB: `/app/data/music.db` (music_tasks, music_library, video_projects, revenue_records, market_trends, trend_reports 테이블)
|
||||||
|
- 파일 구조: `main.py`, `db.py`, `suno_provider.py`, `local_provider.py`, `video_producer.py`, `market.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}` | 가사 삭제 |
|
||||||
|
| 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` | FFmpeg 렌더링 시작 (BackgroundTask) |
|
||||||
|
| GET | `/api/music/video-project/{id}/export` | 내보내기 패키지 (mp4+thumbnail+metadata.json) |
|
||||||
|
| DELETE | `/api/music/video-project/{id}` | 영상 프로젝트 삭제 |
|
||||||
|
| GET | `/api/music/revenue/dashboard` | 수익 대시보드 (총수익·조회수·가중평균 RPM) |
|
||||||
|
| GET | `/api/music/revenue` | 수익 기록 목록 |
|
||||||
|
| POST | `/api/music/revenue` | 수익 기록 추가 (UNIQUE: yt_video_id+record_month+country) |
|
||||||
|
| PUT | `/api/music/revenue/{id}` | 수익 기록 수정 |
|
||||||
|
| DELETE | `/api/music/revenue/{id}` | 수익 기록 삭제 |
|
||||||
|
| POST | `/api/music/market/ingest` | agent-office 트렌드 수신 + 리포트 생성 |
|
||||||
|
| GET | `/api/music/market/trends` | 트렌드 조회 (country, genre, source, days=7) |
|
||||||
|
| GET | `/api/music/market/report/latest` | 최신 트렌드 리포트 |
|
||||||
|
| GET | `/api/music/market/report` | 트렌드 리포트 목록 (limit=10) |
|
||||||
|
| GET | `/api/music/market/suggest` | Suno 프롬프트 추천 (limit=5) |
|
||||||
|
|
||||||
|
**환경변수**
|
||||||
|
- `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`)
|
||||||
|
- `PEXELS_API_KEY`: Pexels 스톡 이미지 API 키 (미설정 시 슬라이드쇼 Pexels 이미지 비활성화)
|
||||||
|
- `ANTHROPIC_API_KEY`: Claude Haiku — YouTube 메타데이터 생성 + 시장 인사이트 (미설정 시 폴백 텍스트)
|
||||||
|
- `VIDEO_DATA_DIR`: 영상 파일 저장 경로 (기본 `/app/data/videos`)
|
||||||
|
|
||||||
|
**video_projects 테이블**
|
||||||
|
- format: `visualizer` | `slideshow`
|
||||||
|
- status: `pending` → `rendering` → `done` | `failed`
|
||||||
|
- target_countries: JSON 배열 (예: `["BR","US"]`)
|
||||||
|
- render_params: JSON 객체 (FFmpeg 파라미터 캐시)
|
||||||
|
|
||||||
|
**revenue_records 테이블**
|
||||||
|
- UNIQUE(yt_video_id, record_month, country)
|
||||||
|
- avg_rpm 계산: 가중평균 `SUM(revenue_usd)/SUM(views)*1000` (단순 AVG 아님)
|
||||||
|
|
||||||
|
**market_trends 테이블**
|
||||||
|
- source: `youtube` | `google_trends` | `billboard`
|
||||||
|
- metadata: JSON 객체 (원본 API 응답 부분)
|
||||||
|
- 인덱스: `idx_mt_country_source` ON (country, source, collected_at DESC)
|
||||||
|
|
||||||
|
**trend_reports 테이블**
|
||||||
|
- report_date UNIQUE — 같은 날 두 번 ingest 시 upsert
|
||||||
|
- top_genres: JSON 배열 `[{genre, score, countries}]` (최대 10개, score 내림차순)
|
||||||
|
- recommended_styles: JSON 배열 `[{genre, suno_prompt, target_countries, reason}]` (최대 5개)
|
||||||
|
|
||||||
|
**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`
|
||||||
|
- `YOUTUBE_DATA_API_KEY`: YouTube Data API v3 키 (미설정 시 YouTube trending 수집 skip)
|
||||||
|
|
||||||
|
**YouTubeResearchAgent (`agents/youtube.py`)**
|
||||||
|
- `agent_id = "youtube"` — AGENT_REGISTRY에 등록
|
||||||
|
- 09:00 매일 `on_schedule()` → 국가별 YouTube 트렌딩 + Google Trends + Billboard Top20 수집 → music-lab push
|
||||||
|
- `on_command("research", {countries: []})` → 수동 트리거 (백그라운드 asyncio.create_task)
|
||||||
|
- 수집 소스: `youtube_researcher.py` (fetch_youtube_trending, fetch_google_trends, fetch_billboard_top20)
|
||||||
|
- DB: `youtube_research_jobs` 테이블에 실행 이력 기록
|
||||||
|
- 동시실행 방지: `self.state == "working"` 체크 후 거부
|
||||||
|
- 월요일 08:00 `send_weekly_report()` → music-lab 최신 리포트 → 텔레그램 발송
|
||||||
|
|
||||||
|
**텔레그램 자연어 대화 (옵션 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 트리거로 전환)
|
||||||
|
- 09:00 매일 — YouTube 트렌드 수집 (`youtube_research`) → music-lab `/api/music/market/ingest` push
|
||||||
|
- 매주 월요일 08:00 — YouTube 주간 리포트 텔레그램 발송 (`youtube_weekly_report`)
|
||||||
|
|
||||||
|
**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` 필터) |
|
||||||
|
| POST | `/api/agent-office/youtube/research` | YouTube 트렌드 수집 수동 트리거 (body: `{countries: []}`) |
|
||||||
|
| GET | `/api/agent-office/youtube/research/status` | 마지막 수집 작업 상태 |
|
||||||
|
|
||||||
|
### 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}` | 블로그 글 삭제 |
|
||||||
|
|
||||||
|
### packs-lab (packs-lab/)
|
||||||
|
- NAS 자료 다운로드 자동화 — Synology DSM 공유링크 발급 + 5GB 멀티파트 업로드 수신
|
||||||
|
- Vercel SaaS와 HMAC 인증으로 통신, 사용자 인증은 Vercel이 Supabase로 처리 (본 서비스는 외부 인증 없음)
|
||||||
|
- DB: 외부 Supabase `pack_files` 테이블 (DDL: `packs-lab/supabase/pack_files.sql`)
|
||||||
|
- 파일 구조: `app/main.py`, `app/auth.py`, `app/dsm_client.py`, `app/routes.py`, `app/models.py`
|
||||||
|
- 컨테이너 저장 경로: `PACK_BASE_DIR` env (default `/app/data/packs`). docker-compose volume 마운트와 일치 필수.
|
||||||
|
|
||||||
|
**환경변수**
|
||||||
|
- `DSM_HOST` / `DSM_USER` / `DSM_PASS`: Synology DSM 7.x 인증 (공유 링크 발급용)
|
||||||
|
- `BACKEND_HMAC_SECRET`: Vercel SaaS와 양쪽 공유 시크릿 (HMAC SHA256)
|
||||||
|
- `SUPABASE_URL` / `SUPABASE_SERVICE_KEY`: Supabase pack_files 테이블 접근 (service_role, RLS 우회)
|
||||||
|
- `UPLOAD_TOKEN_TTL_SEC`: admin upload 토큰 TTL (기본 1800초 = 30분)
|
||||||
|
- `PACK_BASE_DIR`: 컨테이너 내부 저장 경로 (기본 `/app/data/packs`)
|
||||||
|
- `PACK_DATA_PATH`: 호스트 마운트 경로 (로컬 `./data/packs`, NAS `/volume1/docker/webpage/media/packs`)
|
||||||
|
|
||||||
|
**HMAC 인증 패턴**
|
||||||
|
- Vercel → backend 요청: `X-Timestamp` (UNIX 초) + `X-Signature` (HMAC_SHA256(timestamp + "." + body, secret))
|
||||||
|
- Replay 방어: 타임스탬프 ±5분 윈도우
|
||||||
|
- admin browser → backend upload: `Authorization: Bearer <token>` (jti 단발성)
|
||||||
|
|
||||||
|
**packs-lab API 목록**
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| POST | `/api/packs/sign-link` | Vercel HMAC → DSM Sharing.create로 4시간 유효 다운로드 URL 발급 |
|
||||||
|
| POST | `/api/packs/admin/mint-token` | Vercel HMAC → 일회성 upload 토큰 발급 (기본 30분 TTL) |
|
||||||
|
| POST | `/api/packs/upload` | Bearer token → multipart 5GB 저장 + Supabase INSERT |
|
||||||
|
| GET | `/api/packs/list` | Vercel HMAC → 활성 pack_files 목록 (deleted_at IS NULL) |
|
||||||
|
| DELETE | `/api/packs/{file_id}` | Vercel HMAC → soft delete (DSM 공유는 자동 만료) |
|
||||||
|
|
||||||
|
### 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/` — 서비스별 기획·설계 문서
|
||||||
|
|||||||
109
STATUS.md
Normal file
109
STATUS.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# web-backend — 구현 현황 & 로드맵
|
||||||
|
|
||||||
|
> 최종 갱신: 2026-05-07
|
||||||
|
> 자세한 서비스·환경변수·DB 표는 [CLAUDE.md](./CLAUDE.md), 설계는 `docs/superpowers/specs/`, 실행 계획은 `docs/superpowers/plans/` 참조.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 서비스 구현 현황
|
||||||
|
|
||||||
|
### 1-1. 운영 중인 컨테이너 (10개)
|
||||||
|
|
||||||
|
| 서비스 | 포트 | 상태 | 핵심 기능 |
|
||||||
|
|--------|------|------|-----------|
|
||||||
|
| `lotto-backend` | 18000 | ✅ | 로또 추천·통계·리포트·구매내역 + 블로그·투두 |
|
||||||
|
| `stock-lab` | 18500 | ✅ | 주식 뉴스·지수·트레이딩·포트폴리오·자산 스냅샷 |
|
||||||
|
| `music-lab` | 18600 | ✅ | Suno + MusicGen + YouTube 수익화 + 컴파일 |
|
||||||
|
| `blog-lab` | 18700 | ✅ | 블로그 마케팅 수익화 파이프라인 |
|
||||||
|
| `realestate-lab` | 18800 | ✅ | 청약 수집·5티어 매칭·매칭 알림 |
|
||||||
|
| `agent-office` | 18900 | ✅ | AI 에이전트 (WebSocket + 텔레그램 + YouTubeResearcher) |
|
||||||
|
| `packs-lab` | 18950 | ✅ | NAS 자료 다운로드 자동화 (HMAC + Supabase) — 2026-05-05 |
|
||||||
|
| `travel-proxy` | 19000 | ✅ | 여행 사진 API + 썸네일 + 지역 관리 |
|
||||||
|
| `nginx` | 8080 | ✅ | SPA + 리버스 프록시 (5GB body limit) |
|
||||||
|
| `webpage-deployer` | 19010 | ✅ | Gitea Webhook 자동 배포 |
|
||||||
|
|
||||||
|
### 1-2. 최근 큰 작업 (2026-04 ~ 05)
|
||||||
|
|
||||||
|
| 시기 | 영역 | 핵심 |
|
||||||
|
|------|------|------|
|
||||||
|
| 2026-05-05 | packs-lab | sign-link / upload / list / delete + admin mint-token + 5GB nginx body limit + Supabase DDL |
|
||||||
|
| 2026-05-01~06 | music-lab | YouTube 수익화 백엔드 (market_trends·trend_reports DB + 5개 API) + 다중 트랙 FFmpeg concat MP4 |
|
||||||
|
| 2026-04-28 | realestate-lab | targeting enhancement (5티어 매칭·5축 점수·알림 대상 카운트) |
|
||||||
|
| 2026-04-27 | personal | personal 서비스 분리 마이그레이션 (블로그·투두·포트폴리오 인증) |
|
||||||
|
| 2026-04-27 | agent-office | v2 — youtube_researcher (YouTube API + pytrends + Billboard) + 알림 |
|
||||||
|
| 2026-04-24 | travel-proxy | 갤러리 리디자인 + 성능 개선 (썸네일/페이지네이션) |
|
||||||
|
| 2026-04-15 | lotto-backend | AI 큐레이터 (Claude 기반 주간 브리핑 자동 생성) |
|
||||||
|
| 2026-04-08 | music-lab | Suno enhancement + MusicGen 통합 |
|
||||||
|
| 2026-04-06 | blog-lab | 마케팅 파이프라인 (research → generate → market → review) |
|
||||||
|
|
||||||
|
### 1-3. 인프라 / DX
|
||||||
|
|
||||||
|
| 항목 | 상태 |
|
||||||
|
|------|------|
|
||||||
|
| docker-compose 통합 (10 서비스) | ✅ |
|
||||||
|
| Gitea Webhook → deployer rsync 자동 배포 | ✅ |
|
||||||
|
| nginx 라우팅 표 (/api/* 서비스별) | ✅ |
|
||||||
|
| 배포 환경변수 (PEXELS·YOUTUBE_DATA·VIDEO_DATA_DIR 등) | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 진행 중 / 향후 계획
|
||||||
|
|
||||||
|
### 2-1. 로또 프리미엄 (Phase 3) — 구독 모델
|
||||||
|
> 출처: [docs/lotto-premium-roadmap.md](./docs/lotto-premium-roadmap.md)
|
||||||
|
|
||||||
|
- [ ] 회원 시스템 (JWT 인증, `users` 테이블)
|
||||||
|
- [ ] 구독 플랜 (`subscription_plans`, `user_subscriptions`)
|
||||||
|
- [ ] 결제 연동 (Toss Payments 또는 Stripe)
|
||||||
|
- [ ] 이메일 발송 자동화 (SendGrid)
|
||||||
|
- [ ] 소셜 증거 데이터 집계 API (가장 많이 선택된 번호 TOP 10 등)
|
||||||
|
|
||||||
|
Phase 1·2 (성과 통계 / 회차별 공략 리포트 / 개인 분석 / 구매 추적)는 이미 완료.
|
||||||
|
|
||||||
|
### 2-2. Pet Lab (신규 서비스) — 설계 단계
|
||||||
|
> 출처: `docs/superpowers/specs/2026-04-07-pet-lab-design.md`, `plans/2026-04-07-pet-lab.md`
|
||||||
|
|
||||||
|
- [ ] 컨테이너 추가 + 포트 배정
|
||||||
|
- [ ] 핵심 도메인 모델 (반려동물 등록·기록·일정)
|
||||||
|
- [ ] 프론트 페이지 신설
|
||||||
|
|
||||||
|
### 2-3. Music YouTube 자동화 후속
|
||||||
|
|
||||||
|
- [ ] VideoProjects 실제 렌더링 잡 큐 (현재 스켈레톤)
|
||||||
|
- [ ] 시장 트렌드 → 자동 음악 생성 트리거 연결
|
||||||
|
- [ ] Revenue 트래킹 정확도 개선 (YouTube Analytics API)
|
||||||
|
|
||||||
|
### 2-4. Travel 영상 지원
|
||||||
|
|
||||||
|
- [ ] `travel-proxy`에 영상 메타·썸네일 API 추가
|
||||||
|
- [ ] `/media/travel/.video-thumb/` 처리
|
||||||
|
- [ ] `/api/travel/videos` 엔드포인트
|
||||||
|
|
||||||
|
### 2-5. 청약 (realestate-lab) 후속
|
||||||
|
|
||||||
|
- [ ] 알림 dry-run API (사용자가 사전 시뮬레이션 가능)
|
||||||
|
- [ ] 신규 매칭 텔레그램 알림 노이즈 필터링 (이미 본 공고 제외)
|
||||||
|
- [ ] 백오피스용 공고 수동 보정 API
|
||||||
|
|
||||||
|
### 2-6. packs-lab 후속
|
||||||
|
|
||||||
|
- [ ] 사용자별 다운로드 쿼터 제어
|
||||||
|
- [ ] 만료된 토큰/링크 정리 스케줄러
|
||||||
|
- [ ] Vercel SaaS 측 UI 연결 검증
|
||||||
|
|
||||||
|
### 2-7. 인프라 일반
|
||||||
|
|
||||||
|
- [ ] APScheduler 잡 모니터링 대시보드 (현재 로그 의존)
|
||||||
|
- [ ] 백업 자동화 (lotto.db / stock.db / 사진 메타)
|
||||||
|
- [ ] OpenAPI 스펙 통합 (서비스별 자동 수집)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 참고 문서
|
||||||
|
|
||||||
|
- 서비스·포트·API 전체 표: [CLAUDE.md](./CLAUDE.md)
|
||||||
|
- 워크스페이스 통합 가이드: `../CLAUDE.md`
|
||||||
|
- 프론트엔드 상태: `../web-ui/STATUS.md`
|
||||||
|
- 설계 스펙: `docs/superpowers/specs/`
|
||||||
|
- 실행 계획: `docs/superpowers/plans/`
|
||||||
|
- 로또 프리미엄 로드맵: `docs/lotto-premium-roadmap.md`
|
||||||
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
|
||||||
27
agent-office/app/agents/__init__.py
Normal file
27
agent-office/app/agents/__init__.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from .stock import StockAgent
|
||||||
|
from .music import MusicAgent
|
||||||
|
from .blog import BlogAgent
|
||||||
|
from .realestate import RealestateAgent
|
||||||
|
from .lotto import LottoAgent
|
||||||
|
from .youtube import YouTubeResearchAgent
|
||||||
|
from .youtube_publisher import YoutubePublisherAgent
|
||||||
|
|
||||||
|
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()
|
||||||
|
AGENT_REGISTRY["youtube"] = YouTubeResearchAgent()
|
||||||
|
AGENT_REGISTRY["youtube_publisher"] = YoutubePublisherAgent()
|
||||||
|
|
||||||
|
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}")
|
||||||
75
agent-office/app/agents/classify_intent.py
Normal file
75
agent-office/app/agents/classify_intent.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""텔레그램 사용자 응답 자연어 분류 — 화이트리스트 우선, 모호 시 LLM."""
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger("agent-office.classify_intent")
|
||||||
|
|
||||||
|
CLAUDE_HAIKU_DEFAULT = "claude-haiku-4-5-20251001"
|
||||||
|
|
||||||
|
APPROVE_WORDS = {
|
||||||
|
"승인", "시작", "진행", "ok", "okay", "agree",
|
||||||
|
"네", "예", "좋아", "좋아요", "go", "yes", "y",
|
||||||
|
}
|
||||||
|
REJECT_WORDS = {"반려", "거절", "취소", "no", "nope", "n"}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_api_key() -> str:
|
||||||
|
return os.getenv("ANTHROPIC_API_KEY", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_model() -> str:
|
||||||
|
return os.getenv("CLAUDE_HAIKU_MODEL", CLAUDE_HAIKU_DEFAULT)
|
||||||
|
|
||||||
|
|
||||||
|
def classify(text: str) -> tuple[str, str | None]:
|
||||||
|
"""returns (intent, feedback) — intent ∈ {approve, reject, unclear}"""
|
||||||
|
if not text:
|
||||||
|
return ("unclear", None)
|
||||||
|
t = text.strip().lower()
|
||||||
|
if t in APPROVE_WORDS:
|
||||||
|
return ("approve", None)
|
||||||
|
if t in REJECT_WORDS:
|
||||||
|
return ("reject", None)
|
||||||
|
# 반려 단어로 시작 + 추가 텍스트
|
||||||
|
for w in REJECT_WORDS:
|
||||||
|
if t.startswith(w):
|
||||||
|
rest = text.strip()[len(w):].lstrip(" ,.-:").strip()
|
||||||
|
if rest:
|
||||||
|
return ("reject", rest)
|
||||||
|
# 승인 단어로 시작 (긍정 의도면 추가 텍스트 무시)
|
||||||
|
for w in APPROVE_WORDS:
|
||||||
|
if t.startswith(w + " ") or t == w:
|
||||||
|
return ("approve", None)
|
||||||
|
return _llm_classify(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _llm_classify(text: str) -> tuple[str, str | None]:
|
||||||
|
api_key = _get_api_key()
|
||||||
|
if not api_key:
|
||||||
|
return ("unclear", None)
|
||||||
|
prompt = (
|
||||||
|
"사용자 응답을 분류하세요. JSON으로만 응답.\n"
|
||||||
|
f'응답: "{text}"\n\n'
|
||||||
|
'출력: {"intent":"approve|reject|unclear","feedback":"반려면 수정 방향, 아니면 빈 문자열"}'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
resp = httpx.post(
|
||||||
|
"https://api.anthropic.com/v1/messages",
|
||||||
|
headers={"x-api-key": api_key, "anthropic-version": "2023-06-01"},
|
||||||
|
json={"model": _get_model(), "max_tokens": 200,
|
||||||
|
"messages": [{"role": "user", "content": prompt}]},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
text_out = resp.json()["content"][0]["text"]
|
||||||
|
start = text_out.find("{")
|
||||||
|
end = text_out.rfind("}") + 1
|
||||||
|
if start < 0 or end <= start:
|
||||||
|
return ("unclear", None)
|
||||||
|
data = json.loads(text_out[start:end])
|
||||||
|
return (data.get("intent", "unclear"), data.get("feedback") or None)
|
||||||
|
except (httpx.HTTPError, httpx.TimeoutException, KeyError, ValueError, json.JSONDecodeError) as e:
|
||||||
|
logger.warning("LLM 분류 실패: %s", e)
|
||||||
|
return ("unclear", None)
|
||||||
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
|
||||||
166
agent-office/app/agents/stock.py
Normal file
166
agent-office/app/agents/stock.py
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
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", "최신 뉴스 수집 중...", task_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# stock-lab cron(매일 8:00)이 7:30 브리핑보다 늦게 돌아 어제 뉴스가
|
||||||
|
# 요약되던 문제 방지 — 요약 직전에 동기 스크랩으로 DB를 갱신한다.
|
||||||
|
try:
|
||||||
|
await service_proxy.scrape_stock_news()
|
||||||
|
except Exception as e:
|
||||||
|
add_log(self.agent_id, f"뉴스 스크랩 실패 (이전 데이터로 진행): {e}", "warning", task_id)
|
||||||
|
|
||||||
|
await self.transition("working", "AI 뉴스 요약 생성 중...")
|
||||||
|
|
||||||
|
# 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
|
||||||
93
agent-office/app/agents/youtube.py
Normal file
93
agent-office/app/agents/youtube.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# agent-office/app/agents/youtube.py
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .base import BaseAgent
|
||||||
|
from ..db import add_youtube_research_job, update_youtube_research_job, add_log
|
||||||
|
from ..youtube_researcher import (
|
||||||
|
TARGET_COUNTRIES, TREND_KEYWORDS, MUSIC_LAB_URL,
|
||||||
|
fetch_youtube_trending, fetch_google_trends, fetch_billboard_top20,
|
||||||
|
push_to_music_lab,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class YouTubeResearchAgent(BaseAgent):
|
||||||
|
agent_id = "youtube"
|
||||||
|
display_name = "YouTube 리서치"
|
||||||
|
|
||||||
|
async def on_schedule(self) -> None:
|
||||||
|
await self._run_research(TARGET_COUNTRIES)
|
||||||
|
|
||||||
|
async def on_command(self, command: str, params: dict) -> dict:
|
||||||
|
if command == "research":
|
||||||
|
if self.state == "working":
|
||||||
|
return {"ok": False, "message": "이미 수집 중"}
|
||||||
|
countries = params.get("countries", TARGET_COUNTRIES)
|
||||||
|
asyncio.create_task(self._run_research(countries))
|
||||||
|
return {"ok": True, "message": f"리서치 시작: {countries}"}
|
||||||
|
return {"ok": False, "message": f"Unknown command: {command}"}
|
||||||
|
|
||||||
|
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _run_research(self, countries: list) -> None:
|
||||||
|
job_id = add_youtube_research_job(countries)
|
||||||
|
await self.transition("working", f"트렌드 수집 중 ({','.join(countries)})", str(job_id))
|
||||||
|
|
||||||
|
all_trends = []
|
||||||
|
try:
|
||||||
|
for country in countries:
|
||||||
|
trends = await fetch_youtube_trending(country)
|
||||||
|
all_trends.extend(trends)
|
||||||
|
|
||||||
|
gt = await fetch_google_trends(TREND_KEYWORDS, countries)
|
||||||
|
all_trends.extend(gt)
|
||||||
|
|
||||||
|
bb = await fetch_billboard_top20()
|
||||||
|
all_trends.extend(bb)
|
||||||
|
|
||||||
|
ok = await push_to_music_lab(all_trends, date.today().isoformat())
|
||||||
|
if not ok:
|
||||||
|
raise RuntimeError("music-lab push 실패")
|
||||||
|
|
||||||
|
update_youtube_research_job(job_id, "completed", len(all_trends))
|
||||||
|
await self.transition("reporting", f"수집 완료: {len(all_trends)}건", str(job_id))
|
||||||
|
except Exception as e:
|
||||||
|
update_youtube_research_job(job_id, "failed", len(all_trends), str(e))
|
||||||
|
await self.transition("idle", f"수집 실패: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
await self.transition("idle", "리서치 완료")
|
||||||
|
|
||||||
|
async def send_weekly_report(self) -> None:
|
||||||
|
"""매주 월요일 08:00 — 주간 인사이트 텔레그램 발송."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.get(f"{MUSIC_LAB_URL}/api/music/market/report/latest")
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return
|
||||||
|
report = resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
add_log(self.agent_id, f"주간 리포트 조회 실패: {e}", level="error")
|
||||||
|
logger.error("send_weekly_report: music-lab 조회 실패: %s", e)
|
||||||
|
return
|
||||||
|
|
||||||
|
top = report.get("top_genres", [])[:3]
|
||||||
|
insights = report.get("insights", "")
|
||||||
|
text = "📊 *YouTube 시장 주간 리포트*\n\n🔥 인기 장르:\n"
|
||||||
|
for g in top:
|
||||||
|
text += f" • {g['genre']} (score: {g['score']:.2f})\n"
|
||||||
|
if insights:
|
||||||
|
text += f"\n💡 {insights[:300]}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ..telegram_bot import send_message
|
||||||
|
await send_message(text)
|
||||||
|
except (ImportError, Exception) as e:
|
||||||
|
add_log(self.agent_id, f"주간 리포트 텔레그램 발송 실패: {e}", level="error")
|
||||||
|
logger.error("send_weekly_report: 텔레그램 발송 실패: %s", e)
|
||||||
108
agent-office/app/agents/youtube_publisher.py
Normal file
108
agent-office/app/agents/youtube_publisher.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""텔레그램 단일 채널로 단계별 승인 인터랙션 오케스트레이션."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .base import BaseAgent
|
||||||
|
from . import classify_intent
|
||||||
|
from .. import service_proxy
|
||||||
|
from ..db import add_log
|
||||||
|
from ..telegram.messaging import send_raw
|
||||||
|
|
||||||
|
logger = logging.getLogger("agent-office.youtube_publisher")
|
||||||
|
|
||||||
|
|
||||||
|
_STEP_TITLES = {
|
||||||
|
"cover_pending": ("커버 아트", "cover"),
|
||||||
|
"video_pending": ("영상 비주얼", "video"),
|
||||||
|
"thumb_pending": ("썸네일", "thumb"),
|
||||||
|
"meta_pending": ("메타데이터", "meta"),
|
||||||
|
"publish_pending": ("최종 검토 + 발행", "publish"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class YoutubePublisherAgent(BaseAgent):
|
||||||
|
agent_id = "youtube_publisher"
|
||||||
|
display_name = "YouTube 퍼블리셔"
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._notified_state_per_pipeline: dict[int, str] = {}
|
||||||
|
|
||||||
|
async def poll_state_changes(self) -> None:
|
||||||
|
"""주기적으로 호출되어 *_pending 신규 진입 시 텔레그램 발송."""
|
||||||
|
try:
|
||||||
|
pipelines = await service_proxy.list_active_pipelines()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("폴링 실패: %s", e)
|
||||||
|
return
|
||||||
|
|
||||||
|
for p in pipelines:
|
||||||
|
state = p.get("state")
|
||||||
|
pid = p.get("id")
|
||||||
|
if pid is None:
|
||||||
|
continue
|
||||||
|
if state in _STEP_TITLES and self._notified_state_per_pipeline.get(pid) != state:
|
||||||
|
await self._notify_step(p)
|
||||||
|
self._notified_state_per_pipeline[pid] = state
|
||||||
|
|
||||||
|
async def _notify_step(self, pipeline: dict) -> None:
|
||||||
|
state = pipeline["state"]
|
||||||
|
title_name, step = _STEP_TITLES[state]
|
||||||
|
body = self._format_body(pipeline, step)
|
||||||
|
track_title = pipeline.get("track_title") or f"Pipeline #{pipeline['id']}"
|
||||||
|
text = (
|
||||||
|
f"🎵 [{track_title}] {title_name} 검토\n\n"
|
||||||
|
f"{body}\n\n"
|
||||||
|
f"➡️ 답장으로 알려주세요: '승인' 또는 '반려 + 수정 방향'"
|
||||||
|
)
|
||||||
|
sent = await send_raw(text=text)
|
||||||
|
if sent.get("ok"):
|
||||||
|
msg_id = sent.get("message_id")
|
||||||
|
try:
|
||||||
|
await service_proxy.save_pipeline_telegram_msg(pipeline["id"], step, msg_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("telegram-msg 저장 실패: %s", e)
|
||||||
|
add_log(self.agent_id, f"pipeline {pipeline['id']} {step} 알림 전송", "info")
|
||||||
|
|
||||||
|
def _format_body(self, p: dict, step: str) -> str:
|
||||||
|
if step == "cover":
|
||||||
|
return f"🖼️ 커버: {p.get('cover_url', '-')}"
|
||||||
|
if step == "video":
|
||||||
|
return f"🎬 영상: {p.get('video_url', '-')}"
|
||||||
|
if step == "thumb":
|
||||||
|
return f"🎴 썸네일: {p.get('thumbnail_url', '-')}"
|
||||||
|
if step == "meta":
|
||||||
|
m = p.get("metadata", {}) or {}
|
||||||
|
tags = m.get("tags", []) or []
|
||||||
|
description = (m.get("description", "") or "")
|
||||||
|
return (
|
||||||
|
f"📝 제목: {m.get('title', '')}\n"
|
||||||
|
f"🏷️ 태그: {', '.join(tags[:8])}\n"
|
||||||
|
f"📄 설명(앞부분): {description[:200]}"
|
||||||
|
)
|
||||||
|
if step == "publish":
|
||||||
|
r = p.get("review", {}) or {}
|
||||||
|
return (
|
||||||
|
f"AI 검토 결과: {r.get('verdict', '?')} "
|
||||||
|
f"(가중 {r.get('weighted_total', '?')}/100)\n"
|
||||||
|
f"{r.get('summary', '')}"
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
async def on_telegram_reply(self, pipeline_id: int, step: str, user_text: str) -> None:
|
||||||
|
intent, feedback = classify_intent.classify(user_text)
|
||||||
|
if intent == "unclear":
|
||||||
|
await send_raw("다시 입력해주세요. 예: '승인' 또는 '반려, 제목 짧게'")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await service_proxy.post_pipeline_feedback(pipeline_id, step, intent, feedback)
|
||||||
|
except Exception as e:
|
||||||
|
await send_raw(f"⚠️ 처리 실패: {e}")
|
||||||
|
|
||||||
|
async def on_schedule(self) -> None:
|
||||||
|
await self.poll_state_changes()
|
||||||
|
|
||||||
|
async def on_command(self, command: str, params: dict) -> dict:
|
||||||
|
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
|
||||||
557
agent-office/app/db.py
Normal file
557
agent-office/app/db.py
Normal file
@@ -0,0 +1,557 @@
|
|||||||
|
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)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS youtube_research_jobs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'running',
|
||||||
|
countries TEXT NOT NULL DEFAULT '[]',
|
||||||
|
trends_collected INTEGER NOT NULL DEFAULT 0,
|
||||||
|
error TEXT,
|
||||||
|
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
completed_at TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
# Seed default agent configs
|
||||||
|
for agent_id, name in [
|
||||||
|
("stock", "주식 트레이더"),
|
||||||
|
("music", "음악 프로듀서"),
|
||||||
|
("blog", "블로그 마케터"),
|
||||||
|
("realestate", "청약 애널리스트"),
|
||||||
|
("lotto", "로또 큐레이터"),
|
||||||
|
("youtube", "YouTube 리서치"),
|
||||||
|
]:
|
||||||
|
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}
|
||||||
|
|
||||||
|
|
||||||
|
# ── youtube_research_jobs CRUD ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def add_youtube_research_job(countries: list) -> int:
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO youtube_research_jobs (countries) VALUES (?)",
|
||||||
|
(json.dumps(countries),),
|
||||||
|
)
|
||||||
|
return conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def update_youtube_research_job(
|
||||||
|
job_id: int, status: str, trends_collected: int, error: Optional[str] = None
|
||||||
|
) -> None:
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""UPDATE youtube_research_jobs
|
||||||
|
SET status=?, trends_collected=?, error=?,
|
||||||
|
completed_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||||
|
WHERE id=?""",
|
||||||
|
(status, trends_collected, error, job_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_latest_youtube_research_job() -> Optional[Dict[str, Any]]:
|
||||||
|
with _conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM youtube_research_jobs ORDER BY id DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"status": row["status"],
|
||||||
|
"countries": json.loads(row["countries"]),
|
||||||
|
"trends_collected": row["trends_collected"],
|
||||||
|
"error": row["error"],
|
||||||
|
"started_at": row["started_at"],
|
||||||
|
"completed_at": row["completed_at"],
|
||||||
|
}
|
||||||
227
agent-office/app/main.py
Normal file
227
agent-office/app/main.py
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
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, get_latest_youtube_research_job
|
||||||
|
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, Optional
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
# --- YouTube Research Agent Endpoints ---
|
||||||
|
|
||||||
|
class YouTubeResearchBody(BaseModel):
|
||||||
|
countries: List[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/agent-office/youtube/research")
|
||||||
|
async def trigger_youtube_research(body: Optional[YouTubeResearchBody] = None):
|
||||||
|
agent = get_agent("youtube")
|
||||||
|
if not agent:
|
||||||
|
raise HTTPException(status_code=503, detail="YouTubeResearchAgent 없음")
|
||||||
|
params = {}
|
||||||
|
if body and body.countries:
|
||||||
|
params["countries"] = body.countries
|
||||||
|
result = await agent.on_command("research", params)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/agent-office/youtube/research/status")
|
||||||
|
def youtube_research_status():
|
||||||
|
job = get_latest_youtube_research_job()
|
||||||
|
if not job:
|
||||||
|
return {"status": "never_run"}
|
||||||
|
return job
|
||||||
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
|
||||||
50
agent-office/app/scheduler.py
Normal file
50
agent-office/app/scheduler.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
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()
|
||||||
|
|
||||||
|
async def _run_youtube_research():
|
||||||
|
agent = AGENT_REGISTRY.get("youtube")
|
||||||
|
if agent:
|
||||||
|
await agent.on_schedule()
|
||||||
|
|
||||||
|
async def _send_youtube_weekly_report():
|
||||||
|
agent = AGENT_REGISTRY.get("youtube")
|
||||||
|
if agent:
|
||||||
|
await agent.send_weekly_report()
|
||||||
|
|
||||||
|
async def _poll_pipelines():
|
||||||
|
agent = AGENT_REGISTRY.get("youtube_publisher")
|
||||||
|
if agent:
|
||||||
|
await agent.poll_state_changes()
|
||||||
|
|
||||||
|
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(_run_youtube_research, "cron", hour=9, minute=0, id="youtube_research")
|
||||||
|
scheduler.add_job(_send_youtube_weekly_report, "cron", day_of_week="mon", hour=8, minute=0, id="youtube_weekly_report")
|
||||||
|
scheduler.add_job(_check_idle_breaks, "interval", seconds=60, id="idle_check")
|
||||||
|
scheduler.add_job(_poll_pipelines, "interval", seconds=30, id="pipeline_poll")
|
||||||
|
scheduler.start()
|
||||||
223
agent-office/app/service_proxy.py
Normal file
223
agent-office/app/service_proxy.py
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
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 scrape_stock_news() -> Dict[str, Any]:
|
||||||
|
"""stock-lab의 수동 뉴스 스크랩 트리거 — DB에 최신 뉴스 저장.
|
||||||
|
|
||||||
|
아침 브리핑 직전 호출하여 어제 데이터가 아닌 오늘 새벽 뉴스를 보장한다.
|
||||||
|
네이버 금융 단일 요청이라 보통 수 초 내 완료, 여유있게 60s.
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
resp = await client.post(f"{STOCK_LAB_URL}/api/stock/scrap")
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
# --- music-lab pipeline (YouTube publisher orchestration) ---
|
||||||
|
|
||||||
|
async def list_active_pipelines() -> list[dict]:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
resp = await client.get(f"{MUSIC_LAB_URL}/api/music/pipeline?status=active")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json().get("pipelines", [])
|
||||||
|
|
||||||
|
|
||||||
|
async def get_pipeline(pid: int) -> dict:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
resp = await client.get(f"{MUSIC_LAB_URL}/api/music/pipeline/{pid}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def post_pipeline_feedback(pid: int, step: str, intent: str,
|
||||||
|
feedback_text: Optional[str] = None) -> dict:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{MUSIC_LAB_URL}/api/music/pipeline/{pid}/feedback",
|
||||||
|
json={"step": step, "intent": intent, "feedback_text": feedback_text},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def save_pipeline_telegram_msg(pid: int, step: str, msg_id: int) -> None:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
await client.patch(
|
||||||
|
f"{MUSIC_LAB_URL}/api/music/pipeline/{pid}/telegram-msg",
|
||||||
|
json={"step": step, "message_id": msg_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def lookup_pipeline_by_msg(msg_id: int) -> Optional[dict]:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.get(f"{MUSIC_LAB_URL}/api/music/pipeline/lookup-by-msg/{msg_id}")
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.json()
|
||||||
|
return None
|
||||||
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()
|
||||||
182
agent-office/app/telegram/conversational.py
Normal file
182
agent-office/app/telegram/conversational.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
"""텔레그램 자연어 대화 핸들러 — 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 maybe_route_to_pipeline(message: dict) -> bool:
|
||||||
|
"""파이프라인 텔레그램 메시지에 대한 reply 인 경우 youtube_publisher 로 라우팅.
|
||||||
|
|
||||||
|
Returns True if message was routed (caller should stop further processing).
|
||||||
|
"""
|
||||||
|
reply_to = message.get("reply_to_message") or {}
|
||||||
|
msg_id = reply_to.get("message_id")
|
||||||
|
if not msg_id:
|
||||||
|
return False
|
||||||
|
from .. import service_proxy
|
||||||
|
try:
|
||||||
|
link = await service_proxy.lookup_pipeline_by_msg(msg_id)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
if not link:
|
||||||
|
return False
|
||||||
|
from ..agents import AGENT_REGISTRY
|
||||||
|
agent = AGENT_REGISTRY.get("youtube_publisher")
|
||||||
|
if not agent:
|
||||||
|
return False
|
||||||
|
pipeline_id = link.get("pipeline_id")
|
||||||
|
step = link.get("step")
|
||||||
|
if pipeline_id is None or not step:
|
||||||
|
return False
|
||||||
|
await agent.on_telegram_reply(pipeline_id, step, message.get("text", ""))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
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 — 청약 현황 요약
|
||||||
|
"""
|
||||||
204
agent-office/app/telegram/webhook.py
Normal file
204
agent-office/app/telegram/webhook.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
"""텔레그램 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
|
||||||
|
from .conversational import maybe_route_to_pipeline
|
||||||
|
|
||||||
|
# 파이프라인 메시지에 대한 reply라면 youtube_publisher 로 라우팅
|
||||||
|
if await maybe_route_to_pipeline(message):
|
||||||
|
return {"handled": "pipeline_reply"}
|
||||||
|
|
||||||
|
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()
|
||||||
142
agent-office/app/youtube_researcher.py
Normal file
142
agent-office/app/youtube_researcher.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
import asyncio
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
YOUTUBE_DATA_API_KEY = os.getenv("YOUTUBE_DATA_API_KEY", "")
|
||||||
|
MUSIC_LAB_URL = os.getenv("MUSIC_LAB_URL", "http://music-lab:8000")
|
||||||
|
TARGET_COUNTRIES = ["BR", "ID", "MX", "US", "KR"]
|
||||||
|
TREND_KEYWORDS = ["lofi music", "phonk", "ambient music", "chill beats", "study music"]
|
||||||
|
YOUTUBE_MUSIC_CAT = "10"
|
||||||
|
|
||||||
|
GENRE_TAGS = {
|
||||||
|
"lo-fi": ["lofi", "lo-fi", "lo fi", "chill", "study"],
|
||||||
|
"phonk": ["phonk", "drift", "memphis"],
|
||||||
|
"ambient": ["ambient", "relaxing", "meditation"],
|
||||||
|
"pop": ["pop", "kpop", "k-pop"],
|
||||||
|
"funk": ["funk", "baile funk"],
|
||||||
|
"latin": ["latin", "reggaeton", "sertanejo"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _tags_to_genre(tags: list) -> str:
|
||||||
|
joined = " ".join(t.lower() for t in tags)
|
||||||
|
for genre, kws in GENRE_TAGS.items():
|
||||||
|
if any(kw in joined for kw in kws):
|
||||||
|
return genre
|
||||||
|
return "general"
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_youtube_trending(country: str, max_results: int = 50) -> List[Dict[str, Any]]:
|
||||||
|
"""YouTube Data API v3 — 국가별 트렌딩 음악 영상 (categoryId=10)."""
|
||||||
|
if not YOUTUBE_DATA_API_KEY:
|
||||||
|
return []
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
try:
|
||||||
|
resp = await client.get(
|
||||||
|
"https://www.googleapis.com/youtube/v3/videos",
|
||||||
|
params={
|
||||||
|
"part": "snippet,statistics",
|
||||||
|
"chart": "mostPopular",
|
||||||
|
"regionCode": country,
|
||||||
|
"videoCategoryId": YOUTUBE_MUSIC_CAT,
|
||||||
|
"maxResults": max_results,
|
||||||
|
"key": YOUTUBE_DATA_API_KEY,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return []
|
||||||
|
items = resp.json().get("items", [])
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for i, item in enumerate(items):
|
||||||
|
snippet = item.get("snippet", {})
|
||||||
|
stats = item.get("statistics", {})
|
||||||
|
genre = _tags_to_genre(snippet.get("tags") or [])
|
||||||
|
results.append({
|
||||||
|
"source": "youtube",
|
||||||
|
"country": country,
|
||||||
|
"genre": genre,
|
||||||
|
"keyword": snippet.get("title", "")[:100],
|
||||||
|
"score": round(1.0 - i / max_results, 3),
|
||||||
|
"rank": i + 1,
|
||||||
|
"metadata": {
|
||||||
|
"video_id": item["id"],
|
||||||
|
"view_count": int(stats.get("viewCount", 0)),
|
||||||
|
"channel": snippet.get("channelTitle", ""),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_google_trends(keywords: List[str], countries: List[str]) -> List[Dict[str, Any]]:
|
||||||
|
"""pytrends — 키워드별 Google 관심도 (sync → threadpool)."""
|
||||||
|
try:
|
||||||
|
from pytrends.request import TrendReq
|
||||||
|
except ImportError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _sync_fetch(kw: str) -> List[Dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
pt = TrendReq(hl="en-US", tz=0, timeout=(5, 15))
|
||||||
|
pt.build_payload([kw], timeframe="now 7-d")
|
||||||
|
df = pt.interest_over_time()
|
||||||
|
if df.empty or kw not in df.columns:
|
||||||
|
return []
|
||||||
|
score = round(float(df[kw].mean()) / 100.0, 3)
|
||||||
|
return [
|
||||||
|
{"source": "google_trends", "country": c, "genre": "",
|
||||||
|
"keyword": kw, "score": score, "rank": None, "metadata": {}}
|
||||||
|
for c in countries
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
results = []
|
||||||
|
for kw in keywords[:5]:
|
||||||
|
rows = await loop.run_in_executor(None, _sync_fetch, kw)
|
||||||
|
results.extend(rows)
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_billboard_top20() -> List[Dict[str, Any]]:
|
||||||
|
"""Billboard Hot 100 스크래핑 — 상위 20위."""
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=10.0,
|
||||||
|
headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"},
|
||||||
|
follow_redirects=True,
|
||||||
|
) as client:
|
||||||
|
try:
|
||||||
|
resp = await client.get("https://www.billboard.com/charts/hot-100/")
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return []
|
||||||
|
titles = re.findall(
|
||||||
|
r'class="c-title[^"]*"[^>]*>\s*([^<\n]{3,80})\s*<', resp.text
|
||||||
|
)[:20]
|
||||||
|
return [
|
||||||
|
{"source": "billboard", "country": "US", "genre": "pop",
|
||||||
|
"keyword": t.strip(), "score": round(1.0 - i / 20, 3),
|
||||||
|
"rank": i + 1, "metadata": {}}
|
||||||
|
for i, t in enumerate(titles) if t.strip()
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def push_to_music_lab(trends: List[Dict[str, Any]], report_date: str) -> bool:
|
||||||
|
"""수집한 트렌드를 music-lab /api/music/market/ingest로 push."""
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
try:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{MUSIC_LAB_URL}/api/music/market/ingest",
|
||||||
|
json={"trends": trends, "report_date": report_date},
|
||||||
|
)
|
||||||
|
return resp.status_code == 200
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
8
agent-office/requirements.txt
Normal file
8
agent-office/requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
apscheduler==3.10.4
|
||||||
|
websockets>=12.0
|
||||||
|
httpx>=0.27
|
||||||
|
respx>=0.21
|
||||||
|
google-api-python-client>=2.100.0
|
||||||
|
pytrends>=4.9.2
|
||||||
48
agent-office/tests/test_classify_intent.py
Normal file
48
agent-office/tests/test_classify_intent.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import pytest
|
||||||
|
import respx
|
||||||
|
from httpx import Response
|
||||||
|
from app.agents import classify_intent as ci
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_approve_no_llm(monkeypatch):
|
||||||
|
# Patch _llm_classify so we can assert it wasn't called
|
||||||
|
called = {"n": 0}
|
||||||
|
def fake(text):
|
||||||
|
called["n"] += 1
|
||||||
|
return ("unclear", None)
|
||||||
|
monkeypatch.setattr(ci, "_llm_classify", fake)
|
||||||
|
assert ci.classify("승인") == ("approve", None)
|
||||||
|
assert ci.classify("OK") == ("approve", None)
|
||||||
|
assert ci.classify("진행") == ("approve", None)
|
||||||
|
assert ci.classify("agree") == ("approve", None)
|
||||||
|
assert called["n"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_reject_only_no_llm(monkeypatch):
|
||||||
|
monkeypatch.setattr(ci, "_llm_classify", lambda t: ("unclear", None))
|
||||||
|
assert ci.classify("반려") == ("reject", None)
|
||||||
|
assert ci.classify("거절") == ("reject", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_with_text_split(monkeypatch):
|
||||||
|
monkeypatch.setattr(ci, "_llm_classify", lambda t: ("unclear", None))
|
||||||
|
intent, fb = ci.classify("반려, 제목 짧게")
|
||||||
|
assert intent == "reject"
|
||||||
|
assert "제목 짧게" in fb
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_ambiguous_calls_llm(monkeypatch):
|
||||||
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "k")
|
||||||
|
respx.post("https://api.anthropic.com/v1/messages").mock(
|
||||||
|
return_value=Response(200, json={"content": [{"type": "text",
|
||||||
|
"text": '{"intent":"reject","feedback":"좀 더 화려하게"}'}]})
|
||||||
|
)
|
||||||
|
intent, fb = ci.classify("음... 좀 더 화려한 분위기가 좋겠어")
|
||||||
|
assert intent == "reject"
|
||||||
|
assert "화려하게" in fb
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_text_returns_unclear():
|
||||||
|
assert ci.classify("") == ("unclear", None)
|
||||||
|
assert ci.classify(None) == ("unclear", None)
|
||||||
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)
|
||||||
110
agent-office/tests/test_pipeline_polling.py
Normal file
110
agent-office/tests/test_pipeline_polling.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
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 pytest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
|
||||||
|
@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()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_notifies_once_per_state():
|
||||||
|
from app.agents.youtube_publisher import YoutubePublisherAgent
|
||||||
|
|
||||||
|
pipelines = [{
|
||||||
|
"id": 1,
|
||||||
|
"state": "cover_pending",
|
||||||
|
"cover_url": "/x.jpg",
|
||||||
|
"track_title": "Test",
|
||||||
|
}]
|
||||||
|
with patch(
|
||||||
|
"app.agents.youtube_publisher.service_proxy.list_active_pipelines",
|
||||||
|
new=AsyncMock(return_value=pipelines),
|
||||||
|
), patch(
|
||||||
|
"app.agents.youtube_publisher.send_raw",
|
||||||
|
new=AsyncMock(return_value={"ok": True, "message_id": 99}),
|
||||||
|
) as mock_send, patch(
|
||||||
|
"app.agents.youtube_publisher.service_proxy.save_pipeline_telegram_msg",
|
||||||
|
new=AsyncMock(),
|
||||||
|
):
|
||||||
|
a = YoutubePublisherAgent()
|
||||||
|
await a.poll_state_changes()
|
||||||
|
await a.poll_state_changes() # 같은 상태 — 두 번째는 알림 안 함
|
||||||
|
assert mock_send.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_telegram_reply_approve_calls_feedback():
|
||||||
|
from app.agents.youtube_publisher import YoutubePublisherAgent
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.agents.youtube_publisher.service_proxy.post_pipeline_feedback",
|
||||||
|
new=AsyncMock(),
|
||||||
|
) as mock_fb, patch(
|
||||||
|
"app.agents.youtube_publisher.send_raw",
|
||||||
|
new=AsyncMock(),
|
||||||
|
):
|
||||||
|
a = YoutubePublisherAgent()
|
||||||
|
await a.on_telegram_reply(pipeline_id=42, step="cover", user_text="승인")
|
||||||
|
mock_fb.assert_called_once_with(42, "cover", "approve", None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_telegram_reply_reject_with_feedback():
|
||||||
|
from app.agents.youtube_publisher import YoutubePublisherAgent
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.agents.youtube_publisher.service_proxy.post_pipeline_feedback",
|
||||||
|
new=AsyncMock(),
|
||||||
|
) as mock_fb, patch(
|
||||||
|
"app.agents.youtube_publisher.send_raw",
|
||||||
|
new=AsyncMock(),
|
||||||
|
):
|
||||||
|
a = YoutubePublisherAgent()
|
||||||
|
await a.on_telegram_reply(pipeline_id=43, step="meta", user_text="반려, 제목 짧게")
|
||||||
|
args = mock_fb.call_args[0]
|
||||||
|
assert args[0] == 43
|
||||||
|
assert args[1] == "meta"
|
||||||
|
assert args[2] == "reject"
|
||||||
|
assert "제목 짧게" in (args[3] or "")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_telegram_reply_unclear_asks_again():
|
||||||
|
from app.agents.youtube_publisher import YoutubePublisherAgent
|
||||||
|
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
async def mock_send(text=None, **kw):
|
||||||
|
sent.append(text)
|
||||||
|
return {"ok": True, "message_id": 1}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.agents.youtube_publisher.send_raw",
|
||||||
|
new=mock_send,
|
||||||
|
), patch(
|
||||||
|
"app.agents.youtube_publisher.classify_intent.classify",
|
||||||
|
return_value=("unclear", None),
|
||||||
|
):
|
||||||
|
a = YoutubePublisherAgent()
|
||||||
|
await a.on_telegram_reply(pipeline_id=44, step="cover", user_text="huh?")
|
||||||
|
assert any("다시 입력" in (s or "") for s in sent)
|
||||||
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,260 @@ 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}
|
||||||
|
- PEXELS_API_KEY=${PEXELS_API_KEY:-}
|
||||||
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||||
|
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||||
|
- YOUTUBE_OAUTH_CLIENT_ID=${YOUTUBE_OAUTH_CLIENT_ID:-}
|
||||||
|
- YOUTUBE_OAUTH_CLIENT_SECRET=${YOUTUBE_OAUTH_CLIENT_SECRET:-}
|
||||||
|
- YOUTUBE_OAUTH_REDIRECT_URI=${YOUTUBE_OAUTH_REDIRECT_URI:-}
|
||||||
|
- CLAUDE_HAIKU_MODEL=${CLAUDE_HAIKU_MODEL:-claude-haiku-4-5-20251001}
|
||||||
|
- CLAUDE_SONNET_MODEL=${CLAUDE_SONNET_MODEL:-claude-sonnet-4-6}
|
||||||
|
- VIDEO_DATA_DIR=${VIDEO_DATA_DIR:-/app/data/videos}
|
||||||
|
volumes:
|
||||||
|
- ${RUNTIME_PATH}/data/music:/app/data
|
||||||
|
- ${RUNTIME_PATH:-.}/data/videos:/app/data/videos
|
||||||
|
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:-}
|
||||||
|
- CLAUDE_HAIKU_MODEL=${CLAUDE_HAIKU_MODEL:-claude-haiku-4-5-20251001}
|
||||||
|
- CLAUDE_SONNET_MODEL=${CLAUDE_SONNET_MODEL:-claude-sonnet-4-6}
|
||||||
|
- 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}
|
||||||
|
- YOUTUBE_DATA_API_KEY=${YOUTUBE_DATA_API_KEY:-}
|
||||||
|
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
|
||||||
|
|
||||||
|
packs-lab:
|
||||||
|
build:
|
||||||
|
context: ./packs-lab
|
||||||
|
container_name: packs-lab
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "18950:8000"
|
||||||
|
environment:
|
||||||
|
- TZ=${TZ:-Asia/Seoul}
|
||||||
|
- DSM_HOST=${DSM_HOST:-}
|
||||||
|
- DSM_USER=${DSM_USER:-}
|
||||||
|
- DSM_PASS=${DSM_PASS:-}
|
||||||
|
- BACKEND_HMAC_SECRET=${BACKEND_HMAC_SECRET:-}
|
||||||
|
- SUPABASE_URL=${SUPABASE_URL:-}
|
||||||
|
- SUPABASE_SERVICE_KEY=${SUPABASE_SERVICE_KEY:-}
|
||||||
|
- UPLOAD_TOKEN_TTL_SEC=${UPLOAD_TOKEN_TTL_SEC:-1800}
|
||||||
|
- PACK_BASE_DIR=${PACK_BASE_DIR:-/app/data/packs}
|
||||||
|
volumes:
|
||||||
|
- ${PACK_DATA_PATH:-./data/packs}:${PACK_BASE_DIR:-/app/data/packs}
|
||||||
|
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
|
||||||
|
- ${RUNTIME_PATH}/data/videos:/data/videos: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
|
||||||
|
|||||||
252
docs/lotto-premium-roadmap.md
Normal file
252
docs/lotto-premium-roadmap.md
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
# 로또랩 프리미엄 서비스 고도화 로드맵
|
||||||
|
|
||||||
|
> 작성일: 2026-03-19
|
||||||
|
> 목표: 번호 생성 도구 → 데이터 기반 로또 전략 코치
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 현재 서비스 한계
|
||||||
|
|
||||||
|
현재 구조는 **"번호 생성 도구"** 수준으로 수익화에 한계가 있음.
|
||||||
|
|
||||||
|
| 문제 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 차별점 부재 | 무료 로또 번호 생성기와 구분되지 않음 |
|
||||||
|
| 신뢰 근거 부족 | 사용자가 결과를 믿을 데이터 시각화 없음 |
|
||||||
|
| 리텐션 약함 | 지속적으로 돌아올 이유가 없음 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 포지셔닝 전환
|
||||||
|
|
||||||
|
> **"번호 생성"이 아니라 "데이터 기반 로또 전략 코치"**
|
||||||
|
|
||||||
|
사람들이 구독료를 지불하는 심리적 동기:
|
||||||
|
|
||||||
|
- **확신**: 내가 선택한 번호가 좋은 선택이라는 데이터 근거
|
||||||
|
- **FOMO**: 이번 주 리포트를 못 받으면 놓치는 느낌
|
||||||
|
- **소유감**: 내 데이터와 이력이 축적된다는 느낌
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 고도화 방향 (5가지)
|
||||||
|
|
||||||
|
### 3-1. 당첨 근접도 추적 — 신뢰 기반 구축
|
||||||
|
|
||||||
|
**목표**: 기존 채점 데이터(`check_results_for_draw`)를 신뢰 지표로 전환
|
||||||
|
|
||||||
|
**구현 내용**:
|
||||||
|
- 추천 번호의 회차별 일치 개수 통계 집계
|
||||||
|
- 전국 평균 대비 성과 비교 지표 노출
|
||||||
|
- 매주 "지난 주 내 번호 성과" 이메일/푸시 발송
|
||||||
|
|
||||||
|
**예시 UI 문구**:
|
||||||
|
```
|
||||||
|
"지난 52주간 우리 추천번호의 평균 일치 개수: 2.7개 (전국 평균 1.9개)"
|
||||||
|
"3개 일치율이 일반 무작위 대비 43% 높습니다"
|
||||||
|
```
|
||||||
|
|
||||||
|
**활용 데이터**: 기존 `recommendations` + `draws` 테이블 채점 결과
|
||||||
|
|
||||||
|
**우선순위**: ⭐⭐⭐ (데이터 이미 존재, 즉시 구현 가능)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3-2. 개인화 분석 리포트 — 프리미엄 핵심 기능
|
||||||
|
|
||||||
|
**목표**: 모든 사용자에게 동일한 번호 → 개인 패턴 기반 맞춤 추천
|
||||||
|
|
||||||
|
**구현 내용**:
|
||||||
|
- 사용자 번호 선택 이력 패턴 분석
|
||||||
|
- 홀짝 비율, 번호대 분포, 연속번호 포함률 등 개인 성향 분석
|
||||||
|
- 약점을 보완한 AI 보정 추천번호 생성
|
||||||
|
|
||||||
|
**예시 분석 항목**:
|
||||||
|
```
|
||||||
|
"당신은 홀수를 선호하는 경향 (67%)"
|
||||||
|
"당신이 자주 피하는 번호대: 30번대"
|
||||||
|
"당신 번호의 약점: 연속번호 포함률 낮음"
|
||||||
|
→ "이를 보완한 AI 보정 추천번호 제공"
|
||||||
|
```
|
||||||
|
|
||||||
|
**신규 테이블**: `user_preferences`
|
||||||
|
|
||||||
|
**우선순위**: ⭐⭐ (신규 테이블 및 분석 로직 필요)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3-3. 회차별 공략 리포트 — 킬러 콘텐츠
|
||||||
|
|
||||||
|
**목표**: 매주 추첨 전 발행하는 주간 분석 레포트 → 구독 유지 동기
|
||||||
|
|
||||||
|
**구현 내용**:
|
||||||
|
- 매주 자동 생성되는 회차별 공략 리포트
|
||||||
|
- 과출현/냉각 번호 분석
|
||||||
|
- 패턴 기반 번호군 추천
|
||||||
|
- AI 신뢰도 점수 표시
|
||||||
|
|
||||||
|
**예시 리포트 구조**:
|
||||||
|
```
|
||||||
|
[1180회 공략 리포트]
|
||||||
|
- 최근 10회 과출현 번호 제외 추천
|
||||||
|
- 이번 주 "냉각 구간" 번호 (오랫동안 미출현)
|
||||||
|
- 패턴 분석: 직전 3회 연속 출현한 번호군
|
||||||
|
- AI 신뢰도 점수: 87/100
|
||||||
|
```
|
||||||
|
|
||||||
|
**스케줄러**: 매주 토요일 추첨 전 자동 생성 (APScheduler)
|
||||||
|
|
||||||
|
**우선순위**: ⭐⭐⭐ (주간 구독 모델의 핵심 훅)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3-4. 번호 포트폴리오 관리 — 차별화 UX
|
||||||
|
|
||||||
|
**목표**: 로또를 투자처럼 관리하는 경험 제공
|
||||||
|
|
||||||
|
**구현 내용**:
|
||||||
|
- 세트 분류: 고위험/안정형/균형형
|
||||||
|
- 구매 금액 직접 입력 → 수익률 자동 계산
|
||||||
|
- 누적 투자 대비 당첨금 통계
|
||||||
|
|
||||||
|
**예시 화면**:
|
||||||
|
```
|
||||||
|
내 번호 포트폴리오
|
||||||
|
├── 고위험/고수익 세트 (출현 빈도 낮은 번호 조합)
|
||||||
|
├── 안정형 세트 (평균 출현 패턴)
|
||||||
|
└── 균형형 세트 (시뮬레이션 최적화)
|
||||||
|
|
||||||
|
이번 주 매입: 3세트 (₩3,000)
|
||||||
|
누적 투자: ₩240,000 / 누적 당첨: ₩45,000
|
||||||
|
수익률: -81.2% (전국 평균 대비 +12.1%)
|
||||||
|
```
|
||||||
|
|
||||||
|
**활용 데이터**: `best_picks`, `recommendations` 확장
|
||||||
|
|
||||||
|
**우선순위**: ⭐⭐ (UX 임팩트 큼, 중기 구현)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3-5. 커뮤니티 + 소셜 증거 — 바이럴 유도
|
||||||
|
|
||||||
|
**목표**: 사용자 참여 및 구전 마케팅
|
||||||
|
|
||||||
|
**구현 내용**:
|
||||||
|
- 이번 주 가장 많이 선택된 번호 TOP 10 공개
|
||||||
|
- "나와 같은 번호 선택한 회원 수" 표시
|
||||||
|
- AI 추천으로 X개 일치 달성한 회원 수 표시
|
||||||
|
|
||||||
|
**예시**:
|
||||||
|
```
|
||||||
|
"이번 주 가장 많이 선택된 번호 TOP 10"
|
||||||
|
"AI 추천 번호로 3개 일치 달성한 회원: 1,247명"
|
||||||
|
"나와 같은 번호를 선택한 회원: 34명"
|
||||||
|
```
|
||||||
|
|
||||||
|
**전략**: 무료 티어에 일부 공개 → 상세 분석은 유료 전환
|
||||||
|
|
||||||
|
**우선순위**: ⭐ (회원 시스템 구축 후 가능)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 구독 티어 설계
|
||||||
|
|
||||||
|
| 기능 | 무료 | 스탠다드 (₩2,900/월) | 프리미엄 (₩5,900/월) |
|
||||||
|
|------|:----:|:----:|:----:|
|
||||||
|
| 기본 추천 번호 | 1세트 | 5세트 | 무제한 |
|
||||||
|
| 통계 분석 | 기본 | 심화 | 전체 |
|
||||||
|
| 회차 공략 리포트 | - | 주간 요약 | 풀 리포트 |
|
||||||
|
| 개인 패턴 분석 | - | - | ✓ |
|
||||||
|
| 번호 포트폴리오 | - | ✓ | ✓ |
|
||||||
|
| 당첨 근접도 통계 | - | ✓ | ✓ |
|
||||||
|
| 당첨 알림 | - | 이메일 | 이메일 + 앱 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 기술 구현 로드맵
|
||||||
|
|
||||||
|
### Phase 1 — 즉시 가능 (데이터 이미 존재)
|
||||||
|
|
||||||
|
- [ ] 추천 이력 채점 통계 API (`GET /api/lotto/stats/performance`)
|
||||||
|
- [ ] 신뢰도 지표 UI (평균 일치 개수, 전국 평균 비교)
|
||||||
|
- [ ] 회차별 공략 리포트 API (`GET /api/lotto/report/{drw_no}`)
|
||||||
|
- [ ] 개인 추천 이력 성과 대시보드
|
||||||
|
|
||||||
|
### Phase 2 — 단기 (1-2주)
|
||||||
|
|
||||||
|
- [ ] `user_preferences` 테이블 설계 및 구현
|
||||||
|
- [ ] 개인 패턴 분석 API (`GET /api/lotto/analysis/personal`)
|
||||||
|
- [ ] 주간 리포트 자동 생성 스케줄러 (토요일 오전)
|
||||||
|
- [ ] 투자 추적 기능 (구매 금액 입력 → 수익률 계산)
|
||||||
|
- [ ] `purchase_history` 테이블 추가
|
||||||
|
|
||||||
|
### Phase 3 — 중기 (1개월)
|
||||||
|
|
||||||
|
- [ ] 회원 시스템 구축 (JWT 인증, SQLite `users` 테이블)
|
||||||
|
- [ ] 구독 플랜 관리 (`subscription_plans`, `user_subscriptions` 테이블)
|
||||||
|
- [ ] 결제 연동 (Toss Payments 또는 Stripe)
|
||||||
|
- [ ] 이메일 발송 자동화 (SendGrid)
|
||||||
|
- [ ] 소셜 증거 데이터 집계 API
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. DB 스키마 확장 계획
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Phase 2
|
||||||
|
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 DEFAULT 0, -- 당첨금
|
||||||
|
note TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_preferences (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
odd_ratio REAL, -- 홀수 선호 비율
|
||||||
|
high_ratio REAL, -- 고번호(23+) 선호 비율
|
||||||
|
consecutive INTEGER, -- 연속번호 포함 선호 여부
|
||||||
|
excluded_numbers TEXT, -- JSON 배열, 기피 번호
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Phase 3
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
plan TEXT DEFAULT 'free', -- free | standard | premium
|
||||||
|
plan_expires_at TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. API 확장 계획
|
||||||
|
|
||||||
|
| Phase | 메서드 | 경로 | 설명 |
|
||||||
|
|-------|--------|------|------|
|
||||||
|
| 1 | GET | `/api/lotto/stats/performance` | 추천 성과 통계 (평균 일치 수 등) |
|
||||||
|
| 1 | GET | `/api/lotto/report/latest` | 최신 회차 공략 리포트 |
|
||||||
|
| 1 | GET | `/api/lotto/report/{drw_no}` | 특정 회차 공략 리포트 |
|
||||||
|
| 2 | GET | `/api/lotto/purchase` | 구매 이력 조회 |
|
||||||
|
| 2 | POST | `/api/lotto/purchase` | 구매 이력 추가 |
|
||||||
|
| 2 | GET | `/api/lotto/purchase/stats` | 투자 수익률 통계 |
|
||||||
|
| 2 | GET | `/api/lotto/analysis/personal` | 개인 패턴 분석 |
|
||||||
|
| 3 | POST | `/api/auth/register` | 회원가입 |
|
||||||
|
| 3 | POST | `/api/auth/login` | 로그인 |
|
||||||
|
| 3 | GET | `/api/subscription/plans` | 구독 플랜 목록 |
|
||||||
|
| 3 | POST | `/api/subscription/checkout` | 결제 시작 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 참고
|
||||||
|
|
||||||
|
- 현재 운영 중인 lotto API: `CLAUDE.md` → `lotto-lab API 목록` 섹션 참고
|
||||||
|
- 채점 로직: `backend/app/checker.py`
|
||||||
|
- 시뮬레이션 로직: `backend/app/recommender.py`
|
||||||
|
- DB 스키마: `backend/app/db.py` `init_db()`
|
||||||
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 호출 일치.
|
||||||
3325
docs/superpowers/plans/2026-05-07-music-youtube-pipeline.md
Normal file
3325
docs/superpowers/plans/2026-05-07-music-youtube-pipeline.md
Normal file
File diff suppressed because it is too large
Load Diff
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,519 @@
|
|||||||
|
# Music YouTube 파이프라인 — 단계별 승인 자동화 설계
|
||||||
|
|
||||||
|
> 작성일: 2026-05-07
|
||||||
|
> 상태: 설계 승인 대기
|
||||||
|
> 관련 후속 작업: STATUS.md 2-3, 2-4
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 배경
|
||||||
|
|
||||||
|
현재 Music YouTube 탭에는 영상 제작 / 수익 추적 / 시장 트렌드 / 컴파일 4개 서브탭이 있고, music-lab 백엔드는 video_producer로 로컬 영상(MP4)까지 만들 수 있다. 그러나 **YouTube 자동 업로드와 AI 커버·메타데이터 자동 생성, AI 검토는 없다.** 트랙 생성부터 발행까지 한 편 완성하려면 매번 수동으로 영상 만들고 직접 YouTube Studio에 업로드해야 한다.
|
||||||
|
|
||||||
|
목표: **트랙을 골라 한 번 시작하면 단계별로 텔레그램 승인을 받으며 영상이 발행되는 파이프라인**을 구축한다. 사용자는 각 단계 산출물을 텔레그램에서 승인/반려할 수 있고, 반려 시 자연어 피드백으로 같은 단계가 재생성된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 비목표 (Out of scope)
|
||||||
|
|
||||||
|
- 가사 자막 영상 (synced lyrics → 영상) — 차후
|
||||||
|
- YouTube Shorts 전용 워크플로 (1080×1920) — 비주얼 기본값에 옵션만 두고, 실제 Shorts 최적화(60초 클립 추출 등)는 차후
|
||||||
|
- 멀티 채널 운영 — 단일 채널 OAuth 1행만 지원
|
||||||
|
- 비디오 편집기 UI — 트림/페이드 등은 컴파일 탭에 있고 본 파이프라인은 단일 트랙 1개 영상 가정
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 사용자 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
[사용자가 진행 시작]
|
||||||
|
Library 트랙 카드 → "🎬 영상 파이프라인" 또는 진행 탭 → "+ 새 파이프라인"
|
||||||
|
↓
|
||||||
|
step 2: AI 커버 아트 생성 → 텔레그램 알림 "커버 승인?"
|
||||||
|
step 3: 영상 비주얼 생성 (커버 + 음원) → 텔레그램 알림
|
||||||
|
step 4: 썸네일 생성 → 텔레그램 알림
|
||||||
|
step 5: 메타데이터 생성 → 텔레그램 알림
|
||||||
|
↓
|
||||||
|
AI 최종 검토 (자동, 4축 검사) → 텔레그램에 점수 + 발행 요청
|
||||||
|
↓
|
||||||
|
[사용자 발행 승인]
|
||||||
|
step 6: YouTube 업로드 (private/public 정책에 따라)
|
||||||
|
step 7: 발행 후 추적 시작 (수익 추적 탭에 표시)
|
||||||
|
```
|
||||||
|
|
||||||
|
각 단계 텔레그램 알림에 사용자가 자연어로 응답한다.
|
||||||
|
- 승인: "승인" / "시작" / "진행" / "OK" / "Agree" / "네" / "예" / "좋아"
|
||||||
|
- 반려: "반려" / "거절" / "취소" / "no" + 수정 방향 텍스트 (예: "썸네일 색 더 어둡게")
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ Frontend (web-ui) │
|
||||||
|
│ /lab/music → MusicStudio → YouTube 탭 │
|
||||||
|
│ ├─ 영상 제작 (기존) │
|
||||||
|
│ ├─ 수익 추적 (기존) │
|
||||||
|
│ ├─ 시장 트렌드 (기존) │
|
||||||
|
│ ├─ 컴파일 (기존) │
|
||||||
|
│ ├─ 진행 (NEW) ← 파이프라인 카드 보드 │
|
||||||
|
│ └─ 구성 (NEW) ← 설정 허브 │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
↓ /api/music/pipeline/* (REST)
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ music-lab (FastAPI, 18600) │
|
||||||
|
│ • 파이프라인 CRUD + 상태 머신 │
|
||||||
|
│ • AI 커버 (DALL·E 3) — 비동기 BackgroundTask │
|
||||||
|
│ • 영상 비주얼 (FFmpeg, 기존 video_producer 확장) │
|
||||||
|
│ • 썸네일 (FFmpeg + 텍스트 오버레이) │
|
||||||
|
│ • 메타데이터 생성 (Claude Haiku) │
|
||||||
|
│ • AI 최종 검토 (Claude Sonnet, 4축 가중) │
|
||||||
|
│ • YouTube 업로드 (google-api-python-client) │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
↑ poll (30s) / push 결과
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ agent-office (FastAPI + Telegram, 18900) │
|
||||||
|
│ • youtube_publisher 에이전트 (NEW) — 오케스트레이터 │
|
||||||
|
│ • 단계 *_pending 진입 감지 → 텔레그램 알림 발송 │
|
||||||
|
│ • 텔레그램 reply 자연어 의도 분류 (Claude or 화이트리스트) │
|
||||||
|
│ • music-lab /feedback 호출 → 다음 단계 또는 재생성 │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**책임 경계**:
|
||||||
|
- **music-lab**: 무엇을 만들지 안다. 산출물 생성·저장·상태 전이.
|
||||||
|
- **agent-office**: 언제 다음으로 넘길지 결정. 텔레그램 단일 채널 인터페이스.
|
||||||
|
- **frontend**: 진행 상태 조회 + 사용자 트리거(시작/취소/수동 발행).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 상태 머신
|
||||||
|
|
||||||
|
```
|
||||||
|
created
|
||||||
|
→ cover_pending (자동 생성 후 진입)
|
||||||
|
→ cover_approved (승인)
|
||||||
|
→ video_pending
|
||||||
|
→ video_approved
|
||||||
|
→ thumb_pending
|
||||||
|
→ thumb_approved
|
||||||
|
→ meta_pending
|
||||||
|
→ meta_approved
|
||||||
|
→ ai_review (자동, 사용자 액션 X)
|
||||||
|
→ publish_pending (검토 결과 + 발행 요청 텔레그램)
|
||||||
|
→ publishing (업로드 중)
|
||||||
|
→ published (완료)
|
||||||
|
|
||||||
|
어디서나:
|
||||||
|
→ cancelled (사용자 취소)
|
||||||
|
→ failed (복구 불가 오류)
|
||||||
|
→ awaiting_manual (재생성 5회 한도 초과)
|
||||||
|
```
|
||||||
|
|
||||||
|
각 `*_pending` 진입 시 → 텔레그램 알림.
|
||||||
|
각 `*_approved` 진입 시 → 다음 단계 BackgroundTask 시작.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 프론트엔드 상세
|
||||||
|
|
||||||
|
### 6-1. 새 탭 — 구성 (`SetupTab.jsx`)
|
||||||
|
|
||||||
|
세로 카드 형식, 카드별 저장 버튼:
|
||||||
|
|
||||||
|
| 카드 | 필드 |
|
||||||
|
|------|------|
|
||||||
|
| YouTube 채널 연동 | OAuth 시작 → Google 인증 → 채널명·아바타 표시. 재인증 / 연결 해제 |
|
||||||
|
| Telegram 알림 채널 | 현재 chat_id (read-only, ENV 출처). 테스트 메시지 발송 |
|
||||||
|
| 메타데이터 템플릿 | 제목 패턴 (`[{genre}] {title} \| {bpm}BPM Lo-fi Mix` 등), 설명 multiline, 태그 CSV, 카테고리 |
|
||||||
|
| AI 커버 아트 prompt | 장르별 prompt 템플릿 (lo-fi/phonk/ambient/pop/...) 추가/편집/삭제 |
|
||||||
|
| AI 최종 검토 기준 | 4축 가중치 슬라이더 + pass score 임계값 (기본 60) |
|
||||||
|
| 영상 비주얼 기본값 | 해상도 (1920×1080 / 1080×1920), 스타일 (visualizer/슬라이드쇼), 배경 (AI 커버/그라데이션) |
|
||||||
|
| 발행 정책 | 즉시 / 예약 시간대 / privacy (private 우선) |
|
||||||
|
|
||||||
|
### 6-2. 새 탭 — 진행 (`PipelineTab.jsx`)
|
||||||
|
|
||||||
|
**상단**: "+ 새 파이프라인 시작" 버튼 → Library 트랙 선택 모달.
|
||||||
|
|
||||||
|
**카드 그리드** — 진행 중 + 완료/실패/취소 (필터 토글):
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Track Title (genre · BPM) ───────────── [Cancel] ─┐
|
||||||
|
│ ●━━━━━●━━━━━●━━━━━○━━━━━○━━━━━○ (6단계 진행 바) │
|
||||||
|
│ 커버 영상 썸네 메타 검토 발행 │
|
||||||
|
│ │
|
||||||
|
│ 현재: [메타데이터 승인 대기] │
|
||||||
|
│ 텔레그램에 알림 보냄 — 12분 전 │
|
||||||
|
│ │
|
||||||
|
│ [최근 산출물 미리보기] │
|
||||||
|
│ • 메타: "[Lo-fi] Midnight Drive | 85BPM..." │
|
||||||
|
│ • 썸네일: ▭ │
|
||||||
|
│ │
|
||||||
|
│ 📜 피드백 히스토리 │
|
||||||
|
│ • "썸네일 색이 너무 어두워" → 재생성 (5분 전) │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**상태 시각**:
|
||||||
|
- `running` — 스피너 + "처리 중..."
|
||||||
|
- `awaiting_approval` — 점멸 도트 + "텔레그램 응답 대기"
|
||||||
|
- `regenerating` — 회전 화살표 + "피드백 반영 중"
|
||||||
|
- `completed` — 체크 + YouTube 링크
|
||||||
|
- `failed` / `awaiting_manual` — 빨간 배지 + 사유
|
||||||
|
|
||||||
|
**폴링**: 카드 보일 때 5초 간격 `GET /api/music/pipeline?status=active`.
|
||||||
|
|
||||||
|
### 6-3. 영상 제작 탭 (기존)
|
||||||
|
|
||||||
|
그대로 유지. footer에 "💡 단계별 자동화는 진행 탭에서" 1줄 안내.
|
||||||
|
|
||||||
|
### 6-4. Library 카드 변경
|
||||||
|
|
||||||
|
기존 액션 옆에 "🎬 영상 파이프라인" 버튼 추가 → 클릭 시 신규 파이프라인 생성 후 진행 탭 이동.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 백엔드 상세
|
||||||
|
|
||||||
|
### 7-1. music-lab 신규 모듈
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `app/pipeline/state_machine.py` | 상태 전이 + 검증 |
|
||||||
|
| `app/pipeline/orchestrator.py` | `start_step(pipeline_id, step)` — BackgroundTask 등록 |
|
||||||
|
| `app/pipeline/cover.py` | DALL·E 3 호출 + 폴백 |
|
||||||
|
| `app/pipeline/metadata.py` | Claude Haiku 호출 + 템플릿 치환 |
|
||||||
|
| `app/pipeline/review.py` | Claude Sonnet 4축 검토 + 가중평균 |
|
||||||
|
| `app/pipeline/youtube.py` | OAuth + 업로드 (google-api-python-client) |
|
||||||
|
| `app/pipeline/storage.py` | `/data/videos/{id}/` 산출물 관리 |
|
||||||
|
|
||||||
|
기존 `app/video_producer.py`는 `app/pipeline/video.py`로 이동 + 슬라이드쇼 입력으로 AI 커버 사용 옵션 추가.
|
||||||
|
|
||||||
|
### 7-2. agent-office 신규/변경
|
||||||
|
|
||||||
|
| 파일 | 변경 |
|
||||||
|
|------|------|
|
||||||
|
| `app/agents/youtube_publisher.py` | NEW — 오케스트레이터 |
|
||||||
|
| `app/scheduler.py` | 30초 간격 `_poll_pipelines` 잡 추가 |
|
||||||
|
| `app/telegram/conversational.py` | reply 매칭 + youtube_publisher로 라우팅 |
|
||||||
|
| `app/service_proxy.py` | music-lab pipeline 호출 헬퍼 추가 |
|
||||||
|
|
||||||
|
`youtube_publisher`:
|
||||||
|
- `poll_state_changes()` — music-lab `/api/music/pipeline?status=active` 폴링, `*_pending` 신규 진입 시 텔레그램 발송. 멱등 처리(메시지 ID 저장).
|
||||||
|
- `on_telegram_reply(message)` — `reply_to_message_id`로 pipeline 매칭, 자연어 분류 → `/feedback` 호출.
|
||||||
|
|
||||||
|
### 7-3. 자연어 의도 분류
|
||||||
|
|
||||||
|
```python
|
||||||
|
APPROVE_WORDS = {"승인", "시작", "진행", "ok", "okay", "agree", "네", "예", "좋아", "go"}
|
||||||
|
REJECT_WORDS = {"반려", "거절", "취소", "no", "nope"}
|
||||||
|
|
||||||
|
def classify_intent(text: str) -> tuple[str, str | None]:
|
||||||
|
t = text.strip().lower()
|
||||||
|
# 1. 명확한 단어만 — LLM 우회
|
||||||
|
if t in APPROVE_WORDS:
|
||||||
|
return ("approve", None)
|
||||||
|
if t in REJECT_WORDS:
|
||||||
|
return ("reject", None)
|
||||||
|
# 2. 반려 단어 + 추가 텍스트 — 단순 분리
|
||||||
|
for w in REJECT_WORDS:
|
||||||
|
if t.startswith(w):
|
||||||
|
return ("reject", text[len(w):].strip(" ,.-:"))
|
||||||
|
# 3. 모호한 경우 — Claude Haiku 호출
|
||||||
|
return _llm_classify(text)
|
||||||
|
```
|
||||||
|
|
||||||
|
LLM 분류 응답 (JSON):
|
||||||
|
```json
|
||||||
|
{"intent": "approve|reject|unclear", "feedback": "..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
`unclear` → 텔레그램에 "다시 입력해주세요. 예: '승인' 또는 '제목을 짧게'" 안내 + 같은 상태 유지.
|
||||||
|
|
||||||
|
### 7-4. AI 최종 검토 (4축)
|
||||||
|
|
||||||
|
`meta_approved` 직후 자동 진행. Claude Sonnet 1회 호출.
|
||||||
|
|
||||||
|
입력:
|
||||||
|
- 트랙 정보 (title, genre, BPM, key, scale, moods, instruments)
|
||||||
|
- 영상 정보 (length, resolution, style)
|
||||||
|
- 메타데이터 (title, description, tags, category)
|
||||||
|
- 썸네일 URL
|
||||||
|
- 트렌드 데이터 (`market_trends` top 10)
|
||||||
|
|
||||||
|
출력 JSON:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"metadata_quality": {"score": 0-100, "notes": "..."},
|
||||||
|
"policy_compliance": {"score": 0-100, "issues": []},
|
||||||
|
"viewer_experience": {"score": 0-100, "notes": "..."},
|
||||||
|
"trend_alignment": {"score": 0-100, "matched_keywords": []},
|
||||||
|
"weighted_total": 0-100,
|
||||||
|
"verdict": "pass" | "fail",
|
||||||
|
"summary": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**가중치 (기본, 구성 탭에서 조정 가능)**:
|
||||||
|
- 메타데이터 품질 25
|
||||||
|
- 콘텐츠 정책 30
|
||||||
|
- 시청 경험 25
|
||||||
|
- 트렌드 정렬 20
|
||||||
|
|
||||||
|
**임계값 60 미만 → `fail`**. 텔레그램 메시지에 "강제 발행" / "메타로 돌아가 재검토" 안내.
|
||||||
|
|
||||||
|
### 7-5. AI 커버 아트
|
||||||
|
|
||||||
|
- 모델: OpenAI `gpt-image-1` (DALL·E 3 후속)
|
||||||
|
- 해상도: 1024×1024
|
||||||
|
- 환경변수: `OPENAI_API_KEY`
|
||||||
|
- 비용: 1024×1024 standard ≈ $0.04/장 (단계당 최대 5회 = $0.20)
|
||||||
|
- 폴백: 그라데이션 (`GENRE_COLORS`) + 트랙 제목 텍스트 오버레이
|
||||||
|
|
||||||
|
prompt 빌더 (구성 탭의 장르별 템플릿 사용):
|
||||||
|
```
|
||||||
|
{genre_template}, {mood_descriptor}, no text, high quality
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7-6. 메타데이터 자동 생성
|
||||||
|
|
||||||
|
- 모델: Claude Haiku
|
||||||
|
- 호출 시점: `meta_pending` 진입 시 (커버 승인 후 미리 생성하지 않음)
|
||||||
|
- 입력: 트랙 정보 + 구성 탭 메타 템플릿 + 트렌드 키워드
|
||||||
|
- 출력: title (60자 이내), description (3-5문단, 1000자 이내), tags (15개 이내), category_id
|
||||||
|
|
||||||
|
### 7-7. YouTube 업로드
|
||||||
|
|
||||||
|
- 라이브러리: `google-api-python-client` + `google-auth-oauthlib`
|
||||||
|
- OAuth flow: Authorization Code → refresh_token 저장 (`youtube_oauth_tokens` 테이블)
|
||||||
|
- 업로드 시 access_token 갱신 → resumable upload
|
||||||
|
- Privacy: 구성 탭 정책 (private/unlisted/public)
|
||||||
|
- 카테고리: 메타데이터의 category_id (기본 10 = Music)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 데이터 모델
|
||||||
|
|
||||||
|
### 8-1. 신규 테이블 (music-lab `db.py`)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE video_pipelines (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
track_id INTEGER NOT NULL,
|
||||||
|
state TEXT NOT NULL,
|
||||||
|
state_started_at TEXT NOT NULL,
|
||||||
|
cover_url TEXT,
|
||||||
|
video_url TEXT,
|
||||||
|
thumbnail_url TEXT,
|
||||||
|
metadata_json TEXT,
|
||||||
|
review_json TEXT,
|
||||||
|
youtube_video_id TEXT,
|
||||||
|
feedback_count_per_step TEXT NOT NULL DEFAULT '{}',
|
||||||
|
last_telegram_msg_ids TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
cancelled_at TEXT,
|
||||||
|
failed_reason TEXT,
|
||||||
|
FOREIGN KEY (track_id) REFERENCES tracks(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE pipeline_jobs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
pipeline_id INTEGER NOT NULL,
|
||||||
|
step TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
error TEXT,
|
||||||
|
started_at TEXT,
|
||||||
|
finished_at TEXT,
|
||||||
|
duration_ms INTEGER,
|
||||||
|
FOREIGN KEY (pipeline_id) REFERENCES video_pipelines(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE pipeline_feedback (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
pipeline_id INTEGER NOT NULL,
|
||||||
|
step TEXT NOT NULL,
|
||||||
|
feedback_text TEXT NOT NULL,
|
||||||
|
received_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (pipeline_id) REFERENCES video_pipelines(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE youtube_oauth_tokens (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
channel_id TEXT NOT NULL,
|
||||||
|
channel_title TEXT,
|
||||||
|
avatar_url TEXT,
|
||||||
|
refresh_token TEXT NOT NULL,
|
||||||
|
access_token TEXT,
|
||||||
|
expires_at TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE youtube_setup (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
metadata_template_json TEXT NOT NULL,
|
||||||
|
cover_prompts_json TEXT NOT NULL,
|
||||||
|
review_weights_json TEXT NOT NULL,
|
||||||
|
review_threshold INTEGER NOT NULL DEFAULT 60,
|
||||||
|
visual_defaults_json TEXT NOT NULL,
|
||||||
|
publish_policy_json TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8-2. 산출물 저장 경로
|
||||||
|
|
||||||
|
```
|
||||||
|
/data/videos/{pipeline_id}/
|
||||||
|
├─ cover.jpg (AI 또는 폴백)
|
||||||
|
├─ video.mp4 (FFmpeg 결과)
|
||||||
|
├─ thumbnail.jpg
|
||||||
|
└─ logs/ (FFmpeg/upload 로그)
|
||||||
|
```
|
||||||
|
|
||||||
|
노출 URL: `/media/videos/{pipeline_id}/<file>` (nginx 정적 서빙).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. API 엔드포인트
|
||||||
|
|
||||||
|
### 9-1. music-lab 신규
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 용도 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/music/pipeline` | 파이프라인 목록 (`?status=active|all`) |
|
||||||
|
| GET | `/api/music/pipeline/{id}` | 단건 + jobs + feedback |
|
||||||
|
| POST | `/api/music/pipeline` | 신규 (body: `{track_id}`) |
|
||||||
|
| POST | `/api/music/pipeline/{id}/start` | 첫 단계 시작 → 202 |
|
||||||
|
| POST | `/api/music/pipeline/{id}/feedback` | 승인/반려 (body: `{step, intent, feedback_text?}`) |
|
||||||
|
| POST | `/api/music/pipeline/{id}/cancel` | 취소 |
|
||||||
|
| POST | `/api/music/pipeline/{id}/publish` | 검토 후 업로드 트리거 |
|
||||||
|
| GET | `/api/music/setup` | 구성 조회 |
|
||||||
|
| PUT | `/api/music/setup` | 구성 저장 |
|
||||||
|
| GET | `/api/music/youtube/auth-url` | OAuth 시작 URL |
|
||||||
|
| GET | `/api/music/youtube/callback` | OAuth callback |
|
||||||
|
| POST | `/api/music/youtube/disconnect` | 연결 해제 |
|
||||||
|
| GET | `/api/music/youtube/status` | 연결 상태 |
|
||||||
|
|
||||||
|
모든 생성/처리 엔드포인트는 **즉시 202 + job_id 반환**, BackgroundTask로 처리. 프론트는 `GET /api/music/pipeline/{id}`로 폴링.
|
||||||
|
|
||||||
|
### 9-2. 멱등성
|
||||||
|
|
||||||
|
- `/feedback`은 동일 `(pipeline_id, step, intent)` 중복 호출 시 무시 (이미 다음 상태로 넘어간 경우 텔레그램 reply 지연 방지)
|
||||||
|
- 텔레그램 메시지 ID 저장으로 동일 메시지 중복 처리 방지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 비동기 처리 + 폴백
|
||||||
|
|
||||||
|
**원칙**: 모든 AI/생성 작업은 `BackgroundTasks` + DB job 상태로 처리. 호출 즉시 202, 폴링으로 결과 확인. **사용자 경험: 어떻게든 다음 단계로 보낸다, 단 폴백 사용 시 텔레그램에 명시.**
|
||||||
|
|
||||||
|
| 작업 | 타임아웃 | 폴백 |
|
||||||
|
|------|---------|------|
|
||||||
|
| DALL·E 3 | 90초 | 그라데이션 + 텍스트 오버레이 |
|
||||||
|
| Claude Haiku (메타) | 30초 | 템플릿 변수 그대로 치환 |
|
||||||
|
| Claude Sonnet (검토) | 60초 | 휴리스틱만 (정책 단어 매치 + 길이 체크) |
|
||||||
|
| FFmpeg | 5분 | `failed` + 텔레그램 알림 |
|
||||||
|
| YouTube upload | 10분 | 재시도 3회 → `failed` |
|
||||||
|
|
||||||
|
각 BackgroundTask는 `pipeline_jobs`에 `running → succeeded/failed` 기록. 진행 탭은 이 정보로 카드 진행도 표시.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 에러 처리 매트릭스
|
||||||
|
|
||||||
|
| 시나리오 | 동작 |
|
||||||
|
|---------|------|
|
||||||
|
| OAuth refresh 실패 | 발행 단계 `failed` + 텔레그램 "재인증 필요" + 구성 탭 빨간 배지 |
|
||||||
|
| DALL·E timeout | 폴백(그라데이션) + 텔레그램 "AI 폴백 사용됨" |
|
||||||
|
| Claude timeout | 폴백(템플릿/휴리스틱) + 동일 표기 |
|
||||||
|
| FFmpeg 실패 | `failed` + 텔레그램 "수동 점검 필요" + task_id |
|
||||||
|
| YouTube quota | 24시간 후 자동 재시도 1회 → 그래도 실패 시 `failed` |
|
||||||
|
| 텔레그램 reply 의도 `unclear` | 안내 메시지 + 같은 상태 유지 |
|
||||||
|
| 재생성 5회 초과 | `awaiting_manual` + 텔레그램 안내 |
|
||||||
|
| 동일 트랙 파이프라인 중복 | 409 Conflict |
|
||||||
|
| 트랙 삭제됨 | 파이프라인 보존, 재생성 불가, 진행 탭 "트랙 누락" 배지 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 보안 / 비밀
|
||||||
|
|
||||||
|
- OAuth refresh_token: SQLite에 평문(현재 패턴) — 향후 Fernet 암호화 또는 OS keystore 검토. 기본은 컨테이너 파일 권한 600 + DB 읽기 deny (이미 settings.json에 `Read(**/*.db)` 차단 추가됨)
|
||||||
|
- `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `YOUTUBE_OAUTH_CLIENT_ID/SECRET`: docker-compose env로 주입
|
||||||
|
- 구성 탭은 인증 게이트 없음(개인 사이트 가정) — 향후 admin 게이트 필요시 personal 서비스의 `/api/profile/auth` 패턴 적용
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 테스트 전략
|
||||||
|
|
||||||
|
### 13-1. 단위 테스트 (music-lab)
|
||||||
|
|
||||||
|
| 대상 | 테스트 |
|
||||||
|
|------|--------|
|
||||||
|
| `state_machine` | 정상 전이 / 잘못된 전이 거부 |
|
||||||
|
| `feedback_handler` | approve → 다음 / reject → 동일 + feedback 저장 / 5회 초과 → awaiting_manual |
|
||||||
|
| `cover.generate` | DALL·E mock 성공/timeout/오류 → 폴백 |
|
||||||
|
| `metadata.generate` | Claude mock + 템플릿 치환 |
|
||||||
|
| `review.run_4_axis` | 4축 점수 계산 + 가중평균 + verdict 임계값(60) |
|
||||||
|
| `youtube_upload.upload` | google-api mock + 재시도 + quota 분기 |
|
||||||
|
| OAuth | code → refresh_token, refresh 만료 시 재인증 트리거 |
|
||||||
|
|
||||||
|
`pytest` + `httpx_mock` + `freezegun`. 기존 music-lab 테스트 컨벤션 준수.
|
||||||
|
|
||||||
|
### 13-2. 단위 테스트 (agent-office)
|
||||||
|
|
||||||
|
| 대상 | 테스트 |
|
||||||
|
|------|--------|
|
||||||
|
| `classify_intent` | 화이트리스트 → LLM 미호출, 반려 단어 + 텍스트 → 분리, 모호 → LLM 호출 검증 |
|
||||||
|
| `_poll_pipelines` | state 변경 → 텔레그램 1회만(멱등) |
|
||||||
|
| reply 매칭 | message_id로 정확한 pipeline_id 매칭 |
|
||||||
|
|
||||||
|
### 13-3. 통합 테스트
|
||||||
|
|
||||||
|
`tests/test_pipeline_flow.py`:
|
||||||
|
- 전체 흐름 1회: track → pipeline → 모든 단계 mock 승인 → published
|
||||||
|
- 반려 분기: cover에서 reject + feedback → 같은 단계 재생성 → 승인 → 다음 단계
|
||||||
|
|
||||||
|
### 13-4. 프론트엔드 테스트
|
||||||
|
|
||||||
|
- `SetupTab` 폼 저장: 단순 단위 테스트 (API 인자 검증)
|
||||||
|
- `PipelineTab` 카드 렌더링: 상태별 시각 — 빌드 + 수동 브라우저 확인
|
||||||
|
- 폴링 로직: mock fetch + setInterval
|
||||||
|
|
||||||
|
기존 web-ui 패턴 (vitest 등 별도 러너 없음) 유지.
|
||||||
|
|
||||||
|
### 13-5. 수동 E2E 체크리스트 (출시 전)
|
||||||
|
|
||||||
|
- [ ] OAuth 인증 → 구성 탭 채널명 표시
|
||||||
|
- [ ] 트랙 → 파이프라인 시작 → 텔레그램 "커버 승인" 알림
|
||||||
|
- [ ] "승인" 답장 → 다음 단계 진행
|
||||||
|
- [ ] "썸네일 색 어둡게" 답장 → 재생성 → 알림 재도착
|
||||||
|
- [ ] AI 최종 검토 4축 점수 표시
|
||||||
|
- [ ] 발행 승인 → YouTube 업로드 (private) → URL 수신
|
||||||
|
- [ ] 24시간 후 수익 추적 탭에 신규 영상 표시
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 마이그레이션 / 환경
|
||||||
|
|
||||||
|
- 신규 환경변수: `OPENAI_API_KEY`, `YOUTUBE_OAUTH_CLIENT_ID`, `YOUTUBE_OAUTH_CLIENT_SECRET`, `YOUTUBE_OAUTH_REDIRECT_URI`
|
||||||
|
- music-lab Dockerfile: `google-api-python-client`, `google-auth-oauthlib`, `openai` 추가
|
||||||
|
- 기존 music.db 마이그레이션: `init_db()`에 신규 테이블 5개 `CREATE IF NOT EXISTS` 추가
|
||||||
|
- nginx 설정: `/api/music/youtube/callback` 외부 노출 필요 (OAuth redirect)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 산출물 / 후속
|
||||||
|
|
||||||
|
본 스펙은 다음 산출물을 가진다:
|
||||||
|
- music-lab: pipeline 모듈, OAuth, 5개 테이블, 12개 엔드포인트
|
||||||
|
- agent-office: youtube_publisher 에이전트, scheduler 폴링 잡, 자연어 분류기
|
||||||
|
- web-ui: SetupTab, PipelineTab, Library 카드 트리거 버튼
|
||||||
|
- 통합/단위 테스트, 수동 E2E 체크리스트
|
||||||
|
|
||||||
|
후속(이 스펙 외):
|
||||||
|
- Shorts 전용 파이프라인 (60초 클립 추출 + 1080×1920)
|
||||||
|
- 가사 자막 영상 (synced lyrics 영상화)
|
||||||
|
- 멀티 채널 운영
|
||||||
|
- 검토 임계값/가중치 학습 (실제 발행 후 성과 데이터 기반 자동 튜닝)
|
||||||
6
lotto/.dockerignore
Normal file
6
lotto/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.git
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.md
|
||||||
22
lotto/Dockerfile
Normal file
22
lotto/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY app/requirements.txt /app/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||||
|
|
||||||
|
COPY app /app/app
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|
||||||
|
ARG APP_VERSION=dev
|
||||||
|
ENV APP_VERSION=$APP_VERSION
|
||||||
|
LABEL org.opencontainers.image.version=$APP_VERSION
|
||||||
655
lotto/app/analyzer.py
Normal file
655
lotto/app/analyzer.py
Normal file
@@ -0,0 +1,655 @@
|
|||||||
|
"""
|
||||||
|
통계 분석 엔진 - lotto-lab 고도화
|
||||||
|
|
||||||
|
[팀 회의 합의 기반 5가지 통계 기법]
|
||||||
|
1. 빈도 Z-score 분석: 각 번호의 출현 빈도가 기댓값에서 얼마나 벗어났는지
|
||||||
|
2. 조합 지문(Fingerprint): 조합의 합계, 홀짝 비율, 구간 분포가 역대 당첨번호와 유사한지
|
||||||
|
3. 갭 분석(Gap): 각 번호의 마지막 출현으로부터 경과 회차 수 기반 점수
|
||||||
|
4. 공동 출현 행렬(Co-occurrence): 번호 쌍이 역대에 함께 나온 빈도 기반 점수
|
||||||
|
5. 다양성(Diversity): 연속 번호, 범위, 구간 분포 다양성
|
||||||
|
|
||||||
|
[통계 근거]
|
||||||
|
- 1~45번 각각의 이론적 출현 확률: 6/45 ≈ 13.33% per draw
|
||||||
|
- 기댓값 합계: E[sum] = 6 × E[1..45] = 6 × 23 = 138
|
||||||
|
- 표준편차 합계: std ≈ sqrt(6 × Var[uniform 1..45]) ≈ 31
|
||||||
|
- 홀수 23개 (1,3,...,45), 짝수 22개 (2,4,...,44)
|
||||||
|
- 번호 쌍 공동 출현 확률: C(43,4)/C(45,6) ≈ 1.516% per draw
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import List, Tuple, Dict, Any, Optional
|
||||||
|
|
||||||
|
# 구간 정의: (시작, 끝) 포함
|
||||||
|
ZONE_RANGES: List[Tuple[int, int]] = [
|
||||||
|
(1, 9),
|
||||||
|
(10, 19),
|
||||||
|
(20, 29),
|
||||||
|
(30, 39),
|
||||||
|
(40, 45),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_zone(n: int) -> int:
|
||||||
|
"""번호가 속하는 구간 인덱스 (0-4)"""
|
||||||
|
for z, (lo, hi) in enumerate(ZONE_RANGES):
|
||||||
|
if lo <= n <= hi:
|
||||||
|
return z
|
||||||
|
return 4
|
||||||
|
|
||||||
|
|
||||||
|
def build_analysis_cache(draws: List[Tuple[int, List[int]]]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
역대 당첨번호 데이터 기반 통계 분석 캐시 구성.
|
||||||
|
시뮬레이션 실행 시 한 번만 호출하여 재사용 (성능 최적화).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
draws: [(drw_no, [n1,n2,n3,n4,n5,n6]), ...] 오름차순
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
통계 캐시 딕셔너리
|
||||||
|
"""
|
||||||
|
if not draws:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
total_draws = len(draws)
|
||||||
|
all_nums_list = [n for _, nums in draws for n in nums]
|
||||||
|
freq_all = Counter(all_nums_list)
|
||||||
|
|
||||||
|
# ── 1. 빈도 Z-score ──────────────────────────────────────────────────────
|
||||||
|
freq_values = [freq_all.get(n, 0) for n in range(1, 46)]
|
||||||
|
mean_freq = sum(freq_values) / 45.0
|
||||||
|
variance_freq = sum((f - mean_freq) ** 2 for f in freq_values) / 45.0
|
||||||
|
std_freq = math.sqrt(variance_freq)
|
||||||
|
|
||||||
|
z_scores: Dict[int, float] = {}
|
||||||
|
for n in range(1, 46):
|
||||||
|
z_scores[n] = (freq_all.get(n, 0) - mean_freq) / max(std_freq, 0.001)
|
||||||
|
|
||||||
|
# ── 2. 갭 분석: 마지막 출현 이후 경과 회차 ──────────────────────────────
|
||||||
|
# gap = 0: 가장 최근 회차에 출현, gap = k: k회 전에 마지막 출현
|
||||||
|
last_seen_gap: Dict[int, int] = {}
|
||||||
|
for gap_idx, (_, nums) in enumerate(reversed(draws)):
|
||||||
|
for n in nums:
|
||||||
|
if n not in last_seen_gap:
|
||||||
|
last_seen_gap[n] = gap_idx
|
||||||
|
for n in range(1, 46):
|
||||||
|
if n not in last_seen_gap:
|
||||||
|
last_seen_gap[n] = total_draws # 한 번도 안 나옴 (이론상 거의 불가)
|
||||||
|
|
||||||
|
# ── 3. 공동 출현 행렬 ────────────────────────────────────────────────────
|
||||||
|
# cooccur[(i,j)] = 번호 i와 j가 같은 회차에 함께 출현한 횟수 (i < j)
|
||||||
|
cooccur: Dict[Tuple[int, int], int] = defaultdict(int)
|
||||||
|
for _, nums in draws:
|
||||||
|
s = sorted(nums)
|
||||||
|
for i in range(len(s)):
|
||||||
|
for j in range(i + 1, len(s)):
|
||||||
|
cooccur[(s[i], s[j])] += 1
|
||||||
|
|
||||||
|
# 번호 쌍 공동 출현 기댓값: C(43,4)/C(45,6) × total_draws
|
||||||
|
# C(43,4) = 123,410 / C(45,6) = 8,145,060
|
||||||
|
expected_cooccur = total_draws * 123410.0 / 8145060.0
|
||||||
|
|
||||||
|
# ── 4. 역대 조합 통계 (합계, 홀수 개수) ──────────────────────────────────
|
||||||
|
historical_sums = [sum(nums) for _, nums in draws]
|
||||||
|
mean_sum = sum(historical_sums) / total_draws
|
||||||
|
std_sum = math.sqrt(
|
||||||
|
sum((s - mean_sum) ** 2 for s in historical_sums) / total_draws
|
||||||
|
)
|
||||||
|
std_sum = max(std_sum, 1.0) # 0 나누기 방지
|
||||||
|
|
||||||
|
historical_odds = [sum(1 for n in nums if n % 2 == 1) for _, nums in draws]
|
||||||
|
odd_dist = Counter(historical_odds)
|
||||||
|
odd_prob: Dict[int, float] = {k: v / total_draws for k, v in odd_dist.items()}
|
||||||
|
max_odd_prob = max(odd_prob.values()) if odd_prob else 1.0
|
||||||
|
|
||||||
|
# ── 5. 구간별 분포 통계 ───────────────────────────────────────────────────
|
||||||
|
# 각 구간에 몇 개 포함되는지의 역대 분포
|
||||||
|
zone_counts = [Counter() for _ in ZONE_RANGES]
|
||||||
|
for _, nums in draws:
|
||||||
|
for z_idx, (lo, hi) in enumerate(ZONE_RANGES):
|
||||||
|
cnt = sum(1 for n in nums if lo <= n <= hi)
|
||||||
|
zone_counts[z_idx][cnt] += 1
|
||||||
|
|
||||||
|
zone_probs: List[Dict[int, float]] = []
|
||||||
|
for zc in zone_counts:
|
||||||
|
total_z = sum(zc.values())
|
||||||
|
zone_probs.append({k: v / total_z for k, v in zc.items()})
|
||||||
|
|
||||||
|
max_zone_probs = [max(zp.values()) if zp else 1.0 for zp in zone_probs]
|
||||||
|
|
||||||
|
# ── 6. 최근 빈도 (후보 생성 가중치용) ────────────────────────────────────
|
||||||
|
recent_100 = draws[-100:] if len(draws) >= 100 else draws
|
||||||
|
freq_recent = Counter(n for _, nums in recent_100 for n in nums)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_draws": total_draws,
|
||||||
|
"freq_all": freq_all,
|
||||||
|
"z_scores": z_scores,
|
||||||
|
"last_seen_gap": last_seen_gap,
|
||||||
|
"cooccur": dict(cooccur),
|
||||||
|
"expected_cooccur": expected_cooccur,
|
||||||
|
"mean_sum": mean_sum,
|
||||||
|
"std_sum": std_sum,
|
||||||
|
"odd_prob": odd_prob,
|
||||||
|
"max_odd_prob": max_odd_prob,
|
||||||
|
"zone_probs": zone_probs,
|
||||||
|
"max_zone_probs": max_zone_probs,
|
||||||
|
"freq_recent": freq_recent,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_number_weights(cache: Dict[str, Any]) -> Dict[int, float]:
|
||||||
|
"""
|
||||||
|
몬테카를로 시뮬레이션의 후보 생성에 사용할 번호별 샘플링 가중치.
|
||||||
|
빈도 + 최근 빈도 + 갭 분석을 반영하여 '좋은' 번호가 더 자주 선택되도록 유도.
|
||||||
|
"""
|
||||||
|
freq_all = cache["freq_all"]
|
||||||
|
last_seen_gap = cache["last_seen_gap"]
|
||||||
|
freq_recent = cache["freq_recent"]
|
||||||
|
|
||||||
|
weights: Dict[int, float] = {}
|
||||||
|
for n in range(1, 46):
|
||||||
|
w = freq_all.get(n, 0) + 1.5 * freq_recent.get(n, 0)
|
||||||
|
|
||||||
|
gap = last_seen_gap.get(n, 0)
|
||||||
|
if gap <= 1:
|
||||||
|
gap_factor = 0.50 # 바로 직전 등장 → 패널티
|
||||||
|
elif gap <= 3:
|
||||||
|
gap_factor = 0.75
|
||||||
|
elif gap <= 12:
|
||||||
|
gap_factor = 1.00 # 적정 범위
|
||||||
|
elif gap <= 25:
|
||||||
|
gap_factor = 1.10 # 약간 오래된 번호 → 소폭 보너스
|
||||||
|
else:
|
||||||
|
gap_factor = 1.20 # 오래된 번호 → 보너스
|
||||||
|
|
||||||
|
weights[n] = max(w * gap_factor, 0.5)
|
||||||
|
|
||||||
|
return weights
|
||||||
|
|
||||||
|
|
||||||
|
def score_combination(numbers: List[int], cache: Dict[str, Any]) -> Dict[str, float]:
|
||||||
|
"""
|
||||||
|
6개 번호 조합의 통계적 품질 점수 계산 (0~1 범위 정규화).
|
||||||
|
|
||||||
|
5가지 기법별 점수:
|
||||||
|
- score_frequency (25%): 빈도 Z-score
|
||||||
|
- score_fingerprint(30%): 조합의 통계적 지문 (합계, 홀짝, 구간)
|
||||||
|
- score_gap (20%): 갭 분석
|
||||||
|
- score_cooccur (15%): 공동 출현 기댓값 대비
|
||||||
|
- score_diversity (10%): 연속번호, 범위, 구간 다양성
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"score_total": ..., "score_frequency": ..., ...}
|
||||||
|
"""
|
||||||
|
nums = sorted(numbers)
|
||||||
|
|
||||||
|
# ── 1. 빈도 점수 (Frequency Score) ────────────────────────────────────────
|
||||||
|
z_scores = cache["z_scores"]
|
||||||
|
avg_z = sum(z_scores.get(n, 0.0) for n in nums) / 6.0
|
||||||
|
# Sigmoid 정규화: avg_z > 0이면 0.5 이상
|
||||||
|
score_frequency = 1.0 / (1.0 + math.exp(-avg_z / 1.5))
|
||||||
|
|
||||||
|
# ── 2. 조합 지문 점수 (Fingerprint Score) ─────────────────────────────────
|
||||||
|
# 2a. 합계 정규분포 점수
|
||||||
|
total = sum(nums)
|
||||||
|
mean_sum = cache["mean_sum"]
|
||||||
|
std_sum = cache["std_sum"]
|
||||||
|
z_sum = (total - mean_sum) / std_sum
|
||||||
|
sum_score = math.exp(-0.5 * z_sum ** 2) # 정규분포 밀도 (peak=1 at mean)
|
||||||
|
|
||||||
|
# 2b. 홀짝 비율 점수
|
||||||
|
odd_count = sum(1 for n in nums if n % 2 == 1)
|
||||||
|
odd_prob = cache["odd_prob"]
|
||||||
|
max_odd_prob = cache["max_odd_prob"]
|
||||||
|
odd_score = odd_prob.get(odd_count, 0.01) / max_odd_prob
|
||||||
|
|
||||||
|
# 2c. 구간 분포 점수
|
||||||
|
zone_probs = cache["zone_probs"]
|
||||||
|
max_zone_probs = cache["max_zone_probs"]
|
||||||
|
zone_score = 0.0
|
||||||
|
for z_idx, (lo, hi) in enumerate(ZONE_RANGES):
|
||||||
|
cnt = sum(1 for n in nums if lo <= n <= hi)
|
||||||
|
zp = zone_probs[z_idx]
|
||||||
|
mzp = max_zone_probs[z_idx]
|
||||||
|
zone_score += zp.get(cnt, 0.01) / mzp
|
||||||
|
zone_score /= len(ZONE_RANGES)
|
||||||
|
|
||||||
|
score_fingerprint = sum_score * 0.50 + odd_score * 0.30 + zone_score * 0.20
|
||||||
|
|
||||||
|
# ── 3. 갭 점수 (Gap Score) ────────────────────────────────────────────────
|
||||||
|
last_seen_gap = cache["last_seen_gap"]
|
||||||
|
gap_scores: List[float] = []
|
||||||
|
for n in nums:
|
||||||
|
gap = last_seen_gap.get(n, 0)
|
||||||
|
if gap <= 1:
|
||||||
|
gs = 0.20 # 직전 등장 번호 - 강한 패널티
|
||||||
|
elif gap <= 3:
|
||||||
|
gs = 0.55
|
||||||
|
elif gap <= 7:
|
||||||
|
gs = 0.85
|
||||||
|
elif gap <= 15:
|
||||||
|
gs = 1.00 # 최적 범위
|
||||||
|
elif gap <= 25:
|
||||||
|
gs = 0.90
|
||||||
|
else:
|
||||||
|
gs = 0.75 # 오래된 번호 - 여전히 양호
|
||||||
|
gap_scores.append(gs)
|
||||||
|
score_gap = sum(gap_scores) / 6.0
|
||||||
|
|
||||||
|
# ── 4. 공동 출현 점수 (Co-occurrence Score) ───────────────────────────────
|
||||||
|
cooccur = cache["cooccur"]
|
||||||
|
expected_cooccur = cache["expected_cooccur"]
|
||||||
|
|
||||||
|
pair_scores: List[float] = []
|
||||||
|
for i in range(len(nums)):
|
||||||
|
for j in range(i + 1, len(nums)):
|
||||||
|
actual = cooccur.get((nums[i], nums[j]), 0)
|
||||||
|
ratio = actual / max(expected_cooccur, 0.001)
|
||||||
|
# Sigmoid: ratio = 1에서 0.5, ratio > 1이면 > 0.5
|
||||||
|
ps = 1.0 / (1.0 + math.exp(-2.0 * (ratio - 1.0)))
|
||||||
|
pair_scores.append(ps)
|
||||||
|
score_cooccur = sum(pair_scores) / max(len(pair_scores), 1)
|
||||||
|
|
||||||
|
# ── 5. 다양성 점수 (Diversity Score) ─────────────────────────────────────
|
||||||
|
# 5a. 연속 번호 포함 여부 (역대 당첨번호 약 52%에 최소 1쌍 포함)
|
||||||
|
has_consecutive = any(nums[i + 1] - nums[i] == 1 for i in range(len(nums) - 1))
|
||||||
|
consecutive_score = 0.65 if has_consecutive else 0.40
|
||||||
|
|
||||||
|
# 5b. 범위 점수 (최소~최대 차이)
|
||||||
|
num_range = nums[-1] - nums[0]
|
||||||
|
if 28 <= num_range <= 43:
|
||||||
|
spread_score = 1.00
|
||||||
|
elif 20 <= num_range < 28:
|
||||||
|
spread_score = 0.85
|
||||||
|
elif 13 <= num_range < 20:
|
||||||
|
spread_score = 0.65
|
||||||
|
elif num_range < 13:
|
||||||
|
spread_score = 0.25
|
||||||
|
else: # > 43 (최대 44: 1~45)
|
||||||
|
spread_score = 0.95
|
||||||
|
|
||||||
|
# 5c. 구간 커버리지 (몇 개 구간에 걸쳐 있는가)
|
||||||
|
zones_used = set(_get_zone(n) for n in nums)
|
||||||
|
zone_coverage = (len(zones_used) - 1) / 4.0 # 0~1
|
||||||
|
|
||||||
|
score_diversity = (
|
||||||
|
consecutive_score * 0.35
|
||||||
|
+ spread_score * 0.35
|
||||||
|
+ zone_coverage * 0.30
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 최종 가중 합산 ────────────────────────────────────────────────────────
|
||||||
|
score_total = (
|
||||||
|
score_frequency * 0.25
|
||||||
|
+ score_fingerprint * 0.30
|
||||||
|
+ score_gap * 0.20
|
||||||
|
+ score_cooccur * 0.15
|
||||||
|
+ score_diversity * 0.10
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"score_total": round(score_total, 6),
|
||||||
|
"score_frequency": round(score_frequency, 6),
|
||||||
|
"score_fingerprint": round(score_fingerprint, 6),
|
||||||
|
"score_gap": round(score_gap, 6),
|
||||||
|
"score_cooccur": round(score_cooccur, 6),
|
||||||
|
"score_diversity": round(score_diversity, 6),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_statistical_report(draws: List[Tuple[int, List[int]]]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
통계 분석 리포트 생성 (GET /api/lotto/analysis 응답용).
|
||||||
|
각 번호의 빈도, Z-score, 갭, 히트/콜드/오버듀 분류를 반환.
|
||||||
|
"""
|
||||||
|
if not draws:
|
||||||
|
return {"error": "데이터 없음"}
|
||||||
|
|
||||||
|
cache = build_analysis_cache(draws)
|
||||||
|
total_draws = cache["total_draws"]
|
||||||
|
freq_all = cache["freq_all"]
|
||||||
|
z_scores = cache["z_scores"]
|
||||||
|
last_seen_gap = cache["last_seen_gap"]
|
||||||
|
|
||||||
|
number_stats = []
|
||||||
|
for n in range(1, 46):
|
||||||
|
freq = freq_all.get(n, 0)
|
||||||
|
expected = total_draws * 6.0 / 45.0
|
||||||
|
number_stats.append({
|
||||||
|
"number": n,
|
||||||
|
"frequency": freq,
|
||||||
|
"expected": round(expected, 1),
|
||||||
|
"frequency_pct": round(freq / (total_draws * 6) * 100, 2),
|
||||||
|
"z_score": round(z_scores.get(n, 0.0), 3),
|
||||||
|
"gap": last_seen_gap.get(n, total_draws),
|
||||||
|
"zone": _get_zone(n),
|
||||||
|
})
|
||||||
|
|
||||||
|
sorted_by_freq = sorted(number_stats, key=lambda x: -x["frequency"])
|
||||||
|
sorted_by_gap = sorted(number_stats, key=lambda x: -x["gap"])
|
||||||
|
|
||||||
|
# 역대 합계 분포 요약
|
||||||
|
hist_sums = [sum(nums) for _, nums in draws]
|
||||||
|
sum_buckets: Dict[str, int] = {}
|
||||||
|
for lo in range(21, 256, 20):
|
||||||
|
hi = lo + 19
|
||||||
|
key = f"{lo}-{hi}"
|
||||||
|
sum_buckets[key] = sum(1 for s in hist_sums if lo <= s <= hi)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_draws": total_draws,
|
||||||
|
"mean_sum": round(cache["mean_sum"], 2),
|
||||||
|
"std_sum": round(cache["std_sum"], 2),
|
||||||
|
"odd_distribution": {
|
||||||
|
str(k): round(v * 100, 1)
|
||||||
|
for k, v in sorted(cache["odd_prob"].items())
|
||||||
|
},
|
||||||
|
"number_stats": number_stats,
|
||||||
|
"hot_numbers": [x["number"] for x in sorted_by_freq[:10]],
|
||||||
|
"cold_numbers": [x["number"] for x in sorted_by_freq[-10:]],
|
||||||
|
"overdue_numbers": [x["number"] for x in sorted_by_gap[:10]],
|
||||||
|
"sum_distribution": sum_buckets,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_personal_patterns(
|
||||||
|
all_numbers: List[List[int]],
|
||||||
|
draws: List[Tuple[int, List[int]]],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
사용자 추천 이력 기반 개인 패턴 분석.
|
||||||
|
all_numbers: 저장된 모든 추천 번호 리스트 (각 원소는 6개짜리 리스트)
|
||||||
|
draws: 역대 당첨번호 (홀짝/합계 평균 비교용)
|
||||||
|
"""
|
||||||
|
if not all_numbers:
|
||||||
|
return {"total_analyzed": 0, "message": "추천 이력이 없습니다"}
|
||||||
|
|
||||||
|
total = len(all_numbers)
|
||||||
|
flat = [n for nums in all_numbers for n in nums]
|
||||||
|
freq = Counter(flat)
|
||||||
|
|
||||||
|
# 번호별 선택 빈도
|
||||||
|
number_frequency = {n: freq.get(n, 0) for n in range(1, 46)}
|
||||||
|
top_picks = sorted(range(1, 46), key=lambda n: -freq.get(n, 0))[:10]
|
||||||
|
least_picks = [n for n in sorted(range(1, 46), key=lambda n: freq.get(n, 0)) if freq.get(n, 0) == 0][:10]
|
||||||
|
|
||||||
|
# 패턴 지표
|
||||||
|
odd_counts = [sum(1 for n in nums if n % 2 == 1) for nums in all_numbers]
|
||||||
|
sums = [sum(nums) for nums in all_numbers]
|
||||||
|
ranges = [max(nums) - min(nums) for nums in all_numbers]
|
||||||
|
consecutive_count = sum(
|
||||||
|
1 for nums in all_numbers
|
||||||
|
if any(sorted(nums)[i + 1] - sorted(nums)[i] == 1 for i in range(5))
|
||||||
|
)
|
||||||
|
|
||||||
|
zone_totals = {k: 0 for k in ["1-9", "10-19", "20-29", "30-39", "40-45"]}
|
||||||
|
zone_ranges = [("1-9", 1, 9), ("10-19", 10, 19), ("20-29", 20, 29), ("30-39", 30, 39), ("40-45", 40, 45)]
|
||||||
|
for nums in all_numbers:
|
||||||
|
for label, lo, hi in zone_ranges:
|
||||||
|
zone_totals[label] += sum(1 for n in nums if lo <= n <= hi)
|
||||||
|
zone_avg = {k: round(v / total, 2) for k, v in zone_totals.items()}
|
||||||
|
|
||||||
|
avg_odd = sum(odd_counts) / total
|
||||||
|
avg_sum = sum(sums) / total
|
||||||
|
avg_range = sum(ranges) / total
|
||||||
|
|
||||||
|
# 역대 당첨번호 평균과 비교
|
||||||
|
if draws:
|
||||||
|
draw_odd_avg = sum(sum(1 for n in nums if n % 2 == 1) for _, nums in draws) / len(draws)
|
||||||
|
draw_sum_avg = sum(sum(nums) for _, nums in draws) / len(draws)
|
||||||
|
else:
|
||||||
|
draw_odd_avg = 3.0
|
||||||
|
draw_sum_avg = 138.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_analyzed": total,
|
||||||
|
"number_frequency": number_frequency,
|
||||||
|
"top_picks": top_picks,
|
||||||
|
"least_picks": least_picks,
|
||||||
|
"pattern": {
|
||||||
|
"avg_odd_count": round(avg_odd, 2),
|
||||||
|
"avg_sum": round(avg_sum, 1),
|
||||||
|
"avg_range": round(avg_range, 1),
|
||||||
|
"consecutive_rate": round(consecutive_count / total, 3),
|
||||||
|
"zone_avg": zone_avg,
|
||||||
|
},
|
||||||
|
"vs_draw_avg": {
|
||||||
|
"odd_diff": round(avg_odd - draw_odd_avg, 2),
|
||||||
|
"sum_diff": round(avg_sum - draw_sum_avg, 1),
|
||||||
|
"odd_tendency": "홀수 선호" if avg_odd > draw_odd_avg + 0.2 else ("짝수 선호" if avg_odd < draw_odd_avg - 0.2 else "균형"),
|
||||||
|
"sum_tendency": "고합계 선호" if avg_sum > draw_sum_avg + 5 else ("저합계 선호" if avg_sum < draw_sum_avg - 5 else "균형"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_combined_recommendation(draws: List[Tuple[int, List[int]]]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
5가지 통계 기법을 종합한 추론 번호 추천.
|
||||||
|
|
||||||
|
각 기법이 상위 6개 번호를 추천하고, 기법별 가중치(score_combination 가중치와 동일)로
|
||||||
|
투표를 집계한 뒤 최종 6개 번호를 선정한다.
|
||||||
|
|
||||||
|
가중치: 빈도Z(25%) · 지문(30%) · 갭(20%) · 공동출현(15%) · 다양성(10%)
|
||||||
|
"""
|
||||||
|
if not draws:
|
||||||
|
return {"error": "데이터 없음"}
|
||||||
|
|
||||||
|
cache = build_analysis_cache(draws)
|
||||||
|
z = cache["z_scores"]
|
||||||
|
gap = cache["last_seen_gap"]
|
||||||
|
freq = cache["freq_all"]
|
||||||
|
cooccur = cache["cooccur"]
|
||||||
|
zone_probs = cache["zone_probs"]
|
||||||
|
|
||||||
|
# ── Method 1: 빈도 Z-score ────────────────────────────────────────────────
|
||||||
|
# Z-score 내림차순 상위 6 (출현 빈도가 기댓값보다 높은 번호)
|
||||||
|
m_frequency = sorted(range(1, 46), key=lambda n: -z.get(n, 0))[:6]
|
||||||
|
|
||||||
|
# ── Method 2: 갭 분석 ─────────────────────────────────────────────────────
|
||||||
|
# 가장 오래 미출현한 번호 6개 (오버듀)
|
||||||
|
m_gap = sorted(range(1, 46), key=lambda n: -gap.get(n, 0))[:6]
|
||||||
|
|
||||||
|
# ── Method 3: 공동출현 ────────────────────────────────────────────────────
|
||||||
|
# 각 번호의 총 공동출현 합산 점수 내림차순 6개
|
||||||
|
cooccur_total: Dict[int, float] = defaultdict(float)
|
||||||
|
for (a, b), cnt in cooccur.items():
|
||||||
|
cooccur_total[a] += cnt
|
||||||
|
cooccur_total[b] += cnt
|
||||||
|
m_cooccur = sorted(range(1, 46), key=lambda n: -cooccur_total.get(n, 0))[:6]
|
||||||
|
|
||||||
|
# ── Method 4: 조합 지문 ───────────────────────────────────────────────────
|
||||||
|
# 역대 당첨 조합의 구간별 최빈 분포에 맞게 각 구간에서 빈도 상위 번호 선택
|
||||||
|
zone_targets: List[int] = []
|
||||||
|
for zp in zone_probs:
|
||||||
|
zone_targets.append(max(zp, key=zp.get) if zp else 1)
|
||||||
|
|
||||||
|
# 합이 정확히 6이 되도록 조정
|
||||||
|
diff = sum(zone_targets) - 6
|
||||||
|
if diff > 0:
|
||||||
|
idxs = sorted(range(5), key=lambda i: -zone_targets[i])
|
||||||
|
for i in idxs:
|
||||||
|
if diff <= 0:
|
||||||
|
break
|
||||||
|
zone_targets[i] = max(0, zone_targets[i] - 1)
|
||||||
|
diff -= 1
|
||||||
|
elif diff < 0:
|
||||||
|
idxs = sorted(range(5), key=lambda i: zone_targets[i])
|
||||||
|
for i in idxs:
|
||||||
|
if diff >= 0:
|
||||||
|
break
|
||||||
|
zone_targets[i] += 1
|
||||||
|
diff += 1
|
||||||
|
|
||||||
|
m_fingerprint: List[int] = []
|
||||||
|
for (lo, hi), tgt in zip(ZONE_RANGES, zone_targets):
|
||||||
|
zone_nums = sorted(range(lo, hi + 1), key=lambda x: -freq.get(x, 0))
|
||||||
|
m_fingerprint.extend(zone_nums[:tgt])
|
||||||
|
m_fingerprint = sorted(m_fingerprint[:6])
|
||||||
|
|
||||||
|
# ── Method 5: 다양성 ──────────────────────────────────────────────────────
|
||||||
|
# 각 구간에서 갭 가장 큰 번호 1개씩 (5개) + 전체 갭 상위 1개 보충
|
||||||
|
m_diversity: List[int] = []
|
||||||
|
for lo, hi in ZONE_RANGES:
|
||||||
|
zone_nums = sorted(range(lo, hi + 1), key=lambda n: -gap.get(n, 0))
|
||||||
|
if zone_nums:
|
||||||
|
m_diversity.append(zone_nums[0])
|
||||||
|
if len(m_diversity) < 6:
|
||||||
|
rest = sorted(
|
||||||
|
[x for x in range(1, 46) if x not in m_diversity],
|
||||||
|
key=lambda n: -gap.get(n, 0),
|
||||||
|
)
|
||||||
|
m_diversity.extend(rest[: 6 - len(m_diversity)])
|
||||||
|
m_diversity = sorted(m_diversity[:6])
|
||||||
|
|
||||||
|
# ── 가중 투표 집계 ────────────────────────────────────────────────────────
|
||||||
|
# score_combination 가중치와 동일: 빈도25, 지문30, 갭20, 공동출현15, 다양성10
|
||||||
|
method_entries = [
|
||||||
|
(m_frequency, 25, "frequency", "빈도 Z-score"),
|
||||||
|
(m_fingerprint, 30, "fingerprint", "조합 지문"),
|
||||||
|
(m_gap, 20, "gap", "갭 분석"),
|
||||||
|
(m_cooccur, 15, "cooccur", "공동 출현"),
|
||||||
|
(m_diversity, 10, "diversity", "다양성"),
|
||||||
|
]
|
||||||
|
|
||||||
|
vote_scores: Dict[int, float] = {n: 0.0 for n in range(1, 46)}
|
||||||
|
for method_nums, weight, _, _ in method_entries:
|
||||||
|
for rank, n in enumerate(method_nums):
|
||||||
|
# rank 0 = 1위: (6-0)×weight = 6w, rank 5 = 6위: (6-5)×weight = w
|
||||||
|
vote_scores[n] += (6 - rank) * weight
|
||||||
|
|
||||||
|
# 상위 6개 — 동점 시 Z-score 타이브레이크
|
||||||
|
final_numbers = sorted(
|
||||||
|
sorted(range(1, 46), key=lambda n: (-vote_scores[n], -z.get(n, 0)))[:6]
|
||||||
|
)
|
||||||
|
|
||||||
|
scores = score_combination(final_numbers, cache)
|
||||||
|
|
||||||
|
# 각 번호가 몇 개 방법에서 채택됐는지
|
||||||
|
vote_counts: Dict[str, int] = {
|
||||||
|
str(n): sum(1 for nums, _, _, _ in method_entries if n in nums)
|
||||||
|
for n in range(1, 46)
|
||||||
|
}
|
||||||
|
|
||||||
|
methods_result: Dict[str, Any] = {}
|
||||||
|
for nums, weight, key, label in method_entries:
|
||||||
|
methods_result[key] = {
|
||||||
|
"label": label,
|
||||||
|
"weight_pct": weight,
|
||||||
|
"numbers": sorted(nums),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"methods": methods_result,
|
||||||
|
"final_numbers": final_numbers,
|
||||||
|
"scores": scores,
|
||||||
|
"vote_scores": {str(n): round(vote_scores[n], 1) for n in range(1, 46)},
|
||||||
|
"vote_counts": vote_counts,
|
||||||
|
"total_draws": cache["total_draws"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_weekly_report(draws: List[Tuple[int, List[int]]], target_drw_no: int) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
특정 회차 공략 리포트 생성.
|
||||||
|
target_drw_no: 공략 대상 회차 (아직 추첨 안 된 회차)
|
||||||
|
draws: target_drw_no 이전까지의 당첨번호 (오름차순)
|
||||||
|
"""
|
||||||
|
if not draws:
|
||||||
|
return {"error": "데이터 없음"}
|
||||||
|
|
||||||
|
cache = build_analysis_cache(draws)
|
||||||
|
total_draws = cache["total_draws"]
|
||||||
|
freq_all = cache["freq_all"]
|
||||||
|
last_seen_gap = cache["last_seen_gap"]
|
||||||
|
|
||||||
|
recent_10 = draws[-10:] if len(draws) >= 10 else draws
|
||||||
|
recent_3 = draws[-3:] if len(draws) >= 3 else draws
|
||||||
|
|
||||||
|
# 과출현: 최근 10회에 2회 이상 출현 번호 (출현 많은 순)
|
||||||
|
r10_nums = [n for _, nums in recent_10 for n in nums]
|
||||||
|
r10_freq = Counter(r10_nums)
|
||||||
|
hot_numbers = [n for n, _ in sorted(r10_freq.items(), key=lambda x: -x[1]) if r10_freq[n] >= 2]
|
||||||
|
|
||||||
|
# 냉각: 역대 출현 빈도 낮은 번호
|
||||||
|
cold_numbers = sorted(range(1, 46), key=lambda n: freq_all.get(n, 0))[:10]
|
||||||
|
|
||||||
|
# 오버듀: 가장 오래 미출현 번호
|
||||||
|
overdue_numbers = sorted(range(1, 46), key=lambda n: -last_seen_gap.get(n, 0))[:10]
|
||||||
|
|
||||||
|
# 최근 3회 연속 출현 (2회 이상)
|
||||||
|
r3_nums = [n for _, nums in recent_3 for n in nums]
|
||||||
|
r3_freq = Counter(r3_nums)
|
||||||
|
triple_appear = sorted(n for n, cnt in r3_freq.items() if cnt >= 2)
|
||||||
|
|
||||||
|
recent_sums = [sum(nums) for _, nums in recent_10]
|
||||||
|
recent_odd = [sum(1 for n in nums if n % 2 == 1) for _, nums in recent_10]
|
||||||
|
|
||||||
|
# 갭 기반 가중치 (오래된 번호일수록 높음)
|
||||||
|
gap_w = {n: last_seen_gap.get(n, 0) for n in range(1, 46)}
|
||||||
|
|
||||||
|
def _pick(exclude=None, prefer=None, n=6):
|
||||||
|
ex = set(exclude or [])
|
||||||
|
chosen = []
|
||||||
|
# prefer에서 최대 3개 우선 선택
|
||||||
|
for p in (prefer or []):
|
||||||
|
if p not in ex and len(chosen) < 3:
|
||||||
|
chosen.append(p)
|
||||||
|
# 구간별 1개씩 (갭 우선)
|
||||||
|
for lo, hi in [(1, 9), (10, 19), (20, 29), (30, 39), (40, 45)]:
|
||||||
|
if len(chosen) >= n:
|
||||||
|
break
|
||||||
|
cands = [x for x in range(lo, hi + 1) if x not in ex and x not in chosen]
|
||||||
|
if cands:
|
||||||
|
chosen.append(max(cands, key=lambda x: gap_w.get(x, 0)))
|
||||||
|
# 부족하면 나머지에서 갭 순
|
||||||
|
rest = sorted(
|
||||||
|
[x for x in range(1, 46) if x not in ex and x not in chosen],
|
||||||
|
key=lambda x: -gap_w.get(x, 0),
|
||||||
|
)
|
||||||
|
while len(chosen) < n and rest:
|
||||||
|
chosen.append(rest.pop(0))
|
||||||
|
return sorted(chosen[:n])
|
||||||
|
|
||||||
|
set1 = _pick(exclude=hot_numbers[:5], prefer=overdue_numbers[:5])
|
||||||
|
set2 = _pick()
|
||||||
|
set3 = _pick(exclude=hot_numbers)
|
||||||
|
|
||||||
|
# 신뢰도 점수
|
||||||
|
data_vol = min(total_draws / 500, 1.0)
|
||||||
|
if len(recent_sums) > 1:
|
||||||
|
avg_s = sum(recent_sums) / len(recent_sums)
|
||||||
|
std_s = (sum((s - avg_s) ** 2 for s in recent_sums) / len(recent_sums)) ** 0.5
|
||||||
|
pattern = max(0.0, 1.0 - std_s / 60.0)
|
||||||
|
else:
|
||||||
|
pattern = 0.5
|
||||||
|
trend = max(0.0, 1.0 - len(hot_numbers) / max(len(r10_nums), 1))
|
||||||
|
confidence = round((data_vol * 0.4 + pattern * 0.35 + trend * 0.25) * 100)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"target_drw_no": target_drw_no,
|
||||||
|
"based_on_draw": draws[-1][0],
|
||||||
|
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"hot_numbers": hot_numbers[:8],
|
||||||
|
"cold_numbers": cold_numbers,
|
||||||
|
"overdue_numbers": overdue_numbers,
|
||||||
|
"recent_pattern": {
|
||||||
|
"last3_numbers": sorted(set(r3_nums)),
|
||||||
|
"triple_appear": triple_appear,
|
||||||
|
"recent_sum_avg": round(sum(recent_sums) / len(recent_sums), 1) if recent_sums else 0,
|
||||||
|
"recent_odd_avg": round(sum(recent_odd) / len(recent_odd), 1) if recent_odd else 0,
|
||||||
|
},
|
||||||
|
"recommended_sets": [
|
||||||
|
{"strategy": "냉각번호 중심", "numbers": set1, "description": "오랫동안 미출현 번호 위주 + 과출현 제외"},
|
||||||
|
{"strategy": "균형형", "numbers": set2, "description": "구간 균형 + 갭 최적화"},
|
||||||
|
{"strategy": "과출현 피하기", "numbers": set3, "description": "최근 자주 나온 번호 완전 제외"},
|
||||||
|
],
|
||||||
|
"confidence_score": confidence,
|
||||||
|
"confidence_factors": {
|
||||||
|
"data_volume": round(data_vol * 100),
|
||||||
|
"pattern_consistency": round(pattern * 100),
|
||||||
|
"recent_trend": round(trend * 100),
|
||||||
|
},
|
||||||
|
}
|
||||||
73
lotto/app/checker.py
Normal file
73
lotto/app/checker.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import json
|
||||||
|
from .db import (
|
||||||
|
_conn, get_draw, update_recommendation_result
|
||||||
|
)
|
||||||
|
|
||||||
|
def _calc_rank(my_nums: list[int], win_nums: list[int], bonus: int) -> tuple[int, int, bool]:
|
||||||
|
"""
|
||||||
|
(rank, correct_cnt, has_bonus) 반환
|
||||||
|
rank: 1~5 (1등~5등), 0 (낙첨)
|
||||||
|
"""
|
||||||
|
matched = set(my_nums) & set(win_nums)
|
||||||
|
cnt = len(matched)
|
||||||
|
has_bonus = bonus in my_nums
|
||||||
|
|
||||||
|
if cnt == 6:
|
||||||
|
return 1, cnt, has_bonus
|
||||||
|
if cnt == 5 and has_bonus:
|
||||||
|
return 2, cnt, has_bonus
|
||||||
|
if cnt == 5:
|
||||||
|
return 3, cnt, has_bonus
|
||||||
|
if cnt == 4:
|
||||||
|
return 4, cnt, has_bonus
|
||||||
|
if cnt == 3:
|
||||||
|
return 5, cnt, has_bonus
|
||||||
|
|
||||||
|
return 0, cnt, has_bonus
|
||||||
|
|
||||||
|
def check_results_for_draw(drw_no: int) -> int:
|
||||||
|
"""
|
||||||
|
특정 회차(drw_no) 결과가 나왔을 때,
|
||||||
|
해당 회차를 타겟으로 했던(based_on_draw == drw_no - 1) 추천들을 채점한다.
|
||||||
|
반환값: 채점한 개수
|
||||||
|
"""
|
||||||
|
win_row = get_draw(drw_no)
|
||||||
|
if not win_row:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
win_nums = [
|
||||||
|
win_row["n1"], win_row["n2"], win_row["n3"],
|
||||||
|
win_row["n4"], win_row["n5"], win_row["n6"]
|
||||||
|
]
|
||||||
|
bonus = win_row["bonus"]
|
||||||
|
|
||||||
|
# based_on_draw가 (이번회차 - 1)인 것들 조회
|
||||||
|
# 즉, 1000회차 추첨 결과로는, 999회차 데이터를 바탕으로 1000회차를 예측한 것들을 채점
|
||||||
|
target_based_on = drw_no - 1
|
||||||
|
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, numbers
|
||||||
|
FROM recommendations
|
||||||
|
WHERE based_on_draw = ? AND checked = 0
|
||||||
|
""",
|
||||||
|
(target_based_on,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for r in rows:
|
||||||
|
my_nums = json.loads(r["numbers"])
|
||||||
|
rank, correct, has_bonus = _calc_rank(my_nums, win_nums, bonus)
|
||||||
|
|
||||||
|
update_recommendation_result(r["id"], rank, correct, has_bonus)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
# ── 구매 이력 체크 연동 ──────────────────────────────────────
|
||||||
|
try:
|
||||||
|
from .purchase_manager import check_purchases_for_draw as _check_purchases
|
||||||
|
_check_purchases(drw_no) # 내부에서 evolve_after_check → recalculate_weights 호출
|
||||||
|
except ImportError:
|
||||||
|
pass # purchase_manager 미설치 시 무시 (하위호환)
|
||||||
|
|
||||||
|
return count
|
||||||
85
lotto/app/collector.py
Normal file
85
lotto/app/collector.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import requests
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from .db import get_draw, upsert_draw, upsert_many_draws, get_latest_draw, count_draws
|
||||||
|
|
||||||
|
def _normalize_item(item: dict) -> dict:
|
||||||
|
# smok95 all.json / latest.json 구조
|
||||||
|
# - 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]
|
||||||
|
|
||||||
|
# 정규화
|
||||||
|
rows = [_normalize_item(item) for item in data]
|
||||||
|
|
||||||
|
# Bulk Insert (성능 향상)
|
||||||
|
upsert_many_draws(rows)
|
||||||
|
|
||||||
|
return {"mode": "all_json", "url": all_url, "total": len(rows)}
|
||||||
|
|
||||||
|
def sync_latest(latest_url: str) -> Dict[str, Any]:
|
||||||
|
r = requests.get(latest_url, timeout=30)
|
||||||
|
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"]}
|
||||||
|
|
||||||
|
def sync_ensure_all(latest_url: str, all_url: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
1회부터 최신 회차까지 빠짐없이 있는지 확인하고, 없으면 전체 동기화 수행.
|
||||||
|
반환값: {"synced": bool, "reason": str, ...}
|
||||||
|
"""
|
||||||
|
# 1. 원격 최신 회차 확인
|
||||||
|
try:
|
||||||
|
r = requests.get(latest_url, timeout=10)
|
||||||
|
r.raise_for_status()
|
||||||
|
remote_item = r.json()
|
||||||
|
remote_no = int(remote_item["draw_no"])
|
||||||
|
except Exception as e:
|
||||||
|
# 외부 통신 실패 시, 그냥 로컬 데이터로 진행하도록 에러 억제 (혹은 에러 발생)
|
||||||
|
# 여기서는 통계 기능 작동이 우선이므로 로그만 남기고 pass하고 싶지만,
|
||||||
|
# 확실한 동기화를 위해 에러를 던지거나 False 리턴
|
||||||
|
return {"synced": False, "error": str(e)}
|
||||||
|
|
||||||
|
# 2. 로컬 상태 확인
|
||||||
|
local_latest_row = get_latest_draw()
|
||||||
|
local_no = local_latest_row["drw_no"] if local_latest_row else 0
|
||||||
|
local_cnt = count_draws()
|
||||||
|
|
||||||
|
# 3. 동기화 필요 여부 판단
|
||||||
|
# - 전체 개수가 최신 회차 번호보다 적으면 중간에 빈 것 (1회부터 시작한다고 가정)
|
||||||
|
# - 혹은 DB 최신 번호가 원격보다 낮으면 업데이트 필요
|
||||||
|
need_sync = (local_no < remote_no) or (local_cnt < local_no)
|
||||||
|
|
||||||
|
if not need_sync:
|
||||||
|
return {"synced": True, "updated": False, "local_no": local_no}
|
||||||
|
|
||||||
|
# 4. 전체 동기화 실행
|
||||||
|
# (단순 latest sync로는 중간 구멍을 못 채우므로, 구멍이 있거나 차이가 크면 all_sync 수행)
|
||||||
|
# 만약 차이가 1회차 뿐이고 구멍이 없다면 sync_latest만 해도 되지만,
|
||||||
|
# 로직 단순화를 위해 missing 감지 시 그냥 all_sync (Bulk Insert라 빠름)
|
||||||
|
res = sync_all_from_json(all_url)
|
||||||
|
return {"synced": True, "updated": True, "detail": res}
|
||||||
|
|
||||||
151
lotto/app/curator_helpers.py
Normal file
151
lotto/app/curator_helpers.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""큐레이터용 후보 가공 — 여러 엔진 결과를 하나로 병합, 중복 제거, 피처 계산."""
|
||||||
|
from typing import Dict, List, Any, Set
|
||||||
|
from . import db
|
||||||
|
from .recommender import recommend_numbers, recommend_with_heatmap
|
||||||
|
from .analyzer import get_statistical_report
|
||||||
|
from .strategy_evolver import generate_smart_recommendation
|
||||||
|
|
||||||
|
|
||||||
|
LOW_HIGH_CUT = 22
|
||||||
|
|
||||||
|
|
||||||
|
def compute_features(numbers: List[int], hot: Set[int], cold: Set[int]) -> Dict[str, Any]:
|
||||||
|
nums = sorted(numbers)
|
||||||
|
odd = sum(1 for n in nums if n % 2 == 1)
|
||||||
|
low = sum(1 for n in nums if n <= LOW_HIGH_CUT)
|
||||||
|
buckets = [0, 0, 0, 0, 0]
|
||||||
|
for n in nums:
|
||||||
|
if n <= 10: buckets[0] += 1
|
||||||
|
elif n <= 20: buckets[1] += 1
|
||||||
|
elif n <= 30: buckets[2] += 1
|
||||||
|
elif n <= 40: buckets[3] += 1
|
||||||
|
else: buckets[4] += 1
|
||||||
|
consecutive = any(nums[i+1] - nums[i] == 1 for i in range(len(nums) - 1))
|
||||||
|
return {
|
||||||
|
"odd_count": odd,
|
||||||
|
"even_count": 6 - odd,
|
||||||
|
"low_count": low,
|
||||||
|
"high_count": 6 - low,
|
||||||
|
"range_distribution": buckets,
|
||||||
|
"has_consecutive": consecutive,
|
||||||
|
"hot_number_count": len(set(nums) & hot),
|
||||||
|
"cold_number_count": len(set(nums) & cold),
|
||||||
|
"sum": sum(nums),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _key(numbers: List[int]) -> str:
|
||||||
|
return ",".join(str(n) for n in sorted(numbers))
|
||||||
|
|
||||||
|
|
||||||
|
def collect_candidates(n: int, hot: Set[int], cold: Set[int]) -> List[Dict[str, Any]]:
|
||||||
|
"""우선순위: simulation best_picks → meta → heatmap → statistics. 중복 제거 후 최대 n세트."""
|
||||||
|
seen: Dict[str, Dict[str, Any]] = {}
|
||||||
|
order: List[str] = []
|
||||||
|
|
||||||
|
def _add(numbers: List[int], source: str) -> None:
|
||||||
|
if not numbers:
|
||||||
|
return
|
||||||
|
k = _key(numbers)
|
||||||
|
if k in seen:
|
||||||
|
return
|
||||||
|
seen[k] = {"numbers": sorted(numbers), "source": source}
|
||||||
|
order.append(k)
|
||||||
|
|
||||||
|
# 1. simulation best_picks
|
||||||
|
try:
|
||||||
|
for row in db.get_best_picks(limit=n):
|
||||||
|
_add(row.get("numbers") or [], "simulation")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# draws는 한 번만 로드
|
||||||
|
draws = []
|
||||||
|
try:
|
||||||
|
draws = db.get_all_draw_numbers()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2. meta-strategy (smart)
|
||||||
|
try:
|
||||||
|
meta = generate_smart_recommendation(sets=n)
|
||||||
|
for s in meta.get("sets", []):
|
||||||
|
_add(s.get("numbers") or [], "meta")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 3. heatmap (n번 호출, 중복 회피)
|
||||||
|
if draws:
|
||||||
|
try:
|
||||||
|
for _ in range(n * 2):
|
||||||
|
if len(order) >= n * 2:
|
||||||
|
break
|
||||||
|
r = recommend_with_heatmap(draws, [])
|
||||||
|
_add(r.get("numbers") or [], "heatmap")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 4. statistics
|
||||||
|
if draws:
|
||||||
|
try:
|
||||||
|
for _ in range(n * 2):
|
||||||
|
if len(order) >= n * 2:
|
||||||
|
break
|
||||||
|
r = recommend_numbers(draws)
|
||||||
|
_add(r.get("numbers") or [], "statistics")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for k in order[:n]:
|
||||||
|
item = seen[k]
|
||||||
|
item["features"] = compute_features(item["numbers"], hot, cold)
|
||||||
|
out.append(item)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_context(hot_limit: int = 10, cold_limit: int = 10) -> Dict[str, Any]:
|
||||||
|
"""주간 맥락 패키지 — get_statistical_report가 이미 hot/cold를 제공."""
|
||||||
|
hot: List[int] = []
|
||||||
|
cold: List[int] = []
|
||||||
|
last_summary = ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
draws = db.get_all_draw_numbers()
|
||||||
|
except Exception:
|
||||||
|
draws = []
|
||||||
|
|
||||||
|
if draws:
|
||||||
|
try:
|
||||||
|
report = get_statistical_report(draws)
|
||||||
|
hot = list(report.get("hot_numbers", []))[:hot_limit]
|
||||||
|
cold = list(report.get("cold_numbers", []))[:cold_limit]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
latest = db.get_latest_draw()
|
||||||
|
except Exception:
|
||||||
|
latest = None
|
||||||
|
|
||||||
|
if latest:
|
||||||
|
nums = [latest.get(f"n{i}") for i in range(1, 7)]
|
||||||
|
nums = [n for n in nums if n is not None]
|
||||||
|
if nums:
|
||||||
|
odd = sum(1 for n in nums if n % 2 == 1)
|
||||||
|
low = sum(1 for n in nums if n <= LOW_HIGH_CUT)
|
||||||
|
last_summary = f"{latest.get('drw_no')}회: {', '.join(str(n) for n in nums)} (홀{odd}짝{6-odd}, 저{low}고{6-low})"
|
||||||
|
|
||||||
|
my_perf: List[Dict[str, Any]] = []
|
||||||
|
try:
|
||||||
|
from .purchase_manager import get_recent_performance
|
||||||
|
my_perf = get_recent_performance(limit=3)
|
||||||
|
except Exception:
|
||||||
|
my_perf = []
|
||||||
|
|
||||||
|
return {
|
||||||
|
"hot_numbers": hot,
|
||||||
|
"cold_numbers": cold,
|
||||||
|
"last_draw_summary": last_summary,
|
||||||
|
"my_recent_performance": my_perf,
|
||||||
|
}
|
||||||
1054
lotto/app/db.py
Normal file
1054
lotto/app/db.py
Normal file
File diff suppressed because it is too large
Load Diff
135
lotto/app/generator.py
Normal file
135
lotto/app/generator.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
"""
|
||||||
|
시뮬레이션 엔진 - lotto-lab 고도화
|
||||||
|
|
||||||
|
[몬테카를로 시뮬레이션 흐름]
|
||||||
|
1. 역대 당첨번호 기반 통계 캐시 구성 (build_analysis_cache)
|
||||||
|
2. 통계 가중치로 N개 후보 조합 생성 (weighted sampling)
|
||||||
|
3. 5가지 기법으로 각 후보 스코어링 (score_combination)
|
||||||
|
4. 상위 top_k개 선별하여 DB 저장 (simulation_candidates, best_picks 교체)
|
||||||
|
|
||||||
|
[시뮬레이션 파라미터]
|
||||||
|
- n_candidates: 1회 시뮬레이션당 생성 후보 수 (기본 20,000)
|
||||||
|
- top_k: 선별 및 저장할 상위 개수 (기본 100)
|
||||||
|
- best_n: best_picks에 올릴 최상위 개수 (기본 20)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
|
||||||
|
from .db import (
|
||||||
|
get_latest_draw,
|
||||||
|
get_all_draw_numbers,
|
||||||
|
save_simulation_run,
|
||||||
|
save_simulation_candidates_bulk,
|
||||||
|
replace_best_picks,
|
||||||
|
)
|
||||||
|
from .analyzer import build_analysis_cache, build_number_weights, score_combination
|
||||||
|
from .utils import weighted_sample_6
|
||||||
|
|
||||||
|
|
||||||
|
def run_simulation(
|
||||||
|
n_candidates: int = 20000,
|
||||||
|
top_k: int = 100,
|
||||||
|
best_n: int = 20,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
몬테카를로 시뮬레이션 실행 메인 함수.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
n_candidates: 생성할 후보 조합 수 (기본 20,000)
|
||||||
|
top_k: DB에 저장할 상위 후보 수 (기본 100)
|
||||||
|
best_n: best_picks에 올릴 최상위 수 (기본 20)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{run_id, total_generated, top_k_selected, avg_score, best_score, based_on_draw}
|
||||||
|
또는 {"error": ...}
|
||||||
|
"""
|
||||||
|
draws = get_all_draw_numbers()
|
||||||
|
if not draws:
|
||||||
|
return {"error": "당첨번호 데이터가 없습니다. 먼저 동기화를 실행하세요."}
|
||||||
|
|
||||||
|
latest = get_latest_draw()
|
||||||
|
based_on_draw = latest["drw_no"] if latest else None
|
||||||
|
|
||||||
|
# ── 1. 통계 캐시 및 가중치 구성 (시뮬레이션 전체에서 재사용) ────────────
|
||||||
|
cache = build_analysis_cache(draws)
|
||||||
|
weights = build_number_weights(cache)
|
||||||
|
|
||||||
|
# ── 2. 후보 생성 및 스코어링 ──────────────────────────────────────────────
|
||||||
|
candidates: List[Dict[str, Any]] = []
|
||||||
|
seen_keys: set = set()
|
||||||
|
max_attempts = n_candidates * 3 # 중복 제거 여유분
|
||||||
|
|
||||||
|
attempts = 0
|
||||||
|
while len(candidates) < n_candidates and attempts < max_attempts:
|
||||||
|
attempts += 1
|
||||||
|
nums = weighted_sample_6(weights)
|
||||||
|
key = tuple(sorted(nums))
|
||||||
|
if key in seen_keys:
|
||||||
|
continue
|
||||||
|
seen_keys.add(key)
|
||||||
|
|
||||||
|
scores = score_combination(nums, cache)
|
||||||
|
candidates.append({
|
||||||
|
"numbers": sorted(nums),
|
||||||
|
**scores,
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── 3. 점수 내림차순 정렬 및 상위 선별 ──────────────────────────────────
|
||||||
|
candidates.sort(key=lambda x: -x["score_total"])
|
||||||
|
top_candidates = candidates[:top_k]
|
||||||
|
|
||||||
|
# is_best 플래그 표시
|
||||||
|
best_keys = {tuple(c["numbers"]) for c in top_candidates[:best_n]}
|
||||||
|
for c in top_candidates:
|
||||||
|
c["is_best"] = tuple(c["numbers"]) in best_keys
|
||||||
|
|
||||||
|
avg_score = (
|
||||||
|
sum(c["score_total"] for c in top_candidates) / len(top_candidates)
|
||||||
|
if top_candidates else 0.0
|
||||||
|
)
|
||||||
|
best_score = top_candidates[0]["score_total"] if top_candidates else 0.0
|
||||||
|
|
||||||
|
# ── 4. DB 저장 ────────────────────────────────────────────────────────────
|
||||||
|
run_id = save_simulation_run(
|
||||||
|
strategy="monte_carlo",
|
||||||
|
total_generated=len(candidates),
|
||||||
|
top_k_selected=len(top_candidates),
|
||||||
|
avg_score=avg_score,
|
||||||
|
notes=f"based_on_draw={based_on_draw}, history={len(draws)}회",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 상위 top_k개만 DB에 저장 (전체 20,000개는 메모리에서만 처리)
|
||||||
|
save_simulation_candidates_bulk(run_id, top_candidates, based_on_draw)
|
||||||
|
|
||||||
|
# best_picks 교체 (상위 best_n개)
|
||||||
|
best_picks_data = [
|
||||||
|
{
|
||||||
|
"numbers": c["numbers"],
|
||||||
|
"score_total": c["score_total"],
|
||||||
|
"rank_in_run": i + 1,
|
||||||
|
}
|
||||||
|
for i, c in enumerate(top_candidates[:best_n])
|
||||||
|
]
|
||||||
|
replace_best_picks(best_picks_data, run_id, based_on_draw)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"run_id": run_id,
|
||||||
|
"total_generated": len(candidates),
|
||||||
|
"top_k_selected": len(top_candidates),
|
||||||
|
"best_n_saved": len(best_picks_data),
|
||||||
|
"avg_score": round(avg_score, 6),
|
||||||
|
"best_score": round(best_score, 6),
|
||||||
|
"based_on_draw": based_on_draw,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_smart_recommendations(count: int = 10) -> int:
|
||||||
|
"""
|
||||||
|
하위 호환성 유지용 래퍼.
|
||||||
|
내부적으로 run_simulation을 호출하며, 기존 /api/admin/auto_gen 등에서 계속 사용 가능.
|
||||||
|
"""
|
||||||
|
result = run_simulation(n_candidates=5000, top_k=count, best_n=count)
|
||||||
|
if "error" in result:
|
||||||
|
return 0
|
||||||
|
return result.get("best_n_saved", 0)
|
||||||
837
lotto/app/main.py
Normal file
837
lotto/app/main.py
Normal file
@@ -0,0 +1,837 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from typing import Optional, List, Dict, Any, Tuple
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
|
||||||
|
logger = logging.getLogger("lotto-backend")
|
||||||
|
|
||||||
|
from .db import (
|
||||||
|
init_db, get_draw, get_latest_draw, get_all_draw_numbers,
|
||||||
|
save_recommendation_dedup, list_recommendations_ex, delete_recommendation,
|
||||||
|
update_recommendation,
|
||||||
|
# 시뮬레이션 관련
|
||||||
|
get_best_picks, get_simulation_runs, get_simulation_candidates,
|
||||||
|
# 성과 통계
|
||||||
|
get_recommendation_performance,
|
||||||
|
# Phase 2: 구매 이력
|
||||||
|
add_purchase, get_purchases, update_purchase, delete_purchase, get_purchase_stats,
|
||||||
|
# Phase 2: 주간 리포트 캐시
|
||||||
|
save_weekly_report, get_weekly_report_list, get_weekly_report,
|
||||||
|
# Phase 2: 개인 패턴 분석
|
||||||
|
get_all_recommendation_numbers,
|
||||||
|
# Phase 3: 전략 관련
|
||||||
|
get_strategy_performance as db_get_strategy_performance,
|
||||||
|
)
|
||||||
|
from .recommender import recommend_numbers, recommend_with_heatmap
|
||||||
|
from .collector import sync_latest, sync_ensure_all
|
||||||
|
from .generator import run_simulation, generate_smart_recommendations
|
||||||
|
from .checker import check_results_for_draw
|
||||||
|
from .utils import calc_metrics, calc_recent_overlap
|
||||||
|
from .analyzer import get_statistical_report, generate_weekly_report, analyze_personal_patterns, generate_combined_recommendation
|
||||||
|
from .purchase_manager import check_purchases_for_draw
|
||||||
|
from .strategy_evolver import (
|
||||||
|
get_weights_with_trend, recalculate_weights,
|
||||||
|
generate_smart_recommendation,
|
||||||
|
)
|
||||||
|
from .routers import curator as curator_router
|
||||||
|
from .routers import briefing as briefing_router
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(curator_router.router)
|
||||||
|
app.include_router(briefing_router.router)
|
||||||
|
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")
|
||||||
|
|
||||||
|
# ── 성과 통계 인메모리 캐시 ───────────────────────────────────────────────────
|
||||||
|
# 채점 데이터는 하루 2번 스케줄러 실행 시에만 갱신되므로 인메모리 캐시로 충분
|
||||||
|
_PERF_CACHE: Dict[str, Any] = {"data": None, "at": 0.0}
|
||||||
|
_PERF_CACHE_TTL = 3600 # 1시간 (스케줄러 미실행 상황 대비 폴백)
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_perf_cache() -> None:
|
||||||
|
_PERF_CACHE["data"] = get_recommendation_performance()
|
||||||
|
_PERF_CACHE["at"] = time.time()
|
||||||
|
logger.info("성과 통계 캐시 갱신")
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def on_startup():
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
# 1. 로또 당첨번호 동기화 (매일 9시, 21시 10분)
|
||||||
|
# 동기화 후 새로운 회차가 있으면 채점(check)까지 수행
|
||||||
|
def _sync_and_check():
|
||||||
|
res = sync_latest(LATEST_URL)
|
||||||
|
if res["was_new"]:
|
||||||
|
check_results_for_draw(res["drawNo"])
|
||||||
|
_refresh_perf_cache() # 새 채점 결과 반영 → 즉시 갱신
|
||||||
|
|
||||||
|
scheduler.add_job(_sync_and_check, "cron", hour="9,21", minute=10)
|
||||||
|
|
||||||
|
# 2. 몬테카를로 시뮬레이션 (하루 6회: 0, 4, 8, 12, 16, 20시)
|
||||||
|
# 20,000개 후보 생성 → 스코어링 → 상위 100개 저장 → best_picks 교체
|
||||||
|
def _run_simulation_job():
|
||||||
|
run_simulation(n_candidates=20000, top_k=100, best_n=20)
|
||||||
|
|
||||||
|
scheduler.add_job(_run_simulation_job, "cron", hour="0,4,8,12,16,20", minute=5)
|
||||||
|
|
||||||
|
# 3. 토요일 오전 9시 — 다음 회차 공략 리포트 자동 캐싱
|
||||||
|
def _save_weekly_report_job():
|
||||||
|
import json as _json
|
||||||
|
draws = get_all_draw_numbers()
|
||||||
|
latest = get_latest_draw()
|
||||||
|
if not draws or not latest:
|
||||||
|
return
|
||||||
|
target = latest["drw_no"] + 1
|
||||||
|
report = generate_weekly_report(draws, target)
|
||||||
|
save_weekly_report(target, _json.dumps(report, ensure_ascii=False))
|
||||||
|
logger.info(f"{target}회차 리포트 저장 완료")
|
||||||
|
|
||||||
|
scheduler.add_job(_save_weekly_report_job, "cron", day_of_week="sat", hour=9, minute=0)
|
||||||
|
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/lotto/latest")
|
||||||
|
def api_latest():
|
||||||
|
row = get_latest_draw()
|
||||||
|
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"],
|
||||||
|
"metrics": calc_metrics([row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/lotto/{drw_no:int}")
|
||||||
|
def api_draw(drw_no: int):
|
||||||
|
row = get_draw(drw_no)
|
||||||
|
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"],
|
||||||
|
"metrics": calc_metrics([row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/admin/sync_latest")
|
||||||
|
def admin_sync_latest():
|
||||||
|
res = sync_latest(LATEST_URL)
|
||||||
|
if res["was_new"]:
|
||||||
|
check_results_for_draw(res["drawNo"])
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/admin/auto_gen")
|
||||||
|
def admin_auto_gen(count: int = 10):
|
||||||
|
"""기존 호환 유지: 소규모 시뮬레이션 수동 트리거"""
|
||||||
|
n = generate_smart_recommendations(count)
|
||||||
|
return {"generated": n}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/admin/simulate")
|
||||||
|
def admin_simulate(n_candidates: int = 20000, top_k: int = 100, best_n: int = 20):
|
||||||
|
"""
|
||||||
|
몬테카를로 시뮬레이션 수동 트리거.
|
||||||
|
백그라운드 스케줄과 동일한 동작을 즉시 실행.
|
||||||
|
"""
|
||||||
|
result = run_simulation(
|
||||||
|
n_candidates=max(1000, min(n_candidates, 50000)),
|
||||||
|
top_k=max(10, min(top_k, 500)),
|
||||||
|
best_n=max(10, min(best_n, 50)),
|
||||||
|
)
|
||||||
|
if "error" in result:
|
||||||
|
raise HTTPException(status_code=500, detail=result["error"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/lotto/stats")
|
||||||
|
def api_stats():
|
||||||
|
sync_ensure_all(LATEST_URL, ALL_URL)
|
||||||
|
|
||||||
|
draws = get_all_draw_numbers()
|
||||||
|
if not draws:
|
||||||
|
raise HTTPException(status_code=404, detail="No data yet")
|
||||||
|
|
||||||
|
frequency = {n: 0 for n in range(1, 46)}
|
||||||
|
total_draws = len(draws)
|
||||||
|
|
||||||
|
for _, nums in draws:
|
||||||
|
for n in nums:
|
||||||
|
frequency[n] += 1
|
||||||
|
|
||||||
|
stats = [
|
||||||
|
{"number": n, "count": frequency[n]}
|
||||||
|
for n in range(1, 46)
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_draws": total_draws,
|
||||||
|
"frequency": stats,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 추천 성과 통계 (Phase 1, 인메모리 캐시) ──────────────────────────────────
|
||||||
|
@app.get("/api/lotto/stats/performance")
|
||||||
|
def api_performance_stats():
|
||||||
|
"""
|
||||||
|
채점된 추천 이력 기반 성과 통계 (캐시 반환).
|
||||||
|
캐시 갱신 시점: 새 회차 채점 직후 | TTL 1시간 만료 시
|
||||||
|
"""
|
||||||
|
if _PERF_CACHE["data"] is None or time.time() - _PERF_CACHE["at"] > _PERF_CACHE_TTL:
|
||||||
|
_refresh_perf_cache()
|
||||||
|
return _PERF_CACHE["data"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 회차 공략 리포트 (Phase 1) ────────────────────────────────────────────────
|
||||||
|
@app.get("/api/lotto/report/latest")
|
||||||
|
def api_report_latest():
|
||||||
|
"""
|
||||||
|
다음 회차 공략 리포트 (최신 회차 기준으로 자동 계산).
|
||||||
|
- 과출현/냉각/오버듀 번호 분석
|
||||||
|
- 최근 3회 패턴
|
||||||
|
- 3가지 전략별 추천 번호
|
||||||
|
- AI 신뢰도 점수
|
||||||
|
"""
|
||||||
|
draws = get_all_draw_numbers()
|
||||||
|
if not draws:
|
||||||
|
raise HTTPException(status_code=404, detail="No data yet")
|
||||||
|
latest = get_latest_draw()
|
||||||
|
target = latest["drw_no"] + 1
|
||||||
|
return generate_weekly_report(draws, target)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/lotto/report/history")
|
||||||
|
def api_report_history(limit: int = 10):
|
||||||
|
"""저장된 주간 리포트 목록 (자동 저장된 것만)"""
|
||||||
|
return {"reports": get_weekly_report_list(limit=min(limit, 52))}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/lotto/report/{drw_no}")
|
||||||
|
def api_report_by_draw(drw_no: int):
|
||||||
|
"""
|
||||||
|
특정 회차 공략 리포트 (캐시 우선, 없으면 실시간 생성).
|
||||||
|
"""
|
||||||
|
cached = get_weekly_report(drw_no)
|
||||||
|
if cached:
|
||||||
|
return {**cached, "cached": True}
|
||||||
|
|
||||||
|
draws = get_all_draw_numbers()
|
||||||
|
if not draws:
|
||||||
|
raise HTTPException(status_code=404, detail="No data yet")
|
||||||
|
base_draws = [(no, nums) for no, nums in draws if no < drw_no]
|
||||||
|
if not base_draws:
|
||||||
|
raise HTTPException(status_code=400, detail=f"{drw_no}회차 이전 데이터가 없습니다")
|
||||||
|
return {**generate_weekly_report(base_draws, drw_no), "cached": False}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 개인 패턴 분석 (Phase 2) ─────────────────────────────────────────────────
|
||||||
|
@app.get("/api/lotto/analysis/personal")
|
||||||
|
def api_personal_analysis():
|
||||||
|
"""
|
||||||
|
저장된 추천 이력 기반 개인 패턴 분석.
|
||||||
|
- 자주 선택한 번호 TOP 10 / 한 번도 선택 안 한 번호
|
||||||
|
- 홀짝 비율, 합계, 범위, 연속번호 포함률
|
||||||
|
- 구간별 분포, 역대 당첨번호 평균과 비교
|
||||||
|
"""
|
||||||
|
all_numbers = get_all_recommendation_numbers()
|
||||||
|
draws = get_all_draw_numbers()
|
||||||
|
return analyze_personal_patterns(all_numbers, draws)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 구매 이력 API (Phase 2) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class PurchaseCreate(BaseModel):
|
||||||
|
draw_no: int
|
||||||
|
amount: int
|
||||||
|
sets: int = 1
|
||||||
|
prize: int = 0
|
||||||
|
note: str = ""
|
||||||
|
numbers: List[List[int]] = []
|
||||||
|
is_real: bool = True
|
||||||
|
source_strategy: str = "manual"
|
||||||
|
source_detail: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseUpdate(BaseModel):
|
||||||
|
draw_no: Optional[int] = None
|
||||||
|
amount: Optional[int] = None
|
||||||
|
sets: Optional[int] = None
|
||||||
|
prize: Optional[int] = None
|
||||||
|
note: Optional[str] = None
|
||||||
|
numbers: Optional[List[List[int]]] = None
|
||||||
|
is_real: Optional[bool] = None
|
||||||
|
source_strategy: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/lotto/purchase/stats")
|
||||||
|
def api_purchase_stats():
|
||||||
|
"""투자 수익률 통계 (총 투자금, 총 당첨금, 수익률 등)"""
|
||||||
|
return get_purchase_stats()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/lotto/purchase")
|
||||||
|
def api_purchase_list(draw_no: Optional[int] = None, days: Optional[int] = None,
|
||||||
|
is_real: Optional[bool] = None, strategy: Optional[str] = None):
|
||||||
|
"""구매 이력 조회 (필터: draw_no, days, is_real, strategy)"""
|
||||||
|
return {"records": get_purchases(draw_no=draw_no, days=days, is_real=is_real, strategy=strategy)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/lotto/purchase", status_code=201)
|
||||||
|
def api_purchase_create(body: PurchaseCreate):
|
||||||
|
"""구매 이력 추가 (실제/가상)"""
|
||||||
|
sets = body.sets if body.sets > 0 else max(len(body.numbers), 1)
|
||||||
|
amount = body.amount if body.amount > 0 else sets * 1000
|
||||||
|
return add_purchase(
|
||||||
|
draw_no=body.draw_no,
|
||||||
|
amount=amount,
|
||||||
|
sets=sets,
|
||||||
|
prize=body.prize,
|
||||||
|
note=body.note,
|
||||||
|
numbers=body.numbers,
|
||||||
|
is_real=body.is_real,
|
||||||
|
source_strategy=body.source_strategy,
|
||||||
|
source_detail=body.source_detail,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/lotto/purchase/{purchase_id}")
|
||||||
|
def api_purchase_update(purchase_id: int, body: PurchaseUpdate):
|
||||||
|
"""구매 이력 수정 (당첨금 업데이트 등)"""
|
||||||
|
updated = update_purchase(purchase_id, body.model_dump(exclude_none=True))
|
||||||
|
if updated is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Purchase not found")
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/lotto/purchase/{purchase_id}")
|
||||||
|
def api_purchase_delete(purchase_id: int):
|
||||||
|
"""구매 이력 삭제"""
|
||||||
|
if not delete_purchase(purchase_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Purchase not found")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 전략 진화 API ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/api/lotto/strategy/weights")
|
||||||
|
def api_strategy_weights():
|
||||||
|
"""현재 전략별 가중치 + 성과 요약 + trend"""
|
||||||
|
return get_weights_with_trend()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/lotto/strategy/performance")
|
||||||
|
def api_strategy_performance(strategy: Optional[str] = None, days: Optional[int] = None):
|
||||||
|
"""전략별 회차 성과 이력 (차트용)"""
|
||||||
|
rows = db_get_strategy_performance(strategy=strategy, days=days)
|
||||||
|
return {"records": rows}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/lotto/strategy/evolve")
|
||||||
|
def api_strategy_evolve():
|
||||||
|
"""수동 가중치 재계산 트리거"""
|
||||||
|
new_weights = recalculate_weights()
|
||||||
|
return {"ok": True, "weights": new_weights}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 스마트 추천 API ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/api/lotto/recommend/smart")
|
||||||
|
def api_recommend_smart(sets: int = 5):
|
||||||
|
"""전략 가중치 기반 메타 전략 추천"""
|
||||||
|
sets = max(1, min(sets, 10))
|
||||||
|
result = generate_smart_recommendation(sets=sets)
|
||||||
|
if "error" in result:
|
||||||
|
raise HTTPException(status_code=500, detail=result["error"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ── 통계 분석 리포트 ────────────────────────────────────────────────────────
|
||||||
|
@app.get("/api/lotto/analysis")
|
||||||
|
def api_analysis():
|
||||||
|
"""
|
||||||
|
5가지 통계 기법 기반 분석 리포트.
|
||||||
|
- 번호별 빈도, Z-score, 갭
|
||||||
|
- 핫/콜드/오버듀 번호
|
||||||
|
- 역대 합계 분포, 홀짝 분포
|
||||||
|
"""
|
||||||
|
draws = get_all_draw_numbers()
|
||||||
|
if not draws:
|
||||||
|
raise HTTPException(status_code=404, detail="No data yet")
|
||||||
|
return get_statistical_report(draws)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 시뮬레이션 best_picks (메인 추천 엔드포인트) ────────────────────────────
|
||||||
|
@app.get("/api/lotto/best")
|
||||||
|
def api_best_picks(limit: int = 20):
|
||||||
|
"""
|
||||||
|
시뮬레이션을 통해 선별된 최적 번호 조합 반환 (기본 20쌍).
|
||||||
|
하루 6회 시뮬레이션 후 자동 갱신됨.
|
||||||
|
각 조합에 점수 및 메트릭 포함.
|
||||||
|
"""
|
||||||
|
limit = max(1, min(limit, 50))
|
||||||
|
picks = get_best_picks(limit=limit)
|
||||||
|
if not picks:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="시뮬레이션 결과가 없습니다. /api/admin/simulate로 먼저 실행하세요.",
|
||||||
|
)
|
||||||
|
|
||||||
|
draws = get_all_draw_numbers()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for p in picks:
|
||||||
|
nums = p["numbers"]
|
||||||
|
result.append({
|
||||||
|
"rank": p["rank_in_run"],
|
||||||
|
"numbers": nums,
|
||||||
|
"score_total": p["score_total"],
|
||||||
|
"based_on_draw": p["based_on_draw"],
|
||||||
|
"simulation_run_id": p["source_run_id"],
|
||||||
|
"created_at": p["created_at"],
|
||||||
|
"metrics": calc_metrics(nums),
|
||||||
|
})
|
||||||
|
|
||||||
|
latest = get_latest_draw()
|
||||||
|
return {
|
||||||
|
"based_on_draw": latest["drw_no"] if latest else None,
|
||||||
|
"count": len(result),
|
||||||
|
"items": result,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 시뮬레이션 전체 결과 조회 (상세 API) ────────────────────────────────────
|
||||||
|
@app.get("/api/lotto/simulation")
|
||||||
|
def api_simulation(run_id: Optional[int] = None, runs_limit: int = 5):
|
||||||
|
"""
|
||||||
|
시뮬레이션 실행 기록 및 상위 후보 상세 조회.
|
||||||
|
run_id 미지정 시: 최근 runs_limit개 실행 기록 + 가장 최근 run의 후보 반환.
|
||||||
|
run_id 지정 시: 해당 run의 후보만 반환.
|
||||||
|
"""
|
||||||
|
runs = get_simulation_runs(limit=runs_limit)
|
||||||
|
if not runs:
|
||||||
|
raise HTTPException(status_code=404, detail="시뮬레이션 기록이 없습니다.")
|
||||||
|
|
||||||
|
target_run_id = run_id if run_id is not None else runs[0]["id"]
|
||||||
|
candidates = get_simulation_candidates(target_run_id, limit=100)
|
||||||
|
|
||||||
|
# 후보에 메트릭 추가
|
||||||
|
enriched = []
|
||||||
|
for c in candidates:
|
||||||
|
enriched.append({
|
||||||
|
**c,
|
||||||
|
"metrics": calc_metrics(c["numbers"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"runs": runs,
|
||||||
|
"selected_run_id": target_run_id,
|
||||||
|
"candidates_count": len(enriched),
|
||||||
|
"candidates": enriched,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 종합 추론 추천 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/api/lotto/recommend/combined")
|
||||||
|
def api_recommend_combined():
|
||||||
|
"""5가지 통계 기법 종합 추론 추천 — 결과를 이력에 저장한다."""
|
||||||
|
draws = get_all_draw_numbers()
|
||||||
|
if not draws:
|
||||||
|
raise HTTPException(status_code=404, detail="No data")
|
||||||
|
|
||||||
|
latest = get_latest_draw()
|
||||||
|
result = generate_combined_recommendation(draws)
|
||||||
|
if "error" in result:
|
||||||
|
raise HTTPException(status_code=500, detail=result["error"])
|
||||||
|
|
||||||
|
# 추천 이력 저장 (태그: 종합추론)
|
||||||
|
params = {"method": "combined"}
|
||||||
|
saved = save_recommendation_dedup(
|
||||||
|
latest["drw_no"] if latest else None,
|
||||||
|
result["final_numbers"],
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
if saved["saved"]:
|
||||||
|
update_recommendation(saved["id"], tags=["종합추론"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
**result,
|
||||||
|
"id": saved["id"],
|
||||||
|
"saved": saved["saved"],
|
||||||
|
"deduped": saved["deduped"],
|
||||||
|
"based_on_latest_draw": latest["drw_no"] if latest else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/lotto/recommend/combined/history")
|
||||||
|
def api_combined_history(limit: int = 30):
|
||||||
|
"""종합추론 추천 이력 조회."""
|
||||||
|
items = list_recommendations_ex(limit=limit, tag="종합추론", sort="id_desc")
|
||||||
|
return {"items": items, "total": len(items)}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 기존 수동 추천 API (하위 호환 유지) ─────────────────────────────────────
|
||||||
|
@app.get("/api/lotto/recommend")
|
||||||
|
def api_recommend(
|
||||||
|
recent_window: int = 200,
|
||||||
|
recent_weight: float = 2.0,
|
||||||
|
avoid_recent_k: int = 5,
|
||||||
|
sum_min: Optional[int] = None,
|
||||||
|
sum_max: Optional[int] = None,
|
||||||
|
odd_min: Optional[int] = None,
|
||||||
|
odd_max: Optional[int] = None,
|
||||||
|
range_min: Optional[int] = None,
|
||||||
|
range_max: Optional[int] = None,
|
||||||
|
max_overlap_latest: Optional[int] = None,
|
||||||
|
max_try: int = 200,
|
||||||
|
):
|
||||||
|
draws = get_all_draw_numbers()
|
||||||
|
if not draws:
|
||||||
|
raise HTTPException(status_code=404, detail="No data yet")
|
||||||
|
|
||||||
|
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}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
saved = save_recommendation_dedup(
|
||||||
|
latest["drw_no"] if latest else None,
|
||||||
|
chosen,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
metrics = calc_metrics(chosen)
|
||||||
|
overlap = calc_recent_overlap(chosen, draws, last_k=avoid_recent_k)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": saved["id"],
|
||||||
|
"saved": saved["saved"],
|
||||||
|
"deduped": saved["deduped"],
|
||||||
|
"based_on_latest_draw": latest["drw_no"] if latest else None,
|
||||||
|
"numbers": chosen,
|
||||||
|
"explain": explain,
|
||||||
|
"params": params,
|
||||||
|
"metrics": metrics,
|
||||||
|
"recent_overlap": overlap,
|
||||||
|
"tries": tries,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 히트맵 기반 추천 (하위 호환 유지) ────────────────────────────────────────
|
||||||
|
@app.get("/api/lotto/recommend/heatmap")
|
||||||
|
def api_recommend_heatmap(
|
||||||
|
heatmap_window: int = 20,
|
||||||
|
heatmap_weight: float = 1.5,
|
||||||
|
recent_window: int = 200,
|
||||||
|
recent_weight: float = 2.0,
|
||||||
|
avoid_recent_k: int = 5,
|
||||||
|
sum_min: Optional[int] = None,
|
||||||
|
sum_max: Optional[int] = None,
|
||||||
|
odd_min: Optional[int] = None,
|
||||||
|
odd_max: Optional[int] = None,
|
||||||
|
range_min: Optional[int] = None,
|
||||||
|
range_max: Optional[int] = None,
|
||||||
|
max_overlap_latest: Optional[int] = None,
|
||||||
|
max_try: int = 200,
|
||||||
|
):
|
||||||
|
draws = get_all_draw_numbers()
|
||||||
|
if not draws:
|
||||||
|
raise HTTPException(status_code=404, detail="No data yet")
|
||||||
|
|
||||||
|
past_recs = list_recommendations_ex(limit=100, sort="id_desc")
|
||||||
|
latest = get_latest_draw()
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"heatmap_window": heatmap_window,
|
||||||
|
"heatmap_weight": float(heatmap_weight),
|
||||||
|
"recent_window": recent_window,
|
||||||
|
"recent_weight": float(recent_weight),
|
||||||
|
"avoid_recent_k": avoid_recent_k,
|
||||||
|
"sum_min": sum_min,
|
||||||
|
"sum_max": sum_max,
|
||||||
|
"odd_min": odd_min,
|
||||||
|
"odd_max": odd_max,
|
||||||
|
"range_min": range_min,
|
||||||
|
"range_max": range_max,
|
||||||
|
"max_overlap_latest": max_overlap_latest,
|
||||||
|
"max_try": int(max_try),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _accept(nums: List[int]) -> bool:
|
||||||
|
m = calc_metrics(nums)
|
||||||
|
if sum_min is not None and m["sum"] < sum_min:
|
||||||
|
return False
|
||||||
|
if sum_max is not None and m["sum"] > sum_max:
|
||||||
|
return False
|
||||||
|
if odd_min is not None and m["odd"] < odd_min:
|
||||||
|
return False
|
||||||
|
if odd_max is not None and m["odd"] > odd_max:
|
||||||
|
return False
|
||||||
|
if range_min is not None and m["range"] < range_min:
|
||||||
|
return False
|
||||||
|
if range_max is not None and m["range"] > range_max:
|
||||||
|
return False
|
||||||
|
if max_overlap_latest is not None:
|
||||||
|
ov = calc_recent_overlap(nums, draws, last_k=avoid_recent_k)
|
||||||
|
if ov["repeats"] > max_overlap_latest:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
chosen = None
|
||||||
|
explain = None
|
||||||
|
|
||||||
|
tries = 0
|
||||||
|
while tries < max_try:
|
||||||
|
tries += 1
|
||||||
|
result = recommend_with_heatmap(
|
||||||
|
draws,
|
||||||
|
past_recs,
|
||||||
|
heatmap_window=heatmap_window,
|
||||||
|
heatmap_weight=heatmap_weight,
|
||||||
|
recent_window=recent_window,
|
||||||
|
recent_weight=recent_weight,
|
||||||
|
avoid_recent_k=avoid_recent_k,
|
||||||
|
)
|
||||||
|
nums = result["numbers"]
|
||||||
|
if _accept(nums):
|
||||||
|
chosen = nums
|
||||||
|
explain = result["explain"]
|
||||||
|
break
|
||||||
|
|
||||||
|
if chosen is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Constraints too strict. No valid set found in max_try={max_try}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
saved = save_recommendation_dedup(
|
||||||
|
latest["drw_no"] if latest else None,
|
||||||
|
chosen,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
metrics = calc_metrics(chosen)
|
||||||
|
overlap = calc_recent_overlap(chosen, draws, last_k=avoid_recent_k)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": saved["id"],
|
||||||
|
"saved": saved["saved"],
|
||||||
|
"deduped": saved["deduped"],
|
||||||
|
"based_on_latest_draw": latest["drw_no"] if latest else None,
|
||||||
|
"numbers": chosen,
|
||||||
|
"explain": explain,
|
||||||
|
"params": params,
|
||||||
|
"metrics": metrics,
|
||||||
|
"recent_overlap": overlap,
|
||||||
|
"tries": tries,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 추천 이력 ────────────────────────────────────────────────────────────────
|
||||||
|
@app.get("/api/history")
|
||||||
|
def api_history(
|
||||||
|
limit: int = 30,
|
||||||
|
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}
|
||||||
|
|
||||||
|
|
||||||
|
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}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 배치 추천 (하위 호환 유지) ───────────────────────────────────────────────
|
||||||
|
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"],
|
||||||
|
"metrics": calc_metrics(it["numbers"]),
|
||||||
|
} for it in items],
|
||||||
|
"params": params,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class BatchSave(BaseModel):
|
||||||
|
items: List[List[int]]
|
||||||
|
params: dict
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/lotto/recommend/batch")
|
||||||
|
def api_recommend_batch_save(body: BatchSave):
|
||||||
|
latest = get_latest_draw()
|
||||||
|
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}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/version")
|
||||||
|
def version():
|
||||||
|
return {"version": os.getenv("APP_VERSION", "dev")}
|
||||||
|
|
||||||
|
|
||||||
116
lotto/app/purchase_manager.py
Normal file
116
lotto/app/purchase_manager.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"""
|
||||||
|
구매 이력 관리 + 결과 체크 모듈.
|
||||||
|
|
||||||
|
- check_purchases_for_draw(): 특정 회차 구매 건들의 결과를 자동 체크
|
||||||
|
- 체커의 _calc_rank 재사용
|
||||||
|
- 결과 체크 후 strategy_performance 자동 갱신
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from .db import (
|
||||||
|
get_draw, get_purchases, update_purchase_results,
|
||||||
|
upsert_strategy_performance,
|
||||||
|
)
|
||||||
|
from .checker import _calc_rank
|
||||||
|
|
||||||
|
logger = logging.getLogger("lotto-backend")
|
||||||
|
|
||||||
|
RANK_PRIZE = {1: 0, 2: 0, 3: 1_500_000, 4: 50_000, 5: 5_000}
|
||||||
|
|
||||||
|
|
||||||
|
def check_purchases_for_draw(drw_no: int) -> int:
|
||||||
|
"""
|
||||||
|
특정 회차 결과로 해당 회차 구매 건들을 채점한다.
|
||||||
|
Returns: 채점한 구매 건 수
|
||||||
|
"""
|
||||||
|
win_row = get_draw(drw_no)
|
||||||
|
if not win_row:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
win_nums = [win_row["n1"], win_row["n2"], win_row["n3"],
|
||||||
|
win_row["n4"], win_row["n5"], win_row["n6"]]
|
||||||
|
bonus = win_row["bonus"]
|
||||||
|
|
||||||
|
unchecked = get_purchases(draw_no=drw_no, checked=False)
|
||||||
|
|
||||||
|
strategy_agg = {}
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for purchase in unchecked:
|
||||||
|
numbers_list = purchase["numbers"]
|
||||||
|
if not numbers_list:
|
||||||
|
continue
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for nums in numbers_list:
|
||||||
|
rank, correct, has_bonus = _calc_rank(nums, win_nums, bonus)
|
||||||
|
prize = RANK_PRIZE.get(rank, 0)
|
||||||
|
results.append({
|
||||||
|
"numbers": nums,
|
||||||
|
"rank": rank,
|
||||||
|
"correct": correct,
|
||||||
|
"has_bonus": has_bonus,
|
||||||
|
"prize": prize,
|
||||||
|
})
|
||||||
|
|
||||||
|
total_prize = sum(r["prize"] for r in results)
|
||||||
|
update_purchase_results(purchase["id"], results, total_prize)
|
||||||
|
|
||||||
|
strat = purchase["source_strategy"]
|
||||||
|
if strat not in strategy_agg:
|
||||||
|
strategy_agg[strat] = {
|
||||||
|
"sets_count": 0,
|
||||||
|
"total_correct": 0,
|
||||||
|
"max_correct": 0,
|
||||||
|
"prize_total": 0,
|
||||||
|
"scores": [],
|
||||||
|
"_results": [],
|
||||||
|
}
|
||||||
|
agg = strategy_agg[strat]
|
||||||
|
agg["_results"].extend(results)
|
||||||
|
for r in results:
|
||||||
|
agg["sets_count"] += 1
|
||||||
|
agg["total_correct"] += r["correct"]
|
||||||
|
agg["max_correct"] = max(agg["max_correct"], r["correct"])
|
||||||
|
agg["prize_total"] += r["prize"]
|
||||||
|
agg["scores"].append(r["correct"] / 6.0)
|
||||||
|
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
for strat, agg in strategy_agg.items():
|
||||||
|
avg_score = sum(agg["scores"]) / len(agg["scores"]) if agg["scores"] else 0.0
|
||||||
|
upsert_strategy_performance(
|
||||||
|
strategy=strat,
|
||||||
|
draw_no=drw_no,
|
||||||
|
sets_count=agg["sets_count"],
|
||||||
|
total_correct=agg["total_correct"],
|
||||||
|
max_correct=agg["max_correct"],
|
||||||
|
prize_total=agg["prize_total"],
|
||||||
|
avg_score=round(avg_score, 4),
|
||||||
|
)
|
||||||
|
|
||||||
|
# EMA 피드백 루프: 전략 가중치 진화
|
||||||
|
try:
|
||||||
|
from .strategy_evolver import evolve_after_check
|
||||||
|
evolve_after_check(strat, drw_no, agg["_results"])
|
||||||
|
except Exception:
|
||||||
|
logger.debug(f"[purchase_manager] evolve_after_check 건너뜀: {strat}")
|
||||||
|
|
||||||
|
logger.info(f"[purchase_manager] {drw_no}회차 구매 {count}건 체크 완료")
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def get_recent_performance(limit: int = 3) -> list:
|
||||||
|
"""최근 N회차 내 구매 성과 요약. 없으면 빈 리스트."""
|
||||||
|
from . import db
|
||||||
|
purchases = db.get_purchases() or []
|
||||||
|
by_draw: dict = {}
|
||||||
|
for p in purchases:
|
||||||
|
d = p.get("draw_no")
|
||||||
|
if not d:
|
||||||
|
continue
|
||||||
|
results = p.get("results") or []
|
||||||
|
max_correct = max((int(r.get("correct") or 0) for r in results), default=0)
|
||||||
|
slot = by_draw.setdefault(d, {"draw_no": d, "purchased_sets": 0, "best_match": 0})
|
||||||
|
slot["purchased_sets"] += int(p.get("sets") or 1)
|
||||||
|
slot["best_match"] = max(slot["best_match"], max_correct)
|
||||||
|
return sorted(by_draw.values(), key=lambda x: -x["draw_no"])[:limit]
|
||||||
139
lotto/app/recommender.py
Normal file
139
lotto/app/recommender.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import random
|
||||||
|
from collections import Counter
|
||||||
|
from typing import Dict, Any, List, Tuple
|
||||||
|
|
||||||
|
from .utils import weighted_sample_6
|
||||||
|
|
||||||
|
def recommend_numbers(
|
||||||
|
draws: List[Tuple[int, List[int]]],
|
||||||
|
*,
|
||||||
|
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_sorted = sorted(weighted_sample_6(weights))
|
||||||
|
|
||||||
|
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}
|
||||||
|
|
||||||
|
|
||||||
|
def recommend_with_heatmap(
|
||||||
|
draws: List[Tuple[int, List[int]]],
|
||||||
|
past_recommendations: List[Dict[str, Any]],
|
||||||
|
*,
|
||||||
|
heatmap_window: int = 10,
|
||||||
|
heatmap_weight: float = 1.5,
|
||||||
|
recent_window: int = 200,
|
||||||
|
recent_weight: float = 2.0,
|
||||||
|
avoid_recent_k: int = 5,
|
||||||
|
seed: int | None = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
히트맵 기반 가중치 추천:
|
||||||
|
- 과거 추천 번호들의 적중률을 분석하여 잘 맞춘 번호에 가중치 부여
|
||||||
|
- 기존 통계 기반 추천과 결합
|
||||||
|
|
||||||
|
Args:
|
||||||
|
draws: 실제 당첨 번호 리스트 [(회차, [번호들]), ...]
|
||||||
|
past_recommendations: 과거 추천 데이터 [{"numbers": [...], "correct_count": N, "based_on_draw": M}, ...]
|
||||||
|
heatmap_window: 히트맵 분석할 최근 추천 개수
|
||||||
|
heatmap_weight: 히트맵 가중치 (높을수록 과거 적중 번호 선호)
|
||||||
|
"""
|
||||||
|
if seed is not None:
|
||||||
|
random.seed(seed)
|
||||||
|
|
||||||
|
# 1. 기존 통계 기반 가중치 계산
|
||||||
|
all_nums = [n for _, nums in draws for n in nums]
|
||||||
|
freq_all = Counter(all_nums)
|
||||||
|
|
||||||
|
recent = draws[-recent_window:] if len(draws) >= recent_window else draws
|
||||||
|
recent_nums = [n for _, nums in recent for n in nums]
|
||||||
|
freq_recent = Counter(recent_nums)
|
||||||
|
|
||||||
|
last_k = draws[-avoid_recent_k:] if len(draws) >= avoid_recent_k else draws
|
||||||
|
last_k_nums = set(n for _, nums in last_k for n in nums)
|
||||||
|
|
||||||
|
# 2. 히트맵 생성: 과거 추천에서 적중한 번호들의 빈도
|
||||||
|
heatmap = Counter()
|
||||||
|
recent_recs = past_recommendations[-heatmap_window:] if len(past_recommendations) >= heatmap_window else past_recommendations
|
||||||
|
|
||||||
|
for rec in recent_recs:
|
||||||
|
if rec.get("correct_count", 0) > 0: # 적중한 추천만
|
||||||
|
# 적중 개수에 비례해서 가중치 부여 (많이 맞춘 추천일수록 높은 가중)
|
||||||
|
weight = rec["correct_count"] ** 1.5 # 제곱으로 강조
|
||||||
|
for num in rec["numbers"]:
|
||||||
|
heatmap[num] += weight
|
||||||
|
|
||||||
|
# 3. 최종 가중치 = 기존 통계 + 히트맵
|
||||||
|
weights = {}
|
||||||
|
for n in range(1, 46):
|
||||||
|
w = freq_all[n] + recent_weight * freq_recent[n]
|
||||||
|
|
||||||
|
# 히트맵 가중치 추가
|
||||||
|
if n in heatmap:
|
||||||
|
w += heatmap_weight * heatmap[n]
|
||||||
|
|
||||||
|
# 최근 출현 번호 패널티
|
||||||
|
if n in last_k_nums:
|
||||||
|
w *= 0.6
|
||||||
|
|
||||||
|
weights[n] = max(w, 0.1)
|
||||||
|
|
||||||
|
# 4. 가중 샘플링으로 6개 선택
|
||||||
|
chosen_sorted = sorted(weighted_sample_6(weights))
|
||||||
|
|
||||||
|
# 5. 설명 데이터
|
||||||
|
explain = {
|
||||||
|
"recent_window": recent_window,
|
||||||
|
"recent_weight": recent_weight,
|
||||||
|
"avoid_recent_k": avoid_recent_k,
|
||||||
|
"heatmap_window": heatmap_window,
|
||||||
|
"heatmap_weight": heatmap_weight,
|
||||||
|
"top_all": [n for n, _ in freq_all.most_common(10)],
|
||||||
|
"top_recent": [n for n, _ in freq_recent.most_common(10)],
|
||||||
|
"top_heatmap": [n for n, _ in heatmap.most_common(10)],
|
||||||
|
"last_k_draws": [d for d, _ in last_k],
|
||||||
|
"analyzed_recommendations": len(recent_recs),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"numbers": chosen_sorted, "explain": explain}
|
||||||
|
|
||||||
0
lotto/app/routers/__init__.py
Normal file
0
lotto/app/routers/__init__.py
Normal file
53
lotto/app/routers/briefing.py
Normal file
53
lotto/app/routers/briefing.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""브리핑 저장/조회 + 큐레이터 사용량 엔드포인트."""
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from .. import db
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/lotto")
|
||||||
|
|
||||||
|
|
||||||
|
class BriefingRequest(BaseModel):
|
||||||
|
draw_no: int
|
||||||
|
picks: List[Dict[str, Any]]
|
||||||
|
narrative: Dict[str, Any]
|
||||||
|
confidence: int = Field(ge=0, le=100)
|
||||||
|
model: str
|
||||||
|
tokens_input: int = 0
|
||||||
|
tokens_output: int = 0
|
||||||
|
cache_read: int = 0
|
||||||
|
cache_write: int = 0
|
||||||
|
latency_ms: int = 0
|
||||||
|
source: str = "auto"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/briefing", status_code=201)
|
||||||
|
def save_briefing(body: BriefingRequest):
|
||||||
|
bid = db.save_briefing(body.model_dump())
|
||||||
|
return {"ok": True, "id": bid}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/briefing/latest")
|
||||||
|
def latest():
|
||||||
|
b = db.get_latest_briefing()
|
||||||
|
if not b:
|
||||||
|
raise HTTPException(404, "no briefing yet")
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/briefing/{draw_no}")
|
||||||
|
def get_one(draw_no: int):
|
||||||
|
b = db.get_briefing(draw_no)
|
||||||
|
if not b:
|
||||||
|
raise HTTPException(404, f"no briefing for draw {draw_no}")
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/briefing")
|
||||||
|
def history(limit: int = 10):
|
||||||
|
return {"briefings": db.list_briefings(limit)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/curator/usage")
|
||||||
|
def usage(days: int = 30):
|
||||||
|
return db.get_curator_usage(days)
|
||||||
24
lotto/app/routers/curator.py
Normal file
24
lotto/app/routers/curator.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""큐레이터 입력 엔드포인트 — agent-office에서만 호출."""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from ..curator_helpers import collect_candidates, build_context
|
||||||
|
from .. import db
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/lotto/curator")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/candidates")
|
||||||
|
def candidates(n: int = 20):
|
||||||
|
ctx = build_context()
|
||||||
|
hot = set(ctx["hot_numbers"])
|
||||||
|
cold = set(ctx["cold_numbers"])
|
||||||
|
latest = db.get_latest_draw()
|
||||||
|
draw_no = (latest["drw_no"] + 1) if latest else 0
|
||||||
|
items = collect_candidates(n, hot, cold)
|
||||||
|
return {"draw_no": draw_no, "candidates": items}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/context")
|
||||||
|
def context():
|
||||||
|
latest = db.get_latest_draw()
|
||||||
|
draw_no = (latest["drw_no"] + 1) if latest else 0
|
||||||
|
return {"draw_no": draw_no, **build_context()}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user