Compare commits
451 Commits
05e7ffdfd9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c8684280af | |||
| 6895e2f8dc | |||
| 34619dc70b | |||
| 47cdc43aa5 | |||
| 2270072fe5 | |||
| 15f24dc890 | |||
| 2915f2b697 | |||
| 7640a2b4a8 | |||
| 427522bd1a | |||
| 0bddc5c607 | |||
| 54c677f75a | |||
| 01bb837525 | |||
| 8ceb0af736 | |||
| ecf1f643b2 | |||
| 077d411f83 | |||
| 6674755800 | |||
| d919c75ea7 | |||
| 3a71c91eeb | |||
| 9d0e9aa8aa | |||
| d9c39a0206 | |||
| 0f73b6b07d | |||
| faffca0967 | |||
| 49c5c57be5 | |||
| 6053e69afc | |||
| 1e5e1bcdff | |||
| 64fbbb7958 | |||
| cfbb72051f | |||
| bf5897fc85 | |||
| ad6c744f2c | |||
| aad9bfbe8b | |||
| 42bd53ee7b | |||
| 86694ae4fe | |||
| 41225b3337 | |||
| 6bb5c2fb40 | |||
| bd1773e29e | |||
| 685320f3cf | |||
| b3982c8f72 | |||
| 002c0893f8 | |||
| d6081ba2d3 | |||
| 10cb3ae1df | |||
| e3348da642 | |||
| 088bbaa097 | |||
| be322557ee | |||
| 70438caa1f | |||
| e16029ebdb | |||
| cefc3119c0 | |||
| 5485d4858a | |||
| fbd963db86 | |||
| 9095423026 | |||
| 6eb24090ed | |||
| 8cb5a01431 | |||
| 8a4a8790ca | |||
| 2200748122 | |||
| 7bc0a7cd77 | |||
| b84efd730b | |||
| 11bd223612 | |||
| c3a5d7210f | |||
| 07c4459085 | |||
| c057304981 | |||
| d1245d040c | |||
| 34ca407ca2 | |||
| b1ef778fc5 | |||
| 30706e2eb6 | |||
| 6062445c12 | |||
| 13da2226c3 | |||
| 1e377e1559 | |||
| eb75d692f5 | |||
| 6c25866487 | |||
| 6ac7469f26 | |||
| d1b2b6a4ba | |||
| 2abfa5cb23 | |||
| 227e294bd3 | |||
| ace0339d33 | |||
| 8812bd870a | |||
| b3fac4f442 | |||
| 19aed304cb | |||
| bbe5221e57 | |||
| ec0ccf649e | |||
| 84d90f6e1c | |||
| ddfe0ca3eb | |||
| 943f676414 | |||
| 06162b1e6e | |||
| c3659eb6c5 | |||
| 16941d76e8 | |||
| 9f91dae1a4 | |||
| 2a552d3cc8 | |||
| f37b21a408 | |||
| df7a8d985e | |||
| c5d0c84183 | |||
| 53a78a1062 | |||
| ca8bcb3fed | |||
| 4b4f91c052 | |||
| 6c3a84b8ec | |||
| 2ff2645240 | |||
| f2143b3889 | |||
| 810cc76d40 | |||
| 0a91f43c46 | |||
| 3d321f2b4b | |||
| 6ba29599aa | |||
| 658ed13571 | |||
| 15ee3c3301 | |||
| 2b5009f864 | |||
| d9b612253a | |||
| db4322006d | |||
| a05e6ba8ca | |||
| 4a333434ac | |||
| 119ac88e1e | |||
| c4cb18a25c | |||
| 50e811c5dd | |||
| 5ec7c2461b | |||
| 5f0fed7f13 | |||
| 070f2de3f1 | |||
| 01ebd2e7d9 | |||
| 7db9869722 | |||
| 97cb38ca7f | |||
| 90c408aa77 | |||
| 55f2fa9cff | |||
| 3ded781059 | |||
| 4eaeea9833 | |||
| 9709e5b019 | |||
| 94d6a39ce8 | |||
| 804fdcba26 | |||
| 204cee67d6 | |||
| 779e78405e | |||
| 16a651f670 | |||
| e508b7dc35 | |||
| 6c5481971b | |||
| d7e235c008 | |||
| 8707d322e4 | |||
| b4dd21e67a | |||
| 448dbd5f48 | |||
| a826e00399 | |||
| 134e628e5e | |||
| ce3a734e81 | |||
| fb81c51dc8 | |||
| 715e1598ce | |||
| 57a4a72ff1 | |||
| e14278ec69 | |||
| ff3134b838 | |||
| 95c5dc4217 | |||
| 9fb1c37eae | |||
| 3bd819b5e2 | |||
| b936233e7c | |||
| 4f85496fe5 | |||
| 2a2209a86c | |||
| 30bc627ae7 | |||
| d972ea66c3 | |||
| 66165ebb88 | |||
| 5621cc7687 | |||
| fb54998def | |||
| b792cdb8d5 | |||
| 1d4bff31c4 | |||
| e31bf549a8 | |||
| aec0fdcd31 | |||
| f1f1dc98a6 | |||
| 8b5cb2c16a | |||
| 77b8d05ad7 | |||
| f0cb06268e | |||
| f074cbec2d | |||
| 84548a326e | |||
| 5f5010ded4 | |||
| 755dea63f4 | |||
| 20c5268def | |||
| dc3f9cb6a9 | |||
| 262366bc1e | |||
| 5fc914cd8f | |||
| 8f859274c4 | |||
| a347da075c | |||
| e754fb30f5 | |||
| f0c0c18beb | |||
| d11023decb | |||
| 70a256bbe4 | |||
| ebbfa6299a | |||
| d4fb485931 | |||
| b6dffb4d42 | |||
| 240bd38541 | |||
| bb0b0dff25 | |||
| 47e5315487 | |||
| 97b15cb985 | |||
| 6d416aab78 | |||
| 2c13e7cc85 | |||
| 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 |
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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
79
.env.example
79
.env.example
@@ -49,4 +49,81 @@ PGID=1000
|
||||
# 실제 KIS API 호출 및 AI 분석은 Windows PC에서 수행됩니다.
|
||||
|
||||
# Windows AI Server (NAS 입장에서 바라본 Windows PC IP)
|
||||
WINDOWS_AI_SERVER_URL=http://192.168.0.5:8000
|
||||
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
|
||||
|
||||
# Admin API Key — /api/trade/* 등 민감 엔드포인트 보호.
|
||||
# 운영 .env에는 반드시 값을 채워야 함. 빈 값이면 503 응답으로 거부됨 (CODE_REVIEW F2).
|
||||
ADMIN_API_KEY=
|
||||
|
||||
# 개발 모드: 위 ADMIN_API_KEY 비워둔 채로 trade/admin 엔드포인트 호출 허용.
|
||||
# 운영 환경에서는 절대 true로 두지 말 것. 기본 false (보호 활성).
|
||||
ALLOW_UNAUTHENTICATED_ADMIN=false
|
||||
|
||||
# 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=
|
||||
# LAN IP로 DSM 접근 시 self-signed cert가 IP에 매칭 안 되어 검증 실패. 그 경우 false 설정 (LAN 내부 통신이라 허용 가능). 도메인 + 정상 cert면 true 유지.
|
||||
DSM_VERIFY_SSL=true
|
||||
|
||||
# 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
|
||||
|
||||
# DSM·Supabase에 노출되는 NAS 호스트 절대경로 (PACK_DATA_PATH와 같은 디렉토리를 호스트 시점에서 가리킴).
|
||||
# 운영 NAS는 반드시 /volume1/docker/webpage/media/packs 같은 절대경로 설정.
|
||||
# 미설정 시 PACK_DATA_PATH로 fallback (로컬 개발용).
|
||||
PACK_HOST_DIR=/docker/webpage/media/packs
|
||||
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -63,3 +63,14 @@ uploads/
|
||||
################################
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
||||
################################
|
||||
# Local working files
|
||||
################################
|
||||
# Superpowers 스킬 캐시·세션 메타
|
||||
.superpowers/
|
||||
# 임시 코드 리뷰 노트 (작업 끝나면 폐기 또는 docs/로 이동)
|
||||
CODE_REVIEW.md
|
||||
|
||||
706
CLAUDE.md
Normal file
706
CLAUDE.md
Normal file
@@ -0,0 +1,706 @@
|
||||
# CLAUDE.md — web-backend 프로젝트 가이드
|
||||
|
||||
> Claude Code가 이 프로젝트를 작업할 때 참조하는 설정 및 구조 문서.
|
||||
|
||||
---
|
||||
|
||||
## 1. 프로젝트 개요
|
||||
|
||||
Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||
- **서비스**: lotto-lab, stock, travel-proxy, music-lab, insta-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/ # stock 소스 (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` | 18500 | 주식 뉴스·AI 분석·KIS API 연동 |
|
||||
| `music-lab` | 18600 | AI 음악 생성·라이브러리 관리 API |
|
||||
| `insta-lab` | 18700 | 인스타 카드 피드 자동 생성 (뉴스→키워드→10페이지 카드) |
|
||||
| `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:8000` | stock API |
|
||||
| `/api/trade/` | `stock:8000` | KIS 실계좌 API |
|
||||
| `/api/portfolio` | `stock:8000` | trailing slash 유무 모두 매칭 |
|
||||
| `/api/music/` | `music-lab:8000` | AI 음악 생성·라이브러리 API |
|
||||
| `/api/insta/` | `insta-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 |
|
||||
| Insta 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 (stock/)
|
||||
- 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 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}` | 커스텀 지역 이름/좌표 수정 (지도 핀 표시용) |
|
||||
|
||||
### insta-lab (insta-lab/)
|
||||
- 인스타그램 카드 피드 자동 생성 — 뉴스 모니터링 → 키워드 추출 → 10페이지 카드 카피 + PNG 렌더 → 텔레그램 푸시 → 사용자 수동 업로드
|
||||
- DB: `/app/data/insta.db` (news_articles, trending_keywords, card_slates, card_assets, generation_tasks, prompt_templates)
|
||||
- 카드 사이즈: 1080×1350 (인스타 4:5 세로)
|
||||
- 카드 렌더: Jinja2 템플릿 → Playwright headless Chromium 스크린샷
|
||||
- 파일 구조: `app/main.py`, `config.py`, `db.py`, `news_collector.py`, `keyword_extractor.py`, `card_writer.py`, `card_renderer.py`, `templates/default/card.html.j2`
|
||||
|
||||
**환경변수**
|
||||
- `NAVER_CLIENT_ID` / `NAVER_CLIENT_SECRET`: 네이버 검색 API
|
||||
- `ANTHROPIC_API_KEY`: Claude API (Haiku=키워드 정제, Sonnet=카드 카피)
|
||||
- `ANTHROPIC_MODEL_HAIKU` / `ANTHROPIC_MODEL_SONNET`: 모델명 오버라이드
|
||||
- `INSTA_DATA_PATH`: SQLite + 카드 PNG 저장 경로 (기본 `/app/data`)
|
||||
- `CARD_TEMPLATE_DIR`: HTML 템플릿 디렉토리 (기본 `/app/app/templates`)
|
||||
- `INSTA_DEFAULT_THEME`: 카드 렌더에 사용할 theme 디렉토리명 (기본 `default`). `templates/<theme>/card.html.j2`가 없으면 자동으로 default 폴백
|
||||
- `NEWS_PER_CATEGORY` / `KEYWORDS_PER_CATEGORY`: 수집·추출 limit 튜닝
|
||||
|
||||
**카테고리 시드 키워드**
|
||||
- 기본 economy / psychology / celebrity 3종 (config.DEFAULT_CATEGORY_SEEDS)
|
||||
- `prompt_templates.name='category_seeds'`에 JSON으로 오버라이드 가능
|
||||
|
||||
**카드 슬레이트 (`card_slates`)**
|
||||
- status: `draft` → `rendered` → `sent` (또는 `failed`)
|
||||
- cover_copy / body_copies (8개) / cta_copy / suggested_caption / hashtags JSON 컬럼
|
||||
- accent_color는 카테고리별 기본값 (economy=#0F62FE, psychology=#A66CFF, celebrity=#FF5C8A)
|
||||
|
||||
**스케줄러 job (agent-office)**
|
||||
- 09:30 매일 — `_run_insta_schedule` (insta_pipeline) → 뉴스 수집 → 키워드 추출 → 텔레그램 후보 푸시
|
||||
- `agent_config.custom_config.auto_select=True`이면 카테고리당 1위 키워드 자동 슬레이트 생성·발송
|
||||
|
||||
**디자인 import (사용자 디자인 PNG → Claude Vision → Jinja HTML 자동 생성)**
|
||||
- `insta-lab/app/templates/<theme>/pages/*.png` (10장, 1080×1350, placeholder 텍스트 박혀있는 형태) → Claude Sonnet Vision → `templates/<theme>/card.html.j2` 자동 생성
|
||||
- CLI: `docker exec insta-lab python -m app.design_importer <theme>`
|
||||
- 파일명 자동 매핑: `cover`/`start`/`intro` → page 1, `cta`/`outro`/`finish`/`end` → page 10, 나머지 알파벳 순 → page 2~9
|
||||
- 매핑 override: `pages/_order.json`에 `{filename: page_no}` 명시 (10장 + page 1~10 완전 매핑일 때만 적용)
|
||||
- Vision prompt에 placeholder 마스킹 요구 포함 (2-layer: 마스킹 박스 + 동적 텍스트 layer)
|
||||
- 기존 HTML 자동 백업 (`card.html.j2.bak.YYYYMMDD-HHMMSS`)
|
||||
- Jinja 문법 깨진 응답은 `card.html.j2.error.txt`로 보존 + ValueError
|
||||
- 활성화: NAS `.env`에 `INSTA_DEFAULT_THEME=<theme>` 추가 + `docker compose restart insta-lab`
|
||||
- 토큰 비용: 1회당 ~15K tokens (~$0.05 Sonnet 기준)
|
||||
|
||||
**insta-lab API 목록**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/insta/status` | 서비스 상태 (NAVER/ANTHROPIC 키 여부) |
|
||||
| POST | `/api/insta/news/collect` | 뉴스 수집 트리거 (BackgroundTask) |
|
||||
| GET | `/api/insta/news/articles` | 수집 기사 목록 (category, days) |
|
||||
| POST | `/api/insta/keywords/extract` | 키워드 추출 트리거 (BackgroundTask) |
|
||||
| GET | `/api/insta/keywords` | 트렌딩 키워드 목록 (category, used) |
|
||||
| POST | `/api/insta/slates` | 슬레이트 생성 (keyword, category) |
|
||||
| GET | `/api/insta/slates` | 슬레이트 목록 |
|
||||
| GET | `/api/insta/slates/{id}` | 슬레이트 상세 + 자산 |
|
||||
| POST | `/api/insta/slates/{id}/render` | 카드 렌더 재시도 |
|
||||
| GET | `/api/insta/slates/{id}/assets/{page}` | 카드 PNG 다운로드 (1~10) |
|
||||
| DELETE | `/api/insta/slates/{id}` | 슬레이트 삭제 (자산 파일 포함) |
|
||||
| GET | `/api/insta/tasks/{task_id}` | BackgroundTask 상태 폴링 |
|
||||
| GET/PUT | `/api/insta/templates/prompts/{name}` | 프롬프트 템플릿 CRUD |
|
||||
|
||||
### agent-office (agent-office/)
|
||||
- AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 에이전트가 실제 작업 수행
|
||||
- stock/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_URL`: stock 내부 URL (기본 `http://stock: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`
|
||||
- 경로 3분리: `PACK_DATA_PATH`(호스트 OS path, docker volume 좌측) → `PACK_BASE_DIR`(컨테이너 내부, upload 저장 target) → `PACK_HOST_DIR`(DSM API path, Supabase에 저장). 운영 NAS에서 `PACK_HOST_DIR` 미설정 시 sign-link가 컨테이너 경로를 DSM에 전달해 파일을 못 찾음.
|
||||
- ⚠️ **DSM API path 형식**: Synology DSM API는 일반 사용자 권한일 때 `/<shared_folder>/...` 형식만 인식하고 `/volume1/...` 절대경로는 거부(error 408). 운영 NAS는 반드시 `PACK_HOST_DIR=/docker/webpage/media/packs` (shared folder 시점) 설정. admin 사용자만 `/volume1/...` 사용 가능하나 보안상 권장 안 함.
|
||||
|
||||
**환경변수**
|
||||
- `DSM_HOST` / `DSM_USER` / `DSM_PASS`: Synology DSM 7.x 인증 (공유 링크 발급용)
|
||||
- `DSM_VERIFY_SSL`: SSL 검증 (default `true`). LAN IP + self-signed cert 환경에서 IP mismatch 시 `false` 설정 (LAN 내부 통신이라 허용)
|
||||
- `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_HOST_DIR`: DSM API용 path. **운영 NAS는 `/docker/webpage/media/packs` (shared folder 시점)**. 미설정 시 `PACK_BASE_DIR`로 fallback (DSM 호출 X 환경에서만 안전)
|
||||
- `PACK_DATA_PATH`: docker-compose volume 마운트의 호스트 측 OS 경로 (로컬 `./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 (single-shot) → multipart 5GB 저장 + Supabase INSERT |
|
||||
| POST | `/api/packs/upload/init` | Bearer token → chunked upload 세션 초기화 (`session_id = jti`, `chunk_max_size` 반환). init만 jti consume |
|
||||
| PUT | `/api/packs/upload/{session_id}/chunk?offset=N` | 동일 Bearer token → 부분파일 append (offset 불일치 시 409 + `X-Current-Offset` 헤더) |
|
||||
| GET | `/api/packs/upload/{session_id}/status` | 동일 Bearer token → `{written, expected_size}` 조회 (재개용) |
|
||||
| POST | `/api/packs/upload/{session_id}/complete` | 동일 Bearer token → 부분파일 rename + Supabase INSERT |
|
||||
| DELETE | `/api/packs/upload/{session_id}` | 동일 Bearer token → 세션 중단 + 부분파일 정리 |
|
||||
| GET | `/api/packs/list` | Vercel HMAC → 활성 pack_files 목록 (deleted_at IS NULL) |
|
||||
| DELETE | `/api/packs/{file_id}` | Vercel HMAC → soft delete (DSM 공유는 자동 만료) |
|
||||
|
||||
**Chunked upload 흐름 (5GB+ 안정성)**
|
||||
- 같은 mint-token을 init·chunk·status·complete·abort 전체에서 Bearer로 재사용 (jti consume은 init에서만)
|
||||
- 세션 state: 컨테이너 내부 `PACK_BASE_DIR/.uploads/{jti}/meta.json + data.part`
|
||||
- chunk 재시도: 클라이언트는 PUT 응답 헤더 `X-Current-Offset` 또는 `GET /status`로 재개 지점 확인
|
||||
- 환경변수 `PACK_CHUNK_MAX_SIZE` (기본 64MB) — 너무 크면 nginx buffering 부담, 너무 작으면 RTT 비용
|
||||
|
||||
### 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/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`으로 비활성화 후 신규 입력
|
||||
- **insta-lab Playwright**: NAS에서 chromium 빌드는 가능하지만 +500MB 이미지. 메모리 부족 시 카드 렌더 실패 가능 — 한 번에 1슬레이트만 렌더하도록 직렬화됨
|
||||
409
README.md
409
README.md
@@ -1,32 +1,45 @@
|
||||
# web-backend
|
||||
|
||||
Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
|
||||
로또 분석, 주식 포트폴리오, 여행 앨범, 블로그, 투두리스트를 하나의 서비스로 운영한다.
|
||||
로또 분석, 주식 포트폴리오, AI 음악 생성, 인스타 카드 피드, 부동산 청약, AI 에이전트 오피스, 여행 앨범, 개인 서비스(포트폴리오·블로그·투두), NAS 자료 다운로드 자동화를 하나의 Docker Compose 스택으로 운영한다.
|
||||
|
||||
---
|
||||
|
||||
## 서비스 구성
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ lotto-frontend (Nginx:8080) │
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ frontend (Nginx:8080) │
|
||||
│ ├── 정적 SPA 서빙 (React + Vite) │
|
||||
│ └── API 리버스 프록시 │
|
||||
│ ├── /api/ → lotto-backend:8000 │
|
||||
│ ├── /api/stock/ → stock-lab:8000 │
|
||||
│ ├── /api/trade/ → stock-lab:8000 │
|
||||
│ ├── /api/portfolio → stock-lab:8000 │
|
||||
│ ├── /api/ → lotto:8000 (로또) │
|
||||
│ ├── /api/stock/, /trade/ → stock:8000 │
|
||||
│ ├── /api/portfolio → stock:8000 │
|
||||
│ ├── /api/music/ → music-lab:8000 │
|
||||
│ ├── /api/insta/ → insta-lab:8000 │
|
||||
│ ├── /api/realestate/ → realestate-lab:8000 │
|
||||
│ ├── /api/agent-office/ → agent-office:8000 (+ WebSocket) │
|
||||
│ ├── /api/profile/, /todos, /blog/ → personal:8000 │
|
||||
│ ├── /api/packs/ → packs-lab:8000 (HMAC + 5GB upload) │
|
||||
│ ├── /api/travel/ → travel-proxy:8000 │
|
||||
│ ├── /media/music/, /media/videos/ (nginx 직접 서빙, 미디어) │
|
||||
│ ├── /media/travel/… (nginx 직접 서빙, 사진/썸네일) │
|
||||
│ └── /webhook → deployer:9000 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 컨테이너 | 포트 | 역할 |
|
||||
|---------|------|------|
|
||||
| `lotto-backend` | 18000 | 로또·블로그·투두 API |
|
||||
| `stock-lab` | 18500 | 주식 뉴스·포트폴리오·자산 추적 |
|
||||
| `travel-proxy` | 19000 | 여행 사진 API + 썸네일 생성 |
|
||||
| `lotto-frontend` | 8080 | SPA 서빙 + 리버스 프록시 |
|
||||
| `lotto` | 18000 | 로또 데이터 수집·분석·추천 API |
|
||||
| `stock` | 18500 | 주식 뉴스·AI 요약·KIS 실계좌·포트폴리오·자산 추적 |
|
||||
| `music-lab` | 18600 | AI 음악 생성 (Suno + 로컬 MusicGen 듀얼 프로바이더) + YouTube 수익화 |
|
||||
| `insta-lab` | 18700 | 인스타 카드 피드 자동 생성 (뉴스→키워드→10페이지 카드, Playwright) |
|
||||
| `realestate-lab` | 18800 | 청약 공고 자동 수집·5티어 매칭·신규 매칭 push |
|
||||
| `agent-office` | 18900 | AI 에이전트 가상 오피스 (WebSocket + 텔레그램 봇) |
|
||||
| `personal` | 18850 | 개인 서비스 — 포트폴리오·블로그·투두 통합 |
|
||||
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 청크 업로드) |
|
||||
| `travel-proxy` | 19000 | 여행 사진 API + 온디맨드 썸네일 |
|
||||
| `frontend` | 8080 | SPA 서빙 + 리버스 프록시 |
|
||||
| `webpage-deployer` | 19010 | Gitea Webhook → 자동 배포 |
|
||||
|
||||
---
|
||||
@@ -35,47 +48,21 @@ Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
|
||||
|
||||
```
|
||||
web-backend/
|
||||
├── backend/ # lotto-backend 서비스 (Python/FastAPI)
|
||||
│ ├── app/
|
||||
│ │ ├── main.py # 라우터, 스케줄러
|
||||
│ │ ├── db.py # SQLite CRUD (7개 테이블)
|
||||
│ │ ├── generator.py # 몬테카를로 시뮬레이션 엔진
|
||||
│ │ ├── analyzer.py # 5가지 통계 분석
|
||||
│ │ ├── checker.py # 당첨 결과 채점
|
||||
│ │ ├── collector.py # 로또 데이터 수집
|
||||
│ │ ├── recommender.py # 추천 알고리즘
|
||||
│ │ └── utils.py # 메트릭 계산
|
||||
│ └── Dockerfile
|
||||
│
|
||||
├── stock-lab/ # stock-lab 서비스 (Python/FastAPI)
|
||||
│ ├── app/
|
||||
│ │ ├── main.py # 라우터, 스케줄러
|
||||
│ │ ├── db.py # SQLite CRUD (4개 테이블)
|
||||
│ │ ├── scraper.py # 네이버 금융 뉴스 크롤링
|
||||
│ │ ├── price_fetcher.py # 현재가 조회 (3분 캐시)
|
||||
│ │ └── holidays.json # 한국 주식시장 휴장일
|
||||
│ └── Dockerfile
|
||||
│
|
||||
├── travel-proxy/ # travel-proxy 서비스 (Python/FastAPI)
|
||||
│ ├── app/
|
||||
│ │ └── main.py # 사진 API, 썸네일 생성 (Pillow)
|
||||
│ └── Dockerfile
|
||||
│
|
||||
├── lotto/ # 로또 추천·통계·시뮬레이션
|
||||
├── stock/ # 주식·포트폴리오·KIS 연동
|
||||
├── music-lab/ # AI 음악 생성 + YouTube 수익화
|
||||
├── insta-lab/ # 인스타 카드 피드 자동 생성 (Playwright)
|
||||
├── realestate-lab/ # 청약 자동 수집·5티어 매칭
|
||||
├── agent-office/ # AI 에이전트 오피스 (WS + 텔레그램)
|
||||
├── personal/ # 포트폴리오·블로그·투두 통합
|
||||
├── packs-lab/ # NAS 자료 다운로드 자동화 (HMAC + Supabase)
|
||||
├── travel-proxy/ # 여행 사진 + 썸네일
|
||||
├── deployer/ # Gitea Webhook 수신 → 자동 배포
|
||||
│ ├── app.py # HMAC SHA256 검증 + 배포 트리거
|
||||
│ └── Dockerfile
|
||||
│
|
||||
├── nginx/
|
||||
│ └── default.conf # 리버스 프록시 + SPA + 캐시
|
||||
│
|
||||
├── scripts/
|
||||
│ ├── deploy.sh # 운영 배포 (git pull → rsync → compose up)
|
||||
│ ├── deploy-nas.sh # rsync 전용 스크립트
|
||||
│ └── healthcheck.sh # 전체 서비스 헬스 체크
|
||||
│
|
||||
├── nginx/default.conf # 리버스 프록시 + SPA + 캐시
|
||||
├── scripts/ # deploy.sh, deploy-nas.sh, healthcheck.sh
|
||||
├── docker-compose.yml
|
||||
├── .env.example
|
||||
└── CLAUDE.md
|
||||
└── CLAUDE.md # Claude Code 작업용 상세 컨텍스트
|
||||
```
|
||||
|
||||
---
|
||||
@@ -83,13 +70,9 @@ web-backend/
|
||||
## 빠른 시작 (로컬 개발)
|
||||
|
||||
```bash
|
||||
# 1. 환경변수 설정
|
||||
cp .env.example .env
|
||||
|
||||
# 2. 컨테이너 실행 (.env 기본값으로 즉시 실행 가능)
|
||||
docker compose up -d
|
||||
|
||||
# 3. 확인
|
||||
curl http://localhost:18000/health
|
||||
curl http://localhost:18500/health
|
||||
```
|
||||
@@ -97,108 +80,145 @@ curl http://localhost:18500/health
|
||||
| 서비스 | 로컬 URL |
|
||||
|--------|----------|
|
||||
| Frontend + API | http://localhost:8080 |
|
||||
| lotto-backend | http://localhost:18000 |
|
||||
| stock-lab | http://localhost:18500 |
|
||||
| lotto | http://localhost:18000 |
|
||||
| stock | http://localhost:18500 |
|
||||
| music-lab | http://localhost:18600 |
|
||||
| insta-lab | http://localhost:18700 |
|
||||
| realestate-lab | http://localhost:18800 |
|
||||
| personal | http://localhost:18850 |
|
||||
| agent-office | http://localhost:18900 |
|
||||
| packs-lab | http://localhost:18950 |
|
||||
| travel-proxy | http://localhost:19000 |
|
||||
|
||||
---
|
||||
|
||||
## API 목록
|
||||
## 서비스별 기능
|
||||
|
||||
### lotto-backend (`/api/`)
|
||||
### 1. lotto-backend (`/api/`)
|
||||
|
||||
#### 로또
|
||||
로또 당첨번호 수집·통계 분석·몬테카를로 시뮬레이션 기반 추천 + 투두·블로그 CRUD.
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/lotto/latest` | 최신 당첨번호 |
|
||||
| GET | `/api/lotto/{drw_no}` | 특정 회차 |
|
||||
| GET | `/api/lotto/stats` | 번호 빈도 통계 |
|
||||
| GET | `/api/lotto/analysis` | 5가지 통계 분석 리포트 |
|
||||
| GET | `/api/lotto/best` | 시뮬레이션 최적 번호 (기본 20쌍) |
|
||||
| GET | `/api/lotto/simulation` | 시뮬레이션 상세 결과 |
|
||||
| GET | `/api/lotto/recommend` | 통계 기반 추천 |
|
||||
| GET | `/api/lotto/recommend/heatmap` | 히트맵 기반 추천 |
|
||||
| GET | `/api/lotto/recommend/batch` | 배치 추천 |
|
||||
| POST | `/api/admin/simulate` | 시뮬레이션 수동 실행 |
|
||||
| POST | `/api/admin/sync_latest` | 당첨번호 수동 동기화 |
|
||||
- **로또**: 당첨번호 조회, 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쌍 교체)
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/history` | 목록 (limit, offset, favorite, tag, sort) |
|
||||
| PATCH | `/api/history/{id}` | 즐겨찾기·메모·태그 수정 |
|
||||
| DELETE | `/api/history/{id}` | 삭제 |
|
||||
### 2. stock (`/api/stock/`, `/api/trade/`, `/api/portfolio`)
|
||||
|
||||
#### 투두리스트
|
||||
주식 뉴스 스크래핑 + LLM 요약 + KIS 실계좌 연동 + 포트폴리오·자산 스냅샷.
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/todos` | 전체 목록 |
|
||||
| POST | `/api/todos` | 생성 (status: todo\|in_progress\|done) |
|
||||
| PUT | `/api/todos/{id}` | 수정 |
|
||||
| DELETE | `/api/todos/done` | 완료 항목 일괄 삭제 |
|
||||
| DELETE | `/api/todos/{id}` | 개별 삭제 |
|
||||
- **뉴스**: 네이버 증권 + 해외 사이트 크롤링, LLM 기반 한국어 요약
|
||||
- **실계좌**: Windows AI 서버(192.168.45.59:8000) 프록시 → KIS Open API (잔고/주문)
|
||||
- **포트폴리오**: 종목·예수금·매도 히스토리 관리, 현재가 자동 조회
|
||||
- **자산 스냅샷**: 평일 15:40 자동 저장 (KRX 공휴일 판별, `holidays.json` 매년 갱신)
|
||||
|
||||
> ⚠️ `/done` 라우트는 반드시 `/{id}` 보다 먼저 등록해야 함
|
||||
**LLM provider 전환** — `LLM_PROVIDER` 환경변수
|
||||
- `claude` (기본): Anthropic Messages API (`claude-haiku-4-5`)
|
||||
- `ollama`: Windows AI 서버 Ollama (`qwen3:14b`)
|
||||
|
||||
#### 블로그
|
||||
**현재가 조회**: 네이버 모바일 API → HTML 파싱 폴백, 3분 TTL 메모리 캐시
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/blog/posts` | 글 목록 (`{"posts": [...]}`, date DESC) |
|
||||
| POST | `/api/blog/posts` | 글 생성 (date 미입력 시 오늘 날짜) |
|
||||
| PUT | `/api/blog/posts/{id}` | 글 수정 |
|
||||
| DELETE | `/api/blog/posts/{id}` | 글 삭제 |
|
||||
### 3. music-lab (`/api/music/`)
|
||||
|
||||
블로그 포스트 구조: `{ id, title, tags[], body, date, excerpt, created_at, updated_at }`
|
||||
듀얼 프로바이더 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/`으로 직접 서빙
|
||||
- **가사 도구**: 저장·편집·타임스탬프 기반 가라오케 동기
|
||||
|
||||
### stock-lab (`/api/stock/`, `/api/trade/`, `/api/portfolio`)
|
||||
### 4. insta-lab (`/api/insta/`)
|
||||
|
||||
#### 뉴스 & 지표
|
||||
인스타그램 카드 피드 자동 생성 — 뉴스 모니터링 → 키워드 추출 → 10페이지 카드 카피·PNG 렌더 → 텔레그램 푸시 → 사용자 수동 업로드.
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/stock/news` | 뉴스 목록 (limit, category) |
|
||||
| GET | `/api/stock/indices` | 주요 지표 (KOSPI 등) |
|
||||
| POST | `/api/stock/scrap` | 뉴스 수동 스크랩 |
|
||||
```
|
||||
NAVER 뉴스 + YouTube 인기 (외부 트렌드)
|
||||
→ 카테고리별 빈도 + Claude Haiku 정제 → 트렌딩 키워드
|
||||
→ 사용자가 키워드 선택
|
||||
→ Claude Sonnet으로 10페이지 카피 추론 (커버 1 + 본문 8 + CTA 1)
|
||||
→ Jinja2 + Playwright 1080×1350 PNG 10장 렌더
|
||||
→ 텔레그램 미디어 그룹 + 추천 캡션·해시태그
|
||||
```
|
||||
|
||||
#### 실계좌 (Windows AI 서버 프록시)
|
||||
- **AI 엔진**: Claude Sonnet (카피) + Claude Haiku (키워드 분류)
|
||||
- **데이터 소스**: NAVER 뉴스 검색 + YouTube Data API v3 mostPopular(KR)
|
||||
- **카테고리 가중치**: 사용자가 economy/psychology/celebrity 등 카테고리별 가중치 설정 → 자동 추출 비율에 반영
|
||||
- **카드 디자인**: `insta-lab/app/templates/default/card.html.j2` — 사용자가 자유 수정 (Tailwind 등)
|
||||
- **프롬프트 템플릿**: DB에 저장 → 코드 배포 없이 수정 가능
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/trade/balance` | 실계좌 잔고 조회 |
|
||||
| POST | `/api/trade/order` | 주문 (BUY\|SELL, price=0이면 시장가) |
|
||||
### 5. realestate-lab (`/api/realestate/`)
|
||||
|
||||
#### 포트폴리오
|
||||
공공데이터포털 청약홈 API 연동 + 프로필 기반 자동 매칭.
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| 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: 전체) |
|
||||
- **공고 수집**: 09:00 매일 자동 (`DATA_GO_KR_API_KEY` 필요)
|
||||
- **상태 갱신 + 재매칭**: 00:00 매일 자동
|
||||
- **프로필 매칭**: 지역·주택형·소득·부양가족 등으로 점수화, 신규 매칭 알림
|
||||
- **대시보드**: 진행 중 공고수, 신규 매칭수, 다가오는 일정 요약
|
||||
|
||||
---
|
||||
### 6. agent-office (`/api/agent-office/`)
|
||||
|
||||
### travel-proxy (`/api/travel/`)
|
||||
AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 4명의 에이전트가 실제 작업을 수행한다.
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/travel/regions` | 지역 GeoJSON |
|
||||
| GET | `/api/travel/photos` | 사진 목록 (region, page, size) |
|
||||
| POST | `/api/travel/reload` | 캐시 초기화 |
|
||||
- **아키텍처**: stock / music-lab / insta-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` 기준 라우팅
|
||||
|
||||
- 썸네일: `/media/travel/.thumb/{album}/{file}` (nginx 직접 서빙, 30일 캐시)
|
||||
- 원본: `/media/travel/{album}/{file}` (nginx 직접 서빙, 7일 캐시)
|
||||
#### 에이전트 구성
|
||||
|
||||
| 에이전트 | 스케줄 | 승인 | 주요 기능 |
|
||||
|---------|--------|-----|----------|
|
||||
| 📈 **주식 트레이더** (`stock`) | 08:00 매일 | — | 뉴스 요약 (LLM) → 텔레그램 아침 브리핑, 종목 알람 등록 |
|
||||
| 🎵 **음악 프로듀서** (`music`) | 수동 트리거 | ✅ 작곡 | 프롬프트 수신 → 승인 → Suno API 작곡 → 트랙 푸시 |
|
||||
| 🎴 **인스타 큐레이터** (`insta`) | 09:00 / 09:30 매일 | — | 09:00 외부 트렌드(NAVER + YouTube) 수집 → 09:30 가중치 기반 키워드 추출 → 텔레그램 후보 5개씩 카테고리당 인라인 버튼 푸시 → 사용자 선택 시 카드 10장 미디어 그룹 |
|
||||
| 🏢 **청약 애널리스트** (`realestate`) | realestate-lab push trigger | — | realestate-lab이 신규 매칭 발견 시 push → 인라인 [북마크] 버튼 포함 텔레그램 알림 |
|
||||
| 🎬 **YouTube 리서처** (`youtube`) | 09:00 매일 | — | 한국 YouTube 트렌딩 + Google Trends + Billboard → music-lab market_trends push |
|
||||
|
||||
#### 에이전트별 명령
|
||||
|
||||
**Stock** — `fetch_news`, `list_alerts`, `add_alert`, `test_telegram`
|
||||
**Music** — `compose` (승인 필요), `credits`
|
||||
**Insta** — `extract`, `render <keyword_id>`, `collect_trends`
|
||||
**Realestate** — `fetch_matches`, `dashboard`
|
||||
**YouTube** — `research {countries: [...]}`
|
||||
|
||||
#### 스케줄러 잡
|
||||
|
||||
- 07:00 월요일 — Lotto: AI 큐레이터 브리핑 (5세트 + 내러티브)
|
||||
- 07:30 — Stock: 뉴스 요약
|
||||
- 08:00 평일 — Stock: AI 뉴스 sentiment 분석
|
||||
- 09:00 — YouTube: 한국 트렌딩 수집
|
||||
- 09:00 — Insta: 외부 트렌드 수집 (NAVER 인기 + YouTube mostPopular)
|
||||
- 09:30 — Insta: 키워드 추출 (가중치 적용) + 텔레그램 후보 푸시
|
||||
- 15:40 평일 — Stock: 총 자산 스냅샷
|
||||
- 16:30 평일 — Stock: 스크리너 실행
|
||||
- 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분
|
||||
|
||||
---
|
||||
|
||||
@@ -213,8 +233,6 @@ curl http://localhost:18500/health
|
||||
→ 상위 100개 DB 저장 → best_picks 20개 교체
|
||||
```
|
||||
|
||||
**5가지 채점 기법:**
|
||||
|
||||
| 기법 | 가중치 | 내용 |
|
||||
|------|--------|------|
|
||||
| 빈도 Z-score | 25% | 번호 출현 빈도의 표준편차 |
|
||||
@@ -223,28 +241,21 @@ curl http://localhost:18500/health
|
||||
| 공동 출현 | 15% | 번호 쌍 동시 출현 빈도 |
|
||||
| 다양성 | 10% | 연속번호·범위·구간 커버리지 |
|
||||
|
||||
**스케줄:** 매일 0, 4, 8, 12, 16, 20시 (하루 6회, 각 5분)
|
||||
### LLM 요약 provider 추상화 (stock)
|
||||
|
||||
### 총 자산 스냅샷 (stock-lab)
|
||||
`ai_summarizer.py`는 provider 분리 구조. `summarize_news(articles)` 시그니처는 provider와 무관하게 고정.
|
||||
|
||||
```
|
||||
평일 15:40 자동 실행 → holidays.json으로 공휴일 스킵
|
||||
→ 포트폴리오 현재가 조회 → total_eval
|
||||
→ 예수금 합계 → total_cash
|
||||
→ asset_snapshots upsert (date UNIQUE, 같은 날 중복 시 덮어씀)
|
||||
```
|
||||
- `_summarize_with_claude`: Anthropic Messages API 직접 호출 (httpx, SDK 의존성 없음)
|
||||
- `_summarize_with_ollama`: Ollama `/api/generate` (타임아웃 180s, qwen3:14b 첫 로드 대응)
|
||||
- 실패 시 `LLMError` (구 `OllamaError` alias 유지)
|
||||
|
||||
### 현재가 조회 (stock-lab)
|
||||
### 총 자산 스냅샷 (stock)
|
||||
|
||||
- 네이버 모바일 API 우선 (`m.stock.naver.com/api/stock/{ticker}/basic`)
|
||||
- 실패 시 네이버 금융 HTML 파싱 폴백
|
||||
- 3분 TTL 메모리 캐시
|
||||
평일 15:40 자동 실행 → `holidays.json`으로 공휴일 스킵 → 포트폴리오 현재가 조회 + 예수금 합계 → `asset_snapshots` upsert (date UNIQUE).
|
||||
|
||||
### 여행 사진 썸네일 (travel-proxy)
|
||||
### 에이전트 FSM + WS 동기화 (agent-office)
|
||||
|
||||
- 480×480 리사이징 (Pillow), 확장자 유지 (JPEG/PNG/WEBP)
|
||||
- 온디맨드 생성 후 `/data/thumbs/` 영구 캐시
|
||||
- 원자성 보장: tmp 파일 작성 후 rename
|
||||
DB에 저장된 에이전트 상태가 바뀔 때마다 `websocket_manager`가 전체 클라이언트에 브로드캐스트. 텔레그램 봇은 `waiting` 상태 작업에 인라인 키보드를 붙여 승인 요청. 승인/거부 결과가 DB → WS → 프론트로 전파.
|
||||
|
||||
---
|
||||
|
||||
@@ -252,7 +263,7 @@ curl http://localhost:18500/health
|
||||
|
||||
```
|
||||
git push → Gitea → X-Gitea-Signature (HMAC SHA256)
|
||||
→ deployer:9000/webhook (서명 검증, compare_digest 사용)
|
||||
→ deployer:9000/webhook (서명 검증, compare_digest)
|
||||
→ BackgroundTask: scripts/deploy.sh (10분 타임아웃)
|
||||
1. git pull
|
||||
2. .releases/{timestamp}/ 백업
|
||||
@@ -261,39 +272,32 @@ git push → Gitea → X-Gitea-Signature (HMAC SHA256)
|
||||
5. chown PUID:PGID
|
||||
```
|
||||
|
||||
> 프론트엔드는 **자동 배포 안 됨** — 로컬 빌드 후 NAS에 수동 업로드
|
||||
> 프론트엔드는 **자동 배포 안 됨** — 로컬 빌드 후 NAS에 수동 업로드 (`scripts/deploy.bat --frontend`)
|
||||
|
||||
---
|
||||
|
||||
## 데이터베이스
|
||||
|
||||
### lotto.db (`/app/data/lotto.db`)
|
||||
각 서비스는 독립 SQLite DB를 `/app/data/` 볼륨에 저장.
|
||||
|
||||
| 테이블 | 설명 |
|
||||
|--------|------|
|
||||
| `draws` | 로또 당첨번호 |
|
||||
| `recommendations` | 추천 이력 (즐겨찾기·태그·채점 포함) |
|
||||
| `simulation_runs` | 시뮬레이션 실행 기록 |
|
||||
| `simulation_candidates` | 시뮬레이션 후보 (점수 5종) |
|
||||
| `best_picks` | 현재 활성 최적 번호 20개 (is_active 플래그) |
|
||||
| `todos` | 투두리스트 (UUID PK) |
|
||||
| `blog_posts` | 블로그 글 (tags: JSON 배열) |
|
||||
|
||||
### stock.db (`/app/data/stock.db`)
|
||||
|
||||
| 테이블 | 설명 |
|
||||
|--------|------|
|
||||
| `articles` | 뉴스 기사 (hash UNIQUE, category: domestic\|overseas) |
|
||||
| `portfolio` | 보유 종목 (broker, ticker, quantity, avg_price) |
|
||||
| `broker_cash` | 증권사별 예수금 (broker UNIQUE) |
|
||||
| `asset_snapshots` | 일별 총 자산 스냅샷 (date UNIQUE) |
|
||||
| DB | 소유 서비스 | 주요 테이블 |
|
||||
|----|------------|-----------|
|
||||
| `lotto.db` | lotto | draws, recommendations, simulation_runs/candidates, best_picks, purchase_history, strategy_performance/weights, weekly_reports, lotto_briefings |
|
||||
| `stock.db` | stock | 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), video_projects, revenue_records, market_trends, trend_reports |
|
||||
| `insta.db` | insta-lab | news_articles, trending_keywords (source 컬럼), card_slates, card_assets, generation_tasks, prompt_templates, account_preferences |
|
||||
| `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 |
|
||||
| `personal.db` | personal | profile, careers, projects, skills, introductions, todos, blog_posts |
|
||||
| `travel.db` | travel-proxy | photos (album, filename, mtime, has_thumb), album_covers |
|
||||
| `pack_files` (외부 Supabase) | packs-lab | filename, host_path, mime, byte_size, sha256, deleted_at |
|
||||
|
||||
---
|
||||
|
||||
## 환경변수
|
||||
|
||||
```env
|
||||
# 경로 설정
|
||||
# 경로
|
||||
RUNTIME_PATH=.
|
||||
REPO_PATH=.
|
||||
FRONTEND_PATH=./frontend/dist
|
||||
@@ -306,6 +310,51 @@ PGID=1000
|
||||
# 외부 서비스
|
||||
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
|
||||
WEBHOOK_SECRET=your_secret_here
|
||||
|
||||
# LLM (stock, insta-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
|
||||
|
||||
# stock admin protection (CODE_REVIEW F2)
|
||||
ADMIN_API_KEY=
|
||||
ALLOW_UNAUTHENTICATED_ADMIN=false
|
||||
|
||||
# music-lab
|
||||
SUNO_API_KEY=
|
||||
MUSIC_AI_SERVER_URL=
|
||||
MUSIC_MEDIA_BASE=/media/music
|
||||
|
||||
# insta-lab + agent-office (NAVER 검색 + YouTube Data API 공유)
|
||||
NAVER_CLIENT_ID=
|
||||
NAVER_CLIENT_SECRET=
|
||||
YOUTUBE_DATA_API_KEY=
|
||||
|
||||
# realestate-lab
|
||||
DATA_GO_KR_API_KEY=
|
||||
|
||||
# packs-lab (DSM + Supabase)
|
||||
DSM_HOST=
|
||||
DSM_USER=
|
||||
DSM_PASS=
|
||||
BACKEND_HMAC_SECRET=
|
||||
SUPABASE_URL=
|
||||
SUPABASE_SERVICE_KEY=
|
||||
PACK_HOST_DIR=/docker/webpage/media/packs # shared folder 시점 (CLAUDE.md F5)
|
||||
|
||||
# agent-office
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_CHAT_ID=
|
||||
TELEGRAM_WEBHOOK_URL=
|
||||
STOCK_URL=http://stock:8000
|
||||
MUSIC_LAB_URL=http://music-lab:8000
|
||||
INSTA_LAB_URL=http://insta-lab:8000
|
||||
REALESTATE_LAB_URL=http://realestate-lab:8000
|
||||
|
||||
# personal (포트폴리오 편집 인증)
|
||||
PORTFOLIO_EDIT_PASSWORD=
|
||||
```
|
||||
|
||||
---
|
||||
@@ -316,9 +365,9 @@ WEBHOOK_SECRET=your_secret_here
|
||||
|------|----|
|
||||
| 장비 | Synology NAS (Intel Celeron J4025, 18GB RAM) |
|
||||
| Docker | Synology Container Manager |
|
||||
| Git 서버 | Gitea (NAS 내부 self-hosted) |
|
||||
| AI 서버 | Windows PC (192.168.45.59:8000) — RTX 3070 Ti + Ollama |
|
||||
| Python | 3.12 (`slim` / `alpine` 기반 이미지) |
|
||||
| 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 (볼륨 마운트로 영속 저장) |
|
||||
|
||||
---
|
||||
@@ -327,8 +376,18 @@ WEBHOOK_SECRET=your_secret_here
|
||||
|
||||
- **`.env` 파일** — 절대 커밋 금지. `.env.example`만 레포에 포함
|
||||
- **Nginx trailing slash** — `/api/portfolio`는 두 location 블록으로 처리 (trailing slash 유무 모두 매칭)
|
||||
- **라우트 순서** — `/api/todos/done`은 `/api/todos/{id}` 보다 먼저 등록 필수
|
||||
- **라우트 순서** — `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 고정 예약)
|
||||
- **공휴일 목록** — `stock/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/` — 서비스별 기획·설계 문서
|
||||
|
||||
111
STATUS.md
Normal file
111
STATUS.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# web-backend — 구현 현황 & 로드맵
|
||||
|
||||
> 최종 갱신: 2026-05-17
|
||||
> 자세한 서비스·환경변수·DB 표는 [CLAUDE.md](./CLAUDE.md), 설계는 `docs/superpowers/specs/`, 실행 계획은 `docs/superpowers/plans/` 참조.
|
||||
|
||||
---
|
||||
|
||||
## 1. 서비스 구현 현황
|
||||
|
||||
### 1-1. 운영 중인 컨테이너 (11개)
|
||||
|
||||
| 서비스 | 포트 | 상태 | 핵심 기능 |
|
||||
|--------|------|------|-----------|
|
||||
| `lotto` | 18000 | ✅ | 로또 추천·통계·리포트·구매내역·AI 큐레이터 |
|
||||
| `stock` | 18500 | ✅ | 주식 뉴스·지수·트레이딩·포트폴리오·자산 스냅샷·스크리너 |
|
||||
| `music-lab` | 18600 | ✅ | Suno + MusicGen + YouTube 수익화 + 컴파일 |
|
||||
| `insta-lab` | 18700 | ✅ | 인스타 카드 피드 자동 생성 (NAVER + YouTube 트렌드 → 10페이지 카드, Playwright) |
|
||||
| `realestate-lab` | 18800 | ✅ | 청약 수집·5티어 매칭·매칭 알림 push |
|
||||
| `personal` | 18850 | ✅ | 포트폴리오·블로그·투두 통합 (개인 서비스) |
|
||||
| `agent-office` | 18900 | ✅ | AI 에이전트 (WebSocket + 텔레그램 + InstaAgent + YouTubeResearcher) |
|
||||
| `packs-lab` | 18950 | ✅ | NAS 자료 다운로드 자동화 (HMAC + Supabase + 5GB chunked upload) |
|
||||
| `travel-proxy` | 19000 | ✅ | 여행 사진 API + 썸네일 + 지역 관리 |
|
||||
| `frontend` (nginx) | 8080 | ✅ | SPA + 리버스 프록시 (5GB body limit, 인스타 라우팅 포함) |
|
||||
| `webpage-deployer` | 19010 | ✅ | Gitea Webhook 자동 배포 (BUILDKIT timeout 600s, healthcheck via docker inspect) |
|
||||
|
||||
### 1-2. 최근 큰 작업 (2026-05)
|
||||
|
||||
| 시기 | 영역 | 핵심 |
|
||||
|------|------|------|
|
||||
| 2026-05-17 | 보안 / 정합성 | CODE_REVIEW F1 (packs-lab path traversal `startswith→relative_to`) + F2 (stock admin auth 503 거부) + F4 (portfolio total_buy 수량 곱산) |
|
||||
| 2026-05-17 | insta-lab | Google Trends API 폐기 대응 → YouTube Data API v3로 source 교체. trend_collector 재작성 |
|
||||
| 2026-05-16 | insta-lab | Trends 탭 추가 — 외부 트렌드 수집 (NAVER 인기 + YouTube) + 카테고리 가중치 (`account_preferences`) + 가중치 기반 키워드 추출 |
|
||||
| 2026-05-15 | insta-lab | blog-lab 폐기 → insta-lab 신설. 뉴스 모니터링 → 키워드 추출 → 10페이지 카드 카피·PNG → 텔레그램 푸시 → 수동 인스타 업로드 파이프라인 |
|
||||
| 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축 점수·알림 대상 카운트, realestate-lab push → agent-office RealestateAgent) |
|
||||
| 2026-04-27 | personal | personal 서비스 분리 마이그레이션 (블로그·투두·포트폴리오 인증) |
|
||||
| 2026-04-27 | agent-office | v2 — youtube_researcher (YouTube API + pytrends + Billboard) + 알림 |
|
||||
| 2026-04-15 | lotto | AI 큐레이터 (Claude 기반 주간 브리핑 자동 생성) |
|
||||
|
||||
### 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 .insta import InstaAgent
|
||||
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["insta"] = InstaAgent()
|
||||
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,
|
||||
}
|
||||
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)
|
||||
170
agent-office/app/agents/insta.py
Normal file
170
agent-office/app/agents/insta.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""인스타 카드 에이전트 — 매일 09:30 뉴스 수집·키워드 추출 → 텔레그램 후보 푸시.
|
||||
사용자가 키워드 버튼을 누르면 카드 슬레이트 생성 + 10장 미디어 그룹 발송."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from .base import BaseAgent
|
||||
from ..db import (
|
||||
create_task, update_task_status, add_log, get_agent_config,
|
||||
)
|
||||
from ..config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID
|
||||
from .. import service_proxy
|
||||
from ..telegram import messaging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _send_media_group(media: List[Dict[str, Any]], caption: str = "") -> Dict[str, Any]:
|
||||
"""텔레그램 sendMediaGroup. media는 InputMediaPhoto dicts.
|
||||
각 항목에는 임시 키 '_bytes'로 PNG 바이트가 담겨 있어 attach:// 형식으로 multipart 업로드."""
|
||||
if not TELEGRAM_BOT_TOKEN:
|
||||
return {"ok": False, "reason": "TELEGRAM_BOT_TOKEN missing"}
|
||||
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMediaGroup"
|
||||
files: Dict[str, tuple] = {}
|
||||
for i, m in enumerate(media):
|
||||
attach_key = f"photo{i+1}"
|
||||
files[attach_key] = (f"{i+1}.png", m["_bytes"], "image/png")
|
||||
m["media"] = f"attach://{attach_key}"
|
||||
m.pop("_bytes", None)
|
||||
if caption and media:
|
||||
media[0]["caption"] = caption[:1024]
|
||||
payload = {"chat_id": TELEGRAM_CHAT_ID, "media": json.dumps(media, ensure_ascii=False)}
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.post(url, data=payload, files=files)
|
||||
return resp.json()
|
||||
|
||||
|
||||
class InstaAgent(BaseAgent):
|
||||
agent_id = "insta"
|
||||
display_name = "인스타 큐레이터"
|
||||
|
||||
async def on_schedule(self) -> None:
|
||||
"""09:30 매일: 뉴스 수집 → 키워드 추출 → 텔레그램 후보 푸시.
|
||||
custom_config.auto_select=True면 카테고리당 1위 키워드 자동 슬레이트 생성."""
|
||||
if self.state not in ("idle", "break"):
|
||||
return
|
||||
config = get_agent_config(self.agent_id) or {}
|
||||
custom = config.get("custom_config", {}) or {}
|
||||
auto_select = bool(custom.get("auto_select", False))
|
||||
|
||||
task_id = create_task(self.agent_id, "insta_daily", {"auto_select": auto_select},
|
||||
requires_approval=False)
|
||||
await self.transition("working", "뉴스 수집·키워드 추출", task_id)
|
||||
try:
|
||||
prefs = await service_proxy.insta_get_preferences()
|
||||
add_log(self.agent_id, f"insta preferences: {prefs}", "info", task_id)
|
||||
await self._run_collect_and_extract()
|
||||
kws = await service_proxy.insta_list_keywords(used=False)
|
||||
if auto_select:
|
||||
await self._auto_render(kws)
|
||||
else:
|
||||
await self._push_keyword_candidates(kws)
|
||||
update_task_status(task_id, "succeeded", {"keywords": len(kws)})
|
||||
await self.transition("idle", "후보 푸시 완료")
|
||||
except Exception as e:
|
||||
add_log(self.agent_id, f"insta daily failed: {e}", "error", task_id)
|
||||
update_task_status(task_id, "failed", {"error": str(e)})
|
||||
await self.transition("idle", f"오류: {e}")
|
||||
|
||||
async def _run_collect_and_extract(self) -> None:
|
||||
col = await service_proxy.insta_collect()
|
||||
await self._wait_task(col["task_id"], step="collect", timeout_sec=300)
|
||||
ext = await service_proxy.insta_extract()
|
||||
await self._wait_task(ext["task_id"], step="extract", timeout_sec=300)
|
||||
|
||||
async def _wait_task(self, task_id: str, step: str, timeout_sec: int = 300) -> Dict[str, Any]:
|
||||
attempts = max(1, timeout_sec // 5)
|
||||
for _ in range(attempts):
|
||||
await asyncio.sleep(5)
|
||||
st = await service_proxy.insta_task_status(task_id)
|
||||
if st["status"] == "succeeded":
|
||||
return st
|
||||
if st["status"] == "failed":
|
||||
raise RuntimeError(f"{step} failed: {st.get('error')}")
|
||||
raise TimeoutError(f"{step} timeout {timeout_sec}s")
|
||||
|
||||
async def _push_keyword_candidates(self, keywords: List[Dict[str, Any]]) -> None:
|
||||
by_cat: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for k in keywords:
|
||||
by_cat.setdefault(k["category"], []).append(k)
|
||||
if not by_cat:
|
||||
await messaging.send_raw("📰 [인스타 큐레이터] 오늘은 추천할 키워드가 없습니다.")
|
||||
return
|
||||
rows: List[List[Dict[str, Any]]] = []
|
||||
text_lines = ["📰 <b>[인스타 큐레이터]</b> 오늘의 키워드 후보"]
|
||||
for cat, items in by_cat.items():
|
||||
text_lines.append(f"\n<b>{cat}</b>")
|
||||
for k in items[:5]:
|
||||
text_lines.append(f" · {k['keyword']} (score {k['score']:.2f})")
|
||||
rows.append([{
|
||||
"text": f"🎴 {k['keyword']}",
|
||||
"callback_data": f"render_{k['id']}",
|
||||
}])
|
||||
await messaging.send_raw("\n".join(text_lines), reply_markup={"inline_keyboard": rows})
|
||||
|
||||
async def _auto_render(self, keywords: List[Dict[str, Any]]) -> None:
|
||||
by_cat: Dict[str, Dict[str, Any]] = {}
|
||||
for k in keywords:
|
||||
cat = k["category"]
|
||||
if cat not in by_cat or k["score"] > by_cat[cat]["score"]:
|
||||
by_cat[cat] = k
|
||||
for kw in by_cat.values():
|
||||
await self._render_and_push(kw["id"])
|
||||
|
||||
async def _render_and_push(self, keyword_id: int) -> None:
|
||||
kw = await service_proxy.insta_get_keyword(keyword_id)
|
||||
if not kw:
|
||||
await messaging.send_raw(f"⚠️ 키워드 {keyword_id} 없음")
|
||||
return
|
||||
await messaging.send_raw(f"🎨 카드 생성 중: <b>{kw['keyword']}</b>")
|
||||
created = await service_proxy.insta_create_slate(
|
||||
keyword=kw["keyword"], category=kw["category"], keyword_id=kw["id"],
|
||||
)
|
||||
st = await self._wait_task(created["task_id"], step="slate", timeout_sec=600)
|
||||
slate_id = st["result_id"]
|
||||
slate = await service_proxy.insta_get_slate(slate_id)
|
||||
media = []
|
||||
for a in slate["assets"][:10]:
|
||||
data = await service_proxy.insta_get_asset_bytes(slate_id, a["page_index"])
|
||||
media.append({"type": "photo", "_bytes": data})
|
||||
caption = slate.get("suggested_caption", "")
|
||||
hashtags = " ".join(slate.get("hashtags", []) or [])
|
||||
full_caption = f"{caption}\n\n{hashtags}".strip()
|
||||
await _send_media_group(media, caption=full_caption)
|
||||
|
||||
async def on_command(self, command: str, params: dict) -> dict:
|
||||
if command == "extract":
|
||||
await self._run_collect_and_extract()
|
||||
kws = await service_proxy.insta_list_keywords(used=False)
|
||||
await self._push_keyword_candidates(kws)
|
||||
return {"ok": True, "count": len(kws)}
|
||||
if command == "render":
|
||||
kid = int(params.get("keyword_id") or 0)
|
||||
if not kid:
|
||||
return {"ok": False, "message": "keyword_id 필수"}
|
||||
await self._render_and_push(kid)
|
||||
return {"ok": True}
|
||||
if command == "collect_trends":
|
||||
await messaging.send_raw("🌐 외부 트렌드 수집 시작")
|
||||
created = await service_proxy.insta_collect_trends()
|
||||
st = await self._wait_task(created["task_id"], step="trends_collect", timeout_sec=300)
|
||||
await messaging.send_raw(f"✅ 트렌드 수집 완료: {st.get('message', '')}")
|
||||
return {"ok": True, "result": st}
|
||||
return {"ok": False, "message": f"Unknown command: {command}"}
|
||||
|
||||
async def on_callback(self, action: str, params: dict) -> dict:
|
||||
if action == "render":
|
||||
kid = int(params.get("keyword_id") or 0)
|
||||
if not kid:
|
||||
return {"ok": False}
|
||||
await self._render_and_push(kid)
|
||||
return {"ok": True}
|
||||
return {"ok": False}
|
||||
|
||||
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
|
||||
return
|
||||
54
agent-office/app/agents/lotto.py
Normal file
54
agent-office/app/agents/lotto.py
Normal file
@@ -0,0 +1,54 @@
|
||||
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={
|
||||
k: v for k, v in result.items() if k != "payload"
|
||||
})
|
||||
await self.transition("reporting", f"#{result['draw_no']} 브리핑 저장 완료")
|
||||
add_log(self.agent_id, f"큐레이션 완료: #{result['draw_no']} conf={result['confidence']}", task_id=task_id)
|
||||
|
||||
# 텔레그램 헤드라인 푸시 (실패해도 큐레이션은 성공으로 마감)
|
||||
try:
|
||||
from ..notifiers.telegram_lotto import send_curator_briefing
|
||||
await send_curator_briefing(result["payload"])
|
||||
except Exception as e:
|
||||
add_log(self.agent_id, f"텔레그램 알림 실패: {e}", level="warning", task_id=task_id)
|
||||
|
||||
await self.transition("idle", "대기 중")
|
||||
return {"ok": True, **{k: v for k, v in result.items() if k != "payload"}}
|
||||
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
|
||||
391
agent-office/app/agents/stock.py
Normal file
391
agent-office/app/agents/stock.py
Normal file
@@ -0,0 +1,391 @@
|
||||
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 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이 담당)
|
||||
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_screener_schedule(self) -> None:
|
||||
"""KRX 강세주 스크리너 자동 잡 (평일 16:30 KST).
|
||||
|
||||
흐름:
|
||||
1) snapshot/refresh — 일봉 갱신 (실패해도 진행, 경고 로그)
|
||||
2) screener/run mode='auto' — 실행 + 결과 영구화 + telegram_payload 응답
|
||||
3) status=='skipped_holiday' → 종료 (텔레그램 미발신)
|
||||
4) status=='success' → telegram_payload.text 를 parse_mode 그대로 전송
|
||||
5) 예외/실패 → 운영자에게 별도 텔레그램 알림 (HTML)
|
||||
"""
|
||||
if self.state not in ("idle", "break"):
|
||||
return
|
||||
|
||||
task_id = create_task(self.agent_id, "screener_run", {"mode": "auto"})
|
||||
await self.transition("working", "스크리너 스냅샷 갱신 중...", task_id)
|
||||
|
||||
try:
|
||||
# 1) 스냅샷 갱신 — 실패해도 기존 일봉 데이터로 진행
|
||||
try:
|
||||
snap = await service_proxy.refresh_screener_snapshot()
|
||||
add_log(
|
||||
self.agent_id,
|
||||
f"snapshot refreshed: status={snap.get('status', '?')}",
|
||||
"info", task_id,
|
||||
)
|
||||
except Exception as e:
|
||||
add_log(
|
||||
self.agent_id,
|
||||
f"스냅샷 갱신 실패 (기존 데이터로 진행): {e}",
|
||||
"warning", task_id,
|
||||
)
|
||||
|
||||
await self.transition("working", "스크리너 실행 중...")
|
||||
|
||||
# 2) 스크리너 실행
|
||||
body = await service_proxy.run_stock_screener(mode="auto")
|
||||
status = body.get("status")
|
||||
asof = body.get("asof")
|
||||
|
||||
# 3) 공휴일 — 종료
|
||||
if status == "skipped_holiday":
|
||||
update_task_status(task_id, "succeeded", {
|
||||
"status": status,
|
||||
"asof": asof,
|
||||
"telegram_sent": False,
|
||||
})
|
||||
add_log(self.agent_id, f"스크리너 건너뜀 (휴일): {asof}", "info", task_id)
|
||||
await self.transition("idle", "휴일 — 스크리너 건너뜀")
|
||||
return
|
||||
|
||||
# 4) 성공 → 텔레그램 전송
|
||||
if status == "success":
|
||||
payload = body.get("telegram_payload") or {}
|
||||
text = payload.get("text") or ""
|
||||
parse_mode = payload.get("parse_mode", "MarkdownV2")
|
||||
|
||||
if not text:
|
||||
raise RuntimeError("telegram_payload.text 누락")
|
||||
|
||||
await self.transition("reporting", "스크리너 결과 전송 중...")
|
||||
|
||||
from ..telegram.messaging import send_raw
|
||||
tg = await send_raw(text, parse_mode=parse_mode)
|
||||
|
||||
update_task_status(task_id, "succeeded", {
|
||||
"status": status,
|
||||
"asof": asof,
|
||||
"run_id": body.get("run_id"),
|
||||
"survivors_count": body.get("survivors_count"),
|
||||
"telegram_sent": tg.get("ok", False),
|
||||
"telegram_message_id": tg.get("message_id"),
|
||||
})
|
||||
|
||||
if not tg.get("ok"):
|
||||
desc = tg.get("description") or "unknown"
|
||||
code = tg.get("error_code")
|
||||
add_log(
|
||||
self.agent_id,
|
||||
f"Screener 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", "스크리너 완료")
|
||||
return
|
||||
|
||||
# 5) 기타 status — failed 취급
|
||||
raise RuntimeError(f"unexpected screener status: {status}")
|
||||
|
||||
except Exception as e:
|
||||
err_msg = str(e)
|
||||
add_log(self.agent_id, f"Screener job failed: {err_msg}", "error", task_id)
|
||||
update_task_status(task_id, "failed", {"error": err_msg})
|
||||
|
||||
# 운영자 알림 — 기본 HTML parse_mode 사용
|
||||
try:
|
||||
from ..telegram.messaging import send_raw
|
||||
await send_raw(
|
||||
f"⚠️ <b>KRX 스크리너 실패</b>\n"
|
||||
f"<code>{html.escape(err_msg)[:500]}</code>"
|
||||
)
|
||||
except Exception as notify_err:
|
||||
add_log(
|
||||
self.agent_id,
|
||||
f"operator notify failed: {notify_err}",
|
||||
"warning", task_id,
|
||||
)
|
||||
|
||||
await self.transition("idle", f"스크리너 오류: {err_msg[:80]}")
|
||||
|
||||
async def on_ai_news_schedule(self) -> None:
|
||||
"""AI 뉴스 sentiment 분석 자동 잡 (평일 08:00 KST).
|
||||
|
||||
흐름:
|
||||
1) stock /snapshot/refresh-news-sentiment 호출
|
||||
2) status='skipped_weekend'/'skipped_holiday' → 종료 (텔레그램 미발신)
|
||||
3) updated=0 → 운영자 알림 (HTML)
|
||||
4) failures > 30% → 경고 알림 후 메인 메시지 발송
|
||||
5) 정상 → Top 5 호재/악재 메시지 발송 (MarkdownV2)
|
||||
"""
|
||||
if self.state not in ("idle", "break"):
|
||||
return
|
||||
|
||||
task_id = create_task(self.agent_id, "ai_news_sentiment", {})
|
||||
await self.transition("working", "AI 뉴스 분석 중...", task_id)
|
||||
|
||||
try:
|
||||
result = await service_proxy.refresh_ai_news_sentiment()
|
||||
except Exception as e:
|
||||
err_msg = str(e)
|
||||
add_log(self.agent_id, f"AI 뉴스 분석 실패: {err_msg}", "error", task_id)
|
||||
update_task_status(task_id, "failed", {"error": err_msg})
|
||||
try:
|
||||
from ..telegram.messaging import send_raw
|
||||
await send_raw(
|
||||
f"⚠️ <b>AI 뉴스 분석 실패</b>\n"
|
||||
f"<code>{html.escape(err_msg)[:500]}</code>"
|
||||
)
|
||||
except Exception as notify_err:
|
||||
add_log(
|
||||
self.agent_id,
|
||||
f"operator notify failed: {notify_err}",
|
||||
"warning", task_id,
|
||||
)
|
||||
await self.transition("idle", f"AI 뉴스 오류: {err_msg[:80]}")
|
||||
return
|
||||
|
||||
status = result.get("status")
|
||||
if status in ("skipped_weekend", "skipped_holiday"):
|
||||
update_task_status(task_id, "succeeded", {"status": status})
|
||||
add_log(self.agent_id, f"AI 뉴스 건너뜀: {status}", "info", task_id)
|
||||
await self.transition("idle", "휴일/주말 — 건너뜀")
|
||||
return
|
||||
|
||||
updated = int(result.get("updated", 0))
|
||||
failures = result.get("failures", []) or []
|
||||
if updated == 0:
|
||||
update_task_status(task_id, "failed", {"reason": "0 tickers updated"})
|
||||
try:
|
||||
from ..telegram.messaging import send_raw
|
||||
await send_raw(
|
||||
"⚠️ <b>AI 뉴스 분석 0종목</b>\n"
|
||||
"스크래핑/LLM 전체 실패 — 어제 데이터 사용"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await self.transition("idle", "AI 뉴스 0건")
|
||||
return
|
||||
|
||||
# 실패율 경고 (별도 알림, 본 메시지는 계속 발송)
|
||||
failure_rate = len(failures) / max(1, updated + len(failures))
|
||||
if failure_rate > 0.3:
|
||||
try:
|
||||
from ..telegram.messaging import send_raw
|
||||
await send_raw(
|
||||
f"⚠️ <b>AI 뉴스 실패율 {failure_rate:.0%}</b>\n"
|
||||
f"updated={updated}, failures={len(failures)}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 정상 — Top 5 메시지 (stock이 빌드해서 응답에 telegram_text 동봉)
|
||||
text = result.get("telegram_text") or ""
|
||||
if not text:
|
||||
add_log(self.agent_id, "telegram_text 누락 — stock 응답 결함", "error", task_id)
|
||||
update_task_status(task_id, "failed", {"error": "telegram_text 누락"})
|
||||
await self.transition("idle", "AI 뉴스 응답 결함")
|
||||
return
|
||||
|
||||
await self.transition("reporting", "AI 뉴스 알림 전송 중...")
|
||||
from ..telegram.messaging import send_raw
|
||||
tg = await send_raw(text, parse_mode="MarkdownV2")
|
||||
|
||||
update_task_status(task_id, "succeeded", {
|
||||
"asof": result["asof"],
|
||||
"updated": updated,
|
||||
"failures": len(failures),
|
||||
"tokens_input": int(result.get("tokens_input", 0)),
|
||||
"tokens_output": int(result.get("tokens_output", 0)),
|
||||
"telegram_sent": tg.get("ok", False),
|
||||
})
|
||||
|
||||
if not tg.get("ok"):
|
||||
desc = tg.get("description") or "unknown"
|
||||
code = tg.get("error_code")
|
||||
add_log(
|
||||
self.agent_id,
|
||||
f"AI news telegram send failed: [{code}] {desc}",
|
||||
"warning", task_id,
|
||||
)
|
||||
|
||||
await self.transition("idle", "AI 뉴스 완료")
|
||||
|
||||
async def on_command(self, command: str, params: dict) -> dict:
|
||||
if command == "run_screener":
|
||||
await self.on_screener_schedule()
|
||||
return {"ok": True, "message": "스크리너 실행 트리거 완료"}
|
||||
|
||||
if command == "run_ai_news":
|
||||
await self.on_ai_news_schedule()
|
||||
return {"ok": True, "message": "AI 뉴스 분석 트리거 완료"}
|
||||
|
||||
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)
|
||||
112
agent-office/app/agents/youtube_publisher.py
Normal file
112
agent-office/app/agents/youtube_publisher.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""텔레그램 단일 채널로 단계별 승인 인터랙션 오케스트레이션."""
|
||||
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, tuple] = {}
|
||||
|
||||
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:
|
||||
_, step = _STEP_TITLES[state]
|
||||
fb_count = (p.get("feedback_count_per_step") or {}).get(step, 0)
|
||||
key = (state, fb_count)
|
||||
if self._notified_state_per_pipeline.get(pid) != key:
|
||||
await self._notify_step(p)
|
||||
self._notified_state_per_pipeline[pid] = key
|
||||
|
||||
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_URL = os.getenv("STOCK_URL", "http://localhost:18500")
|
||||
MUSIC_LAB_URL = os.getenv("MUSIC_LAB_URL", "http://localhost:18600")
|
||||
INSTA_LAB_URL = os.getenv("INSTA_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
132
agent-office/app/curator/pipeline.py
Normal file
132
agent-office/app/curator/pipeline.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""큐레이터 파이프라인 — 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
|
||||
from .retrospective import build_retrospective
|
||||
|
||||
|
||||
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": 8192, # 4계층 20세트 + narrative + retrospective 수용
|
||||
"system": system_blocks,
|
||||
"messages": [{"role": "user", "content": [{"type": "text", "text": user_text}]}],
|
||||
}
|
||||
started = time.monotonic()
|
||||
async with httpx.AsyncClient(timeout=180) 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=30) # ← 30 으로 확장
|
||||
draw_no = cand_resp["draw_no"]
|
||||
candidates = cand_resp["candidates"]
|
||||
context = await service_proxy.lotto_context()
|
||||
|
||||
retrospective = await build_retrospective(draw_no)
|
||||
|
||||
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", []),
|
||||
"retrospective": retrospective,
|
||||
})
|
||||
|
||||
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": {
|
||||
"core": [p.model_dump() for p in validated.core_picks],
|
||||
"bonus": [p.model_dump() for p in validated.bonus_picks],
|
||||
"extended": [p.model_dump() for p in validated.extended_picks],
|
||||
"pool": [p.model_dump() for p in validated.pool_picks],
|
||||
},
|
||||
"narrative": validated.narrative.model_dump(),
|
||||
"tier_rationale": validated.tier_rationale.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"]},
|
||||
"payload": payload, # 텔레그램 알림용
|
||||
}
|
||||
64
agent-office/app/curator/prompt.py
Normal file
64
agent-office/app/curator/prompt.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""큐레이터 system/user 프롬프트. system은 정적이므로 캐시 대상."""
|
||||
import json
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """당신은 로또 번호 큐레이터입니다.
|
||||
주어진 후보 30세트 중 4계층(코어 5, 보너스 5, 확장 5, 풀 5) 총 20세트를 선별합니다.
|
||||
|
||||
계층별 큐레이션 규칙:
|
||||
- core_picks (5): 안정 2 / 균형 2 / 공격 1. 그 주 주축. 홀짝·저고·구간 분포가 세트끼리 겹치지 않게.
|
||||
- bonus_picks (5): 코어 분배의 공백을 메우는 5세트. 코어가 공격 1뿐이면 보너스에 공격 +2 식.
|
||||
- extended_picks (5): 코어·보너스에 없는 시각 — 합계 극단(80↓ / 180↑) / 콜드 4주 누적 / 4주 미등장 번호 노출.
|
||||
- pool_picks (5): 이번 주 한 번도 누르지 않은 패턴 — 연속 3개 / 동일 끝자리 / 5수 균등(각 끝자리 5개씩) 등.
|
||||
- tier_rationale 의 3개 키(bonus·extended·pool)에 각각 30자 이내 한국어 사유.
|
||||
|
||||
공통 규칙:
|
||||
- 후보에 없는 번호 조합은 절대 사용 금지. 모든 픽은 candidates 중 하나와 정확히 일치해야 함.
|
||||
- 4계층 사이에 중복 픽 금지 (총 20세트는 모두 서로 달라야 함).
|
||||
- 각 픽 reason 은 한국어 40자 이내. 해당 픽의 features 와 context 만 근거로.
|
||||
- 중립형(hot_number_count=0 이고 cold_number_count=0) 세트를 코어에 최소 1개 포함.
|
||||
|
||||
회고 규칙:
|
||||
- context.retrospective 가 있으면 narrative.retrospective 에 한 줄(60자 이내)로 작성.
|
||||
- 회고는 큐레이터 자기 결과(curator_avg, best_tier) + 사용자 결과(user_avg, pattern_delta) 둘 다 짚을 것.
|
||||
- 이번 주 코어 분배는 회고에 근거해 조정. 조정 사유는 narrative.headline 에 한 줄로.
|
||||
예: "지난 주 너 저번호 편향 → 보너스 고번호 보강"
|
||||
- context.retrospective 가 없으면 narrative.retrospective 는 빈 문자열.
|
||||
|
||||
narrative 규칙:
|
||||
- headline: 한 줄, 이번 주 추첨 전망 + 조정 사유.
|
||||
- summary_3lines: 정확히 3개 항목.
|
||||
- hot_cold_comment: hot/cold 번호 한 줄 논평.
|
||||
- warnings: 주의사항 없으면 빈 문자열.
|
||||
- retrospective: 회고 한 줄 또는 빈 문자열.
|
||||
|
||||
출력은 반드시 JSON 하나, 그 외 어떤 텍스트도 금지. 스키마:
|
||||
{
|
||||
"core_picks": [{"numbers":[...], "risk_tag":"안정"|"균형"|"공격", "reason": str}, ...5개],
|
||||
"bonus_picks": [...5개],
|
||||
"extended_picks": [...5개],
|
||||
"pool_picks": [...5개],
|
||||
"tier_rationale": {"bonus": str, "extended": str, "pool": str},
|
||||
"narrative": {
|
||||
"headline": str,
|
||||
"summary_3lines": [str, str, str],
|
||||
"hot_cold_comment": str,
|
||||
"warnings": str,
|
||||
"retrospective": str
|
||||
},
|
||||
"confidence": int (0~100)
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def build_user_message(draw_no: int, candidates: list, context: dict) -> str:
|
||||
payload = {
|
||||
"draw_no": draw_no,
|
||||
"context": context, # hot_numbers, cold_numbers, last_draw_summary, my_recent_performance, retrospective
|
||||
"candidates": candidates,
|
||||
}
|
||||
return (
|
||||
f"이번 회차: {draw_no}\n"
|
||||
f"아래 데이터로 4계층 20세트를 큐레이션하고 위 스키마로만 응답하세요.\n\n"
|
||||
f"```json\n{json.dumps(payload, ensure_ascii=False)}\n```"
|
||||
)
|
||||
50
agent-office/app/curator/retrospective.py
Normal file
50
agent-office/app/curator/retrospective.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""큐레이션 직전 호출 — review 1건 + 추세 3건 → 컨텍스트 dict."""
|
||||
import json
|
||||
from typing import Optional, Dict, Any
|
||||
from .. import service_proxy
|
||||
|
||||
|
||||
def _detect_bias(reviews: list) -> str:
|
||||
"""3주↑ 같은 방향 패턴 편향이 유지되면 한 줄로."""
|
||||
deltas = [r.get("pattern_delta") or "" for r in reviews if r.get("pattern_delta")]
|
||||
if len(deltas) < 2:
|
||||
return ""
|
||||
# 단순 휴리스틱 — 같은 키워드("저번호" 등)가 2회 이상이면 지속 편향
|
||||
keywords = ["저번호", "고번호", "합계", "홀짝"]
|
||||
persistent = []
|
||||
for kw in keywords:
|
||||
cnt = sum(1 for d in deltas if kw in d)
|
||||
if cnt >= max(2, len(deltas) - 1):
|
||||
persistent.append(kw)
|
||||
return " · ".join(persistent)
|
||||
|
||||
|
||||
async def build_retrospective(target_draw_no: int) -> Optional[Dict[str, Any]]:
|
||||
"""target_draw_no(이번 주) 직전 회차의 review + 그 앞 3회 추세."""
|
||||
last = await service_proxy.lotto_review_by_draw(target_draw_no - 1)
|
||||
if not last:
|
||||
return None
|
||||
|
||||
history = await service_proxy.lotto_reviews_history(limit=4)
|
||||
# history 는 desc 정렬 → last 와 그 이전 3건 분리
|
||||
others = [r for r in history if r["draw_no"] < target_draw_no - 1][:3]
|
||||
series = [last] + others
|
||||
|
||||
cur_avgs = [r["curator_avg_match"] for r in series if r.get("curator_avg_match") is not None]
|
||||
usr_avgs = [r["user_avg_match"] for r in series if r.get("user_avg_match") is not None]
|
||||
|
||||
return {
|
||||
"last_draw": {
|
||||
"draw_no": last["draw_no"],
|
||||
"curator_avg": last.get("curator_avg_match"),
|
||||
"curator_best_tier": last.get("curator_best_tier"),
|
||||
"user_avg": last.get("user_avg_match"),
|
||||
"user_5plus": last.get("user_5plus_prizes"),
|
||||
"pattern_delta": last.get("pattern_delta") or "",
|
||||
},
|
||||
"trend_4w": {
|
||||
"curator_avg_4w": round(sum(cur_avgs) / len(cur_avgs), 2) if cur_avgs else None,
|
||||
"user_avg_4w": round(sum(usr_avgs) / len(usr_avgs), 2) if usr_avgs else None,
|
||||
"user_persistent_bias": _detect_bias(series),
|
||||
},
|
||||
}
|
||||
58
agent-office/app/curator/schema.py
Normal file
58
agent-office/app/curator/schema.py
Normal file
@@ -0,0 +1,58 @@
|
||||
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 TierRationale(BaseModel):
|
||||
bonus: str = Field(max_length=40)
|
||||
extended: str = Field(max_length=40)
|
||||
pool: str = Field(max_length=40)
|
||||
|
||||
|
||||
class Narrative(BaseModel):
|
||||
headline: str
|
||||
summary_3lines: List[str] = Field(min_length=3, max_length=3)
|
||||
hot_cold_comment: str = ""
|
||||
warnings: str = ""
|
||||
retrospective: str = Field(default="", max_length=80)
|
||||
|
||||
|
||||
class CuratorOutput(BaseModel):
|
||||
core_picks: List[Pick] = Field(min_length=5, max_length=5)
|
||||
bonus_picks: List[Pick] = Field(min_length=5, max_length=5)
|
||||
extended_picks: List[Pick] = Field(min_length=5, max_length=5)
|
||||
pool_picks: List[Pick] = Field(min_length=5, max_length=5)
|
||||
tier_rationale: TierRationale
|
||||
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)
|
||||
candidate_set = {tuple(sorted(c)) for c in candidate_numbers}
|
||||
all_picks = (
|
||||
out.core_picks + out.bonus_picks + out.extended_picks + out.pool_picks
|
||||
)
|
||||
# 중복 픽 검증
|
||||
pick_keys = [tuple(p.numbers) for p in all_picks]
|
||||
if len(pick_keys) != len(set(pick_keys)):
|
||||
raise ValueError("duplicate picks across tiers")
|
||||
# 후보에 없는 번호 조합 금지
|
||||
for p in all_picks:
|
||||
if tuple(p.numbers) not in candidate_set:
|
||||
raise ValueError(f"pick {p.numbers} not in candidates")
|
||||
return out
|
||||
558
agent-office/app/db.py
Normal file
558
agent-office/app/db.py
Normal file
@@ -0,0 +1,558 @@
|
||||
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=120.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=120000")
|
||||
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"],
|
||||
}
|
||||
229
agent-office/app/main.py
Normal file
229
agent-office/app/main.py
Normal file
@@ -0,0 +1,229 @@
|
||||
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
|
||||
from .routers import notify as notify_router
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(notify_router.router)
|
||||
|
||||
_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
|
||||
0
agent-office/app/notifiers/__init__.py
Normal file
0
agent-office/app/notifiers/__init__.py
Normal file
61
agent-office/app/notifiers/telegram_lotto.py
Normal file
61
agent-office/app/notifiers/telegram_lotto.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""로또 큐레이션·당첨 알림 — 텔레그램 푸시."""
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
|
||||
# 기존 에이전트들과 동일한 패턴: send_raw(text, reply_markup=None, chat_id=None)
|
||||
# chat_id 생략 시 기본 TELEGRAM_CHAT_ID로 자동 발송.
|
||||
from ..telegram.messaging import send_raw
|
||||
|
||||
logger = logging.getLogger("agent-office")
|
||||
|
||||
LOTTO_URL = "https://gahusb.synology.me/lotto"
|
||||
|
||||
|
||||
def _format_briefing(payload: Dict[str, Any]) -> str:
|
||||
draw_no = payload["draw_no"]
|
||||
nar = payload["narrative"]
|
||||
conf = payload["confidence"]
|
||||
|
||||
# 분배 칩 — core 5세트의 risk_tag 빈도
|
||||
core = payload["picks"]["core"]
|
||||
role_count = {"안정": 0, "균형": 0, "공격": 0}
|
||||
for p in core:
|
||||
role_count[p["risk_tag"]] = role_count.get(p["risk_tag"], 0) + 1
|
||||
chip = " · ".join(f"{k} {v}" for k, v in role_count.items() if v)
|
||||
|
||||
msg = [
|
||||
f"🎟 {draw_no}회 · 큐레이션 떴음",
|
||||
"",
|
||||
f"\"{nar['headline']}\"",
|
||||
f"신뢰도 {conf} · 분배 {chip}",
|
||||
]
|
||||
retro = nar.get("retrospective") or ""
|
||||
if retro:
|
||||
msg += ["", f"▸ 회고: {retro}"]
|
||||
msg += ["", f"👉 결정 카드 보러가기 ({LOTTO_URL})"]
|
||||
return "\n".join(msg)
|
||||
|
||||
|
||||
def _format_prize_alert(event: Dict[str, Any]) -> str:
|
||||
return (
|
||||
"🚨 로또 당첨 가능성!\n"
|
||||
f"{event['draw_no']}회 — {event['match_count']}개 일치\n"
|
||||
f"번호: {', '.join(str(n) for n in event['numbers'])}\n"
|
||||
"동행복권에서 즉시 확인하세요."
|
||||
)
|
||||
|
||||
|
||||
async def send_curator_briefing(payload: Dict[str, Any]) -> None:
|
||||
text = _format_briefing(payload)
|
||||
try:
|
||||
await send_raw(text)
|
||||
except Exception as e:
|
||||
logger.warning(f"[telegram_lotto] briefing send failed: {e}")
|
||||
|
||||
|
||||
async def send_prize_alert(event: Dict[str, Any]) -> None:
|
||||
text = _format_prize_alert(event)
|
||||
try:
|
||||
await send_raw(text)
|
||||
except Exception as e:
|
||||
logger.warning(f"[telegram_lotto] prize alert send failed: {e}")
|
||||
0
agent-office/app/routers/__init__.py
Normal file
0
agent-office/app/routers/__init__.py
Normal file
20
agent-office/app/routers/notify.py
Normal file
20
agent-office/app/routers/notify.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""다른 서비스가 트리거하는 웹훅 — 현재 lotto-backend → 텔레그램 푸시."""
|
||||
from typing import List
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
from ..notifiers.telegram_lotto import send_prize_alert
|
||||
|
||||
router = APIRouter(prefix="/api/agent-office/notify")
|
||||
|
||||
|
||||
class LottoPrizeEvent(BaseModel):
|
||||
draw_no: int
|
||||
match_count: int
|
||||
numbers: List[int]
|
||||
purchase_id: int
|
||||
|
||||
|
||||
@router.post("/lotto-prize")
|
||||
async def lotto_prize(body: LottoPrizeEvent):
|
||||
await send_prize_alert(body.model_dump())
|
||||
return {"ok": True}
|
||||
83
agent-office/app/scheduler.py
Normal file
83
agent-office/app/scheduler.py
Normal file
@@ -0,0 +1,83 @@
|
||||
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_stock_screener():
|
||||
agent = AGENT_REGISTRY.get("stock")
|
||||
if agent:
|
||||
await agent.on_screener_schedule()
|
||||
|
||||
async def _run_stock_ai_news():
|
||||
agent = AGENT_REGISTRY.get("stock")
|
||||
if agent:
|
||||
await agent.on_ai_news_schedule()
|
||||
|
||||
async def _run_insta_schedule():
|
||||
agent = AGENT_REGISTRY.get("insta")
|
||||
if agent:
|
||||
await agent.on_schedule()
|
||||
|
||||
|
||||
async def _run_insta_trends_collect():
|
||||
agent = AGENT_REGISTRY.get("insta")
|
||||
if agent:
|
||||
await agent.on_command("collect_trends", {})
|
||||
|
||||
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_stock_screener,
|
||||
"cron",
|
||||
day_of_week="mon-fri",
|
||||
hour=16,
|
||||
minute=30,
|
||||
id="stock_screener",
|
||||
)
|
||||
scheduler.add_job(
|
||||
_run_stock_ai_news,
|
||||
"cron",
|
||||
day_of_week="mon-fri",
|
||||
hour=8,
|
||||
minute=0,
|
||||
id="stock_ai_news_sentiment",
|
||||
)
|
||||
scheduler.add_job(_run_insta_schedule, "cron", hour=9, minute=30, id="insta_pipeline")
|
||||
scheduler.add_job(_run_insta_trends_collect, "cron", hour=9, minute=0, id="insta_trends_collect")
|
||||
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=9, 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()
|
||||
340
agent-office/app/service_proxy.py
Normal file
340
agent-office/app/service_proxy.py
Normal file
@@ -0,0 +1,340 @@
|
||||
import httpx
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .config import STOCK_URL, MUSIC_LAB_URL, INSTA_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_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_URL}/api/stock/indices")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def summarize_stock_news(limit: int = 15) -> Dict[str, Any]:
|
||||
"""stock의 AI 요약 엔드포인트 호출.
|
||||
반환: {"summary": str, "tokens": {...}, "model": str, "duration_ms": int, "article_count": int}
|
||||
"""
|
||||
# stock 내부 Ollama 호출이 180s까지 가능하므로 여유있게 200s
|
||||
async with httpx.AsyncClient(timeout=200.0) as client:
|
||||
resp = await client.post(
|
||||
f"{STOCK_URL}/api/stock/news/summarize",
|
||||
json={"limit": limit},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def refresh_screener_snapshot() -> Dict[str, Any]:
|
||||
"""stock의 KRX 일봉 스냅샷 갱신 (스크리너 실행 전 호출).
|
||||
|
||||
네이버 금융 일괄 다운로드라 보통 30~120s, 여유있게 180s.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||
resp = await client.post(f"{STOCK_URL}/api/stock/screener/snapshot/refresh")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def refresh_ai_news_sentiment() -> Dict[str, Any]:
|
||||
"""stock의 AI 뉴스 sentiment 분석 트리거 (08:00 cron).
|
||||
|
||||
네이버 100종목 스크래핑 + Claude Haiku 100콜 병렬 = 약 30-60초.
|
||||
여유있게 240s timeout.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=240.0) as client:
|
||||
resp = await client.post(
|
||||
f"{STOCK_URL}/api/stock/screener/snapshot/refresh-news-sentiment"
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def run_stock_screener(mode: str = "auto") -> Dict[str, Any]:
|
||||
"""stock의 스크리너 실행.
|
||||
|
||||
반환 status:
|
||||
- 'skipped_holiday': 공휴일/주말 — telegram_payload 없음
|
||||
- 'success': telegram_payload 동봉
|
||||
엔진 자체는 수 초 내 끝나지만, 컨텍스트 로드+200종목 처리 여유 180s.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||
resp = await client.post(
|
||||
f"{STOCK_URL}/api/stock/screener/run",
|
||||
json={"mode": mode},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def scrape_stock_news() -> Dict[str, Any]:
|
||||
"""stock의 수동 뉴스 스크랩 트리거 — DB에 최신 뉴스 저장.
|
||||
|
||||
아침 브리핑 직전 호출하여 어제 데이터가 아닌 오늘 새벽 뉴스를 보장한다.
|
||||
네이버 금융 단일 요청이라 보통 수 초 내 완료, 여유있게 60s.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
resp = await client.post(f"{STOCK_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()
|
||||
|
||||
|
||||
# --- insta-lab ---
|
||||
|
||||
async def insta_collect(categories: Optional[list] = None) -> Dict[str, Any]:
|
||||
"""뉴스 수집 트리거 → task_id 반환."""
|
||||
payload = {"categories": categories} if categories else {}
|
||||
resp = await _client.post(f"{INSTA_LAB_URL}/api/insta/news/collect", json=payload)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def insta_extract(categories: Optional[list] = None) -> Dict[str, Any]:
|
||||
payload = {"categories": categories} if categories else {}
|
||||
resp = await _client.post(f"{INSTA_LAB_URL}/api/insta/keywords/extract", json=payload)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def insta_list_keywords(category: Optional[str] = None,
|
||||
used: Optional[bool] = None) -> List[Dict[str, Any]]:
|
||||
params: Dict[str, Any] = {}
|
||||
if category:
|
||||
params["category"] = category
|
||||
if used is not None:
|
||||
params["used"] = "true" if used else "false"
|
||||
resp = await _client.get(f"{INSTA_LAB_URL}/api/insta/keywords", params=params)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("items", [])
|
||||
|
||||
|
||||
async def insta_get_keyword(keyword_id: int) -> Optional[Dict[str, Any]]:
|
||||
items = await insta_list_keywords()
|
||||
for it in items:
|
||||
if it["id"] == keyword_id:
|
||||
return it
|
||||
return None
|
||||
|
||||
|
||||
async def insta_create_slate(keyword: str, category: str, keyword_id: Optional[int] = None) -> Dict[str, Any]:
|
||||
resp = await _client.post(
|
||||
f"{INSTA_LAB_URL}/api/insta/slates",
|
||||
json={"keyword": keyword, "category": category, "keyword_id": keyword_id},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def insta_task_status(task_id: str) -> Dict[str, Any]:
|
||||
resp = await _client.get(f"{INSTA_LAB_URL}/api/insta/tasks/{task_id}")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def insta_get_slate(slate_id: int) -> Dict[str, Any]:
|
||||
resp = await _client.get(f"{INSTA_LAB_URL}/api/insta/slates/{slate_id}")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def insta_get_asset_bytes(slate_id: int, page: int) -> bytes:
|
||||
"""카드 PNG 바이트를 가져와 텔레그램 미디어 그룹에 첨부."""
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
resp = await client.get(f"{INSTA_LAB_URL}/api/insta/slates/{slate_id}/assets/{page}")
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
|
||||
|
||||
async def insta_collect_trends(categories: Optional[list] = None) -> Dict[str, Any]:
|
||||
payload = {"categories": categories} if categories else {}
|
||||
resp = await _client.post(f"{INSTA_LAB_URL}/api/insta/trends/collect", json=payload)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def insta_list_trends(source: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
days: int = 1) -> List[Dict[str, Any]]:
|
||||
params: Dict[str, Any] = {"days": days}
|
||||
if source:
|
||||
params["source"] = source
|
||||
if category:
|
||||
params["category"] = category
|
||||
resp = await _client.get(f"{INSTA_LAB_URL}/api/insta/trends", params=params)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("items", [])
|
||||
|
||||
|
||||
async def insta_get_preferences() -> Dict[str, float]:
|
||||
resp = await _client.get(f"{INSTA_LAB_URL}/api/insta/preferences")
|
||||
resp.raise_for_status()
|
||||
return {p["category"]: p["weight"] for p in resp.json().get("categories", [])}
|
||||
|
||||
|
||||
async def insta_put_preferences(weights: Dict[str, float]) -> Dict[str, Any]:
|
||||
resp = await _client.put(
|
||||
f"{INSTA_LAB_URL}/api/insta/preferences",
|
||||
json={"categories": weights},
|
||||
)
|
||||
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()
|
||||
|
||||
|
||||
async def lotto_review_latest() -> Optional[Dict[str, Any]]:
|
||||
from .config import LOTTO_BACKEND_URL
|
||||
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/review/latest")
|
||||
if resp.status_code == 404:
|
||||
return None
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def lotto_review_by_draw(draw_no: int) -> Optional[Dict[str, Any]]:
|
||||
from .config import LOTTO_BACKEND_URL
|
||||
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/review/{draw_no}")
|
||||
if resp.status_code == 404:
|
||||
return None
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def lotto_reviews_history(limit: int = 10) -> List[Dict[str, Any]]:
|
||||
from .config import LOTTO_BACKEND_URL
|
||||
resp = await _client.get(
|
||||
f"{LOTTO_BACKEND_URL}/api/lotto/review/history",
|
||||
params={"limit": limit},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("reviews", [])
|
||||
|
||||
|
||||
# --- 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)
|
||||
83
agent-office/app/telegram/messaging.py
Normal file
83
agent-office/app/telegram/messaging.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""고수준 메시지 전송 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,
|
||||
parse_mode: str = "HTML",
|
||||
) -> dict:
|
||||
"""가장 저수준. 원문 텍스트 그대로 전송. chat_id 생략 시 기본 TELEGRAM_CHAT_ID로.
|
||||
|
||||
parse_mode: 기본 'HTML'. MarkdownV2 페이로드(예: 스크리너) 전송 시 명시 지정.
|
||||
"""
|
||||
if not _enabled():
|
||||
return {"ok": False, "message_id": None}
|
||||
payload = {
|
||||
"chat_id": chat_id or TELEGRAM_CHAT_ID,
|
||||
"text": text,
|
||||
"parse_mode": parse_mode,
|
||||
}
|
||||
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 — 청약 현황 요약
|
||||
"""
|
||||
239
agent-office/app/telegram/webhook.py
Normal file
239
agent-office/app/telegram/webhook.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""텔레그램 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)
|
||||
|
||||
if callback_id.startswith("render_"):
|
||||
return await _handle_insta_render(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_insta_render(callback_query: dict, callback_id: str) -> dict:
|
||||
"""render_{keyword_id} 콜백 → InstaAgent.on_callback('render', ...).
|
||||
|
||||
텔레그램 인라인 버튼이 보낸 callback_data가 `render_<keyword_id>` 형식.
|
||||
InstaAgent._push_keyword_candidates가 callback_data를 그대로 박아 보내며,
|
||||
별도 DB lookup 없이 keyword_id를 파싱해 dispatch한다."""
|
||||
from .messaging import send_raw
|
||||
from ..agents import AGENT_REGISTRY
|
||||
|
||||
await api_call(
|
||||
"answerCallbackQuery",
|
||||
{"callback_query_id": callback_query["id"], "text": "카드 생성 시작"},
|
||||
)
|
||||
|
||||
try:
|
||||
keyword_id = int(callback_id.removeprefix("render_"))
|
||||
except ValueError:
|
||||
await send_raw("⚠️ 잘못된 render 콜백 데이터")
|
||||
return {"ok": False, "error": "invalid_callback_data"}
|
||||
|
||||
agent = AGENT_REGISTRY.get("insta")
|
||||
if not agent:
|
||||
await send_raw("⚠️ insta agent 미등록")
|
||||
return {"ok": False, "error": "agent_missing"}
|
||||
|
||||
try:
|
||||
return await agent.on_callback("render", {"keyword_id": keyword_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)
|
||||
55
agent-office/tests/test_curator_schema.py
Normal file
55
agent-office/tests/test_curator_schema.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
import pytest
|
||||
from app.curator.schema import validate_response
|
||||
|
||||
|
||||
def _pick(nums, role="안정"):
|
||||
return {"numbers": nums, "risk_tag": role, "reason": "x"}
|
||||
|
||||
|
||||
def _make_payload(core, bonus, ext, pool):
|
||||
return {
|
||||
"core_picks": core, "bonus_picks": bonus,
|
||||
"extended_picks": ext, "pool_picks": pool,
|
||||
"tier_rationale": {"bonus": "a", "extended": "b", "pool": "c"},
|
||||
"narrative": {
|
||||
"headline": "h",
|
||||
"summary_3lines": ["1", "2", "3"],
|
||||
"retrospective": "지난주 평균 1.8",
|
||||
},
|
||||
"confidence": 70,
|
||||
}
|
||||
|
||||
|
||||
def test_valid_4tier():
|
||||
pool = [[i, i+1, i+2, i+3, i+4, i+5] for i in range(1, 21)]
|
||||
cores = [_pick(pool[i]) for i in range(5)]
|
||||
bonus = [_pick(pool[i]) for i in range(5, 10)]
|
||||
ext = [_pick(pool[i]) for i in range(10, 15)]
|
||||
pl = [_pick(pool[i]) for i in range(15, 20)]
|
||||
out = validate_response(_make_payload(cores, bonus, ext, pl), pool)
|
||||
assert len(out.core_picks) == 5
|
||||
assert out.narrative.retrospective.startswith("지난주")
|
||||
|
||||
|
||||
def test_duplicate_pick_rejected():
|
||||
pool = [[i, i+1, i+2, i+3, i+4, i+5] for i in range(1, 21)]
|
||||
cores = [_pick(pool[0])] * 5 # 중복
|
||||
bonus = [_pick(pool[i]) for i in range(5, 10)]
|
||||
ext = [_pick(pool[i]) for i in range(10, 15)]
|
||||
pl = [_pick(pool[i]) for i in range(15, 20)]
|
||||
with pytest.raises(ValueError, match="duplicate"):
|
||||
validate_response(_make_payload(cores, bonus, ext, pl), pool)
|
||||
|
||||
|
||||
def test_pick_not_in_candidates_rejected():
|
||||
pool = [[i, i+1, i+2, i+3, i+4, i+5] for i in range(1, 21)]
|
||||
foreign = [40, 41, 42, 43, 44, 45]
|
||||
cores = [_pick(foreign)] + [_pick(pool[i]) for i in range(1, 5)]
|
||||
bonus = [_pick(pool[i]) for i in range(5, 10)]
|
||||
ext = [_pick(pool[i]) for i in range(10, 15)]
|
||||
pl = [_pick(pool[i]) for i in range(15, 20)]
|
||||
with pytest.raises(ValueError, match="not in candidates"):
|
||||
validate_response(_make_payload(cores, bonus, ext, pl), pool)
|
||||
85
agent-office/tests/test_insta_agent.py
Normal file
85
agent-office/tests/test_insta_agent.py
Normal file
@@ -0,0 +1,85 @@
|
||||
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__))))
|
||||
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.agents.insta import InstaAgent
|
||||
|
||||
|
||||
@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_on_command_extract_dispatches(monkeypatch):
|
||||
agent = InstaAgent()
|
||||
fake_collect = AsyncMock(return_value={"task_id": "tcollect"})
|
||||
fake_extract = AsyncMock(return_value={"task_id": "textract"})
|
||||
fake_status = AsyncMock(side_effect=[
|
||||
{"status": "succeeded", "result_id": 0},
|
||||
{"status": "succeeded", "result_id": 0},
|
||||
])
|
||||
fake_keywords = AsyncMock(return_value=[
|
||||
{"id": 1, "keyword": "K1", "category": "economy", "score": 0.9},
|
||||
{"id": 2, "keyword": "K2", "category": "psychology", "score": 0.8},
|
||||
])
|
||||
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_collect", fake_collect)
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_extract", fake_extract)
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_task_status", fake_status)
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_list_keywords", fake_keywords)
|
||||
monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock(return_value={"ok": True}))
|
||||
|
||||
result = await agent.on_command("extract", {})
|
||||
assert result["ok"] is True
|
||||
fake_collect.assert_awaited()
|
||||
fake_extract.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_callback_render_kicks_pipeline(monkeypatch):
|
||||
agent = InstaAgent()
|
||||
fake_kw = AsyncMock(return_value={"id": 7, "keyword": "테스트", "category": "economy"})
|
||||
fake_create = AsyncMock(return_value={"task_id": "tslate"})
|
||||
fake_status = AsyncMock(side_effect=[
|
||||
{"status": "processing"},
|
||||
{"status": "succeeded", "result_id": 42},
|
||||
])
|
||||
fake_slate = AsyncMock(return_value={
|
||||
"id": 42, "status": "rendered",
|
||||
"suggested_caption": "캡션", "hashtags": ["#a", "#b"],
|
||||
"assets": [{"page_index": i, "file_path": f"/x/{i}.png"} for i in range(1, 11)],
|
||||
})
|
||||
fake_bytes = AsyncMock(side_effect=[b"PNG"] * 10)
|
||||
fake_send_media = AsyncMock(return_value={"ok": True})
|
||||
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_keyword", fake_kw)
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_create_slate", fake_create)
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_task_status", fake_status)
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_slate", fake_slate)
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_asset_bytes", fake_bytes)
|
||||
monkeypatch.setattr("app.agents.insta._send_media_group", fake_send_media)
|
||||
monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock(return_value={"ok": True}))
|
||||
|
||||
out = await agent.on_callback("render", {"keyword_id": 7})
|
||||
assert out["ok"] is True
|
||||
fake_create.assert_awaited()
|
||||
fake_send_media.assert_awaited()
|
||||
73
agent-office/tests/test_insta_agent_trends.py
Normal file
73
agent-office/tests/test_insta_agent_trends.py
Normal file
@@ -0,0 +1,73 @@
|
||||
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__))))
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.agents.insta import InstaAgent
|
||||
|
||||
|
||||
@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_on_command_collect_trends_dispatches(monkeypatch):
|
||||
agent = InstaAgent()
|
||||
fake_collect = AsyncMock(return_value={"task_id": "tcollect"})
|
||||
fake_status = AsyncMock(return_value={"status": "succeeded", "result_id": 8,
|
||||
"message": "naver:5, google:3"})
|
||||
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_collect_trends", fake_collect)
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_task_status", fake_status)
|
||||
monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock(return_value={"ok": True}))
|
||||
|
||||
result = await agent.on_command("collect_trends", {})
|
||||
assert result["ok"] is True
|
||||
fake_collect.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_schedule_loads_preferences(monkeypatch):
|
||||
"""on_schedule이 preferences를 가져오는지 확인."""
|
||||
agent = InstaAgent()
|
||||
|
||||
fake_collect = AsyncMock(return_value={"task_id": "t1"})
|
||||
fake_extract = AsyncMock(return_value={"task_id": "t2"})
|
||||
fake_status = AsyncMock(side_effect=[
|
||||
{"status": "succeeded", "result_id": 0},
|
||||
{"status": "succeeded", "result_id": 0},
|
||||
])
|
||||
fake_keywords = AsyncMock(return_value=[
|
||||
{"id": 1, "keyword": "K", "category": "economy", "score": 0.9},
|
||||
])
|
||||
fake_prefs = AsyncMock(return_value={"economy": 0.6, "psychology": 0.4})
|
||||
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_collect", fake_collect)
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_extract", fake_extract)
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_task_status", fake_status)
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_list_keywords", fake_keywords)
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_preferences", fake_prefs)
|
||||
monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock(return_value={"ok": True}))
|
||||
|
||||
agent.state = "idle"
|
||||
await agent.on_schedule()
|
||||
|
||||
fake_prefs.assert_awaited()
|
||||
132
agent-office/tests/test_pipeline_polling.py
Normal file
132
agent-office/tests/test_pipeline_polling.py
Normal file
@@ -0,0 +1,132 @@
|
||||
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",
|
||||
"feedback_count_per_step": {},
|
||||
}]
|
||||
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_poll_renotifies_on_reject_regen(monkeypatch):
|
||||
from app.agents.youtube_publisher import YoutubePublisherAgent
|
||||
|
||||
pipelines_v1 = [{"id": 1, "state": "cover_pending", "cover_url": "/x.jpg",
|
||||
"track_title": "Test", "feedback_count_per_step": {}}]
|
||||
pipelines_v2 = [{"id": 1, "state": "cover_pending", "cover_url": "/x2.jpg",
|
||||
"track_title": "Test", "feedback_count_per_step": {"cover": 1}}]
|
||||
list_mock = AsyncMock(side_effect=[pipelines_v1, pipelines_v2])
|
||||
with patch("app.agents.youtube_publisher.service_proxy.list_active_pipelines", list_mock), \
|
||||
patch("app.agents.youtube_publisher.send_raw",
|
||||
new=AsyncMock(return_value={"ok": True, "message_id": 99})), \
|
||||
patch("app.agents.youtube_publisher.service_proxy.save_pipeline_telegram_msg",
|
||||
new=AsyncMock()):
|
||||
a = YoutubePublisherAgent()
|
||||
await a.poll_state_changes() # 1st: notify
|
||||
await a.poll_state_changes() # 2nd: feedback count differs → notify again
|
||||
from app.agents.youtube_publisher import send_raw as sr
|
||||
assert sr.call_count == 2
|
||||
|
||||
|
||||
@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
|
||||
47
agent-office/tests/test_retrospective.py
Normal file
47
agent-office/tests/test_retrospective.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from app.curator.retrospective import build_retrospective, _detect_bias
|
||||
|
||||
|
||||
def test_detect_bias_persistent_low():
|
||||
reviews = [
|
||||
{"pattern_delta": "저번호 편향 +1.2 / 합계 -18"},
|
||||
{"pattern_delta": "저번호 편향 +0.8"},
|
||||
{"pattern_delta": "저번호 편향 +1.0 / 홀짝 +0.5"},
|
||||
]
|
||||
assert "저번호" in _detect_bias(reviews)
|
||||
|
||||
|
||||
def test_detect_bias_no_persistence():
|
||||
reviews = [
|
||||
{"pattern_delta": "저번호 편향 +1.2"},
|
||||
{"pattern_delta": "고번호 편향 +0.8"},
|
||||
]
|
||||
assert _detect_bias(reviews) == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_retrospective_with_data():
|
||||
with patch("app.service_proxy.lotto_review_by_draw", new=AsyncMock(return_value={
|
||||
"draw_no": 1153, "curator_avg_match": 1.8, "curator_best_tier": "안정",
|
||||
"user_avg_match": 2.0, "user_5plus_prizes": 1, "pattern_delta": "저번호 편향 +1.2",
|
||||
})), patch("app.service_proxy.lotto_reviews_history", new=AsyncMock(return_value=[
|
||||
{"draw_no": 1153, "curator_avg_match": 1.8, "user_avg_match": 2.0, "pattern_delta": "저번호 편향 +1.2"},
|
||||
{"draw_no": 1152, "curator_avg_match": 1.6, "user_avg_match": 1.5, "pattern_delta": "저번호 편향 +0.8"},
|
||||
{"draw_no": 1151, "curator_avg_match": 1.7, "user_avg_match": 1.8, "pattern_delta": "저번호 편향 +1.0"},
|
||||
{"draw_no": 1150, "curator_avg_match": 1.9, "user_avg_match": 2.2, "pattern_delta": ""},
|
||||
])):
|
||||
out = await build_retrospective(1154)
|
||||
assert out["last_draw"]["draw_no"] == 1153
|
||||
assert out["trend_4w"]["curator_avg_4w"] == round((1.8+1.6+1.7+1.9)/4, 2)
|
||||
assert "저번호" in out["trend_4w"]["user_persistent_bias"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_retrospective_no_review():
|
||||
with patch("app.service_proxy.lotto_review_by_draw", new=AsyncMock(return_value=None)):
|
||||
out = await build_retrospective(1154)
|
||||
assert out is None
|
||||
177
agent-office/tests/test_stock_screener_job.py
Normal file
177
agent-office/tests/test_stock_screener_job.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""StockAgent.on_screener_schedule — 평일 16:30 KST 자동 잡 단위 테스트.
|
||||
|
||||
stock HTTP 호출은 service_proxy mock, 텔레그램은 messaging.send_raw mock.
|
||||
"""
|
||||
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 _success_body(asof="2026-05-12"):
|
||||
return {
|
||||
"asof": asof,
|
||||
"mode": "auto",
|
||||
"status": "success",
|
||||
"run_id": 42,
|
||||
"survivors_count": 600,
|
||||
"top_n": 20,
|
||||
"results": [],
|
||||
"telegram_payload": {
|
||||
"chat_target": "default",
|
||||
"parse_mode": "MarkdownV2",
|
||||
"text": "*KRX 강세주 스크리너* test body",
|
||||
},
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
|
||||
def _holiday_body(asof="2026-05-05"):
|
||||
return {
|
||||
"asof": asof,
|
||||
"mode": "auto",
|
||||
"status": "skipped_holiday",
|
||||
"run_id": None,
|
||||
"survivors_count": None,
|
||||
"top_n": 0,
|
||||
"results": [],
|
||||
"telegram_payload": None,
|
||||
"warnings": [f"{asof} is a holiday — skipped"],
|
||||
}
|
||||
|
||||
|
||||
def test_screener_success_sends_markdownv2_telegram():
|
||||
from app.agents.stock import StockAgent
|
||||
from app import service_proxy
|
||||
from app.telegram import messaging
|
||||
|
||||
fake_snap = AsyncMock(return_value={"status": "ok"})
|
||||
fake_run = AsyncMock(return_value=_success_body())
|
||||
fake_send = AsyncMock(return_value={"ok": True, "message_id": 7777})
|
||||
|
||||
with patch.object(service_proxy, "refresh_screener_snapshot", fake_snap), \
|
||||
patch.object(service_proxy, "run_stock_screener", fake_run), \
|
||||
patch.object(messaging, "send_raw", fake_send):
|
||||
agent = StockAgent()
|
||||
asyncio.run(agent.on_screener_schedule())
|
||||
|
||||
fake_snap.assert_awaited_once()
|
||||
fake_run.assert_awaited_once_with(mode="auto")
|
||||
fake_send.assert_awaited_once()
|
||||
args, kwargs = fake_send.call_args
|
||||
# 첫 인자(text) 또는 kwargs로 전달
|
||||
text = args[0] if args else kwargs.get("text")
|
||||
assert "KRX 강세주 스크리너" in text
|
||||
assert kwargs.get("parse_mode") == "MarkdownV2"
|
||||
assert agent.state == "idle"
|
||||
|
||||
|
||||
def test_screener_holiday_skips_telegram():
|
||||
from app.agents.stock import StockAgent
|
||||
from app import service_proxy
|
||||
from app.telegram import messaging
|
||||
|
||||
fake_snap = AsyncMock(return_value={"status": "skipped_weekend"})
|
||||
fake_run = AsyncMock(return_value=_holiday_body())
|
||||
fake_send = AsyncMock(return_value={"ok": True, "message_id": 1})
|
||||
|
||||
with patch.object(service_proxy, "refresh_screener_snapshot", fake_snap), \
|
||||
patch.object(service_proxy, "run_stock_screener", fake_run), \
|
||||
patch.object(messaging, "send_raw", fake_send):
|
||||
agent = StockAgent()
|
||||
asyncio.run(agent.on_screener_schedule())
|
||||
|
||||
fake_run.assert_awaited_once()
|
||||
# 휴일이면 텔레그램 미발신
|
||||
fake_send.assert_not_awaited()
|
||||
assert agent.state == "idle"
|
||||
|
||||
|
||||
def test_screener_snapshot_failure_still_runs_screener():
|
||||
"""스냅샷 실패는 경고만 남기고 screener 호출은 계속됨."""
|
||||
from app.agents.stock import StockAgent
|
||||
from app import service_proxy
|
||||
from app.telegram import messaging
|
||||
|
||||
fake_snap = AsyncMock(side_effect=RuntimeError("snapshot upstream down"))
|
||||
fake_run = AsyncMock(return_value=_success_body())
|
||||
fake_send = AsyncMock(return_value={"ok": True, "message_id": 8888})
|
||||
|
||||
with patch.object(service_proxy, "refresh_screener_snapshot", fake_snap), \
|
||||
patch.object(service_proxy, "run_stock_screener", fake_run), \
|
||||
patch.object(messaging, "send_raw", fake_send):
|
||||
agent = StockAgent()
|
||||
asyncio.run(agent.on_screener_schedule())
|
||||
|
||||
fake_snap.assert_awaited_once()
|
||||
fake_run.assert_awaited_once_with(mode="auto")
|
||||
fake_send.assert_awaited_once()
|
||||
|
||||
|
||||
def test_screener_run_failure_notifies_operator():
|
||||
"""screener/run 실패 시 운영자 알림 텔레그램 발송."""
|
||||
from app.agents.stock import StockAgent
|
||||
from app import service_proxy
|
||||
from app.telegram import messaging
|
||||
|
||||
fake_snap = AsyncMock(return_value={"status": "ok"})
|
||||
fake_run = AsyncMock(side_effect=RuntimeError("stock 500"))
|
||||
fake_send = AsyncMock(return_value={"ok": True, "message_id": 1})
|
||||
|
||||
with patch.object(service_proxy, "refresh_screener_snapshot", fake_snap), \
|
||||
patch.object(service_proxy, "run_stock_screener", fake_run), \
|
||||
patch.object(messaging, "send_raw", fake_send):
|
||||
agent = StockAgent()
|
||||
asyncio.run(agent.on_screener_schedule())
|
||||
|
||||
# 운영자 알림 1회는 호출
|
||||
assert fake_send.await_count == 1
|
||||
args, kwargs = fake_send.call_args
|
||||
text = args[0] if args else kwargs.get("text")
|
||||
assert "스크리너 실패" in text
|
||||
assert agent.state == "idle"
|
||||
|
||||
|
||||
def test_screener_unexpected_status_treated_as_failure():
|
||||
from app.agents.stock import StockAgent
|
||||
from app import service_proxy
|
||||
from app.telegram import messaging
|
||||
|
||||
fake_snap = AsyncMock(return_value={"status": "ok"})
|
||||
fake_run = AsyncMock(return_value={"status": "weird", "asof": "2026-05-12"})
|
||||
fake_send = AsyncMock(return_value={"ok": True, "message_id": 1})
|
||||
|
||||
with patch.object(service_proxy, "refresh_screener_snapshot", fake_snap), \
|
||||
patch.object(service_proxy, "run_stock_screener", fake_run), \
|
||||
patch.object(messaging, "send_raw", fake_send):
|
||||
agent = StockAgent()
|
||||
asyncio.run(agent.on_screener_schedule())
|
||||
|
||||
# 운영자 알림 1회 + screener payload 미발송
|
||||
assert fake_send.await_count == 1
|
||||
args, kwargs = fake_send.call_args
|
||||
text = args[0] if args else kwargs.get("text")
|
||||
assert "스크리너 실패" in text
|
||||
44
agent-office/tests/test_telegram_lotto_format.py
Normal file
44
agent-office/tests/test_telegram_lotto_format.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from app.notifiers.telegram_lotto import _format_briefing, _format_prize_alert
|
||||
|
||||
|
||||
def test_briefing_with_retrospective():
|
||||
payload = {
|
||||
"draw_no": 1154,
|
||||
"confidence": 72,
|
||||
"narrative": {
|
||||
"headline": "안정 +1, 콜드 누적 보강",
|
||||
"summary_3lines": ["a", "b", "c"],
|
||||
"retrospective": "너 2.0 / 나 1.8 — 저번호 편향",
|
||||
},
|
||||
"picks": {
|
||||
"core": [
|
||||
{"risk_tag": "안정"}, {"risk_tag": "안정"}, {"risk_tag": "안정"},
|
||||
{"risk_tag": "균형"}, {"risk_tag": "공격"},
|
||||
],
|
||||
"bonus": [], "extended": [], "pool": [],
|
||||
},
|
||||
}
|
||||
text = _format_briefing(payload)
|
||||
assert "1154회" in text
|
||||
assert "신뢰도 72" in text
|
||||
assert "안정 3" in text
|
||||
assert "회고: 너 2.0" in text
|
||||
|
||||
|
||||
def test_briefing_without_retrospective():
|
||||
payload = {
|
||||
"draw_no": 1, "confidence": 50,
|
||||
"narrative": {"headline": "h", "summary_3lines": ["a","b","c"], "retrospective": ""},
|
||||
"picks": {"core": [{"risk_tag":"안정"}]*5, "bonus":[],"extended":[],"pool":[]},
|
||||
}
|
||||
text = _format_briefing(payload)
|
||||
assert "회고" not in text
|
||||
|
||||
|
||||
def test_prize_alert():
|
||||
text = _format_prize_alert({"draw_no": 1154, "match_count": 5, "numbers": [3,11,17,25,33,8]})
|
||||
assert "5개 일치" in text
|
||||
assert "3, 11, 17, 25, 33, 8" in text
|
||||
1114
backend/app/db.py
1114
backend/app/db.py
File diff suppressed because it is too large
Load Diff
6
deployer/.dockerignore
Normal file
6
deployer/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
.git
|
||||
__pycache__
|
||||
*.pyc
|
||||
.env
|
||||
.env.*
|
||||
*.md
|
||||
@@ -1,8 +1,16 @@
|
||||
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 \
|
||||
docker.io \
|
||||
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
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import os, hmac, hashlib, subprocess
|
||||
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)
|
||||
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
|
||||
@@ -18,10 +27,13 @@ def verify(sig: str, body: bytes) -> bool:
|
||||
return any(hmac.compare_digest(sig, c) for c in candidates)
|
||||
|
||||
def run_deploy_script():
|
||||
"""배포 스크립트를 백그라운드에서 실행하고 로그를 남김"""
|
||||
logger.info("Starting deployment script...")
|
||||
"""배포 스크립트를 백그라운드에서 실행 (동시 실행 방지)"""
|
||||
if not _deploy_lock.acquire(blocking=False):
|
||||
logger.info("Deploy already in progress, skipping")
|
||||
return
|
||||
|
||||
try:
|
||||
# 타임아웃 10분 설정
|
||||
logger.info("Starting deployment script...")
|
||||
p = subprocess.run(["/bin/bash", "/scripts/deploy.sh"], capture_output=True, text=True, timeout=600)
|
||||
|
||||
if p.returncode == 0:
|
||||
@@ -29,8 +41,16 @@ def run_deploy_script():
|
||||
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):
|
||||
@@ -45,6 +65,13 @@ async def webhook(req: Request, background_tasks: BackgroundTasks):
|
||||
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)
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
version: "3.8"
|
||||
name: webpage
|
||||
|
||||
services:
|
||||
backend:
|
||||
lotto:
|
||||
build:
|
||||
context: ./backend
|
||||
context: ./lotto
|
||||
args:
|
||||
APP_VERSION: ${APP_VERSION:-dev}
|
||||
container_name: lotto-backend
|
||||
container_name: lotto
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18000:8000"
|
||||
@@ -16,21 +16,209 @@ services:
|
||||
- LOTTO_LATEST_URL=${LOTTO_LATEST_URL:-https://smok95.github.io/lotto/results/latest.json}
|
||||
volumes:
|
||||
- ${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:
|
||||
stock:
|
||||
build:
|
||||
context: ./stock-lab
|
||||
context: ./stock
|
||||
args:
|
||||
APP_VERSION: ${APP_VERSION:-dev}
|
||||
container_name: stock-lab
|
||||
container_name: stock
|
||||
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}
|
||||
- WEBAI_API_KEY=${WEBAI_API_KEY:-}
|
||||
volumes:
|
||||
- ${STOCK_DATA_PATH:-./data/stock}:/app/data
|
||||
- ${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}
|
||||
- WINDOWS_VIDEO_ENCODER_URL=${WINDOWS_VIDEO_ENCODER_URL:-}
|
||||
- NAS_VIDEOS_ROOT=${NAS_VIDEOS_ROOT:-/volume1/docker/webpage/data/videos}
|
||||
- NAS_MUSIC_ROOT=${NAS_MUSIC_ROOT:-/volume1/docker/webpage/data/music}
|
||||
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
|
||||
|
||||
insta-lab:
|
||||
build:
|
||||
context: ./insta-lab
|
||||
container_name: insta-lab
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18700:8000"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
- ANTHROPIC_MODEL_HAIKU=${ANTHROPIC_MODEL_HAIKU:-claude-haiku-4-5-20251001}
|
||||
- ANTHROPIC_MODEL_SONNET=${ANTHROPIC_MODEL_SONNET:-claude-sonnet-4-6}
|
||||
- NAVER_CLIENT_ID=${NAVER_CLIENT_ID:-}
|
||||
- NAVER_CLIENT_SECRET=${NAVER_CLIENT_SECRET:-}
|
||||
- YOUTUBE_DATA_API_KEY=${YOUTUBE_DATA_API_KEY:-}
|
||||
- INSTA_DATA_PATH=/app/data
|
||||
- CARD_TEMPLATE_DIR=/app/app/templates
|
||||
- INSTA_DEFAULT_THEME=${INSTA_DEFAULT_THEME:-default}
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/data/insta:/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_URL=http://stock:8000
|
||||
- MUSIC_LAB_URL=http://music-lab:8000
|
||||
- INSTA_LAB_URL=http://insta-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
|
||||
- music-lab
|
||||
- insta-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:-}
|
||||
- DSM_VERIFY_SSL=${DSM_VERIFY_SSL:-true}
|
||||
- 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}
|
||||
- PACK_HOST_DIR=${PACK_HOST_DIR:-${PACK_DATA_PATH:-./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:
|
||||
build: ./travel-proxy
|
||||
@@ -38,22 +226,37 @@ services:
|
||||
restart: unless-stopped
|
||||
user: "${PUID}:${PGID}"
|
||||
ports:
|
||||
- "19000:8000" # 내부 확인용
|
||||
- "19000:8000"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- TRAVEL_ROOT=${TRAVEL_ROOT:-/data/travel}
|
||||
- TRAVEL_THUMB_ROOT=${TRAVEL_THUMB_ROOT:-/data/thumbs}
|
||||
- TRAVEL_MEDIA_BASE=${TRAVEL_MEDIA_BASE:-/media/travel}
|
||||
- TRAVEL_CACHE_TTL=${TRAVEL_CACHE_TTL:-300}
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-*}
|
||||
- TRAVEL_DB_PATH=${TRAVEL_DB_PATH:-/data/thumbs/travel.db}
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
volumes:
|
||||
- ${PHOTO_PATH}:/data/travel:ro
|
||||
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:rw
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
frontend:
|
||||
image: nginx:alpine
|
||||
container_name: lotto-frontend
|
||||
container_name: frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- lotto
|
||||
- stock
|
||||
- music-lab
|
||||
- insta-lab
|
||||
- realestate-lab
|
||||
- agent-office
|
||||
- personal
|
||||
- packs-lab
|
||||
- travel-proxy
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
@@ -61,17 +264,27 @@ services:
|
||||
- ${RUNTIME_PATH}/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ${PHOTO_PATH}:/data/travel:ro
|
||||
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:ro
|
||||
- ${RUNTIME_PATH}/data/music:/data/music:ro
|
||||
- ${RUNTIME_PATH}/data/videos:/data/videos:ro
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:80/"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
deployer:
|
||||
build: ./deployer
|
||||
container_name: webpage-deployer
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "19010:9000" # 외부 노출 필요 없으면 내부만 (리버스프록시로 /webhook만 노출 추천)
|
||||
- "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
|
||||
|
||||
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 호출 일치.
|
||||
977
docs/superpowers/plans/2026-05-05-packs-lab-infra-integration.md
Normal file
977
docs/superpowers/plans/2026-05-05-packs-lab-infra-integration.md
Normal file
@@ -0,0 +1,977 @@
|
||||
# packs-lab 인프라 통합 + admin mint-token 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:** packs-lab을 운영 가능 상태로 만든다 — admin upload 토큰 발급 endpoint + Supabase 스키마 + docker-compose/nginx/env 통합 + 통합 테스트 + 문서 갱신.
|
||||
|
||||
**Architecture:** 기존 코드(HMAC + DSM client + 4 라우트)는 그대로 유지하고, 신규 라우트 1개(`POST /api/packs/admin/mint-token`)를 routes.py에 추가한다. Supabase `pack_files` DDL 파일과 인프라(docker-compose 18950, nginx 5GB streaming, .env.example 6+1 환경변수)를 신설하고, 통합 테스트(routes + dsm_client mock)와 CLAUDE.md 5+1곳을 갱신한다.
|
||||
|
||||
**Tech Stack:** Python 3.12 / FastAPI / pytest + unittest.mock / Supabase(PostgreSQL) / Synology DSM 7.x API / nginx / Docker Compose
|
||||
|
||||
**스펙 참조:** `docs/superpowers/specs/2026-05-05-packs-lab-infra-integration-design.md`
|
||||
|
||||
**작업 디렉토리:** `C:\Users\jaeoh\Desktop\workspace\web-backend` (기존 web-backend repo)
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 테스트 인프라 — `tests/conftest.py`
|
||||
|
||||
기존 `tests/test_auth.py`는 `BACKEND_HMAC_SECRET=secret` 같은 fixture가 없어 환경변수 의존. 모든 테스트가 동일한 secret으로 동작하도록 autouse fixture를 conftest에 정리.
|
||||
|
||||
**Files:**
|
||||
- Create: `packs-lab/tests/conftest.py`
|
||||
|
||||
- [ ] **Step 1: conftest.py 생성**
|
||||
|
||||
`packs-lab/tests/conftest.py`:
|
||||
|
||||
```python
|
||||
"""packs-lab 테스트 공통 fixture."""
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _hmac_secret(monkeypatch):
|
||||
"""모든 테스트에서 동일한 HMAC secret 사용. auth._SECRET 모듈 캐시까지 갱신."""
|
||||
monkeypatch.setenv("BACKEND_HMAC_SECRET", "test-secret-do-not-use-in-prod")
|
||||
# auth.py 모듈은 import 시점에 _SECRET을 캐시하므로 monkeypatch로 함께 갱신
|
||||
from app import auth
|
||||
monkeypatch.setattr(auth, "_SECRET", "test-secret-do-not-use-in-prod")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 기존 test_auth.py 회귀 검증**
|
||||
|
||||
```bash
|
||||
cd C:\Users\jaeoh\Desktop\workspace\web-backend\packs-lab
|
||||
python -m pytest tests/test_auth.py -v
|
||||
```
|
||||
|
||||
Expected: 기존 테스트 모두 PASS (conftest 영향 없거나 PASS 그대로 유지). 만약 secret 인코딩 차이로 실패 시 해당 테스트의 secret 사용 부분을 conftest 값과 일치시킨다.
|
||||
|
||||
- [ ] **Step 3: 커밋**
|
||||
|
||||
```bash
|
||||
git add packs-lab/tests/conftest.py
|
||||
git commit -m "test(packs-lab): conftest로 HMAC secret 통일"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: admin mint-token 라우트 (스키마 + 구현 + 테스트)
|
||||
|
||||
`POST /api/packs/admin/mint-token` 신규. Pydantic 스키마 추가 + 라우트 구현 + 통합 테스트.
|
||||
|
||||
**Files:**
|
||||
- Modify: `packs-lab/app/models.py` (스키마 2개 추가)
|
||||
- Modify: `packs-lab/app/routes.py` (import 보강 + 라우트 추가)
|
||||
- Create: `packs-lab/tests/test_routes.py` (mint-token 관련 테스트만 우선)
|
||||
|
||||
- [ ] **Step 1: failing 테스트 작성**
|
||||
|
||||
`packs-lab/tests/test_routes.py`:
|
||||
|
||||
```python
|
||||
"""packs-lab 라우트 통합 테스트.
|
||||
|
||||
DSM·Supabase는 mock. HMAC 검증·토큰 발급·검증은 실제 코드 사용.
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
SECRET = "test-secret-do-not-use-in-prod"
|
||||
|
||||
|
||||
def _hmac_headers(body_bytes: bytes) -> dict:
|
||||
"""body에 대한 X-Timestamp + X-Signature 헤더 생성."""
|
||||
ts = str(int(time.time()))
|
||||
sig = hmac.new(SECRET.encode(), ts.encode() + b"." + body_bytes, hashlib.sha256).hexdigest()
|
||||
return {"X-Timestamp": ts, "X-Signature": sig}
|
||||
|
||||
|
||||
def test_mint_token_hmac_required():
|
||||
"""HMAC 헤더 누락 → 401."""
|
||||
client = TestClient(app)
|
||||
body = {"tier": "pro", "label": "샘플", "filename": "x.zip", "size_bytes": 1024}
|
||||
resp = client.post("/api/packs/admin/mint-token", json=body)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_mint_token_returns_valid_token():
|
||||
"""발급된 token이 verify_upload_token으로 통과해야 한다."""
|
||||
from app.auth import verify_upload_token
|
||||
|
||||
body = {"tier": "pro", "label": "샘플", "filename": "test.zip", "size_bytes": 2048}
|
||||
body_bytes = json.dumps(body).encode()
|
||||
headers = _hmac_headers(body_bytes)
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/packs/admin/mint-token", content=body_bytes, headers=headers)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "token" in data and "expires_at" in data and "jti" in data
|
||||
|
||||
payload = verify_upload_token(data["token"])
|
||||
assert payload["tier"] == "pro"
|
||||
assert payload["label"] == "샘플"
|
||||
assert payload["filename"] == "test.zip"
|
||||
assert payload["size_bytes"] == 2048
|
||||
assert payload["jti"] == data["jti"]
|
||||
|
||||
|
||||
def test_mint_token_invalid_filename():
|
||||
"""허용 외 확장자 → 400."""
|
||||
body = {"tier": "pro", "label": "샘플", "filename": "x.exe", "size_bytes": 1024}
|
||||
body_bytes = json.dumps(body).encode()
|
||||
headers = _hmac_headers(body_bytes)
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/packs/admin/mint-token", content=body_bytes, headers=headers)
|
||||
assert resp.status_code == 400
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실패 확인**
|
||||
|
||||
```bash
|
||||
cd packs-lab
|
||||
python -m pytest tests/test_routes.py -v
|
||||
```
|
||||
|
||||
Expected: 모든 테스트 FAIL — `/api/packs/admin/mint-token` 라우트 없음 (404 또는 405).
|
||||
|
||||
- [ ] **Step 3: models.py에 스키마 추가**
|
||||
|
||||
`packs-lab/app/models.py` 끝부분에 추가:
|
||||
|
||||
```python
|
||||
class MintTokenRequest(BaseModel):
|
||||
"""Vercel → backend: admin upload 토큰 발급 요청."""
|
||||
tier: PackTier
|
||||
label: str = Field(..., max_length=200)
|
||||
filename: str = Field(..., max_length=255)
|
||||
size_bytes: int = Field(..., gt=0, le=5 * 1024 * 1024 * 1024)
|
||||
|
||||
|
||||
class MintTokenResponse(BaseModel):
|
||||
token: str
|
||||
expires_at: datetime
|
||||
jti: str
|
||||
```
|
||||
|
||||
- [ ] **Step 4: routes.py에 mint-token 라우트 추가**
|
||||
|
||||
`packs-lab/app/routes.py` 상단 import 블록에 다음을 추가:
|
||||
|
||||
```python
|
||||
import time
|
||||
from datetime import timezone
|
||||
```
|
||||
|
||||
(이미 `import uuid`, `from datetime import datetime`은 있음)
|
||||
|
||||
`from .auth import` 라인을 다음과 같이 확장:
|
||||
|
||||
```python
|
||||
from .auth import mint_upload_token, verify_request_hmac, verify_upload_token
|
||||
```
|
||||
|
||||
`from .models import` 라인을 다음과 같이 확장:
|
||||
|
||||
```python
|
||||
from .models import (
|
||||
MintTokenRequest,
|
||||
MintTokenResponse,
|
||||
PackFileItem,
|
||||
SignLinkRequest,
|
||||
SignLinkResponse,
|
||||
UploadResponse,
|
||||
)
|
||||
```
|
||||
|
||||
상수 추가 (`MAX_BYTES` 다음 줄에):
|
||||
|
||||
```python
|
||||
UPLOAD_TOKEN_TTL_SEC = int(os.getenv("UPLOAD_TOKEN_TTL_SEC", "1800")) # 30분 default
|
||||
```
|
||||
|
||||
라우트 추가 (`sign_link` 함수 다음, `upload` 함수 앞):
|
||||
|
||||
```python
|
||||
@router.post("/admin/mint-token", response_model=MintTokenResponse)
|
||||
async def mint_token(
|
||||
request: Request,
|
||||
x_timestamp: str = Header(""),
|
||||
x_signature: str = Header(""),
|
||||
):
|
||||
body = await request.body()
|
||||
verify_request_hmac(body, x_timestamp, x_signature)
|
||||
payload = MintTokenRequest.model_validate_json(body)
|
||||
_check_filename(payload.filename)
|
||||
|
||||
jti = str(uuid.uuid4())
|
||||
expires_ts = int(time.time()) + UPLOAD_TOKEN_TTL_SEC
|
||||
token = mint_upload_token({
|
||||
"tier": payload.tier,
|
||||
"label": payload.label,
|
||||
"filename": payload.filename,
|
||||
"size_bytes": payload.size_bytes,
|
||||
"jti": jti,
|
||||
"expires_at": expires_ts,
|
||||
})
|
||||
return MintTokenResponse(
|
||||
token=token,
|
||||
expires_at=datetime.fromtimestamp(expires_ts, tz=timezone.utc),
|
||||
jti=jti,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 테스트 통과 확인**
|
||||
|
||||
```bash
|
||||
cd packs-lab
|
||||
python -m pytest tests/test_routes.py -v
|
||||
```
|
||||
|
||||
Expected: 3 passed.
|
||||
|
||||
- [ ] **Step 6: 커밋**
|
||||
|
||||
```bash
|
||||
git add packs-lab/app/models.py packs-lab/app/routes.py packs-lab/tests/test_routes.py
|
||||
git commit -m "feat(packs-lab): POST /api/packs/admin/mint-token 라우트 + 통합 테스트"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 기존 4 라우트 통합 테스트 (sign-link / upload / list / delete)
|
||||
|
||||
기존 라우트는 변경 없음. 테스트만 추가해 회귀 안전망 확보.
|
||||
|
||||
**Files:**
|
||||
- Modify: `packs-lab/tests/test_routes.py` (테스트 8개 추가)
|
||||
|
||||
- [ ] **Step 1: sign-link 테스트 추가**
|
||||
|
||||
`tests/test_routes.py` 끝에 추가:
|
||||
|
||||
```python
|
||||
def test_sign_link_hmac_required():
|
||||
"""HMAC 헤더 없으면 401."""
|
||||
client = TestClient(app)
|
||||
body = {"file_path": "/volume1/docker/webpage/media/packs/pro/x.zip"}
|
||||
resp = client.post("/api/packs/sign-link", json=body)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_sign_link_outside_base_dir():
|
||||
"""PACK_BASE_DIR 외부 경로 → 400."""
|
||||
body = {"file_path": "/etc/passwd"}
|
||||
body_bytes = json.dumps(body).encode()
|
||||
headers = _hmac_headers(body_bytes)
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/packs/sign-link", content=body_bytes, headers=headers)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_sign_link_calls_dsm():
|
||||
"""DSM client 호출되고 응답 URL 반환."""
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
body = {"file_path": "/volume1/docker/webpage/media/packs/pro/sample.zip"}
|
||||
body_bytes = json.dumps(body).encode()
|
||||
headers = _hmac_headers(body_bytes)
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
fake_url = "https://gahusb.synology.me:5001/sharing/abc123"
|
||||
fake_expires = datetime(2026, 5, 5, 13, 0, tzinfo=timezone.utc)
|
||||
|
||||
with patch("app.routes.create_share_link", new=AsyncMock(return_value=(fake_url, fake_expires))) as mock:
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/packs/sign-link", content=body_bytes, headers=headers)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["url"] == fake_url
|
||||
mock.assert_awaited_once()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: upload 테스트 추가**
|
||||
|
||||
```python
|
||||
def _make_upload_token(tier="pro", label="샘플", filename="test.zip", size_bytes=1024, jti=None, ttl=1800):
|
||||
"""테스트용 upload token 생성. mint_token endpoint 거치지 않고 직접."""
|
||||
import uuid
|
||||
from app.auth import mint_upload_token
|
||||
return mint_upload_token({
|
||||
"tier": tier,
|
||||
"label": label,
|
||||
"filename": filename,
|
||||
"size_bytes": size_bytes,
|
||||
"jti": jti or str(uuid.uuid4()),
|
||||
"expires_at": int(time.time()) + ttl,
|
||||
})
|
||||
|
||||
|
||||
def test_upload_token_required():
|
||||
"""Authorization Bearer 누락 → 401."""
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/packs/upload", files={"file": ("x.zip", b"hello")})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_upload_size_mismatch(tmp_path, monkeypatch):
|
||||
"""토큰 size_bytes ≠ 실제 → 400 + 파일 정리됨."""
|
||||
monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path)
|
||||
token = _make_upload_token(size_bytes=999) # 실제 5바이트지만 토큰엔 999
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/packs/upload",
|
||||
files={"file": ("test.zip", b"hello")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "크기" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_upload_jti_replay(tmp_path, monkeypatch):
|
||||
"""같은 jti 토큰 두 번 → 두 번째 409."""
|
||||
monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path)
|
||||
|
||||
fake_supabase = MagicMock()
|
||||
fake_supabase.table.return_value.insert.return_value.execute.return_value = MagicMock(
|
||||
data=[{"uploaded_at": "2026-05-05T12:00:00+00:00"}]
|
||||
)
|
||||
|
||||
token = _make_upload_token(filename="replay.zip", size_bytes=5, jti="replay-jti-1")
|
||||
|
||||
with patch("app.routes._supabase", return_value=fake_supabase):
|
||||
client = TestClient(app)
|
||||
|
||||
# 1차: 성공
|
||||
resp1 = client.post(
|
||||
"/api/packs/upload",
|
||||
files={"file": ("replay.zip", b"hello")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp1.status_code == 200
|
||||
|
||||
# 2차: 동일 토큰 재사용 — 두 번째 파일은 다른 이름으로 보내 파일명 충돌 회피
|
||||
resp2 = client.post(
|
||||
"/api/packs/upload",
|
||||
files={"file": ("replay.zip", b"world")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp2.status_code == 409
|
||||
```
|
||||
|
||||
- [ ] **Step 3: list / delete 테스트 추가**
|
||||
|
||||
```python
|
||||
def test_list_returns_active_only():
|
||||
"""mock supabase가 deleted_at IS NULL 행만 반환하는지 (쿼리 빌더 호출 검증)."""
|
||||
fake_rows = [
|
||||
{
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"min_tier": "pro",
|
||||
"label": "샘플",
|
||||
"file_path": "/volume1/docker/webpage/media/packs/pro/a.zip",
|
||||
"filename": "a.zip",
|
||||
"size_bytes": 1024,
|
||||
"sort_order": 0,
|
||||
"uploaded_at": "2026-05-05T12:00:00+00:00",
|
||||
}
|
||||
]
|
||||
|
||||
fake_supabase = MagicMock()
|
||||
chain = fake_supabase.table.return_value.select.return_value
|
||||
chain.is_.return_value.order.return_value.order.return_value.execute.return_value = MagicMock(data=fake_rows)
|
||||
|
||||
body_bytes = b""
|
||||
headers = _hmac_headers(body_bytes)
|
||||
|
||||
with patch("app.routes._supabase", return_value=fake_supabase):
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/packs/list", headers=headers)
|
||||
|
||||
assert resp.status_code == 200
|
||||
items = resp.json()
|
||||
assert len(items) == 1
|
||||
assert items[0]["filename"] == "a.zip"
|
||||
fake_supabase.table.return_value.select.return_value.is_.assert_called_with("deleted_at", "null")
|
||||
|
||||
|
||||
def test_delete_soft_deletes():
|
||||
"""DELETE 시 supabase update에 deleted_at ISO timestamp가 들어가야 한다."""
|
||||
fake_supabase = MagicMock()
|
||||
fake_supabase.table.return_value.update.return_value.eq.return_value.execute.return_value = MagicMock(
|
||||
data=[{"id": "abc"}]
|
||||
)
|
||||
|
||||
body_bytes = b""
|
||||
headers = _hmac_headers(body_bytes)
|
||||
|
||||
with patch("app.routes._supabase", return_value=fake_supabase):
|
||||
client = TestClient(app)
|
||||
resp = client.delete("/api/packs/abc", headers=headers)
|
||||
|
||||
assert resp.status_code == 200
|
||||
update_call = fake_supabase.table.return_value.update.call_args
|
||||
update_kwargs = update_call.args[0]
|
||||
assert "deleted_at" in update_kwargs
|
||||
# ISO 8601 timestamp 형식 검증 (예: 2026-05-05T12:00:00+00:00)
|
||||
assert "T" in update_kwargs["deleted_at"]
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 테스트 실행**
|
||||
|
||||
```bash
|
||||
cd packs-lab
|
||||
python -m pytest tests/test_routes.py -v
|
||||
```
|
||||
|
||||
Expected: 11 passed (3 from Task 2 + 3 sign-link + 3 upload + 2 list/delete).
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
|
||||
```bash
|
||||
git add packs-lab/tests/test_routes.py
|
||||
git commit -m "test(packs-lab): 기존 4 라우트 통합 테스트 (sign-link, upload, list, delete)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `tests/test_dsm_client.py` — DSM client mock 테스트
|
||||
|
||||
**Files:**
|
||||
- Create: `packs-lab/tests/test_dsm_client.py`
|
||||
|
||||
- [ ] **Step 1: DSM client 테스트 작성**
|
||||
|
||||
`packs-lab/tests/test_dsm_client.py`:
|
||||
|
||||
```python
|
||||
"""DSM 7.x API client 테스트 — httpx mock으로 외부 호출 차단."""
|
||||
import asyncio
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
import httpx
|
||||
|
||||
from app.dsm_client import create_share_link, DSMError, _login, _logout
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _dsm_env(monkeypatch):
|
||||
monkeypatch.setenv("DSM_HOST", "https://test-nas:5001")
|
||||
monkeypatch.setenv("DSM_USER", "test-user")
|
||||
monkeypatch.setenv("DSM_PASS", "test-pass")
|
||||
# 모듈 캐시도 갱신
|
||||
from app import dsm_client
|
||||
monkeypatch.setattr(dsm_client, "DSM_HOST", "https://test-nas:5001")
|
||||
monkeypatch.setattr(dsm_client, "DSM_USER", "test-user")
|
||||
monkeypatch.setattr(dsm_client, "DSM_PASS", "test-pass")
|
||||
|
||||
|
||||
def _make_response(json_data, status_code=200):
|
||||
"""httpx.Response mock."""
|
||||
mock = MagicMock(spec=httpx.Response)
|
||||
mock.json.return_value = json_data
|
||||
mock.status_code = status_code
|
||||
mock.raise_for_status = MagicMock()
|
||||
return mock
|
||||
|
||||
|
||||
def test_create_share_link_login_logout():
|
||||
"""login → Sharing.create → logout 순서가 보장되어야 한다."""
|
||||
call_order = []
|
||||
|
||||
async def fake_get(self, url, *, params=None, **kw):
|
||||
api = (params or {}).get("api", "")
|
||||
method = (params or {}).get("method", "")
|
||||
call_order.append(f"{api}.{method}")
|
||||
if api == "SYNO.API.Auth" and method == "login":
|
||||
return _make_response({"success": True, "data": {"sid": "fake-sid"}})
|
||||
if api == "SYNO.API.Auth" and method == "logout":
|
||||
return _make_response({"success": True})
|
||||
if api == "SYNO.FileStation.Sharing" and method == "create":
|
||||
return _make_response({
|
||||
"success": True,
|
||||
"data": {"links": [{"url": "https://test-nas:5001/sharing/abc"}]},
|
||||
})
|
||||
return _make_response({"success": False, "error": "unexpected"})
|
||||
|
||||
with patch.object(httpx.AsyncClient, "get", new=fake_get):
|
||||
url, expires_at = asyncio.run(create_share_link("/volume1/test/file.zip", expires_in_sec=3600))
|
||||
|
||||
assert url == "https://test-nas:5001/sharing/abc"
|
||||
assert call_order == [
|
||||
"SYNO.API.Auth.login",
|
||||
"SYNO.FileStation.Sharing.create",
|
||||
"SYNO.API.Auth.logout",
|
||||
]
|
||||
|
||||
|
||||
def test_create_share_link_returns_url_and_expiry():
|
||||
"""응답 파싱 — links[0].url 사용."""
|
||||
async def fake_get(self, url, *, params=None, **kw):
|
||||
method = (params or {}).get("method", "")
|
||||
if method == "login":
|
||||
return _make_response({"success": True, "data": {"sid": "sid"}})
|
||||
if method == "create":
|
||||
return _make_response({
|
||||
"success": True,
|
||||
"data": {"links": [{"url": "https://nas/sharing/xyz"}]},
|
||||
})
|
||||
return _make_response({"success": True})
|
||||
|
||||
with patch.object(httpx.AsyncClient, "get", new=fake_get):
|
||||
url, expires_at = asyncio.run(create_share_link("/volume1/test/file.zip", expires_in_sec=7200))
|
||||
|
||||
assert url == "https://nas/sharing/xyz"
|
||||
assert expires_at is not None
|
||||
|
||||
|
||||
def test_dsm_login_failure_raises():
|
||||
"""login API success=False → DSMError."""
|
||||
async def fake_get(self, url, *, params=None, **kw):
|
||||
return _make_response({"success": False, "error": {"code": 400}})
|
||||
|
||||
with patch.object(httpx.AsyncClient, "get", new=fake_get):
|
||||
with pytest.raises(DSMError, match="login 실패"):
|
||||
asyncio.run(create_share_link("/volume1/test/file.zip"))
|
||||
|
||||
|
||||
def test_dsm_share_failure_logs_out():
|
||||
"""Sharing.create 실패해도 logout 호출 (try/finally)."""
|
||||
call_order = []
|
||||
|
||||
async def fake_get(self, url, *, params=None, **kw):
|
||||
method = (params or {}).get("method", "")
|
||||
call_order.append(method)
|
||||
if method == "login":
|
||||
return _make_response({"success": True, "data": {"sid": "sid"}})
|
||||
if method == "create":
|
||||
return _make_response({"success": False, "error": {"code": 401}})
|
||||
if method == "logout":
|
||||
return _make_response({"success": True})
|
||||
return _make_response({"success": False})
|
||||
|
||||
with patch.object(httpx.AsyncClient, "get", new=fake_get):
|
||||
with pytest.raises(DSMError, match="Sharing.create 실패"):
|
||||
asyncio.run(create_share_link("/volume1/test/file.zip"))
|
||||
|
||||
assert "login" in call_order
|
||||
assert "logout" in call_order, "logout이 호출되지 않음 (finally 누락 의심)"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실행**
|
||||
|
||||
```bash
|
||||
cd packs-lab
|
||||
python -m pytest tests/test_dsm_client.py -v
|
||||
```
|
||||
|
||||
Expected: 4 passed.
|
||||
|
||||
- [ ] **Step 3: 커밋**
|
||||
|
||||
```bash
|
||||
git add packs-lab/tests/test_dsm_client.py
|
||||
git commit -m "test(packs-lab): DSM client mock 테스트 (login/share/logout 순서)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: DELETE 라우트 docstring 수정
|
||||
|
||||
`routes.py` 모듈 docstring의 한 줄 변경.
|
||||
|
||||
**Files:**
|
||||
- Modify: `packs-lab/app/routes.py:1-7` (모듈 docstring)
|
||||
|
||||
- [ ] **Step 1: docstring 수정**
|
||||
|
||||
`packs-lab/app/routes.py` 첫 docstring을 다음으로 변경:
|
||||
|
||||
```python
|
||||
"""packs-lab API 엔드포인트.
|
||||
|
||||
- POST /api/packs/sign-link — Vercel HMAC 인증 → DSM 공유 링크
|
||||
- POST /api/packs/admin/mint-token — Vercel HMAC 인증 → 일회성 upload 토큰
|
||||
- POST /api/packs/upload — 일회성 토큰 인증 → multipart 저장 + supabase INSERT
|
||||
- GET /api/packs/list — Vercel HMAC 인증 → pack_files 전체 조회
|
||||
- DELETE /api/packs/{file_id} — Vercel HMAC 인증 → soft delete (DSM 공유는 자동 만료)
|
||||
"""
|
||||
```
|
||||
|
||||
(변경: `정리` → `자동 만료`, mint-token 줄 추가)
|
||||
|
||||
- [ ] **Step 2: 회귀 검증**
|
||||
|
||||
```bash
|
||||
cd packs-lab
|
||||
python -m pytest tests/ -v
|
||||
```
|
||||
|
||||
Expected: 모든 테스트 그대로 통과 (15 passed).
|
||||
|
||||
- [ ] **Step 3: 커밋**
|
||||
|
||||
```bash
|
||||
git add packs-lab/app/routes.py
|
||||
git commit -m "docs(packs-lab): routes 모듈 docstring 정리 (mint-token 추가, DSM 자동 만료 명시)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Supabase `pack_files` DDL
|
||||
|
||||
운영 적용 시 Supabase SQL editor에서 실행할 SQL 파일.
|
||||
|
||||
**Files:**
|
||||
- Create: `packs-lab/supabase/pack_files.sql`
|
||||
|
||||
- [ ] **Step 1: SQL 파일 생성**
|
||||
|
||||
`packs-lab/supabase/pack_files.sql`:
|
||||
|
||||
```sql
|
||||
-- pack_files: NAS에 저장된 다운로드 가능한 패키지 파일 메타
|
||||
-- 운영 적용: Supabase Dashboard → SQL editor에서 실행
|
||||
create table if not exists public.pack_files (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
min_tier text not null check (min_tier in ('starter','pro','master')),
|
||||
label text not null,
|
||||
file_path text not null unique,
|
||||
filename text not null,
|
||||
size_bytes bigint not null check (size_bytes > 0),
|
||||
sort_order integer not null default 0,
|
||||
uploaded_at timestamptz not null default now(),
|
||||
deleted_at timestamptz
|
||||
);
|
||||
|
||||
-- list 라우트 hot path: deleted_at IS NULL + tier/order 정렬
|
||||
create index if not exists pack_files_active_idx
|
||||
on public.pack_files (min_tier, sort_order)
|
||||
where deleted_at is null;
|
||||
|
||||
-- soft-deleted 통계 / cleanup 잡 대비
|
||||
create index if not exists pack_files_deleted_at_idx
|
||||
on public.pack_files (deleted_at)
|
||||
where deleted_at is not null;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 커밋**
|
||||
|
||||
```bash
|
||||
git add packs-lab/supabase/pack_files.sql
|
||||
git commit -m "feat(packs-lab): Supabase pack_files DDL + 활성/삭제 인덱스"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 인프라 통합 — docker-compose / nginx / .env.example / deploy-nas.sh
|
||||
|
||||
**Files:**
|
||||
- Modify: `docker-compose.yml` (packs-lab 서비스 추가, env에 PACK_BASE_DIR/PACK_HOST_DIR 포함)
|
||||
- Modify: `nginx/default.conf` (`/api/packs/` 라우팅)
|
||||
- Modify: `.env.example` (DSM/HMAC/Supabase 6 + PACK 3 path)
|
||||
- Modify: `scripts/deploy-nas.sh` (SERVICES 화이트리스트에 `packs-lab` 추가 — 누락 시 NAS 컨테이너 미등장)
|
||||
|
||||
- [ ] **Step 1: docker-compose.yml — packs-lab 서비스 추가**
|
||||
|
||||
`docker-compose.yml`에서 다른 lab 서비스(예: `realestate-lab`) 정의 다음에 추가:
|
||||
|
||||
```yaml
|
||||
packs-lab:
|
||||
build:
|
||||
context: ./packs-lab
|
||||
dockerfile: Dockerfile
|
||||
container_name: packs-lab
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18950:8000"
|
||||
environment:
|
||||
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}
|
||||
volumes:
|
||||
- ${PACK_DATA_PATH:-./data/packs}:/volume1/docker/webpage/media/packs
|
||||
```
|
||||
|
||||
- [ ] **Step 2: nginx/default.conf — /api/packs/ 라우팅**
|
||||
|
||||
기존 `location /api/agent-office/ { ... }` 다음(또는 다른 `/api/...` 라우트들 근처)에 추가:
|
||||
|
||||
```nginx
|
||||
location /api/packs/ {
|
||||
proxy_pass http://packs-lab:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 5GB 멀티파트 업로드 대응
|
||||
client_max_body_size 5G;
|
||||
proxy_request_buffering off;
|
||||
proxy_read_timeout 1800s;
|
||||
proxy_send_timeout 1800s;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: .env.example — 6+1 환경변수 추가**
|
||||
|
||||
`.env.example` 끝에 추가:
|
||||
|
||||
```bash
|
||||
|
||||
# ─── 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
|
||||
```
|
||||
|
||||
- [ ] **Step 4: docker compose config 검증**
|
||||
|
||||
```bash
|
||||
cd C:\Users\jaeoh\Desktop\workspace\web-backend
|
||||
docker compose config 2>&1 | grep -A 10 "packs-lab:"
|
||||
```
|
||||
|
||||
Expected: packs-lab 서비스 정의가 정상 출력 (port mapping, environment 변수, volumes 모두 보임). 환경변수가 비어있어도 docker compose config는 통과.
|
||||
|
||||
> ⚠️ Docker가 로컬에 설치되어 있어야 검증 가능. 실제 실행은 NAS에서. 로컬 docker가 없으면 step skip하고 nginx config 문법만 별도 검증.
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
|
||||
```bash
|
||||
git add docker-compose.yml nginx/default.conf .env.example
|
||||
git commit -m "chore(infra): packs-lab 서비스 통합 (compose 18950 + nginx 5GB streaming + env 7개)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: NAS 디렉토리 준비 가이드 + 문서 갱신
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-backend/CLAUDE.md` (5곳 갱신)
|
||||
- Modify: `workspace/CLAUDE.md` (1줄 추가)
|
||||
|
||||
- [ ] **Step 1: web-backend/CLAUDE.md — 1.프로젝트 개요**
|
||||
|
||||
찾을 위치 (1.프로젝트 개요 섹션):
|
||||
|
||||
```
|
||||
- **서비스**: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, deployer (9개)
|
||||
```
|
||||
|
||||
다음으로 수정:
|
||||
|
||||
```
|
||||
- **서비스**: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, packs-lab, deployer (10개)
|
||||
```
|
||||
|
||||
같은 섹션의 인프라 줄도:
|
||||
|
||||
```
|
||||
- **인프라**: Docker Compose (10컨테이너) + Nginx(리버스 프록시) + Gitea Webhook 자동 배포
|
||||
```
|
||||
|
||||
- [ ] **Step 2: web-backend/CLAUDE.md — 4.Docker 서비스 표**
|
||||
|
||||
표 마지막에 신규 행 추가 (deployer 행 직전 또는 personal 행 다음 — 알파벳 순):
|
||||
|
||||
```
|
||||
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 업로드, Vercel SaaS와 HMAC 통신) |
|
||||
```
|
||||
|
||||
- [ ] **Step 3: web-backend/CLAUDE.md — 5.Nginx 라우팅 표**
|
||||
|
||||
표 적절한 위치에 신규 행 추가:
|
||||
|
||||
```
|
||||
| `/api/packs/` | `packs-lab:8000` | 5GB 업로드 대응 (`client_max_body_size 5G`, `proxy_request_buffering off`, 1800s timeout) |
|
||||
```
|
||||
|
||||
- [ ] **Step 4: web-backend/CLAUDE.md — 8.로컬 개발 표**
|
||||
|
||||
표 끝에 신규 행 추가:
|
||||
|
||||
```
|
||||
| Packs Lab | http://localhost:18950 |
|
||||
```
|
||||
|
||||
- [ ] **Step 5: web-backend/CLAUDE.md — 9.서비스별 packs-lab 신규 섹션**
|
||||
|
||||
`### deployer (deployer/)` 섹션 직전에 추가 (또는 personal 다음):
|
||||
|
||||
```
|
||||
### 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`
|
||||
- 운영 디렉토리: `/volume1/docker/webpage/media/packs/{starter,pro,master}/` (NAS PUID:PGID 권한 필요)
|
||||
|
||||
**환경변수**
|
||||
- `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_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 공유는 자동 만료) |
|
||||
```
|
||||
|
||||
- [ ] **Step 6: workspace/CLAUDE.md — 컨테이너 표 한 줄 추가**
|
||||
|
||||
`workspace/CLAUDE.md`의 "Docker 서비스 & 포트" 표에 추가:
|
||||
|
||||
```
|
||||
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (Vercel SaaS와 HMAC 통신) |
|
||||
```
|
||||
|
||||
(personal 행 다음 또는 적절한 위치)
|
||||
|
||||
- [ ] **Step 7: 커밋 (web-backend repo의 CLAUDE.md만)**
|
||||
|
||||
작업 디렉토리는 `C:\Users\jaeoh\Desktop\workspace\web-backend`. 그 안의 `CLAUDE.md`만 git 추적 대상.
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "docs(claude): packs-lab 10번째 서비스로 등록 (포트/라우팅/API 표 + 신규 섹션)"
|
||||
```
|
||||
|
||||
> ℹ️ `workspace/CLAUDE.md`(상위 디렉토리의 워크스페이스 메모)는 git repo가 아님. 텍스트 편집만 하고 commit 대상에서 제외.
|
||||
|
||||
---
|
||||
|
||||
## Task 9: 회귀 검증 + NAS 디렉토리 가이드
|
||||
|
||||
전체 테스트 + docker compose config + NAS 배포 전 가이드.
|
||||
|
||||
**Files:**
|
||||
- (검증만)
|
||||
|
||||
- [ ] **Step 1: 전체 pytest**
|
||||
|
||||
```bash
|
||||
cd packs-lab
|
||||
python -m pytest tests/ -v
|
||||
```
|
||||
|
||||
Expected: 모든 테스트 통과 (test_auth + test_routes + test_dsm_client = 약 15+ tests).
|
||||
|
||||
- [ ] **Step 2: docker compose config 검증**
|
||||
|
||||
```bash
|
||||
cd C:\Users\jaeoh\Desktop\workspace\web-backend
|
||||
docker compose config 2>&1 | tail -30
|
||||
```
|
||||
|
||||
Expected: error 없이 packs-lab 포함된 전체 config 출력.
|
||||
|
||||
> ⚠️ Docker 미설치 시 skip. NAS에서 git push 후 webhook 배포 시점에 검증됨.
|
||||
|
||||
- [ ] **Step 3: NAS 배포 전 가이드 출력**
|
||||
|
||||
배포 전 NAS에서 SSH로 1회 실행할 명령들을 README 또는 NAS 배포 노트로 정리. 본 task에서는 명령만 제시 (실행은 사용자):
|
||||
|
||||
```bash
|
||||
# NAS SSH로 접속 후
|
||||
mkdir -p /volume1/docker/webpage/media/packs/{starter,pro,master}
|
||||
chown -R PUID:PGID /volume1/docker/webpage/media/packs # PUID/PGID는 .env 값 사용
|
||||
|
||||
# .env에 신규 환경변수 추가 (DSM_*, BACKEND_HMAC_SECRET, SUPABASE_*, UPLOAD_TOKEN_TTL_SEC, PACK_DATA_PATH=/volume1/docker/webpage/media/packs)
|
||||
|
||||
# Supabase에서 packs-lab/supabase/pack_files.sql 실행
|
||||
|
||||
# git push 후 webhook이 자동 배포
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 최종 commit (검증 결과 빈 commit으로 마일스톤 표시 — 선택)**
|
||||
|
||||
```bash
|
||||
# 만약 위 step에서 어떤 자동 수정이 있었으면 commit. 없으면 skip.
|
||||
git status
|
||||
```
|
||||
|
||||
회귀 검증으로 변경 사항 없으면 별도 commit 없이 종료.
|
||||
|
||||
---
|
||||
|
||||
## 완료 기준
|
||||
|
||||
- 모든 task의 step 통과 (체크박스 모두 체크)
|
||||
- `cd packs-lab && python -m pytest tests/ -v` — 통과 (test_auth + test_routes + test_dsm_client)
|
||||
- `docker compose config` — packs-lab 포함된 전체 config 정상
|
||||
- web-backend/CLAUDE.md 5곳 갱신 + workspace/CLAUDE.md 1줄
|
||||
- Supabase DDL 파일 존재 (운영 적용은 사용자가 NAS에서 SQL editor로)
|
||||
- NAS 디렉토리 준비 명령은 사용자가 SSH로 실행 (배포 전 1회)
|
||||
|
||||
---
|
||||
|
||||
## 배포
|
||||
|
||||
git push → Gitea webhook → deployer rsync → docker compose up -d --build (자동).
|
||||
|
||||
**배포 전 사용자 액션 (1회)**:
|
||||
1. Supabase에서 `pack_files` 테이블 생성 (DDL 실행)
|
||||
2. NAS SSH로 `/volume1/docker/webpage/media/packs/{starter,pro,master}` 디렉토리 생성 + 권한
|
||||
3. NAS `.env`에 신규 7개 환경변수 입력 (DSM 인증, HMAC secret, Supabase 키 등)
|
||||
|
||||
---
|
||||
|
||||
## 참고 — 후속 별도 plan (스코프 외)
|
||||
|
||||
- Vercel SaaS-side admin UI / 사용자 다운로드 UI / Supabase user 테이블
|
||||
- DSM 공유 추적 (즉시 차단 필요 시)
|
||||
- deleted_at + N일 후 실제 파일 삭제 cron
|
||||
- multi-admin 토큰 발급 권한 분리
|
||||
- resumable multipart 업로드 (5GB tus 등)
|
||||
- pack_files sort_order 편집 endpoint
|
||||
- 모니터링 (업로드 실패율, DSM API latency)
|
||||
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
2513
docs/superpowers/plans/2026-05-09-essential-mix-pipeline.md
Normal file
2513
docs/superpowers/plans/2026-05-09-essential-mix-pipeline.md
Normal file
File diff suppressed because it is too large
Load Diff
737
docs/superpowers/plans/2026-05-09-gpu-video-offload.md
Normal file
737
docs/superpowers/plans/2026-05-09-gpu-video-offload.md
Normal file
@@ -0,0 +1,737 @@
|
||||
# GPU 영상 인코딩 오프로드 — 구현 계획
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development.
|
||||
|
||||
**Goal:** NAS의 ffmpeg 영상 인코딩을 Windows PC(RTX 5070 Ti) NVENC로 오프로드.
|
||||
|
||||
**Architecture:** music-lab(NAS) → HTTP POST → music_ai(Windows, port 8765 `/encode_video`) → ffmpeg NVENC → SMB로 NAS에 직접 mp4 저장. Windows 서버 다운 시 NAS는 즉시 실패.
|
||||
|
||||
**Tech Stack:** httpx (NAS 측 HTTP 클라이언트), FastAPI (Windows 서버 endpoint), ffmpeg.exe with NVENC.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-09-gpu-video-offload-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 경로 | 책임 |
|
||||
|------|------|
|
||||
| `music_ai/video_encoder.py` (new) | 경로 변환 + ffmpeg NVENC subprocess 호출 + 검증 |
|
||||
| `music_ai/server.py` (modify) | `/encode_video` POST endpoint 등록, `/health`에 ffmpeg/nvenc 정보 추가 |
|
||||
| `music_ai/.env.example` (modify) | NAS_VOLUME_PREFIX, WINDOWS_DRIVE_ROOT, FFMPEG_PATH 문서화 |
|
||||
| `music_ai/tests/test_video_encoder.py` (new) | translate_path, encode endpoint 단위 테스트 |
|
||||
| `music-lab/app/pipeline/video.py` (rewrite) | subprocess 제거, httpx로 Windows 서버 호출 |
|
||||
| `music-lab/tests/test_video_thumb.py` (rewrite video tests) | respx mock 기반 |
|
||||
| `web-backend/docker-compose.yml` (modify) | music-lab env 3개 추가 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Windows `music_ai/video_encoder.py` + 테스트
|
||||
|
||||
**Files:**
|
||||
- Create: `music_ai/video_encoder.py`
|
||||
- Create: `music_ai/tests/test_video_encoder.py`
|
||||
|
||||
### Step 1: Write failing test
|
||||
|
||||
```python
|
||||
# music_ai/tests/test_video_encoder.py
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from video_encoder import translate_path, encode_video, EncodeError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(monkeypatch):
|
||||
monkeypatch.setenv("NAS_VOLUME_PREFIX", "/volume1/")
|
||||
monkeypatch.setenv("WINDOWS_DRIVE_ROOT", "Z:\\")
|
||||
monkeypatch.setenv("FFMPEG_PATH", "C:\\ffmpeg\\bin\\ffmpeg.exe")
|
||||
|
||||
|
||||
def test_translate_path_basic(env):
|
||||
assert translate_path("/volume1/docker/webpage/data/x.jpg") == r"Z:\docker\webpage\data\x.jpg"
|
||||
|
||||
|
||||
def test_translate_path_nested(env):
|
||||
assert translate_path("/volume1/docker/webpage/data/videos/3/cover.jpg") == r"Z:\docker\webpage\data\videos\3\cover.jpg"
|
||||
|
||||
|
||||
def test_translate_path_rejects_bad_prefix(env):
|
||||
with pytest.raises(ValueError):
|
||||
translate_path("/etc/passwd")
|
||||
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_encode_video_success(mock_run, env, tmp_path):
|
||||
# 입력 파일 fake
|
||||
cover = tmp_path / "cover.jpg"
|
||||
cover.write_bytes(b"\x00" * 100)
|
||||
audio = tmp_path / "audio.mp3"
|
||||
audio.write_bytes(b"\x00" * 100)
|
||||
out = tmp_path / "video.mp4"
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
# ffmpeg 실행을 흉내내어 출력 파일을 만듦
|
||||
out.write_bytes(b"\x00" * (2 * 1024 * 1024)) # 2MB
|
||||
return MagicMock(returncode=0, stderr="")
|
||||
mock_run.side_effect = fake_run
|
||||
|
||||
# translate_path를 mock해서 입력 경로를 직접 사용
|
||||
with patch("video_encoder.translate_path", side_effect=lambda p: str(p).replace("/volume1/", str(tmp_path) + "/")):
|
||||
result = encode_video(
|
||||
cover_path_nas="/volume1/cover.jpg",
|
||||
audio_path_nas="/volume1/audio.mp3",
|
||||
output_path_nas="/volume1/video.mp4",
|
||||
resolution="1920x1080",
|
||||
duration_sec=120,
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert result["encoder"] == "h264_nvenc"
|
||||
assert result["output_bytes"] > 1024 * 1024
|
||||
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_encode_video_input_missing(mock_run, env, tmp_path):
|
||||
with pytest.raises(EncodeError) as exc:
|
||||
encode_video(
|
||||
cover_path_nas="/volume1/missing.jpg",
|
||||
audio_path_nas="/volume1/missing.mp3",
|
||||
output_path_nas="/volume1/out.mp4",
|
||||
resolution="1920x1080",
|
||||
duration_sec=120,
|
||||
)
|
||||
assert "input_validation" in str(exc.value)
|
||||
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_encode_video_ffmpeg_failure(mock_run, env, tmp_path):
|
||||
cover = tmp_path / "cover.jpg"; cover.write_bytes(b"\x00")
|
||||
audio = tmp_path / "audio.mp3"; audio.write_bytes(b"\x00")
|
||||
mock_run.return_value = MagicMock(returncode=1, stderr="invalid codec\n" * 50)
|
||||
|
||||
with patch("video_encoder.translate_path", side_effect=lambda p: str(p).replace("/volume1/", str(tmp_path) + "/")):
|
||||
with pytest.raises(EncodeError) as exc:
|
||||
encode_video(
|
||||
cover_path_nas="/volume1/cover.jpg",
|
||||
audio_path_nas="/volume1/audio.mp3",
|
||||
output_path_nas="/volume1/out.mp4",
|
||||
resolution="1920x1080",
|
||||
duration_sec=120,
|
||||
)
|
||||
assert "ffmpeg" in str(exc.value).lower()
|
||||
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_encode_video_output_too_small(mock_run, env, tmp_path):
|
||||
cover = tmp_path / "cover.jpg"; cover.write_bytes(b"\x00")
|
||||
audio = tmp_path / "audio.mp3"; audio.write_bytes(b"\x00")
|
||||
def fake_run(cmd, **kwargs):
|
||||
(tmp_path / "out.mp4").write_bytes(b"\x00" * 100) # 100 bytes — too small
|
||||
return MagicMock(returncode=0, stderr="")
|
||||
mock_run.side_effect = fake_run
|
||||
|
||||
with patch("video_encoder.translate_path", side_effect=lambda p: str(p).replace("/volume1/", str(tmp_path) + "/")):
|
||||
with pytest.raises(EncodeError) as exc:
|
||||
encode_video(
|
||||
cover_path_nas="/volume1/cover.jpg",
|
||||
audio_path_nas="/volume1/audio.mp3",
|
||||
output_path_nas="/volume1/out.mp4",
|
||||
resolution="1920x1080",
|
||||
duration_sec=120,
|
||||
)
|
||||
assert "output_check" in str(exc.value)
|
||||
|
||||
|
||||
def test_resolution_validation(env):
|
||||
with pytest.raises(EncodeError) as exc:
|
||||
encode_video(
|
||||
cover_path_nas="/volume1/x.jpg",
|
||||
audio_path_nas="/volume1/x.mp3",
|
||||
output_path_nas="/volume1/out.mp4",
|
||||
resolution="invalid",
|
||||
duration_sec=120,
|
||||
)
|
||||
assert "resolution" in str(exc.value).lower()
|
||||
```
|
||||
|
||||
### Step 2: Run test to verify it fails
|
||||
|
||||
```bash
|
||||
cd music_ai && python -m pytest tests/test_video_encoder.py -v
|
||||
```
|
||||
|
||||
Expected: ImportError on `video_encoder` module.
|
||||
|
||||
### Step 3: Implement `video_encoder.py`
|
||||
|
||||
```python
|
||||
"""GPU(NVENC) 영상 인코더 — NAS music-lab에서 호출."""
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("music_ai.video_encoder")
|
||||
|
||||
NAS_VOLUME_PREFIX = os.getenv("NAS_VOLUME_PREFIX", "/volume1/")
|
||||
WINDOWS_DRIVE_ROOT = os.getenv("WINDOWS_DRIVE_ROOT", "Z:\\")
|
||||
FFMPEG_PATH = os.getenv("FFMPEG_PATH", "ffmpeg")
|
||||
FFMPEG_TIMEOUT_S = 180
|
||||
RESOLUTION_RE = re.compile(r"^\d{3,4}x\d{3,4}$")
|
||||
MIN_OUTPUT_BYTES = 1024 * 1024 # 1MB
|
||||
|
||||
|
||||
class EncodeError(Exception):
|
||||
"""{stage: input_validation|path_translate|ffmpeg|output_check, message: ...}"""
|
||||
def __init__(self, stage: str, message: str):
|
||||
self.stage = stage
|
||||
self.message = message
|
||||
super().__init__(f"[{stage}] {message}")
|
||||
|
||||
|
||||
def translate_path(nas_path: str) -> str:
|
||||
"""NAS 절대경로 → Windows SMB 경로."""
|
||||
if not nas_path.startswith(NAS_VOLUME_PREFIX):
|
||||
raise ValueError(f"NAS prefix 불일치: {nas_path}")
|
||||
rel = nas_path[len(NAS_VOLUME_PREFIX):]
|
||||
return WINDOWS_DRIVE_ROOT + rel.replace("/", "\\")
|
||||
|
||||
|
||||
def encode_video(*, cover_path_nas: str, audio_path_nas: str,
|
||||
output_path_nas: str, resolution: str,
|
||||
duration_sec: int = 0, style: str = "visualizer") -> dict:
|
||||
"""영상 인코딩 + Z:\\에 직접 저장."""
|
||||
# 1) Resolution 검증
|
||||
if not RESOLUTION_RE.match(resolution):
|
||||
raise EncodeError("input_validation", f"invalid resolution: {resolution}")
|
||||
w, h = resolution.split("x")
|
||||
|
||||
# 2) 경로 변환
|
||||
try:
|
||||
cover_win = translate_path(cover_path_nas)
|
||||
audio_win = translate_path(audio_path_nas)
|
||||
out_win = translate_path(output_path_nas)
|
||||
except ValueError as e:
|
||||
raise EncodeError("path_translate", str(e))
|
||||
|
||||
# 3) 입력 존재 확인
|
||||
if not os.path.isfile(cover_win):
|
||||
raise EncodeError("input_validation", f"cover not found: {cover_win}")
|
||||
if not os.path.isfile(audio_win):
|
||||
raise EncodeError("input_validation", f"audio not found: {audio_win}")
|
||||
|
||||
# 4) 출력 디렉토리 보장
|
||||
os.makedirs(os.path.dirname(out_win), exist_ok=True)
|
||||
|
||||
# 5) ffmpeg 명령
|
||||
cmd = [
|
||||
FFMPEG_PATH, "-y",
|
||||
"-hwaccel", "cuda",
|
||||
"-loop", "1", "-i", cover_win,
|
||||
"-i", audio_win,
|
||||
"-filter_complex",
|
||||
f"[0:v]scale={w}:{h},format=yuv420p[bg];"
|
||||
f"[1:a]showwaves=s={w}x200:mode=cline:colors=0xFF4444@0.8[wave];"
|
||||
f"[bg][wave]overlay=0:({h}-200)[out]",
|
||||
"-map", "[out]", "-map", "1:a",
|
||||
"-c:v", "h264_nvenc",
|
||||
"-preset", "p4",
|
||||
"-rc", "vbr",
|
||||
"-cq", "23",
|
||||
"-b:v", "0",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-c:a", "aac", "-b:a", "192k",
|
||||
"-shortest", out_win,
|
||||
]
|
||||
logger.info("ffmpeg: %s", " ".join(cmd))
|
||||
|
||||
# 6) ffmpeg 실행
|
||||
import time
|
||||
t0 = time.time()
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=FFMPEG_TIMEOUT_S)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise EncodeError("ffmpeg", f"timeout after {FFMPEG_TIMEOUT_S}s")
|
||||
duration_ms = int((time.time() - t0) * 1000)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise EncodeError("ffmpeg", f"returncode={result.returncode}: {result.stderr[-800:]}")
|
||||
|
||||
# 7) 출력 검증
|
||||
if not os.path.isfile(out_win):
|
||||
raise EncodeError("output_check", "output file not created")
|
||||
output_bytes = os.path.getsize(out_win)
|
||||
if output_bytes < MIN_OUTPUT_BYTES:
|
||||
raise EncodeError("output_check", f"output too small: {output_bytes} bytes")
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"duration_ms": duration_ms,
|
||||
"output_path_nas": output_path_nas,
|
||||
"output_bytes": output_bytes,
|
||||
"encoder": "h264_nvenc",
|
||||
"preset": "p4",
|
||||
}
|
||||
|
||||
|
||||
def check_ffmpeg_nvenc() -> bool:
|
||||
"""서버 시작 시 NVENC 가용성 확인."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[FFMPEG_PATH, "-encoders"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
return "h264_nvenc" in result.stdout
|
||||
except Exception:
|
||||
return False
|
||||
```
|
||||
|
||||
### Step 4: Run tests
|
||||
|
||||
```bash
|
||||
cd music_ai && python -m pytest tests/test_video_encoder.py -v
|
||||
```
|
||||
|
||||
Expected: 6 PASS
|
||||
|
||||
### Step 5: Commit
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/music_ai
|
||||
git init 2>/dev/null || true # may not be a git repo, that's OK
|
||||
# music_ai is local-only per CLAUDE.md, no remote push
|
||||
```
|
||||
|
||||
(music_ai is local-only; just save the file. No git push needed.)
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Windows `music_ai/server.py` — `/encode_video` endpoint + 헬스 확장
|
||||
|
||||
**Files:**
|
||||
- Modify: `music_ai/server.py`
|
||||
- Modify: `music_ai/.env.example`
|
||||
|
||||
### Step 1: Read existing server.py to understand FastAPI pattern + existing /health
|
||||
|
||||
### Step 2: Add `/encode_video` endpoint
|
||||
|
||||
```python
|
||||
# server.py — 추가
|
||||
from pydantic import BaseModel
|
||||
from fastapi import HTTPException
|
||||
import video_encoder
|
||||
|
||||
|
||||
class EncodeVideoRequest(BaseModel):
|
||||
cover_path_nas: str
|
||||
audio_path_nas: str
|
||||
output_path_nas: str
|
||||
resolution: str = "1920x1080"
|
||||
duration_sec: int = 0
|
||||
style: str = "visualizer"
|
||||
|
||||
|
||||
@app.post("/encode_video")
|
||||
def encode_video_endpoint(req: EncodeVideoRequest):
|
||||
try:
|
||||
result = video_encoder.encode_video(
|
||||
cover_path_nas=req.cover_path_nas,
|
||||
audio_path_nas=req.audio_path_nas,
|
||||
output_path_nas=req.output_path_nas,
|
||||
resolution=req.resolution,
|
||||
duration_sec=req.duration_sec,
|
||||
style=req.style,
|
||||
)
|
||||
return result
|
||||
except video_encoder.EncodeError as e:
|
||||
# input_validation, path_translate → 400
|
||||
# ffmpeg, output_check → 500
|
||||
status_code = 400 if e.stage in ("input_validation", "path_translate") else 500
|
||||
raise HTTPException(
|
||||
status_code=status_code,
|
||||
detail={"ok": False, "stage": e.stage, "error": e.message},
|
||||
)
|
||||
```
|
||||
|
||||
### Step 3: 확장된 `/health`
|
||||
|
||||
기존 `/health` 응답에 추가:
|
||||
```python
|
||||
import torch # if existing health uses it
|
||||
import video_encoder
|
||||
|
||||
# Module-level cache so health doesn't run ffmpeg every call
|
||||
_FFMPEG_NVENC_CACHED = None
|
||||
def _ffmpeg_nvenc_available():
|
||||
global _FFMPEG_NVENC_CACHED
|
||||
if _FFMPEG_NVENC_CACHED is None:
|
||||
_FFMPEG_NVENC_CACHED = video_encoder.check_ffmpeg_nvenc()
|
||||
return _FFMPEG_NVENC_CACHED
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {
|
||||
"ok": True,
|
||||
"gpu": torch.cuda.get_device_name(0) if torch.cuda.is_available() else None, # 또는 기존 형식 유지
|
||||
"musicgen_loaded": True, # 기존 그대로
|
||||
"ffmpeg_path": video_encoder.FFMPEG_PATH,
|
||||
"ffmpeg_nvenc": _ffmpeg_nvenc_available(),
|
||||
}
|
||||
```
|
||||
|
||||
(기존 `/health`의 정확한 형식은 코드 읽고 매칭. 위는 예시.)
|
||||
|
||||
### Step 4: `.env.example` 업데이트
|
||||
|
||||
```env
|
||||
# Existing
|
||||
MODEL_NAME=facebook/musicgen-stereo-large
|
||||
OUTPUT_DIR=output
|
||||
SERVER_PORT=8765
|
||||
|
||||
# New for video encoder
|
||||
NAS_VOLUME_PREFIX=/volume1/
|
||||
WINDOWS_DRIVE_ROOT=Z:\
|
||||
FFMPEG_PATH=C:\ffmpeg\bin\ffmpeg.exe
|
||||
```
|
||||
|
||||
### Step 5: 수동 검증
|
||||
|
||||
```bash
|
||||
cd music_ai && start.bat # 또는 적절한 시작 명령
|
||||
curl http://localhost:8765/health
|
||||
# Expected: {..., "ffmpeg_nvenc": true}
|
||||
|
||||
curl -X POST http://localhost:8765/encode_video -H "Content-Type: application/json" -d '{
|
||||
"cover_path_nas": "/volume1/docker/webpage/data/videos/3/cover.jpg",
|
||||
"audio_path_nas": "/volume1/docker/webpage/data/1c695df3-8a82-4c09-ba7b-82c07608ec5b.mp3",
|
||||
"output_path_nas": "/volume1/docker/webpage/data/videos/test/video.mp4",
|
||||
"resolution": "1920x1080",
|
||||
"duration_sec": 176
|
||||
}'
|
||||
# Expected: 200 + duration_ms ~ 10-20초
|
||||
```
|
||||
|
||||
(실제 파일 경로는 사용자 환경에 맞게 조정)
|
||||
|
||||
### Step 6: Commit (music_ai is local-only, no remote)
|
||||
|
||||
---
|
||||
|
||||
## Task 3: NAS music-lab — `pipeline/video.py` 재작성 + 테스트
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `music-lab/app/pipeline/video.py`
|
||||
- Rewrite: `music-lab/tests/test_video_thumb.py` (video 부분만)
|
||||
|
||||
### Step 1: Replace failing tests
|
||||
|
||||
```python
|
||||
# music-lab/tests/test_video_thumb.py — video 관련 테스트 부분만 교체
|
||||
import pytest
|
||||
import respx
|
||||
import httpx
|
||||
from httpx import Response
|
||||
from app.pipeline import video, thumb, storage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def encoder_env(monkeypatch):
|
||||
monkeypatch.setenv("WINDOWS_VIDEO_ENCODER_URL", "http://192.168.45.59:8765")
|
||||
monkeypatch.setattr(video, "ENCODER_URL", "http://192.168.45.59:8765")
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_generate_video_calls_remote_encoder(encoder_env, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(storage, "VIDEO_DATA_DIR", str(tmp_path))
|
||||
respx.post("http://192.168.45.59:8765/encode_video").mock(
|
||||
return_value=Response(200, json={
|
||||
"ok": True, "duration_ms": 12000,
|
||||
"output_path_nas": "/volume1/docker/webpage/data/videos/3/video.mp4",
|
||||
"output_bytes": 28000000,
|
||||
"encoder": "h264_nvenc", "preset": "p4",
|
||||
})
|
||||
)
|
||||
out = video.generate(
|
||||
pipeline_id=3,
|
||||
audio_path="/app/data/1c695df3.mp3",
|
||||
cover_path="/app/data/videos/3/cover.jpg",
|
||||
genre="lo-fi", duration_sec=120, resolution="1920x1080",
|
||||
style="visualizer",
|
||||
)
|
||||
assert out["url"].endswith("/3/video.mp4")
|
||||
assert out["used_fallback"] is False
|
||||
assert out["encode_duration_ms"] == 12000
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_generate_video_raises_on_connection_error(encoder_env, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(storage, "VIDEO_DATA_DIR", str(tmp_path))
|
||||
respx.post("http://192.168.45.59:8765/encode_video").mock(
|
||||
side_effect=httpx.ConnectError("Connection refused")
|
||||
)
|
||||
with pytest.raises(video.VideoGenerationError) as exc:
|
||||
video.generate(
|
||||
pipeline_id=4,
|
||||
audio_path="/app/data/x.mp3", cover_path="/app/data/videos/4/cover.jpg",
|
||||
genre="lo-fi", duration_sec=120, resolution="1920x1080",
|
||||
)
|
||||
assert "연결 실패" in str(exc.value) or "Connection" in str(exc.value)
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_generate_video_raises_on_500(encoder_env, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(storage, "VIDEO_DATA_DIR", str(tmp_path))
|
||||
respx.post("http://192.168.45.59:8765/encode_video").mock(
|
||||
return_value=Response(500, json={"ok": False, "stage": "ffmpeg", "error": "bad codec"})
|
||||
)
|
||||
with pytest.raises(video.VideoGenerationError) as exc:
|
||||
video.generate(
|
||||
pipeline_id=5,
|
||||
audio_path="/app/data/x.mp3", cover_path="/app/data/videos/5/cover.jpg",
|
||||
genre="lo-fi", duration_sec=120, resolution="1920x1080",
|
||||
)
|
||||
assert "Windows 인코더 오류" in str(exc.value)
|
||||
assert "ffmpeg" in str(exc.value)
|
||||
|
||||
|
||||
def test_generate_video_no_url_configured(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(storage, "VIDEO_DATA_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(video, "ENCODER_URL", "")
|
||||
with pytest.raises(video.VideoGenerationError) as exc:
|
||||
video.generate(
|
||||
pipeline_id=6,
|
||||
audio_path="/app/data/x.mp3", cover_path="/app/data/videos/6/cover.jpg",
|
||||
genre="lo-fi", duration_sec=120, resolution="1920x1080",
|
||||
)
|
||||
assert "WINDOWS_VIDEO_ENCODER_URL" in str(exc.value)
|
||||
|
||||
|
||||
def test_container_to_nas_videos_path(monkeypatch):
|
||||
monkeypatch.setenv("NAS_VIDEOS_ROOT", "/volume1/docker/webpage/data/videos")
|
||||
monkeypatch.setenv("NAS_MUSIC_ROOT", "/volume1/docker/webpage/data/music")
|
||||
assert video._container_to_nas("/app/data/videos/3/cover.jpg") == "/volume1/docker/webpage/data/videos/3/cover.jpg"
|
||||
|
||||
|
||||
def test_container_to_nas_music_path(monkeypatch):
|
||||
monkeypatch.setenv("NAS_VIDEOS_ROOT", "/volume1/docker/webpage/data/videos")
|
||||
monkeypatch.setenv("NAS_MUSIC_ROOT", "/volume1/docker/webpage/data/music")
|
||||
assert video._container_to_nas("/app/data/abc.mp3") == "/volume1/docker/webpage/data/music/abc.mp3"
|
||||
```
|
||||
|
||||
기존 `test_generate_video_calls_ffmpeg`, `test_generate_video_failure_marks_failed` 삭제. thumb 관련 테스트는 그대로 유지.
|
||||
|
||||
### Step 2: Run, verify fail
|
||||
|
||||
```bash
|
||||
cd music-lab && python -m pytest tests/test_video_thumb.py -v
|
||||
```
|
||||
|
||||
Expected: video 관련 테스트들이 실패 (또는 ImportError).
|
||||
|
||||
### Step 3: Rewrite `app/pipeline/video.py`
|
||||
|
||||
```python
|
||||
"""영상 비주얼 생성 — Windows GPU 서버 (NVENC) 호출.
|
||||
|
||||
Windows 서버 다운/실패 시 즉시 예외 (NAS 로컬 폴백 없음 — 의도적 결정).
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
import httpx
|
||||
|
||||
from . import storage
|
||||
|
||||
logger = logging.getLogger("music-lab.video")
|
||||
|
||||
ENCODER_URL = os.getenv("WINDOWS_VIDEO_ENCODER_URL", "")
|
||||
ENCODER_TIMEOUT_S = 200 # Windows 서버 ffmpeg 180s + 마진
|
||||
|
||||
# NAS 호스트 절대경로 prefix — docker bind mount의 host 측
|
||||
NAS_VIDEOS_ROOT = os.getenv("NAS_VIDEOS_ROOT", "/volume1/docker/webpage/data/videos")
|
||||
NAS_MUSIC_ROOT = os.getenv("NAS_MUSIC_ROOT", "/volume1/docker/webpage/data/music")
|
||||
|
||||
|
||||
class VideoGenerationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def generate(*, pipeline_id: int, audio_path: str, cover_path: str,
|
||||
genre: str, duration_sec: int, resolution: str = "1920x1080",
|
||||
style: str = "visualizer") -> dict:
|
||||
"""원격 Windows GPU 서버 호출. 다운/실패 시 즉시 예외."""
|
||||
if not ENCODER_URL:
|
||||
raise VideoGenerationError(
|
||||
"WINDOWS_VIDEO_ENCODER_URL 미설정 — Windows 인코더 서버 주소 필요"
|
||||
)
|
||||
|
||||
out_path = os.path.join(storage.pipeline_dir(pipeline_id), "video.mp4")
|
||||
nas_audio = _container_to_nas(audio_path)
|
||||
nas_cover = _container_to_nas(cover_path)
|
||||
nas_output = _container_to_nas(out_path)
|
||||
|
||||
payload = {
|
||||
"cover_path_nas": nas_cover,
|
||||
"audio_path_nas": nas_audio,
|
||||
"output_path_nas": nas_output,
|
||||
"resolution": resolution,
|
||||
"duration_sec": duration_sec,
|
||||
"style": style,
|
||||
}
|
||||
|
||||
logger.info("Windows 인코더 호출: pipeline=%d audio=%s", pipeline_id, audio_path)
|
||||
try:
|
||||
with httpx.Client(timeout=ENCODER_TIMEOUT_S) as client:
|
||||
resp = client.post(f"{ENCODER_URL}/encode_video", json=payload)
|
||||
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout, httpx.NetworkError) as e:
|
||||
raise VideoGenerationError(f"Windows 인코더 연결 실패: {e}")
|
||||
|
||||
if resp.status_code != 200:
|
||||
try:
|
||||
detail = resp.json().get("detail", resp.json())
|
||||
except Exception:
|
||||
detail = {"error": resp.text[:300]}
|
||||
stage = detail.get("stage", "?") if isinstance(detail, dict) else "?"
|
||||
error = detail.get("error", str(detail)) if isinstance(detail, dict) else str(detail)
|
||||
raise VideoGenerationError(
|
||||
f"Windows 인코더 오류 ({resp.status_code}): {stage} — {error}"
|
||||
)
|
||||
|
||||
data = resp.json()
|
||||
if not data.get("ok"):
|
||||
raise VideoGenerationError(f"Windows 인코더 응답 ok=false: {data}")
|
||||
|
||||
return {
|
||||
"url": storage.media_url(pipeline_id, "video.mp4"),
|
||||
"used_fallback": False,
|
||||
"duration_sec": duration_sec,
|
||||
"encode_duration_ms": data.get("duration_ms"),
|
||||
"encoder": data.get("encoder", "h264_nvenc"),
|
||||
}
|
||||
|
||||
|
||||
def _container_to_nas(container_path: str) -> str:
|
||||
""" /app/data/videos/3/cover.jpg → /volume1/docker/webpage/data/videos/3/cover.jpg
|
||||
/app/data/abc.mp3 → /volume1/docker/webpage/data/music/abc.mp3
|
||||
"""
|
||||
if container_path.startswith("/app/data/videos/"):
|
||||
return container_path.replace("/app/data/videos/", NAS_VIDEOS_ROOT + "/", 1)
|
||||
if container_path.startswith("/app/data/"):
|
||||
rel = container_path[len("/app/data/"):]
|
||||
return NAS_MUSIC_ROOT + "/" + rel
|
||||
return container_path
|
||||
```
|
||||
|
||||
### Step 4: Run tests
|
||||
|
||||
```bash
|
||||
cd music-lab && python -m pytest tests/ -v
|
||||
```
|
||||
|
||||
Expected: 73 PASS — 2 (제거) + 6 (신규) = 77? 아니면 73 그대로 — count 확인.
|
||||
|
||||
### Step 5: Commit + push
|
||||
|
||||
```bash
|
||||
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add music-lab/app/pipeline/video.py \
|
||||
music-lab/tests/test_video_thumb.py
|
||||
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(music-lab): 영상 인코딩을 Windows GPU 서버로 오프로드
|
||||
|
||||
- pipeline/video.py 재작성: subprocess.run 제거, httpx로 192.168.45.59:8765/encode_video 호출
|
||||
- Windows 서버 다운 시 즉시 VideoGenerationError (NAS 로컬 폴백 X)
|
||||
- /app/data/* → /volume1/docker/webpage/data/* 경로 변환 (_container_to_nas)
|
||||
- 테스트는 respx mock 기반으로 교체 (6개 신규)"
|
||||
git -C C:/Users/jaeoh/Desktop/workspace/web-backend push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: docker-compose.yml env 추가
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-backend/docker-compose.yml`
|
||||
|
||||
### Step 1: music-lab 서비스 environment에 추가
|
||||
|
||||
```yaml
|
||||
music-lab:
|
||||
environment:
|
||||
# ... existing ...
|
||||
- WINDOWS_VIDEO_ENCODER_URL=${WINDOWS_VIDEO_ENCODER_URL}
|
||||
- NAS_VIDEOS_ROOT=${NAS_VIDEOS_ROOT:-/volume1/docker/webpage/data/videos}
|
||||
- NAS_MUSIC_ROOT=${NAS_MUSIC_ROOT:-/volume1/docker/webpage/data/music}
|
||||
```
|
||||
|
||||
### Step 2: docker-compose syntax 검증
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-backend && python -c "import yaml; yaml.safe_load(open('docker-compose.yml'))" && echo OK
|
||||
```
|
||||
|
||||
### Step 3: Commit + push
|
||||
|
||||
```bash
|
||||
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add docker-compose.yml
|
||||
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "chore(infra): GPU 인코더 env 추가 (WINDOWS_VIDEO_ENCODER_URL)"
|
||||
git -C C:/Users/jaeoh/Desktop/workspace/web-backend push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 사용자 매뉴얼 단계 (사람이 직접)
|
||||
|
||||
후속 단계, 코드 작업 아님:
|
||||
|
||||
1. **Windows PC: ffmpeg 설치 + PATH 설정**
|
||||
- https://www.gyan.dev/ffmpeg/builds/ → "release full" 다운로드
|
||||
- `C:\ffmpeg\` 압축 해제 → `C:\ffmpeg\bin\ffmpeg.exe` 확인
|
||||
- 시스템 PATH에 `C:\ffmpeg\bin` 추가
|
||||
- 검증: `ffmpeg -version` + `ffmpeg -encoders | findstr h264_nvenc`
|
||||
|
||||
2. **Windows PC: `music_ai/.env` 추가**
|
||||
```env
|
||||
NAS_VOLUME_PREFIX=/volume1/
|
||||
WINDOWS_DRIVE_ROOT=Z:\
|
||||
FFMPEG_PATH=C:\ffmpeg\bin\ffmpeg.exe
|
||||
```
|
||||
|
||||
3. **Windows PC: SMB 마운트 확인** — `Z:\docker\webpage\data\` 접근 가능
|
||||
|
||||
4. **Windows PC: `music_ai` 서버 재시작** — `start.bat`
|
||||
|
||||
5. **Windows PC 헬스 체크** — `curl http://localhost:8765/health` → `ffmpeg_nvenc: true` 확인
|
||||
|
||||
6. **NAS `.env`에 추가**
|
||||
```env
|
||||
WINDOWS_VIDEO_ENCODER_URL=http://192.168.45.59:8765
|
||||
```
|
||||
|
||||
7. **NAS music-lab 재시작** — `docker compose up -d music-lab`
|
||||
|
||||
8. **E2E 테스트** — 진행 탭에서 새 파이프라인 시작, 영상 단계가 10–20초에 완료되는지 확인
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- §4 Windows endpoint → Task 1, 2 ✓
|
||||
- §5 NAS video.py → Task 3 ✓
|
||||
- §6 에러 처리 → Task 3 (httpx 예외 catch) ✓
|
||||
- §7 헬스 모니터링 → Task 2 (`/health` 확장) ✓
|
||||
- §8 테스트 → Task 1, 3 ✓
|
||||
- §9 Windows 사전 준비 → Task 5 (사용자 수동) ✓
|
||||
- §10 산출물 → 4 task로 모두 커버
|
||||
|
||||
**Placeholder scan:** 없음.
|
||||
|
||||
**Type consistency:**
|
||||
- `EncodeError(stage, message)` Task 1 정의, Task 2에서 `e.stage`/`e.message` 사용 ✓
|
||||
- `VideoGenerationError` Task 3에서 raise, 기존 orchestrator에서 catch ✓
|
||||
- 응답 JSON 형식 spec §4-2와 일치 ✓
|
||||
- 환경변수 이름 일관 (`NAS_VOLUME_PREFIX`, `WINDOWS_DRIVE_ROOT`, `FFMPEG_PATH`, `WINDOWS_VIDEO_ENCODER_URL`, `NAS_VIDEOS_ROOT`, `NAS_MUSIC_ROOT`)
|
||||
|
||||
---
|
||||
815
docs/superpowers/plans/2026-05-10-batch-music-generation.md
Normal file
815
docs/superpowers/plans/2026-05-10-batch-music-generation.md
Normal file
@@ -0,0 +1,815 @@
|
||||
# Batch Music Generation — Implementation Plan
|
||||
|
||||
> **For agentic workers:** Use `superpowers:subagent-driven-development`. Steps use `- [ ]` checkboxes.
|
||||
|
||||
**Goal:** 장르 1개로 N(1-10) 트랙 Suno 자동 순차 생성 + 자동 컴파일 + 영상 파이프라인 자동 시작.
|
||||
|
||||
**Architecture:** music-lab 신규 `batch_generator` 모듈이 BackgroundTask로 N회 Suno 호출 → compile_job 자동 생성 → orchestrator.run_step("cover") 자동 호출.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-10-batch-music-generation-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 경로 | 책임 |
|
||||
|------|------|
|
||||
| `music-lab/app/db.py` (modify) | `music_batch_jobs` 테이블 + 5 헬퍼 |
|
||||
| `music-lab/app/random_pools.py` (new) | 장르별 mood/instr/BPM/key/scale 랜덤 풀 + `randomize()` |
|
||||
| `music-lab/app/batch_generator.py` (new) | `run_batch(batch_id)` 순차 오케스트레이션 |
|
||||
| `music-lab/app/main.py` (modify) | 3개 endpoint (POST /generate-batch, GET /:id, GET 목록) |
|
||||
| `web-ui/src/api.js` (modify) | 3개 헬퍼 |
|
||||
| `web-ui/src/pages/music/components/BatchProgress.jsx` (new) | 진행 표시 컴포넌트 |
|
||||
| `web-ui/src/pages/music/MusicStudio.jsx` (modify) | Create 탭에 배치 섹션 + 폴링 |
|
||||
| `web-ui/src/pages/music/MusicStudio.css` (modify) | 배치 섹션 스타일 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: DB 테이블 + 헬퍼 + random_pools
|
||||
|
||||
**Files:**
|
||||
- Modify: `music-lab/app/db.py`
|
||||
- Create: `music-lab/app/random_pools.py`
|
||||
- Test: `music-lab/tests/test_batch_db.py`
|
||||
|
||||
- [ ] **Step 1: random_pools.py 작성**
|
||||
|
||||
```python
|
||||
"""장르별 음악 파라미터 랜덤 풀."""
|
||||
import random
|
||||
|
||||
POOLS = {
|
||||
"lo-fi": {
|
||||
"moods": ["chill", "relaxing", "dreamy", "melancholic", "mellow", "nostalgic", "peaceful"],
|
||||
"instruments_pool": ["piano", "synth", "drums", "vinyl", "rhodes", "soft bass", "ambient pads"],
|
||||
"instruments_count": (3, 4),
|
||||
"bpm": (70, 90),
|
||||
"keys": ["C", "D", "F", "G", "A"],
|
||||
"scales": ["minor", "major"],
|
||||
"prompt_modifiers": ["cozy bedroom vibes", "rainy night", "late night study", "cafe ambience"],
|
||||
},
|
||||
"phonk": {
|
||||
"moods": ["dark", "aggressive", "moody", "intense", "hypnotic"],
|
||||
"instruments_pool": ["808 bass", "hi-hat", "synth lead", "vocal chops", "bass drops", "trap drums"],
|
||||
"instruments_count": (3, 4),
|
||||
"bpm": (130, 160),
|
||||
"keys": ["C", "D", "F", "G"],
|
||||
"scales": ["minor"],
|
||||
"prompt_modifiers": ["drift atmosphere", "dark neon", "midnight drive"],
|
||||
},
|
||||
"ambient": {
|
||||
"moods": ["peaceful", "meditative", "ethereal", "spacious", "dreamy"],
|
||||
"instruments_pool": ["pad synths", "atmospheric guitar", "soft strings", "field recordings", "drone bass"],
|
||||
"instruments_count": (2, 3),
|
||||
"bpm": (50, 75),
|
||||
"keys": ["C", "D", "E", "G", "A"],
|
||||
"scales": ["major", "minor"],
|
||||
"prompt_modifiers": ["misty mountain morning", "deep space", "still water", "forest dawn"],
|
||||
},
|
||||
"pop": {
|
||||
"moods": ["uplifting", "happy", "energetic", "romantic", "catchy"],
|
||||
"instruments_pool": ["acoustic guitar", "piano", "drums", "bass", "synth", "vocals harmonies"],
|
||||
"instruments_count": (3, 5),
|
||||
"bpm": (95, 130),
|
||||
"keys": ["C", "D", "E", "F", "G", "A"],
|
||||
"scales": ["major"],
|
||||
"prompt_modifiers": ["radio-ready", "summer vibe", "feel-good"],
|
||||
},
|
||||
"default": {
|
||||
"moods": ["chill", "relaxing", "uplifting", "mellow"],
|
||||
"instruments_pool": ["piano", "synth", "drums", "guitar", "bass", "strings"],
|
||||
"instruments_count": (3, 4),
|
||||
"bpm": (80, 110),
|
||||
"keys": ["C", "D", "F", "G", "A"],
|
||||
"scales": ["minor", "major"],
|
||||
"prompt_modifiers": [""],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def randomize(genre: str, rng=None) -> dict:
|
||||
rng = rng or random.Random()
|
||||
pool = POOLS.get(genre.lower(), POOLS["default"])
|
||||
n_instr = rng.randint(*pool["instruments_count"])
|
||||
instruments = rng.sample(pool["instruments_pool"], min(n_instr, len(pool["instruments_pool"])))
|
||||
return {
|
||||
"moods": [rng.choice(pool["moods"])],
|
||||
"instruments": instruments,
|
||||
"bpm": rng.randint(*pool["bpm"]),
|
||||
"key": rng.choice(pool["keys"]),
|
||||
"scale": rng.choice(pool["scales"]),
|
||||
"prompt_modifier": rng.choice(pool["prompt_modifiers"]),
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: DB 테이블 + 헬퍼 추가** (db.py)
|
||||
|
||||
`init_db()`에 추가:
|
||||
```python
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS music_batch_jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
genre TEXT NOT NULL,
|
||||
count INTEGER NOT NULL,
|
||||
target_duration_sec INTEGER NOT NULL DEFAULT 180,
|
||||
auto_pipeline INTEGER NOT NULL DEFAULT 1,
|
||||
completed INTEGER NOT NULL DEFAULT 0,
|
||||
track_ids_json TEXT NOT NULL DEFAULT '[]',
|
||||
current_track_index INTEGER NOT NULL DEFAULT 0,
|
||||
current_track_status TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
error TEXT,
|
||||
compile_job_id INTEGER,
|
||||
pipeline_id INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
```
|
||||
|
||||
`db.py` 끝에 헬퍼:
|
||||
```python
|
||||
_BATCH_ALLOWED_COLS = frozenset([
|
||||
"completed", "track_ids_json", "current_track_index",
|
||||
"current_track_status", "status", "error",
|
||||
"compile_job_id", "pipeline_id",
|
||||
])
|
||||
|
||||
|
||||
def create_batch_job(genre: str, count: int, target_duration_sec: int = 180,
|
||||
auto_pipeline: bool = True) -> int:
|
||||
with _conn() as conn:
|
||||
now = _now()
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO music_batch_jobs
|
||||
(genre, count, target_duration_sec, auto_pipeline,
|
||||
status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'queued', ?, ?)
|
||||
""", (genre, count, target_duration_sec, 1 if auto_pipeline else 0, now, now))
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def get_batch_job(batch_id: int) -> dict | None:
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM music_batch_jobs WHERE id = ?", (batch_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
d = dict(row)
|
||||
d["track_ids"] = json.loads(d.get("track_ids_json") or "[]")
|
||||
return d
|
||||
|
||||
|
||||
def update_batch_job(batch_id: int, **fields) -> None:
|
||||
unknown = set(fields) - _BATCH_ALLOWED_COLS
|
||||
if unknown:
|
||||
raise ValueError(f"unknown batch job columns: {unknown}")
|
||||
cols = ", ".join(f"{k} = ?" for k in fields)
|
||||
vals = list(fields.values()) + [_now(), batch_id]
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
f"UPDATE music_batch_jobs SET {cols}, updated_at = ? WHERE id = ?",
|
||||
vals,
|
||||
)
|
||||
|
||||
|
||||
def append_batch_track(batch_id: int, track_id: int) -> None:
|
||||
"""track_ids_json에 새 track_id 추가 + completed += 1 (atomic)."""
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT track_ids_json, completed FROM music_batch_jobs WHERE id = ?",
|
||||
(batch_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return
|
||||
ids = json.loads(row["track_ids_json"] or "[]")
|
||||
ids.append(track_id)
|
||||
conn.execute(
|
||||
"UPDATE music_batch_jobs SET track_ids_json = ?, completed = ?, updated_at = ? WHERE id = ?",
|
||||
(json.dumps(ids), row["completed"] + 1, _now(), batch_id),
|
||||
)
|
||||
|
||||
|
||||
def list_batch_jobs(active_only: bool = False) -> list[dict]:
|
||||
sql = "SELECT * FROM music_batch_jobs"
|
||||
if active_only:
|
||||
sql += " WHERE status NOT IN ('failed','cancelled','piped')"
|
||||
sql += " ORDER BY created_at DESC"
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(sql).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["track_ids"] = json.loads(d.get("track_ids_json") or "[]")
|
||||
out.append(d)
|
||||
return out
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Test 작성**
|
||||
|
||||
```python
|
||||
# tests/test_batch_db.py
|
||||
import pytest
|
||||
from app import db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_db(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(db, "DB_PATH", str(tmp_path / "music.db"))
|
||||
db.init_db()
|
||||
return db
|
||||
|
||||
|
||||
def test_create_batch_job(fresh_db):
|
||||
bid = db.create_batch_job(genre="lo-fi", count=10)
|
||||
j = db.get_batch_job(bid)
|
||||
assert j["genre"] == "lo-fi"
|
||||
assert j["count"] == 10
|
||||
assert j["status"] == "queued"
|
||||
assert j["track_ids"] == []
|
||||
assert j["auto_pipeline"] == 1
|
||||
|
||||
|
||||
def test_update_batch_job(fresh_db):
|
||||
bid = db.create_batch_job(genre="phonk", count=5)
|
||||
db.update_batch_job(bid, status="generating", current_track_index=2)
|
||||
j = db.get_batch_job(bid)
|
||||
assert j["status"] == "generating"
|
||||
assert j["current_track_index"] == 2
|
||||
|
||||
|
||||
def test_update_batch_rejects_unknown_col(fresh_db):
|
||||
bid = db.create_batch_job(genre="lo-fi", count=1)
|
||||
with pytest.raises(ValueError):
|
||||
db.update_batch_job(bid, evil_col="x")
|
||||
|
||||
|
||||
def test_append_batch_track(fresh_db):
|
||||
bid = db.create_batch_job(genre="lo-fi", count=3)
|
||||
db.append_batch_track(bid, 101)
|
||||
db.append_batch_track(bid, 102)
|
||||
j = db.get_batch_job(bid)
|
||||
assert j["track_ids"] == [101, 102]
|
||||
assert j["completed"] == 2
|
||||
|
||||
|
||||
def test_list_batch_jobs_active_filter(fresh_db):
|
||||
b1 = db.create_batch_job(genre="lo-fi", count=1)
|
||||
b2 = db.create_batch_job(genre="phonk", count=1)
|
||||
db.update_batch_job(b1, status="failed")
|
||||
actives = db.list_batch_jobs(active_only=True)
|
||||
assert all(j["status"] not in ("failed",) for j in actives)
|
||||
assert any(j["id"] == b2 for j in actives)
|
||||
assert not any(j["id"] == b1 for j in actives)
|
||||
|
||||
|
||||
def test_random_pools_randomize():
|
||||
from app.random_pools import randomize, POOLS
|
||||
import random
|
||||
rng = random.Random(42)
|
||||
result = randomize("lo-fi", rng)
|
||||
assert result["bpm"] in range(70, 91)
|
||||
assert result["key"] in POOLS["lo-fi"]["keys"]
|
||||
assert result["scale"] in POOLS["lo-fi"]["scales"]
|
||||
assert len(result["moods"]) == 1
|
||||
assert result["moods"][0] in POOLS["lo-fi"]["moods"]
|
||||
assert 3 <= len(result["instruments"]) <= 4
|
||||
|
||||
|
||||
def test_random_pools_unknown_genre_uses_default():
|
||||
from app.random_pools import randomize, POOLS
|
||||
import random
|
||||
result = randomize("nonexistent", random.Random(0))
|
||||
assert result["bpm"] in range(80, 111) # default range
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run + commit**
|
||||
|
||||
```bash
|
||||
cd music-lab && python -m pytest tests/test_batch_db.py -v
|
||||
```
|
||||
Expected: 7 PASS.
|
||||
|
||||
```bash
|
||||
git add music-lab/app/db.py music-lab/app/random_pools.py music-lab/tests/test_batch_db.py
|
||||
git commit -m "feat(music-lab): music_batch_jobs 테이블 + 장르별 랜덤 풀"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: batch_generator + 3 엔드포인트
|
||||
|
||||
**Files:**
|
||||
- Create: `music-lab/app/batch_generator.py`
|
||||
- Modify: `music-lab/app/main.py`
|
||||
- Test: `music-lab/tests/test_batch_endpoints.py`
|
||||
|
||||
- [ ] **Step 1: batch_generator.py 작성**
|
||||
|
||||
```python
|
||||
"""배치 음악 생성 + 자동 컴파일·영상 파이프라인."""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from . import db
|
||||
from .random_pools import randomize
|
||||
|
||||
logger = logging.getLogger("music-lab.batch")
|
||||
|
||||
POLL_INTERVAL_S = 5
|
||||
TRACK_GEN_TIMEOUT_S = 240
|
||||
|
||||
|
||||
async def run_batch(batch_id: int) -> None:
|
||||
job = db.get_batch_job(batch_id)
|
||||
if not job:
|
||||
return
|
||||
genre = job["genre"]
|
||||
count = job["count"]
|
||||
duration = job["target_duration_sec"]
|
||||
auto_pipe = bool(job["auto_pipeline"])
|
||||
|
||||
db.update_batch_job(batch_id, status="generating")
|
||||
|
||||
track_ids: list[int] = []
|
||||
for i in range(1, count + 1):
|
||||
title = f"{genre.title()} Mix Track {i}"
|
||||
params = randomize(genre)
|
||||
db.update_batch_job(batch_id,
|
||||
current_track_index=i,
|
||||
current_track_status="generating")
|
||||
|
||||
track_id = await _generate_one_track(title=title, genre=genre,
|
||||
duration_sec=duration,
|
||||
params=params)
|
||||
if track_id:
|
||||
track_ids.append(track_id)
|
||||
db.append_batch_track(batch_id, track_id)
|
||||
db.update_batch_job(batch_id, current_track_status="succeeded")
|
||||
else:
|
||||
db.update_batch_job(batch_id, current_track_status="failed")
|
||||
logger.warning("배치 %d 트랙 %d 실패 — 계속 진행", batch_id, i)
|
||||
|
||||
if not track_ids:
|
||||
db.update_batch_job(batch_id, status="failed",
|
||||
error="모든 트랙 생성 실패")
|
||||
return
|
||||
|
||||
db.update_batch_job(batch_id, status="generated")
|
||||
|
||||
if not auto_pipe:
|
||||
return
|
||||
|
||||
# 자동 컴파일
|
||||
db.update_batch_job(batch_id, status="compiling")
|
||||
try:
|
||||
compile_id = db.create_compile_job(
|
||||
title=f"{genre.title()} Mix",
|
||||
track_ids=track_ids,
|
||||
crossfade_sec=3,
|
||||
)
|
||||
db.update_batch_job(batch_id, compile_job_id=compile_id)
|
||||
except Exception as e:
|
||||
db.update_batch_job(batch_id, status="failed", error=f"compile create: {e}")
|
||||
return
|
||||
|
||||
from . import compiler
|
||||
try:
|
||||
await asyncio.to_thread(compiler.run, compile_id)
|
||||
except Exception as e:
|
||||
db.update_batch_job(batch_id, status="failed", error=f"compile run: {e}")
|
||||
return
|
||||
|
||||
job_after = db.get_compile_job(compile_id)
|
||||
if not job_after or job_after.get("status") not in ("done", "succeeded"):
|
||||
db.update_batch_job(
|
||||
batch_id, status="failed",
|
||||
error=f"compile not done (status={job_after.get('status') if job_after else 'unknown'})"
|
||||
)
|
||||
return
|
||||
|
||||
# 자동 영상 파이프라인
|
||||
pipeline_id = db.create_pipeline(compile_job_id=compile_id)
|
||||
db.update_batch_job(batch_id, pipeline_id=pipeline_id, status="piped")
|
||||
|
||||
from .pipeline import orchestrator
|
||||
await orchestrator.run_step(pipeline_id, "cover")
|
||||
|
||||
|
||||
async def _generate_one_track(*, title: str, genre: str, duration_sec: int,
|
||||
params: dict) -> int | None:
|
||||
"""기존 Suno generate 호출 + 완료까지 polling. 성공 시 새 track id 반환."""
|
||||
from .suno_provider import run_suno_generation
|
||||
from .db import create_task, get_task
|
||||
import uuid
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
suno_params = {
|
||||
"title": title,
|
||||
"genre": genre,
|
||||
"moods": params["moods"],
|
||||
"instruments": params["instruments"],
|
||||
"duration_sec": duration_sec,
|
||||
"bpm": params["bpm"],
|
||||
"key": params["key"],
|
||||
"scale": params["scale"],
|
||||
"prompt": params.get("prompt_modifier", ""),
|
||||
}
|
||||
create_task(task_id, suno_params, provider="suno")
|
||||
|
||||
# Suno background task 직접 호출 (BackgroundTasks 미사용 — 우리가 await)
|
||||
asyncio.create_task(asyncio.to_thread(run_suno_generation, task_id, suno_params))
|
||||
|
||||
# Polling
|
||||
waited = 0
|
||||
while waited < TRACK_GEN_TIMEOUT_S:
|
||||
await asyncio.sleep(POLL_INTERVAL_S)
|
||||
waited += POLL_INTERVAL_S
|
||||
task = get_task(task_id)
|
||||
if not task:
|
||||
continue
|
||||
if task.get("status") == "succeeded":
|
||||
tr = task.get("track")
|
||||
return tr.get("id") if tr else None
|
||||
if task.get("status") == "failed":
|
||||
return None
|
||||
return None # timeout
|
||||
```
|
||||
|
||||
NOTE: This assumes existing `db.create_task`, `db.get_task`, `suno_provider.run_suno_generation` are reusable. Read existing code to confirm function signatures, adjust if needed (especially `task["track"]["id"]` vs other format).
|
||||
|
||||
- [ ] **Step 2: main.py에 3 endpoint 추가**
|
||||
|
||||
```python
|
||||
from app.batch_generator import run_batch as _run_batch
|
||||
|
||||
|
||||
class BatchGenerateRequest(BaseModel):
|
||||
genre: str
|
||||
count: int = 10
|
||||
target_duration_sec: int = 180
|
||||
auto_pipeline: bool = True
|
||||
|
||||
|
||||
@app.post("/api/music/generate-batch", status_code=201)
|
||||
async def generate_batch(req: BatchGenerateRequest, bg: BackgroundTasks):
|
||||
if not (1 <= req.count <= 10):
|
||||
raise HTTPException(400, "count는 1-10 사이")
|
||||
if not (60 <= req.target_duration_sec <= 300):
|
||||
raise HTTPException(400, "target_duration_sec는 60-300 사이")
|
||||
if not req.genre:
|
||||
raise HTTPException(400, "genre 필수")
|
||||
if not SUNO_API_KEY:
|
||||
raise HTTPException(400, "SUNO_API_KEY 미설정")
|
||||
|
||||
batch_id = _db_module.create_batch_job(
|
||||
genre=req.genre, count=req.count,
|
||||
target_duration_sec=req.target_duration_sec,
|
||||
auto_pipeline=req.auto_pipeline,
|
||||
)
|
||||
bg.add_task(_run_batch, batch_id)
|
||||
return _db_module.get_batch_job(batch_id)
|
||||
|
||||
|
||||
@app.get("/api/music/generate-batch/{batch_id}")
|
||||
def get_batch(batch_id: int):
|
||||
j = _db_module.get_batch_job(batch_id)
|
||||
if not j:
|
||||
raise HTTPException(404)
|
||||
# tracks 메타 LEFT JOIN (id, title, audio_url)
|
||||
if j["track_ids"]:
|
||||
ids_csv = ",".join(str(i) for i in j["track_ids"])
|
||||
# 간단한 in-Python 매핑 (sqlite IN (...))
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(_db_module.DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = conn.execute(
|
||||
f"SELECT id, title, audio_url, duration_sec FROM music_library WHERE id IN ({ids_csv})"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
j["tracks"] = [dict(r) for r in rows]
|
||||
else:
|
||||
j["tracks"] = []
|
||||
return j
|
||||
|
||||
|
||||
@app.get("/api/music/generate-batch")
|
||||
def list_batches(status: str = "all"):
|
||||
return {"batches": _db_module.list_batch_jobs(active_only=(status == "active"))}
|
||||
```
|
||||
|
||||
(SUNO_API_KEY는 main.py에 이미 import돼있다고 가정. 없으면 `_db_module` 패턴처럼 처리.)
|
||||
|
||||
- [ ] **Step 3: 테스트 작성**
|
||||
|
||||
```python
|
||||
# tests/test_batch_endpoints.py
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import app
|
||||
from app import db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(db, "DB_PATH", str(tmp_path / "music.db"))
|
||||
db.init_db()
|
||||
monkeypatch.setenv("SUNO_API_KEY", "test")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_create_batch_201(client):
|
||||
with patch("app.main._run_batch", new=AsyncMock()):
|
||||
r = client.post("/api/music/generate-batch",
|
||||
json={"genre": "lo-fi", "count": 3})
|
||||
assert r.status_code == 201
|
||||
body = r.json()
|
||||
assert body["genre"] == "lo-fi"
|
||||
assert body["count"] == 3
|
||||
assert body["status"] == "queued"
|
||||
|
||||
|
||||
def test_create_batch_rejects_count_too_high(client):
|
||||
r = client.post("/api/music/generate-batch",
|
||||
json={"genre": "lo-fi", "count": 11})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_create_batch_rejects_count_zero(client):
|
||||
r = client.post("/api/music/generate-batch",
|
||||
json={"genre": "lo-fi", "count": 0})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_create_batch_rejects_no_genre(client):
|
||||
r = client.post("/api/music/generate-batch", json={"count": 3})
|
||||
# Pydantic missing 필드 → 422 (FastAPI default validation)
|
||||
assert r.status_code in (400, 422)
|
||||
|
||||
|
||||
def test_get_batch_returns_tracks(client):
|
||||
bid = db.create_batch_job(genre="lo-fi", count=2)
|
||||
db.append_batch_track(bid, 999) # phantom track id (not in library)
|
||||
r = client.get(f"/api/music/generate-batch/{bid}")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["track_ids"] == [999]
|
||||
# tracks 배열은 비어있음 (해당 track 미존재)
|
||||
assert body["tracks"] == []
|
||||
|
||||
|
||||
def test_list_batches(client):
|
||||
db.create_batch_job(genre="lo-fi", count=1)
|
||||
db.create_batch_job(genre="phonk", count=2)
|
||||
r = client.get("/api/music/generate-batch")
|
||||
assert len(r.json()["batches"]) == 2
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run + commit + push**
|
||||
|
||||
```bash
|
||||
cd music-lab && python -m pytest tests/ -v
|
||||
```
|
||||
Expected: 모두 PASS.
|
||||
|
||||
```bash
|
||||
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add music-lab/app/batch_generator.py \
|
||||
music-lab/app/main.py \
|
||||
music-lab/tests/test_batch_endpoints.py
|
||||
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(music-lab): 배치 음악 생성 endpoint + orchestrator"
|
||||
git -C C:/Users/jaeoh/Desktop/workspace/web-backend push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Frontend Create 탭 배치 섹션
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-ui/src/api.js`
|
||||
- Create: `web-ui/src/pages/music/components/BatchProgress.jsx`
|
||||
- Modify: `web-ui/src/pages/music/MusicStudio.jsx`
|
||||
- Modify: `web-ui/src/pages/music/MusicStudio.css`
|
||||
|
||||
- [ ] **Step 1: api.js 헬퍼**
|
||||
|
||||
```javascript
|
||||
// === Batch generation ===
|
||||
export const startBatchGen = (payload) => apiPost('/api/music/generate-batch', payload);
|
||||
export const getBatchJob = (id) => apiGet(`/api/music/generate-batch/${id}`);
|
||||
export const listBatchJobs = (status='all') => apiGet(`/api/music/generate-batch?status=${status}`);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: BatchProgress.jsx 신규**
|
||||
|
||||
```jsx
|
||||
const STATUS_LABELS = {
|
||||
queued: '대기 중', generating: '음악 생성 중', generated: '음악 완료, 컴파일 대기',
|
||||
compiling: '컴파일 중', piped: '영상 파이프라인 시작됨',
|
||||
failed: '실패', cancelled: '취소',
|
||||
};
|
||||
|
||||
export default function BatchProgress({ batch }) {
|
||||
if (!batch) return null;
|
||||
const trackList = Array.from({ length: batch.count }, (_, i) => i + 1);
|
||||
return (
|
||||
<div className="ms-batch-progress">
|
||||
<div className="ms-batch-header">
|
||||
배치 #{batch.id} — {batch.genre} ·{' '}
|
||||
{batch.completed}/{batch.count} 완료 ·{' '}
|
||||
<strong>{STATUS_LABELS[batch.status] || batch.status}</strong>
|
||||
</div>
|
||||
{batch.error && <div className="ms-error">에러: {batch.error}</div>}
|
||||
<ol className="ms-batch-tracks">
|
||||
{trackList.map(n => {
|
||||
const completed = n <= batch.completed;
|
||||
const current = n === batch.current_track_index && batch.status === 'generating';
|
||||
const tr = (batch.tracks || [])[n - 1];
|
||||
return (
|
||||
<li key={n} className={completed ? 'done' : current ? 'current' : 'pending'}>
|
||||
{completed ? '✓' : current ? '⏳' : '○'}
|
||||
{' '}Track {n}: {tr?.title || (current ? '생성 중...' : '대기')}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
{batch.compile_job_id && (
|
||||
<div className="ms-batch-link">📀 컴파일 #{batch.compile_job_id}</div>
|
||||
)}
|
||||
{batch.pipeline_id && (
|
||||
<div className="ms-batch-link">
|
||||
🎬 영상 파이프라인 #{batch.pipeline_id} —
|
||||
{' '}<em>YouTube 탭 → 진행 탭에서 확인</em>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: MusicStudio.jsx Create 탭에 배치 섹션 추가**
|
||||
|
||||
Create 탭 jsx 영역 (handleGenerate 근처) 위 또는 옆에:
|
||||
|
||||
```jsx
|
||||
import BatchProgress from './components/BatchProgress';
|
||||
import { startBatchGen, getBatchJob } from '../../api';
|
||||
|
||||
// 컴포넌트 내부 state:
|
||||
const [batchOpen, setBatchOpen] = useState(false);
|
||||
const [batchGenre, setBatchGenre] = useState('lo-fi');
|
||||
const [batchCount, setBatchCount] = useState(10);
|
||||
const [batchDuration, setBatchDuration] = useState(180);
|
||||
const [batchAutoPipe, setBatchAutoPipe] = useState(true);
|
||||
const [currentBatch, setCurrentBatch] = useState(null);
|
||||
const [batchPolling, setBatchPolling] = useState(false);
|
||||
const batchPollRef = useRef(null);
|
||||
|
||||
const startBatch = async () => {
|
||||
try {
|
||||
const res = await startBatchGen({
|
||||
genre: batchGenre,
|
||||
count: batchCount,
|
||||
target_duration_sec: batchDuration,
|
||||
auto_pipeline: batchAutoPipe,
|
||||
});
|
||||
setCurrentBatch(res);
|
||||
setBatchPolling(true);
|
||||
} catch (e) {
|
||||
alert(`배치 시작 실패: ${e.message || e}`);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!batchPolling || !currentBatch?.id) return;
|
||||
const tick = async () => {
|
||||
const j = await getBatchJob(currentBatch.id).catch(() => null);
|
||||
if (j) {
|
||||
setCurrentBatch(j);
|
||||
if (['piped', 'failed', 'cancelled'].includes(j.status)) {
|
||||
setBatchPolling(false);
|
||||
if (j.pipeline_id) loadLibrary?.(); // refresh library to show new tracks
|
||||
}
|
||||
}
|
||||
};
|
||||
batchPollRef.current = setInterval(tick, 5000);
|
||||
return () => clearInterval(batchPollRef.current);
|
||||
}, [batchPolling, currentBatch?.id]);
|
||||
|
||||
// ... Create 탭 jsx 안:
|
||||
<details className="ms-batch-section" open={batchOpen} onToggle={(e) => setBatchOpen(e.target.open)}>
|
||||
<summary>🎲 배치 생성 (장르 → 1-10트랙 + 자동 영상)</summary>
|
||||
<div className="ms-batch-form">
|
||||
<label>장르
|
||||
<select value={batchGenre} onChange={e => setBatchGenre(e.target.value)}>
|
||||
<option value="lo-fi">Lo-Fi</option>
|
||||
<option value="phonk">Phonk</option>
|
||||
<option value="ambient">Ambient</option>
|
||||
<option value="pop">Pop</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>트랙 수: {batchCount}
|
||||
<input type="range" min={1} max={10} value={batchCount}
|
||||
onChange={e => setBatchCount(parseInt(e.target.value))} />
|
||||
</label>
|
||||
<label>트랙당 길이: {batchDuration}초
|
||||
<input type="range" min={60} max={300} step={10} value={batchDuration}
|
||||
onChange={e => setBatchDuration(parseInt(e.target.value))} />
|
||||
</label>
|
||||
<label className="ms-batch-checkbox">
|
||||
<input type="checkbox" checked={batchAutoPipe}
|
||||
onChange={e => setBatchAutoPipe(e.target.checked)} />
|
||||
모든 트랙 생성 후 자동 영상 파이프라인 시작
|
||||
</label>
|
||||
<p className="ms-batch-estimate">
|
||||
예상: 약 {Math.ceil(batchCount * 1.5)}-{batchCount * 2}분 ·
|
||||
비용 ~${(batchCount * 0.005 + (batchAutoPipe ? 0.05 : 0)).toFixed(2)}
|
||||
</p>
|
||||
<button className="button primary" onClick={startBatch}
|
||||
disabled={batchPolling}>
|
||||
🎵 배치 생성 시작
|
||||
</button>
|
||||
</div>
|
||||
{currentBatch && <BatchProgress batch={currentBatch} />}
|
||||
</details>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: CSS 추가**
|
||||
|
||||
```css
|
||||
/* === Batch generation section === */
|
||||
.ms-batch-section { margin: 16px 0; padding: 12px; background: rgba(0,0,0,.2);
|
||||
border: 1px solid var(--ms-line, #2a2a3a); border-radius: 12px; }
|
||||
.ms-batch-section summary { cursor: pointer; font-weight: bold; color: var(--ms-text, #f0f0f5); }
|
||||
.ms-batch-form { display: flex; flex-direction: column; gap: 10px; padding: 12px 0; }
|
||||
.ms-batch-form label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
|
||||
.ms-batch-form input[type="range"] { width: 100%; }
|
||||
.ms-batch-checkbox { flex-direction: row !important; align-items: center; gap: 8px; }
|
||||
.ms-batch-checkbox input { width: auto; }
|
||||
.ms-batch-estimate { font-size: 12px; color: var(--ms-muted, #a0a0b0); }
|
||||
|
||||
.ms-batch-progress { margin-top: 12px; padding: 12px; background: rgba(0,0,0,.3);
|
||||
border-radius: 8px; }
|
||||
.ms-batch-header { font-size: 13px; margin-bottom: 8px; }
|
||||
.ms-batch-tracks { padding-left: 24px; font-size: 12px; }
|
||||
.ms-batch-tracks li { margin: 2px 0; }
|
||||
.ms-batch-tracks li.done { color: #86efac; }
|
||||
.ms-batch-tracks li.current { color: var(--ms-accent, #38bdf8); font-weight: bold; }
|
||||
.ms-batch-tracks li.pending { color: var(--ms-muted, #a0a0b0); }
|
||||
.ms-batch-link { margin-top: 8px; font-size: 12px; color: var(--ms-muted, #a0a0b0); }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build + verify + commit + push + deploy**
|
||||
|
||||
```bash
|
||||
cd web-ui && npm run build 2>&1 | tail -5
|
||||
npx eslint src/pages/music/components/BatchProgress.jsx src/pages/music/MusicStudio.jsx 2>&1 | tail
|
||||
```
|
||||
|
||||
```bash
|
||||
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add src/api.js \
|
||||
src/pages/music/components/BatchProgress.jsx \
|
||||
src/pages/music/MusicStudio.jsx \
|
||||
src/pages/music/MusicStudio.css
|
||||
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "feat(web-ui): Create 탭 배치 생성 섹션 + BatchProgress"
|
||||
git -C C:/Users/jaeoh/Desktop/workspace/web-ui push origin main
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-ui && npm run release:nas
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 수동 E2E 검증
|
||||
|
||||
- [ ] Create 탭 → 배치 생성 섹션 펼침 → genre=lo-fi, count=3 (테스트로 적게), duration=120s, auto_pipeline=on → "배치 생성 시작"
|
||||
- [ ] BatchProgress에 Track 1/2/3 진행 표시 확인
|
||||
- [ ] ~5분 후 Library에 3개 트랙 추가됨
|
||||
- [ ] 컴파일 진행 확인 (status: compiling)
|
||||
- [ ] 영상 파이프라인 시작됨 (status: piped) + pipeline_id 표시
|
||||
- [ ] YouTube 탭 → 진행 탭에 새 카드, cover 단계 진행 중
|
||||
- [ ] 텔레그램에 cover 알림 도착
|
||||
- [ ] 일반 흐름대로 5단계 승인 후 발행
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- §3 사용자 흐름 → Task 3 (UI 섹션)
|
||||
- §4 데이터 모델 → Task 1
|
||||
- §5 백엔드 (random_pools, batch_generator) → Task 1, 2
|
||||
- §6 API → Task 2
|
||||
- §7 프론트엔드 → Task 3
|
||||
- §8 에러 처리 → Task 2 (validation, try/except)
|
||||
- §9 테스트 → Task 1, 2
|
||||
- §10 산출물 → 4 task로 모두 커버
|
||||
|
||||
**Placeholder scan:** 없음.
|
||||
|
||||
**Type consistency:**
|
||||
- `batch_id` int, `count` int, `genre` str — 일관
|
||||
- `track_ids` list[int]
|
||||
- `status` 7값 (queued/generating/generated/compiling/piped/failed/cancelled) 일관
|
||||
|
||||
**스펙 보정:** §5-2 batch_generator의 `_generate_one_track`에서 `db.create_task`/`db.get_task` 사용 — 이 함수들이 기존 db.py에 있는지 미확인. Task 2 Step 1 NOTE에 명시함.
|
||||
2753
docs/superpowers/plans/2026-05-15-insta-agent-implementation.md
Normal file
2753
docs/superpowers/plans/2026-05-15-insta-agent-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
1785
docs/superpowers/plans/2026-05-16-insta-trends-implementation.md
Normal file
1785
docs/superpowers/plans/2026-05-16-insta-trends-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
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,471 @@
|
||||
# packs-lab 인프라 통합 + admin mint-token 설계
|
||||
|
||||
> 대상: `web-backend/packs-lab/`
|
||||
> 외부 의존: Supabase(`pack_files` 테이블) + Vercel SaaS(HMAC 호출자)
|
||||
> 후속 별도 스펙: Vercel-side admin UI / 사용자 다운로드 / cleanup cron / multi-admin
|
||||
|
||||
---
|
||||
|
||||
## 1. 목표
|
||||
|
||||
`packs-lab`은 NAS 자료 다운로드 자동화 백엔드. Synology DSM 공유 링크 발급 + 5GB 멀티파트 업로드 수신을 담당하고, Vercel SaaS와 HMAC으로 통신한다. 사용자 인증은 Vercel이 Supabase로 처리하고 본 서비스는 외부 인증을 다루지 않는다.
|
||||
|
||||
이미 코드(HMAC 미들웨어 / DSM client / 4 라우트)는 작성되어 있으나 인프라 통합 + Supabase 스키마 + admin upload 토큰 발급 흐름이 빠져 있어 운영 가능 상태가 아니다. 본 스펙은 그 갭을 메운다.
|
||||
|
||||
### 핵심 변경
|
||||
|
||||
- **신규 라우트**: `POST /api/packs/admin/mint-token` (Vercel HMAC → 일회성 업로드 토큰)
|
||||
- **Supabase DDL**: `pack_files` 테이블 + 활성·삭제 인덱스
|
||||
- **인프라**: docker-compose `packs-lab` 서비스 등록(18950) + nginx `/api/packs/` 5GB 통과 + `.env.example` 6+1 환경변수
|
||||
- **테스트**: routes 통합 + DSM client mock
|
||||
- **문서**: web-backend / workspace CLAUDE.md 5곳 갱신
|
||||
- **DELETE 라우트 docstring**: "DSM 공유 정리" 표현을 "DSM 공유 자동 만료"로 수정 (실제 동작과 일치)
|
||||
|
||||
### 변경하지 않는 것
|
||||
|
||||
- 기존 `auth.py` (`mint_upload_token` 그대로 활용)
|
||||
- 기존 `dsm_client.py`
|
||||
- 기존 `routes.py`의 sign-link / upload / list / delete 본문
|
||||
- DSM 공유 추적 테이블 — 4시간 자동 만료로 충분(브레인스토밍 결정)
|
||||
|
||||
---
|
||||
|
||||
## 2. 컴포넌트 + 통신 흐름
|
||||
|
||||
### 2.1 변경 받는 파일
|
||||
|
||||
| 영역 | 파일 | 변경 |
|
||||
|------|------|------|
|
||||
| 백엔드 | `packs-lab/app/routes.py` | DELETE docstring 수정 + admin mint-token 라우트 추가 |
|
||||
| 백엔드 | `packs-lab/app/models.py` | `MintTokenRequest`, `MintTokenResponse` 스키마 추가 |
|
||||
| 백엔드 | `packs-lab/app/auth.py` | 변경 없음 (기존 `mint_upload_token` 활용) |
|
||||
| 테스트 | `packs-lab/tests/conftest.py` (신규) | autouse `BACKEND_HMAC_SECRET` 셋팅 |
|
||||
| 테스트 | `packs-lab/tests/test_routes.py` (신규) | 5 라우트 통합 테스트 |
|
||||
| 테스트 | `packs-lab/tests/test_dsm_client.py` (신규) | DSM 7.x API mock 테스트 |
|
||||
| DB | `packs-lab/supabase/pack_files.sql` (신규) | DDL + 인덱스 |
|
||||
| 인프라 | `docker-compose.yml` | `packs-lab` 서비스 추가 |
|
||||
| 인프라 | `nginx/default.conf` | `/api/packs/` 라우팅 (`client_max_body_size 5G` + streaming) |
|
||||
| 인프라 | `.env.example` | 6+1 신규 환경변수 |
|
||||
| 문서 | `web-backend/CLAUDE.md` | 1·4·5·8·9 섹션 갱신 |
|
||||
| 문서 | `workspace/CLAUDE.md` | 컨테이너 표 한 줄 추가 |
|
||||
|
||||
### 2.2 통신 흐름
|
||||
|
||||
**ADMIN 업로드**
|
||||
|
||||
```
|
||||
Vercel admin UI ─────→ Vercel API (HMAC 헤더 추가)
|
||||
│
|
||||
▼
|
||||
POST /api/packs/admin/mint-token
|
||||
│
|
||||
backend: verify_request_hmac
|
||||
│
|
||||
mint_upload_token({tier, label, filename, size_bytes, jti, expires_at})
|
||||
│
|
||||
Vercel ←─────────────── token ──────┘
|
||||
│
|
||||
▼
|
||||
admin browser → POST /api/packs/upload
|
||||
Authorization: Bearer <token>
|
||||
multipart body (≤5GB)
|
||||
│
|
||||
backend: verify_upload_token + JTI mark
|
||||
│
|
||||
파일 저장 (PACK_BASE_DIR/{filename}, 평면 구조 — tier는 filename 규칙으로 구분)
|
||||
│
|
||||
Supabase INSERT pack_files
|
||||
```
|
||||
|
||||
**사용자 다운로드**
|
||||
|
||||
```
|
||||
사용자 → Vercel SaaS (Supabase auth + tier·결제 검증)
|
||||
│
|
||||
▼
|
||||
POST /api/packs/sign-link (HMAC + file_path)
|
||||
│
|
||||
backend: verify_request_hmac
|
||||
│
|
||||
DSM Sharing.create (4시간 만료)
|
||||
│
|
||||
사용자 ← Vercel ← 다운로드 URL (4시간 유효)
|
||||
```
|
||||
|
||||
### 2.3 기각된 대안
|
||||
|
||||
| 대안 | 기각 사유 |
|
||||
|------|-----------|
|
||||
| Vercel-side 토큰 발급 | 토큰 포맷 양쪽 분산, 변경 시 동기화 부담 |
|
||||
| admin browser → backend 직접 HMAC | admin browser에 secret 노출, 보안 약화 |
|
||||
| DSM 공유 추적 테이블 | 4시간 자동 만료로 충분, YAGNI |
|
||||
| Resumable multipart upload | 5GB는 단일 stream으로 충분, 복잡도 증가 |
|
||||
| `pack_files.min_tier`를 PostgreSQL ENUM | tier 추가 시 ALTER TYPE 번거로움. text+CHECK 채택 |
|
||||
|
||||
---
|
||||
|
||||
## 3. `POST /api/packs/admin/mint-token`
|
||||
|
||||
### 3.1 Pydantic 스키마 (`models.py` 추가)
|
||||
|
||||
```python
|
||||
class MintTokenRequest(BaseModel):
|
||||
"""Vercel → backend: admin upload 토큰 발급 요청."""
|
||||
tier: PackTier
|
||||
label: str = Field(..., max_length=200)
|
||||
filename: str = Field(..., max_length=255)
|
||||
size_bytes: int = Field(..., gt=0, le=5 * 1024 * 1024 * 1024)
|
||||
|
||||
|
||||
class MintTokenResponse(BaseModel):
|
||||
token: str
|
||||
expires_at: datetime
|
||||
jti: str
|
||||
```
|
||||
|
||||
### 3.2 라우트 본문 (`routes.py` 추가)
|
||||
|
||||
```python
|
||||
import time, uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .auth import mint_upload_token, verify_request_hmac
|
||||
from .models import MintTokenRequest, MintTokenResponse
|
||||
|
||||
UPLOAD_TOKEN_TTL_SEC = int(os.getenv("UPLOAD_TOKEN_TTL_SEC", "1800")) # 30분 default
|
||||
|
||||
@router.post("/admin/mint-token", response_model=MintTokenResponse)
|
||||
async def mint_token(
|
||||
request: Request,
|
||||
x_timestamp: str = Header(""),
|
||||
x_signature: str = Header(""),
|
||||
):
|
||||
body = await request.body()
|
||||
verify_request_hmac(body, x_timestamp, x_signature)
|
||||
payload = MintTokenRequest.model_validate_json(body)
|
||||
_check_filename(payload.filename) # upload 라우트와 동일 검증
|
||||
|
||||
jti = str(uuid.uuid4())
|
||||
expires_ts = int(time.time()) + UPLOAD_TOKEN_TTL_SEC
|
||||
token = mint_upload_token({
|
||||
"tier": payload.tier,
|
||||
"label": payload.label,
|
||||
"filename": payload.filename,
|
||||
"size_bytes": payload.size_bytes,
|
||||
"jti": jti,
|
||||
"expires_at": expires_ts,
|
||||
})
|
||||
return MintTokenResponse(
|
||||
token=token,
|
||||
expires_at=datetime.fromtimestamp(expires_ts, tz=timezone.utc),
|
||||
jti=jti,
|
||||
)
|
||||
```
|
||||
|
||||
### 3.3 결정 근거
|
||||
|
||||
| 항목 | 값 | 근거 |
|
||||
|------|-----|------|
|
||||
| TTL default | 1800s (30분) | 5GB 업로드 시작 + 진행 시간 여유. 1Gbps에서 약 40s, 50Mbps에서 약 14분 |
|
||||
| TTL env override | `UPLOAD_TOKEN_TTL_SEC` | 운영 중 조정 가능 |
|
||||
| filename 검증 | upload와 동일 (`_check_filename`) | 토큰 발급 시점에 미리 거부 → admin UI 즉시 피드백 |
|
||||
| jti 응답 포함 | yes | admin이 업로드 결과 추적용 |
|
||||
| Vercel ↔ backend | HMAC (`X-Timestamp` + `X-Signature`) | 다른 admin 라우트와 동일 패턴 |
|
||||
| admin browser ↔ backend | Bearer token (단발성 jti) | 기존 upload 라우트 그대로 |
|
||||
|
||||
### 3.4 DELETE 라우트 docstring 수정
|
||||
|
||||
`routes.py` 모듈 docstring에서:
|
||||
|
||||
```diff
|
||||
- DELETE /api/packs/{file_id} — Vercel HMAC 인증 → soft delete + DSM 공유 정리
|
||||
+ DELETE /api/packs/{file_id} — Vercel HMAC 인증 → soft delete (DSM 공유는 자동 만료)
|
||||
```
|
||||
|
||||
`delete_file` 함수에는 변경 없음.
|
||||
|
||||
---
|
||||
|
||||
## 4. Supabase `pack_files` DDL
|
||||
|
||||
**파일**: `packs-lab/supabase/pack_files.sql` (신규, 운영 배포 시 Supabase SQL editor에서 실행)
|
||||
|
||||
```sql
|
||||
-- pack_files: NAS에 저장된 다운로드 가능한 패키지 파일 메타
|
||||
create table if not exists public.pack_files (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
min_tier text not null check (min_tier in ('starter','pro','master')),
|
||||
label text not null,
|
||||
file_path text not null unique, -- NAS 절대경로, 동일 경로 중복 방지
|
||||
filename text not null,
|
||||
size_bytes bigint not null check (size_bytes > 0),
|
||||
sort_order integer not null default 0,
|
||||
uploaded_at timestamptz not null default now(),
|
||||
deleted_at timestamptz
|
||||
);
|
||||
|
||||
-- list 라우트의 hot path: deleted_at IS NULL + tier/order 정렬
|
||||
create index if not exists pack_files_active_idx
|
||||
on public.pack_files (min_tier, sort_order)
|
||||
where deleted_at is null;
|
||||
|
||||
-- soft-deleted 통계 / cleanup 잡 대비
|
||||
create index if not exists pack_files_deleted_at_idx
|
||||
on public.pack_files (deleted_at)
|
||||
where deleted_at is not null;
|
||||
```
|
||||
|
||||
### 4.1 필드 결정 근거
|
||||
|
||||
| 필드 | 타입 / 제약 | 근거 |
|
||||
|------|------------|------|
|
||||
| `id` | uuid PK + `gen_random_uuid()` default | routes.py가 client-side `uuid.uuid4()` 생성하지만 default도 둬 fallback |
|
||||
| `min_tier` | text + CHECK | enum 대신 text+CHECK가 PostgreSQL에서 더 유연 |
|
||||
| `file_path` | text NOT NULL UNIQUE | 같은 tier/filename 충돌은 파일시스템에서 잡지만 DB 레벨도 보강 |
|
||||
| `size_bytes` | bigint + CHECK > 0 | 5GB는 int 범위 안이지만 미래 대비 bigint |
|
||||
| `sort_order` | int NOT NULL default 0 | routes INSERT가 sort_order 미지정 → 0 기본 |
|
||||
| `uploaded_at` | timestamptz default now() | routes 코드가 `res.data[0]["uploaded_at"]` 그대로 응답에 사용 — DB가 채워줌 |
|
||||
| `deleted_at` | nullable | soft delete |
|
||||
|
||||
### 4.2 RLS
|
||||
|
||||
비활성. backend가 `service_role` key 사용하므로 RLS 우회. Vercel/사용자 직접 접근 없음 → unsafe 아님.
|
||||
|
||||
---
|
||||
|
||||
## 5. 인프라 통합
|
||||
|
||||
### 5.1 `docker-compose.yml` — `packs-lab` 서비스
|
||||
|
||||
```yaml
|
||||
packs-lab:
|
||||
build:
|
||||
context: ./packs-lab
|
||||
dockerfile: Dockerfile
|
||||
container_name: packs-lab
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18950:8000"
|
||||
environment:
|
||||
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}
|
||||
PACK_HOST_DIR: ${PACK_HOST_DIR:-${PACK_DATA_PATH:-./data/packs}}
|
||||
volumes:
|
||||
- ${PACK_DATA_PATH:-./data/packs}:${PACK_BASE_DIR:-/app/data/packs}
|
||||
```
|
||||
|
||||
| 결정 | 값 | 근거 |
|
||||
|------|-----|------|
|
||||
| 포트 | 18950 | 18800(realestate) → 18900(agent-office) → 18950(packs) 순차 |
|
||||
| `PACK_BASE_DIR` (컨테이너 내부) | `/app/data/packs` | routes.py upload target. docker-compose volume 우측. |
|
||||
| `PACK_HOST_DIR` (NAS 호스트) | 운영 `/volume1/docker/webpage/media/packs` / 로컬 fallback `./data/packs` | DSM·Supabase에 노출되는 절대경로. routes.py가 file_path로 저장. 미설정 시 `PACK_BASE_DIR`로 fallback. |
|
||||
| `PACK_DATA_PATH` (호스트 마운트) | default `./data/packs` (로컬), NAS `/volume1/docker/webpage/media/packs` | docker-compose volume 좌측만 사용 |
|
||||
|
||||
### 5.2 `nginx/default.conf` — `/api/packs/` 라우팅
|
||||
|
||||
```nginx
|
||||
location /api/packs/ {
|
||||
proxy_pass http://packs-lab:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 5GB 멀티파트 업로드 대응
|
||||
client_max_body_size 5G;
|
||||
proxy_request_buffering off; # 스트리밍 통과 (메모리/디스크 buffer 회피)
|
||||
proxy_read_timeout 1800s;
|
||||
proxy_send_timeout 1800s;
|
||||
}
|
||||
```
|
||||
|
||||
| 결정 | 근거 |
|
||||
|------|------|
|
||||
| `client_max_body_size 5G` | 라우트 단위 — 다른 location은 default 유지 |
|
||||
| `proxy_request_buffering off` | 5GB 파일을 nginx가 모두 받고 backend에 forward하면 ~5GB 디스크 buffer 발생 |
|
||||
| `proxy_read/send_timeout 1800s` | 30분 — 업로드 토큰 TTL과 일치, 느린 업링크에서 5GB 전송 여유 |
|
||||
|
||||
### 5.3 `.env.example` — 신규 환경변수 (7 + 3 path)
|
||||
|
||||
```bash
|
||||
# ─── packs-lab — NAS 자료 다운로드 자동화 ────────────────────────────
|
||||
# Synology DSM 7.x 인증 (공유 링크 발급용)
|
||||
DSM_HOST=https://gahusb.synology.me:5001
|
||||
DSM_USER=
|
||||
DSM_PASS=
|
||||
# LAN IP + self-signed cert 환경에서 IP mismatch 시 false (LAN 내부 통신이라 허용)
|
||||
DSM_VERIFY_SSL=false
|
||||
|
||||
# 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
|
||||
|
||||
# 컨테이너 내부 저장 경로 (routes.py upload target. docker-compose volume 우측)
|
||||
PACK_BASE_DIR=/app/data/packs
|
||||
|
||||
# DSM API용 path. Synology DSM API는 일반 사용자 권한일 때 /<shared_folder>/... 형식만 인식하고 /volume1/... 절대경로는 거부(error 408).
|
||||
# 운영 NAS는 반드시 shared folder 시점 — /docker/webpage/media/packs.
|
||||
# admin 사용자는 /volume1/... 도 가능하지만 보안상 별도 packs-bot user 권장.
|
||||
PACK_HOST_DIR=/docker/webpage/media/packs
|
||||
```
|
||||
|
||||
### 5.4 NAS 디렉토리 준비
|
||||
|
||||
운영 첫 배포 시 SSH로 1회. 파일은 `PACK_HOST_DIR` 평면에 직접 저장 — tier 디렉토리 분기는 만들지 않음(tier 구분은 filename 규칙으로 admin이 관리):
|
||||
|
||||
```bash
|
||||
mkdir -p /volume1/docker/webpage/media/packs # 호스트 OS path (volume 마운트용)
|
||||
chown -R PUID:PGID /volume1/docker/webpage/media/packs
|
||||
```
|
||||
|
||||
PUID/PGID는 `.env`의 기존 값 사용.
|
||||
|
||||
> ⚠️ **DSM 사용자 권한 — File Station + Sharing 둘 다 필요**: Control Panel → User → packs-bot(또는 admin) → Permissions → File Station에서 `docker` shared folder Read 권한 + Applications → Sharing 권한 ON.
|
||||
|
||||
### 5.5 `scripts/deploy-nas.sh` SERVICES 화이트리스트
|
||||
|
||||
webhook 자동 배포(deployer)가 호출하는 sync 스크립트는 화이트리스트로 동기화 대상 디렉토리를 명시한다. 신규 서비스 추가 시 반드시 함께 수정해야 NAS 운영 디렉토리에 소스 sync + docker compose 빌드가 동작한다.
|
||||
|
||||
```bash
|
||||
SERVICES="lotto travel-proxy deployer stock-lab music-lab blog-lab realestate-lab agent-office personal packs-lab nginx scripts"
|
||||
```
|
||||
|
||||
(packs-lab 누락 시 `docker compose ps`에 packs-lab 미등장 — 첫 배포 시 가장 흔한 누락 항목)
|
||||
|
||||
---
|
||||
|
||||
## 6. 테스트 전략
|
||||
|
||||
기존 `tests/test_auth.py` 유지. 신규 3 파일.
|
||||
|
||||
### 6.1 `tests/conftest.py` (신규)
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _hmac_secret(monkeypatch):
|
||||
"""모든 테스트에서 동일한 HMAC secret 사용."""
|
||||
monkeypatch.setenv("BACKEND_HMAC_SECRET", "test-secret-do-not-use-in-prod")
|
||||
```
|
||||
|
||||
### 6.2 `tests/test_routes.py` (신규) — 통합 테스트
|
||||
|
||||
DSM·Supabase 모두 mock. `pytest`, `monkeypatch`, `unittest.mock`, `fastapi.testclient.TestClient` 사용.
|
||||
|
||||
| 테스트 | 검증 |
|
||||
|--------|------|
|
||||
| `test_sign_link_hmac_required` | timestamp/signature 헤더 누락 → 401 |
|
||||
| `test_sign_link_outside_base_dir` | file_path가 `PACK_BASE_DIR` 외부 → 400 |
|
||||
| `test_sign_link_calls_dsm` | mock된 `create_share_link` 호출 검증, URL 응답 |
|
||||
| `test_mint_token_hmac_required` | HMAC 누락 → 401 |
|
||||
| `test_mint_token_returns_valid_token` | 발급된 token이 `verify_upload_token`으로 통과 |
|
||||
| `test_mint_token_invalid_filename` | 확장자 미허용 → 400 |
|
||||
| `test_upload_token_required` | Authorization Bearer 누락 → 401 |
|
||||
| `test_upload_size_mismatch` | 토큰 size_bytes ≠ 실제 → 400 |
|
||||
| `test_upload_jti_replay` | 같은 토큰 두 번 → 두 번째 409 |
|
||||
| `test_list_returns_active_only` | mock supabase 응답에서 deleted_at NULL만 반환 |
|
||||
| `test_delete_soft_deletes` | mock supabase update에 deleted_at ISO timestamp 들어감 |
|
||||
|
||||
### 6.3 `tests/test_dsm_client.py` (신규)
|
||||
|
||||
httpx mock(`respx` 또는 `MockTransport`) 또는 `monkeypatch.setattr` 패치.
|
||||
|
||||
| 테스트 | 검증 |
|
||||
|--------|------|
|
||||
| `test_create_share_link_login_logout` | login → Sharing.create → logout 순서 |
|
||||
| `test_create_share_link_returns_url_and_expiry` | 응답 파싱 |
|
||||
| `test_dsm_login_failure_raises` | login API success=false → DSMError |
|
||||
| `test_dsm_share_failure_logs_out` | Sharing.create 실패해도 logout 호출 (try/finally) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 문서 갱신
|
||||
|
||||
### 7.1 `web-backend/CLAUDE.md` — 5곳
|
||||
|
||||
**1. 1.프로젝트 개요**
|
||||
|
||||
```diff
|
||||
- 서비스: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, deployer (9개)
|
||||
+ 서비스: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, packs-lab, deployer (10개)
|
||||
```
|
||||
|
||||
**2. 4.Docker 서비스 표** — 신규 행
|
||||
|
||||
```
|
||||
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 업로드, Vercel SaaS와 HMAC 통신) |
|
||||
```
|
||||
|
||||
**3. 5.Nginx 라우팅 표** — 신규 행
|
||||
|
||||
```
|
||||
| `/api/packs/` | `packs-lab:8000` | 5GB 업로드 (`client_max_body_size 5G` + `proxy_request_buffering off`) |
|
||||
```
|
||||
|
||||
**4. 8.로컬 개발 표** — 신규 행
|
||||
|
||||
```
|
||||
| Packs Lab | http://localhost:18950 |
|
||||
```
|
||||
|
||||
**5. 9.서비스별** — `### packs-lab (packs-lab/)` 신규 섹션
|
||||
|
||||
내용:
|
||||
- 용도 (NAS DSM 공유링크 + 5GB 업로드 + Vercel HMAC, 사용자 인증은 Vercel이 Supabase로 처리)
|
||||
- 환경변수 6+1개
|
||||
- DB는 외부 Supabase `pack_files` (DDL은 `packs-lab/supabase/pack_files.sql`)
|
||||
- 파일 구조: `main.py`, `auth.py`, `dsm_client.py`, `routes.py`, `models.py`
|
||||
- API 표 5개:
|
||||
- `POST /api/packs/sign-link` (Vercel HMAC → DSM Sharing.create)
|
||||
- `POST /api/packs/admin/mint-token` (Vercel HMAC → upload 토큰)
|
||||
- `POST /api/packs/upload` (Bearer token → multipart 5GB)
|
||||
- `GET /api/packs/list` (Vercel HMAC → 활성 파일 목록)
|
||||
- `DELETE /api/packs/{file_id}` (Vercel HMAC → soft delete)
|
||||
|
||||
### 7.2 `workspace/CLAUDE.md`
|
||||
|
||||
컨테이너 표에 한 줄 추가:
|
||||
|
||||
```
|
||||
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (Vercel SaaS와 HMAC 통신) |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 스코프
|
||||
|
||||
### 본 spec 범위
|
||||
|
||||
- ✅ admin mint-token 라우트 신설
|
||||
- ✅ Supabase `pack_files` DDL
|
||||
- ✅ docker-compose / nginx / .env.example / NAS 디렉토리 마운트
|
||||
- ✅ tests (auth 유지 + routes 통합 + dsm_client mock)
|
||||
- ✅ CLAUDE.md 2곳 갱신
|
||||
- ✅ DELETE 라우트 docstring 수정
|
||||
|
||||
### 후속 별도 spec
|
||||
|
||||
- ❌ Vercel SaaS-side admin UI / 사용자 다운로드 UI / Supabase pricing & user 테이블
|
||||
- ❌ DSM 공유 추적 (즉시 차단 필요시)
|
||||
- ❌ deleted_at + N일 후 실제 파일 삭제 cron
|
||||
- ❌ multi-admin 토큰 발급 권한 분리
|
||||
- ❌ resumable multipart 업로드 (5GB tus 등)
|
||||
- ❌ pack_files sort_order 편집 endpoint (admin UI 단계)
|
||||
- ❌ monitoring (업로드 실패율, DSM API latency)
|
||||
@@ -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 영상화)
|
||||
- 멀티 채널 운영
|
||||
- 검토 임계값/가중치 학습 (실제 발행 후 성과 데이터 기반 자동 튜닝)
|
||||
@@ -0,0 +1,706 @@
|
||||
# Essential Mix 파이프라인 — 1시간 mix + essential 시각 스타일 + UX 강화 설계
|
||||
|
||||
> 작성일: 2026-05-09
|
||||
> 관련 spec:
|
||||
> - `2026-05-07-music-youtube-pipeline-design.md` (본 파이프라인의 베이스)
|
||||
> - `2026-05-09-gpu-video-offload-design.md` (Windows GPU 인코딩)
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경
|
||||
|
||||
현재 파이프라인은 **단일 트랙 → 단일 영상**(커버 + 가장자리 파형)만 지원. 사용자는 YouTube essential 채널처럼 **1시간 이상의 음악 mix + 차분한 배경 + 중앙 비주얼라이저** 영상을 원함.
|
||||
|
||||
또한 진행 중 산출물(커버·썸네일·영상)을 NAS 파일시스템에서 직접 확인하는 게 번거로워, 진행 탭에서도 미리보기 가능했으면 함.
|
||||
|
||||
---
|
||||
|
||||
## 2. 비목표
|
||||
|
||||
- 사용자 직접 업로드 사진/영상 (P3로 미룸)
|
||||
- 360° 정확한 방사형 비주얼라이저 (ffmpeg 단독으로 한계 — `showfreqs` + ring overlay로 근사)
|
||||
- Mix 자동 큐레이션(곡 자동 선택) — 기존 컴파일 탭의 수동 선택 그대로 활용
|
||||
- AI 검토 가중치 자동 튜닝 (Mix와 단일 트랙의 다른 기준 등 — P3)
|
||||
- 텔레그램 사진 첨부 — 본 작업의 PipelineDetailModal로 우선 해결, 차후 P3
|
||||
|
||||
---
|
||||
|
||||
## 3. 사용자 흐름
|
||||
|
||||
### 3-1. Mix 영상 만들기
|
||||
|
||||
```
|
||||
[사용자] Compile 탭에서 트랙 N개 선택 → crossfade 설정 → 컴파일 시작
|
||||
→ 컴파일 완료 (1시간+ mp3 생성, 기존 흐름)
|
||||
→ 컴파일 카드에 [🎬 영상 만들기] 버튼 클릭
|
||||
→ 백엔드: POST /api/music/pipeline { compile_job_id, visual_style: 'essential' }
|
||||
→ 진행 탭으로 자동 이동, 새 카드 생성
|
||||
→ 단계별 텔레그램 승인 (기존과 동일):
|
||||
cover (또는 background_video) → video → thumbnail → metadata → AI 검토 → 발행
|
||||
→ YouTube 비공개 영상 1편
|
||||
```
|
||||
|
||||
### 3-2. 단일 트랙 영상 만들기 (기존)
|
||||
|
||||
진행 탭 모달에 라디오 "단일 트랙 / Mix" 추가. 단일 선택 시 기존 흐름 그대로.
|
||||
|
||||
### 3-3. 산출물 미리보기
|
||||
|
||||
진행 탭 카드의 cover/thumbnail 미니 썸네일 → 카드 클릭 → 상세 모달 → 큰 이미지 + 영상 플레이어 + 메타·검토 JSON.
|
||||
|
||||
---
|
||||
|
||||
## 4. 데이터 모델 변경
|
||||
|
||||
### 4-1. `video_pipelines` 테이블 확장
|
||||
|
||||
신규 컬럼:
|
||||
```sql
|
||||
ALTER TABLE video_pipelines ADD COLUMN compile_job_id INTEGER NULL REFERENCES compile_jobs(id);
|
||||
ALTER TABLE video_pipelines ADD COLUMN visual_style TEXT NOT NULL DEFAULT 'essential';
|
||||
ALTER TABLE video_pipelines ADD COLUMN background_mode TEXT NOT NULL DEFAULT 'static';
|
||||
ALTER TABLE video_pipelines ADD COLUMN background_keyword TEXT;
|
||||
```
|
||||
|
||||
| 컬럼 | 의미 |
|
||||
|------|------|
|
||||
| `track_id` (기존) | 단일 트랙 입력 시 |
|
||||
| `compile_job_id` (신규) | Mix 입력 시 — `track_id` XOR `compile_job_id` |
|
||||
| `visual_style` | `single` / `essential` |
|
||||
| `background_mode` | `static` (사진) / `video_loop` (영상) |
|
||||
| `background_keyword` | Pexels 검색용 (예: "rainy window cafe"). 비어있으면 장르 기반 자동 |
|
||||
|
||||
마이그레이션: `ADD COLUMN`은 SQLite에서 안전. 기존 행은 NULL 또는 default 값 부여.
|
||||
|
||||
### 4-2. `youtube_setup.visual_defaults` JSON 확장
|
||||
|
||||
기존:
|
||||
```json
|
||||
{"resolution": "1920x1080", "style": "visualizer", "background": "ai_cover"}
|
||||
```
|
||||
|
||||
신규:
|
||||
```json
|
||||
{
|
||||
"resolution": "1920x1080",
|
||||
"default_visual_style": "essential",
|
||||
"default_background_mode": "static",
|
||||
"default_background_keyword": "",
|
||||
"background_image_source": "ai", // ai | pexels (Mix는 default ai)
|
||||
"subtitle_track_titles": true // Mix에서 곡명 자막 표시
|
||||
}
|
||||
```
|
||||
|
||||
기존 클라이언트 호환을 위해 미설정 키는 default로 fallback.
|
||||
|
||||
---
|
||||
|
||||
## 5. API 변경
|
||||
|
||||
### 5-1. `POST /api/music/pipeline` 요청 body 확장
|
||||
|
||||
```json
|
||||
{
|
||||
"track_id": 13,
|
||||
// 또는
|
||||
"compile_job_id": 5,
|
||||
// 옵션 (default는 setup에서)
|
||||
"visual_style": "essential", // single | essential
|
||||
"background_mode": "static", // static | video_loop
|
||||
"background_keyword": "rainy cafe"
|
||||
}
|
||||
```
|
||||
|
||||
검증:
|
||||
- `track_id` XOR `compile_job_id` 정확히 하나만 — 둘 다거나 둘 다 없으면 400
|
||||
- `compile_job_id`인 경우 `compile_jobs` 테이블에서 status='succeeded' 확인 — 아니면 400
|
||||
- `visual_style` 미지정 시 `youtube_setup.visual_defaults.default_visual_style`
|
||||
- `background_mode` 미지정 시 `youtube_setup.visual_defaults.default_background_mode`
|
||||
|
||||
응답:
|
||||
```json
|
||||
{
|
||||
"id": 7,
|
||||
"track_id": null,
|
||||
"compile_job_id": 5,
|
||||
"visual_style": "essential",
|
||||
"background_mode": "static",
|
||||
"state": "created",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### 5-2. `GET /api/music/pipeline/{id}` 응답 확장
|
||||
|
||||
신규 필드: `compile_job_id`, `visual_style`, `background_mode`, `background_keyword`, `tracks` (Mix면 트랙 리스트, 단일이면 단일 트랙 1개)
|
||||
|
||||
`tracks` 형식:
|
||||
```json
|
||||
[
|
||||
{"id": 13, "title": "Lo-Fi Drive", "start_offset_sec": 0, "duration_sec": 176},
|
||||
{"id": 14, "title": "Midnight Cafe", "start_offset_sec": 173, "duration_sec": 200},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
`start_offset_sec`은 컴파일 시 acrossfade 적용을 고려한 누적 시작 시각 (=영상 자막 트리거 타이밍).
|
||||
|
||||
### 5-3. 변경 없음
|
||||
|
||||
`/feedback`, `/cancel`, `/publish`, `/setup`, `/youtube/*` 모두 그대로.
|
||||
|
||||
---
|
||||
|
||||
## 6. 백엔드 — NAS music-lab
|
||||
|
||||
### 6-1. `pipeline/orchestrator.py` 변경
|
||||
|
||||
`run_step`에 입력 audio 결정 로직 추가:
|
||||
|
||||
```python
|
||||
def _resolve_input(p: dict) -> dict:
|
||||
"""파이프라인 입력 = 단일 트랙 또는 컴파일 결과.
|
||||
|
||||
반환: {"audio_path": str, "duration_sec": int, "tracks": list[dict],
|
||||
"title": str, "genre": str, "moods": list, ...}
|
||||
"""
|
||||
if p.get("compile_job_id"):
|
||||
job = db.get_compile_job(p["compile_job_id"])
|
||||
if not job or job["status"] != "succeeded":
|
||||
raise ValueError(f"compile job {p['compile_job_id']} not ready")
|
||||
# 누적 offset 계산 (acrossfade 고려)
|
||||
tracks = []
|
||||
offset = 0.0
|
||||
crossfade = job["crossfade_sec"]
|
||||
for tid in job["track_ids"]:
|
||||
t = db.get_track_by_id(tid)
|
||||
tracks.append({
|
||||
"id": tid, "title": t["title"],
|
||||
"start_offset_sec": offset,
|
||||
"duration_sec": t["duration_sec"],
|
||||
})
|
||||
offset += t["duration_sec"] - crossfade # acrossfade overlap만큼 차감
|
||||
return {
|
||||
"audio_path": job["audio_path"], # /app/data/compiles/{id}.mp3
|
||||
"duration_sec": int(offset + crossfade), # 마지막 트랙은 풀 길이
|
||||
"tracks": tracks,
|
||||
"title": job["title"] or "Mix",
|
||||
"genre": "mix",
|
||||
"moods": [],
|
||||
}
|
||||
else:
|
||||
t = db.get_track_by_id(p["track_id"])
|
||||
return {
|
||||
"audio_path": t["file_path"],
|
||||
"duration_sec": t["duration_sec"],
|
||||
"tracks": [{"id": t["id"], "title": t["title"],
|
||||
"start_offset_sec": 0, "duration_sec": t["duration_sec"]}],
|
||||
"title": t["title"], "genre": t["genre"], "moods": t.get("moods", []),
|
||||
}
|
||||
```
|
||||
|
||||
각 step runner는 `_resolve_input(p)` 결과를 사용:
|
||||
- `_run_cover`: `genre`, `moods`, `title` 활용 (Mix면 `genre="mix"` → "mix" 키 prompt 또는 default)
|
||||
- `_run_video`: `audio_path`, `duration_sec`, `tracks` 모두 Windows로 전달
|
||||
- `_run_meta`: `tracks` 리스트를 메타 prompt에 포함
|
||||
- `_run_review`: `tracks` 리스트를 검토 prompt에 포함 (트랙 수, 다양한 장르 등)
|
||||
|
||||
### 6-2. `pipeline/cover.py` Pexels 폴백/대안
|
||||
|
||||
```python
|
||||
async def generate(*, pipeline_id: int, genre: str, prompt_template: str,
|
||||
mood: str = "", track_title: str = "", feedback: str = "",
|
||||
image_source: str = "ai") -> dict:
|
||||
"""image_source: 'ai' (DALL·E) | 'pexels' (스톡 검색)."""
|
||||
if image_source == "pexels":
|
||||
return await _generate_with_pexels(pipeline_id, genre, mood, track_title)
|
||||
# 기존 AI 흐름 그대로
|
||||
...
|
||||
# AI 실패 시 — 그라데이션 폴백 대신 Pexels 시도 (config 옵션)
|
||||
...
|
||||
```
|
||||
|
||||
신규 `_generate_with_pexels`:
|
||||
- Pexels API: `GET https://api.pexels.com/v1/search?query={keyword}&per_page=10`
|
||||
- 결과 1번째 큰 사진 다운로드 → `/app/data/videos/{id}/cover.jpg`
|
||||
- API key 미설정/실패 시 그라데이션 폴백
|
||||
|
||||
### 6-3. 신규 `pipeline/background.py` (video_loop 모드)
|
||||
|
||||
```python
|
||||
async def fetch_video_loop(pipeline_id: int, keyword: str) -> dict:
|
||||
"""Pexels Video API로 5–15초 루프 영상 받아옴.
|
||||
|
||||
/app/data/videos/{id}/loop.mp4 저장.
|
||||
"""
|
||||
# GET https://api.pexels.com/videos/search?query=...&per_page=5
|
||||
# SD/HD 720p 중에서 골라 다운로드
|
||||
...
|
||||
return {"path": "/app/data/videos/{id}/loop.mp4", "duration_sec": ...}
|
||||
```
|
||||
|
||||
오케스트레이터에서 `background_mode == "video_loop"` 분기 시 cover step 대신 또는 보조로 호출 (디자인 결정: cover step을 두 모드의 공통 입력 준비 단계로 통합 — 정적이면 cover.jpg, 영상이면 loop.mp4).
|
||||
|
||||
### 6-4. `pipeline/metadata.py` Mix 지원
|
||||
|
||||
`generate(*, track, template, trend_keywords, feedback="", tracks=None)` 시그니처 확장. `tracks` 있으면 Claude prompt에 다음 추가:
|
||||
|
||||
```
|
||||
이 영상은 {len(tracks)}개 트랙의 mix입니다. 트랙 리스트:
|
||||
1. [00:00] Lo-Fi Drive — lo-fi
|
||||
2. [03:00] Midnight Cafe — lo-fi
|
||||
...
|
||||
설명에는 트랙 리스트를 타임스탬프와 함께 포함하세요.
|
||||
```
|
||||
|
||||
응답 description은 자동으로 트랙리스트 포함됨. 이는 YouTube에서 챕터로 자동 인식.
|
||||
|
||||
### 6-5. `pipeline/video.py` (NAS측, 변경 작음)
|
||||
|
||||
기존 함수에 추가 파라미터 전달:
|
||||
|
||||
```python
|
||||
def generate(*, pipeline_id, audio_path, cover_path, genre, duration_sec,
|
||||
resolution="1920x1080", style="essential",
|
||||
background_mode="static", background_path=None,
|
||||
tracks=None) -> dict:
|
||||
payload = {
|
||||
"audio_path_nas": ..., "cover_path_nas": ...,
|
||||
"output_path_nas": ...,
|
||||
"resolution": resolution,
|
||||
"duration_sec": duration_sec,
|
||||
"style": style, # NEW: single | essential
|
||||
"background_mode": background_mode, # NEW: static | video_loop
|
||||
"background_path_nas": ..., # NEW: video_loop일 때 loop.mp4 경로
|
||||
"tracks": tracks, # NEW: Mix면 트랙 리스트 (자막용)
|
||||
}
|
||||
...
|
||||
```
|
||||
|
||||
### 6-6. `db.py` 변경
|
||||
|
||||
신규 컬럼 추가 마이그레이션 + `get_compile_job(id)` (없으면 추가) + `get_track_by_id(id)` 활용.
|
||||
|
||||
---
|
||||
|
||||
## 7. 백엔드 — Windows music_ai
|
||||
|
||||
### 7-1. `/encode_video` 요청 확장
|
||||
|
||||
```json
|
||||
{
|
||||
"audio_path_nas": "...",
|
||||
"cover_path_nas": "...",
|
||||
"output_path_nas": "...",
|
||||
"resolution": "1920x1080",
|
||||
"duration_sec": 3600,
|
||||
"style": "essential", // NEW
|
||||
"background_mode": "static", // NEW
|
||||
"background_path_nas": "...", // NEW: video_loop면 loop.mp4
|
||||
"tracks": [ // NEW: 자막용
|
||||
{"start_offset_sec": 0, "title": "Lo-Fi Drive"},
|
||||
{"start_offset_sec": 173, "title": "Midnight Cafe"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 7-2. `video_encoder.py` 분기 로직
|
||||
|
||||
```python
|
||||
def encode_video(*, ..., style="essential", background_mode="static",
|
||||
background_path_nas=None, tracks=None):
|
||||
if style == "single":
|
||||
cmd = build_single_track_cmd(...)
|
||||
else: # essential
|
||||
if background_mode == "static":
|
||||
cmd = build_essential_static_cmd(cover, audio, out, w, h, tracks)
|
||||
else:
|
||||
bg = translate_path(background_path_nas)
|
||||
cmd = build_essential_video_loop_cmd(bg, audio, out, w, h, tracks)
|
||||
...
|
||||
```
|
||||
|
||||
### 7-3. Essential 정적 ffmpeg 명령
|
||||
|
||||
핵심 filter_complex 구조:
|
||||
|
||||
```
|
||||
[0:v]scale=1920:1080,format=yuv420p[bg]; # 정적 배경 사진
|
||||
[1:a]showfreqs=s=400x200:mode=bar:cmode=combined:colors=0xFFFFFF@0.9[bars]; # 중앙 막대
|
||||
[2:v]format=rgba[ring]; # 데코 ring PNG (사전 제작 1장)
|
||||
[bg][bars]overlay=(W-w)/2:(H-h)/2[mid]; # 막대 정중앙 배치
|
||||
[mid][ring]overlay=(W-w)/2:(H-h)/2[viz]; # ring 데코 같은 위치
|
||||
[viz]drawtext=...:enable='between(t,0,5)+between(t,173,178)+...'[final]
|
||||
```
|
||||
|
||||
- `showfreqs s=400x200 mode=bar` — 가로 막대 (방사형 근사 1차 버전)
|
||||
- `ring.png` — 사전 제작된 투명 PNG (`music_ai/assets/visualizer_ring.png`, 단순 흰색 원 + 외곽 점선)
|
||||
- `drawtext` — 트랙 리스트 순회하며 enable expression 동적 생성
|
||||
|
||||
향후(V2): `showcqt`나 `showspectrum` 시도 + 진짜 360° 방사형은 외부 도구(예: SuperCollider, butterchurn) 검토.
|
||||
|
||||
### 7-4. Essential 영상 루프 ffmpeg 명령
|
||||
|
||||
```
|
||||
[0:v]scale=1920:1080,setpts=PTS-STARTPTS[bg_loop];
|
||||
loop=loop=-1:size=N # 루프 영상 무한 반복
|
||||
[1:a]showfreqs=...[bars];
|
||||
[bg_loop][bars]overlay=center[mid];
|
||||
[mid][ring]overlay=center[viz];
|
||||
... drawtext 동일
|
||||
```
|
||||
|
||||
루프는 `-stream_loop -1 -i loop.mp4` 입력 옵션 + `-shortest` 출력으로 audio 길이만큼 반복.
|
||||
|
||||
### 7-5. 자막(곡명) drawtext
|
||||
|
||||
```python
|
||||
def build_drawtext_filter(tracks, total_duration):
|
||||
expressions = []
|
||||
for tr in tracks:
|
||||
start = tr["start_offset_sec"]
|
||||
end = start + 5 # 5초 표시
|
||||
# alpha fade in/out
|
||||
text = tr["title"].replace(":", r"\:").replace("'", r"\'")
|
||||
expressions.append(
|
||||
f"drawtext=fontfile='Arial Bold':text='{text}'"
|
||||
f":fontcolor=white:fontsize=36:x=(w-text_w)/2:y=h-100"
|
||||
f":alpha='if(between(t,{start},{end}),"
|
||||
f" if(lt(t-{start},1), t-{start}," # 0~1s fade in
|
||||
f" if(gt(t-{start},4), {end}-t, 1)), 0)'" # 4~5s fade out
|
||||
)
|
||||
return ",".join(expressions) # 체인으로 연결
|
||||
```
|
||||
|
||||
폰트는 Windows에 기본 설치된 Arial 또는 NanumGothic 사용. 한글 트랙명 지원 위해 NanumGothic 권장.
|
||||
|
||||
### 7-6. 신규 자산 파일
|
||||
|
||||
`music_ai/assets/visualizer_ring.png` — 1920×1080 캔버스 정중앙 400×400 영역에 그려진 흰색 원형 (외곽선 + 옅은 inner glow). 사전 제작 1장 — Pillow로 자동 생성도 가능 (서버 시작 시 없으면 생성).
|
||||
|
||||
---
|
||||
|
||||
## 8. 프론트엔드 변경
|
||||
|
||||
### 8-1. `CompileTab.jsx` — 영상 만들기 버튼
|
||||
|
||||
완료된 compile job 카드에 버튼 추가:
|
||||
|
||||
```jsx
|
||||
{job.status === 'succeeded' && (
|
||||
<button onClick={() => handleVideoFromCompile(job.id)}>
|
||||
🎬 영상 만들기
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
|
||||
`handleVideoFromCompile`:
|
||||
```js
|
||||
async (compileJobId) => {
|
||||
const p = await createPipeline({ compile_job_id: compileJobId });
|
||||
await startPipeline(p.id);
|
||||
// 진행 탭으로 이동 (router push 또는 setTab + setOpenPipelineFor 패턴)
|
||||
};
|
||||
```
|
||||
|
||||
### 8-2. `PipelineStartModal.jsx` 확장
|
||||
|
||||
```jsx
|
||||
const [inputType, setInputType] = useState('track'); // 'track' | 'compile'
|
||||
const [compileJobs, setCompileJobs] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputType === 'compile') getCompileJobs().then(setCompileJobs);
|
||||
}, [inputType]);
|
||||
|
||||
return (
|
||||
<div className="modal-body">
|
||||
<h3>새 파이프라인 시작</h3>
|
||||
|
||||
<fieldset>
|
||||
<legend>입력</legend>
|
||||
<label><input type="radio" checked={inputType==='track'}
|
||||
onChange={() => setInputType('track')}/> 단일 트랙</label>
|
||||
<label><input type="radio" checked={inputType==='compile'}
|
||||
onChange={() => setInputType('compile')}/> Mix (컴파일 결과)</label>
|
||||
</fieldset>
|
||||
|
||||
{inputType === 'track' && (
|
||||
<select>{library.map(...)}</select>
|
||||
)}
|
||||
{inputType === 'compile' && (
|
||||
<select>{compileJobs.filter(j=>j.status==='succeeded').map(j =>
|
||||
<option key={j.id} value={j.id}>{j.title} ({j.tracks_count}곡, {fmtDuration(j.duration_sec)})</option>
|
||||
)}</select>
|
||||
)}
|
||||
|
||||
{/* 시각 모드 override */}
|
||||
<details>
|
||||
<summary>고급 옵션</summary>
|
||||
<select>visual_style: single | essential</select>
|
||||
<select>background_mode: static | video_loop</select>
|
||||
<input>background_keyword</input>
|
||||
</details>
|
||||
|
||||
{/* ... 기존 시작/취소 버튼 */}
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
### 8-3. `PipelineCard.jsx` — 미리보기 inline
|
||||
|
||||
```jsx
|
||||
return (
|
||||
<div className="pipeline-card" onClick={() => setShowDetail(true)}>
|
||||
<div className="pipeline-card__head">
|
||||
<h4>{pipeline.track_title || pipeline.compile_title || `Pipeline #${pipeline.id}`}</h4>
|
||||
<span className="pipeline-style-badge">{pipeline.visual_style}</span>
|
||||
...
|
||||
</div>
|
||||
|
||||
{/* 미니 미리보기 */}
|
||||
<div className="pipeline-previews">
|
||||
{pipeline.cover_url && <img src={pipeline.cover_url} alt="" className="pipeline-preview-mini" />}
|
||||
{pipeline.thumbnail_url && <img src={pipeline.thumbnail_url} alt="" className="pipeline-preview-mini" />}
|
||||
{pipeline.video_url && <span className="pipeline-video-icon">▶</span>}
|
||||
</div>
|
||||
|
||||
{/* 진행도 바 + 현재 상태 (기존) */}
|
||||
...
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
### 8-4. `PipelineDetailModal.jsx` (신규)
|
||||
|
||||
```jsx
|
||||
export default function PipelineDetailModal({ pipeline, onClose }) {
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-body modal-body--lg" onClick={e=>e.stopPropagation()}>
|
||||
<header>
|
||||
<h3>{pipeline.compile_title || pipeline.track_title}</h3>
|
||||
<span className="badge">{pipeline.visual_style}</span>
|
||||
<button onClick={onClose}>×</button>
|
||||
</header>
|
||||
|
||||
{/* 큰 미리보기 그리드 */}
|
||||
<div className="pdm-grid">
|
||||
{pipeline.cover_url && (
|
||||
<figure>
|
||||
<img src={pipeline.cover_url} alt="cover" />
|
||||
<figcaption>커버 (배경)</figcaption>
|
||||
</figure>
|
||||
)}
|
||||
{pipeline.thumbnail_url && (
|
||||
<figure>
|
||||
<img src={pipeline.thumbnail_url} alt="thumbnail" />
|
||||
<figcaption>썸네일</figcaption>
|
||||
</figure>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 영상 플레이어 */}
|
||||
{pipeline.video_url && (
|
||||
<div className="pdm-video">
|
||||
<video src={pipeline.video_url} controls width="100%" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 메타데이터 */}
|
||||
{pipeline.metadata && (
|
||||
<section className="pdm-meta">
|
||||
<h4>메타데이터</h4>
|
||||
<p><strong>제목:</strong> {pipeline.metadata.title}</p>
|
||||
<details>
|
||||
<summary>설명</summary>
|
||||
<pre>{pipeline.metadata.description}</pre>
|
||||
</details>
|
||||
<p><strong>태그:</strong> {pipeline.metadata.tags?.join(', ')}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* AI 검토 */}
|
||||
{pipeline.review && (
|
||||
<section className="pdm-review">
|
||||
<h4>AI 검토 — <span className="badge">{pipeline.review.verdict}</span> ({pipeline.review.weighted_total}/100)</h4>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><td>메타데이터 품질</td><td>{pipeline.review.metadata_quality.score}</td></tr>
|
||||
<tr><td>콘텐츠 정책</td><td>{pipeline.review.policy_compliance.score}</td></tr>
|
||||
<tr><td>시청 경험</td><td>{pipeline.review.viewer_experience.score}</td></tr>
|
||||
<tr><td>트렌드 정렬</td><td>{pipeline.review.trend_alignment.score}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p><em>{pipeline.review.summary}</em></p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 트랙 리스트 (Mix일 때) */}
|
||||
{pipeline.tracks && pipeline.tracks.length > 1 && (
|
||||
<section className="pdm-tracks">
|
||||
<h4>트랙 리스트 ({pipeline.tracks.length})</h4>
|
||||
<ol>
|
||||
{pipeline.tracks.map(t => (
|
||||
<li key={t.id}>
|
||||
[{fmtTimestamp(t.start_offset_sec)}] {t.title} ({fmtDuration(t.duration_sec)})
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 피드백 히스토리 */}
|
||||
{pipeline.feedback && pipeline.feedback.length > 0 && (
|
||||
<section className="pdm-feedback">
|
||||
<h4>피드백 ({pipeline.feedback.length})</h4>
|
||||
<ul>
|
||||
{pipeline.feedback.map(f => (
|
||||
<li key={f.id}>
|
||||
<code>[{f.step}]</code> {f.feedback_text}
|
||||
<small>{f.received_at}</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* YouTube 링크 */}
|
||||
{pipeline.youtube_video_id && (
|
||||
<a href={`https://youtu.be/${pipeline.youtube_video_id}`}
|
||||
target="_blank" rel="noreferrer" className="pdm-youtube">
|
||||
🎬 YouTube에서 보기
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 8-5. `SetupTab.jsx` 확장
|
||||
|
||||
영상 비주얼 기본값 카드 확장:
|
||||
- **default_visual_style** 드롭다운: `single` / `essential`
|
||||
- **default_background_mode** 드롭다운: `static` / `video_loop`
|
||||
- **default_background_keyword** 텍스트 입력 (예: "lofi cafe")
|
||||
- **background_image_source** 드롭다운: `ai` / `pexels`
|
||||
- **subtitle_track_titles** 체크박스: Mix에서 곡명 자막 표시
|
||||
|
||||
---
|
||||
|
||||
## 9. 환경변수 (NAS측)
|
||||
|
||||
신규 — 이미 `.env`에 있을 가능성 높음:
|
||||
```env
|
||||
PEXELS_API_KEY=xxx # 이미 있음 (현재 미사용)
|
||||
```
|
||||
|
||||
신규 (Windows측 — music_ai/.env):
|
||||
```env
|
||||
# 한글 자막용 폰트 경로 (선택)
|
||||
SUBTITLE_FONT=C:\Windows\Fonts\malgun.ttf
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 에러 처리
|
||||
|
||||
| 시나리오 | 결과 |
|
||||
|---------|------|
|
||||
| compile_job 미완료 (status != succeeded) | POST /pipeline 시 400 |
|
||||
| compile_job 삭제됨 | get_pipeline에서 `compile_title=null`, 진행 탭에 "삭제됨" 배지 |
|
||||
| Pexels API 실패 (image) | AI 폴백 |
|
||||
| Pexels API 실패 (video) | 단색 폴백 + 텔레그램에 "Pexels 실패" 명시 |
|
||||
| drawtext 자막 한글 폰트 누락 | 자막 없이 인코딩 + 경고 로그 |
|
||||
| 1시간 NVENC timeout | 영상 단계 timeout 600s → 그래도 부족하면 failed (보통 NVENC면 5분 내) |
|
||||
|
||||
---
|
||||
|
||||
## 11. 테스트 전략
|
||||
|
||||
### 11-1. 단위 테스트 (NAS music-lab)
|
||||
|
||||
| 대상 | 테스트 |
|
||||
|------|--------|
|
||||
| `orchestrator._resolve_input` | track_id 분기 / compile_job_id 분기 / 둘 다 / 둘 다 없음 / compile not ready |
|
||||
| `cover.generate` `image_source='pexels'` | Pexels API mock + 다운로드 + 파일 저장 |
|
||||
| `background.fetch_video_loop` | Pexels Video API mock + mp4 다운로드 |
|
||||
| `metadata.generate` `tracks=[...]` | 트랙 리스트가 prompt에 포함되는지, 응답 description에 chapter 포맷 |
|
||||
| API `POST /pipeline { compile_job_id }` | 정상 / not ready 400 / 둘 다 400 / 단일은 기존 작동 |
|
||||
| DB 마이그레이션 | 새 컬럼 default 값 |
|
||||
|
||||
### 11-2. 단위 테스트 (Windows music_ai)
|
||||
|
||||
| 대상 | 테스트 |
|
||||
|------|--------|
|
||||
| `build_essential_static_cmd` | filter_complex 문자열 검증 (showfreqs, overlay 위치 등) |
|
||||
| `build_drawtext_filter` | 트랙 N개 → enable expression N개 생성, alpha fade 검증 |
|
||||
| `encode_video` `style='essential'` | 새 분기 호출됨 |
|
||||
| `encode_video` `style='single'` | 기존 단일 트랙 명령 그대로 |
|
||||
| 자산 ring.png 자동 생성 | 서버 시작 시 없으면 PIL로 생성 |
|
||||
|
||||
### 11-3. 통합 테스트
|
||||
|
||||
`test_essential_pipeline_flow.py`:
|
||||
- compile job 생성 → 파이프라인 시작 (compile_job_id) → 모든 단계 mock → published → tracks 리스트가 metadata description에 포함됐는지
|
||||
|
||||
### 11-4. 수동 E2E
|
||||
|
||||
- [ ] 컴파일 탭에서 3-5분 mix 컴파일
|
||||
- [ ] "🎬 영상 만들기" 클릭 → 진행 탭 카드 생성, visual_style=essential
|
||||
- [ ] cover 단계 → 텔레그램 알림 + 카드에 cover 미니 썸네일 표시
|
||||
- [ ] 카드 클릭 → 상세 모달 → cover 큰 이미지, 메타·검토 영역 표시 (해당 단계 진행 시)
|
||||
- [ ] 모든 단계 승인 → 발행 → YouTube 비공개 영상에 essential 시각 + 챕터 자동 인식 확인
|
||||
- [ ] 1시간 mix로 동일 흐름 — Windows NVENC 인코딩 시간 5분 미만 확인
|
||||
- [ ] background_mode=video_loop로 시도 — Pexels 영상 다운로드 + 루프 인코딩
|
||||
|
||||
---
|
||||
|
||||
## 12. 마이그레이션 + 배포
|
||||
|
||||
### 12-1. DB 마이그레이션
|
||||
|
||||
`init_db()` 신규 컬럼 `ALTER TABLE` (SQLite는 idempotent: 컬럼 존재 확인 후 추가):
|
||||
```python
|
||||
def _add_column_if_missing(cursor, table, column, ddl):
|
||||
cursor.execute(f"PRAGMA table_info({table})")
|
||||
cols = [r[1] for r in cursor.fetchall()]
|
||||
if column not in cols:
|
||||
cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}")
|
||||
```
|
||||
|
||||
### 12-2. 자산 파일
|
||||
|
||||
`music_ai/assets/visualizer_ring.png`은 git에 커밋 (small, ~30KB). Windows 측이므로 사용자가 수동 배포 (이미 music_ai는 로컬 전용).
|
||||
|
||||
또는 **서버 시작 시 자동 생성** (PIL로 단순 ring 그리기) — 권장. assets 디렉토리도 자동 생성.
|
||||
|
||||
### 12-3. 환경변수
|
||||
|
||||
NAS `.env` 변경 없음 (PEXELS_API_KEY 이미 있음).
|
||||
Windows `.env`에 `SUBTITLE_FONT` 추가 (선택).
|
||||
|
||||
---
|
||||
|
||||
## 13. 산출물
|
||||
|
||||
| 영역 | 파일 |
|
||||
|------|------|
|
||||
| Spec/Plan | 본 문서 + plan |
|
||||
| NAS music-lab | `db.py` (마이그레이션), `pipeline/orchestrator.py` (resolve_input), `pipeline/cover.py` (Pexels 분기), `pipeline/background.py` (신규), `pipeline/metadata.py` (tracks 옵션), `pipeline/video.py` (style/background 파라미터), `app/main.py` (POST /pipeline body 확장) |
|
||||
| Windows music_ai | `video_encoder.py` (style 분기, drawtext, ring), `server.py` (요청 schema 확장), `assets/visualizer_ring.png` (자동 생성), Pillow 이미 있음 |
|
||||
| Frontend | `CompileTab.jsx` (영상 만들기 버튼), `PipelineStartModal.jsx` (라디오), `PipelineCard.jsx` (미리보기 inline), `PipelineDetailModal.jsx` (신규), `SetupTab.jsx` (visual_defaults 확장), `api.js` 헬퍼 추가, `MusicStudio.css` 스타일 |
|
||||
| 테스트 | NAS 단위 6+ / Windows 단위 5+ / 통합 1 / 수동 E2E |
|
||||
|
||||
---
|
||||
|
||||
## 14. 후속 (P3)
|
||||
|
||||
- 사용자 직접 사진/영상 업로드
|
||||
- 텔레그램에 cover/thumbnail 사진 첨부
|
||||
- 360° 진짜 방사형 visualizer (외부 도구 또는 GPU shader)
|
||||
- AI 검토 가중치 mix vs 단일 자동 분리
|
||||
- Pexels 검색 미리보기 UI (구성 탭에서 "이 키워드로 검색해보기" 버튼)
|
||||
|
||||
---
|
||||
486
docs/superpowers/specs/2026-05-09-gpu-video-offload-design.md
Normal file
486
docs/superpowers/specs/2026-05-09-gpu-video-offload-design.md
Normal file
@@ -0,0 +1,486 @@
|
||||
# GPU 영상 인코딩 오프로드 — 설계
|
||||
|
||||
> 작성일: 2026-05-09
|
||||
> 관련: `2026-05-07-music-youtube-pipeline-design.md` (Task 4 대체)
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경
|
||||
|
||||
NAS Synology Celeron J4025(2 cores @ 2.0GHz, GPU 없음)에서 1920×1080 visualizer 영상 인코딩이 너무 느림. 176초 트랙 인코딩에 5분 초과 → ffmpeg `subprocess.TimeoutExpired`. `-preset ultrafast`로 가속해도 한계 있고 화질 저하.
|
||||
|
||||
대안: 사용자 Windows PC(RTX 5070 Ti, 16GB VRAM)에서 NVIDIA NVENC 하드웨어 인코딩으로 처리. 같은 영상이 **10–20초**에 완료(20×+ 빠름).
|
||||
|
||||
이미 `music_ai` 서버(Windows, port 8765)가 MusicGen용으로 동작 중이므로 **같은 서버에 영상 인코딩 endpoint를 추가**하는 것이 가장 자연스럽다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 비목표
|
||||
|
||||
- 다중 GPU/멀티 머신 — 단일 Windows PC만 지원
|
||||
- NAS 로컬 ffmpeg 폴백 — 사용자 결정으로 제외 (Windows 서버 다운 시 명확한 실패 선호)
|
||||
- 영상 길이 제한 — 일반 트랙 길이(1–10분) 가정
|
||||
- 인증 — LAN 전용, 무인증
|
||||
|
||||
---
|
||||
|
||||
## 3. 아키텍처
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ NAS (Synology) │
|
||||
│ │
|
||||
│ music-lab container │
|
||||
│ pipeline/video.py │
|
||||
│ ↓ HTTP POST {paths, resolution} │
|
||||
│ ↓ 192.168.45.59:8765/encode_video │
|
||||
│ │
|
||||
│ /volume1/docker/webpage/data/ │
|
||||
│ videos/{id}/cover.jpg ← input │
|
||||
│ videos/{id}/video.mp4 ← output (Windows가 직접 씀) │
|
||||
│ {audio}.mp3 ← input │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
↓ HTTP ↑ SMB read/write
|
||||
↓ ↑ (Z:\ 마운트)
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ Windows PC (192.168.45.59) │
|
||||
│ │
|
||||
│ music_ai server.py (port 8765) │
|
||||
│ • POST /generate (기존, MusicGen) │
|
||||
│ • POST /encode_video (신규) │
|
||||
│ ↓ 경로 변환: /volume1/... → Z:\... │
|
||||
│ ↓ ffmpeg.exe -hwaccel cuda -c:v h264_nvenc ... │
|
||||
│ ↓ 입력/출력 모두 Z:\ 직접 (SMB) │
|
||||
│ ↓ 응답: {ok, duration_ms, output_path} │
|
||||
│ │
|
||||
│ Z:\docker\webpage\data\ (NAS SMB mount, 기존) │
|
||||
│ videos\{id}\cover.jpg │
|
||||
│ videos\{id}\video.mp4 │
|
||||
│ {audio}.mp3 │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**핵심 원칙:** 파일은 SMB로 직접 읽고 쓰기 — HTTP는 메타데이터(경로 + 옵션)만 전달.
|
||||
|
||||
---
|
||||
|
||||
## 4. Windows `music_ai` 서버 — `/encode_video` endpoint
|
||||
|
||||
### 4-1. Request
|
||||
|
||||
```http
|
||||
POST /encode_video HTTP/1.1
|
||||
Host: 192.168.45.59:8765
|
||||
Content-Type: application/json
|
||||
|
||||
```
|
||||
|
||||
| 필드 | 타입 | 필수 | 설명 |
|
||||
|------|------|------|------|
|
||||
| `cover_path_nas` | string | ✓ | 배경 이미지 NAS 절대경로 |
|
||||
| `audio_path_nas` | string | ✓ | 오디오 파일 NAS 절대경로 |
|
||||
| `output_path_nas` | string | ✓ | 출력 mp4 NAS 절대경로 |
|
||||
| `resolution` | string | ✓ | `WIDTHxHEIGHT` (예: `1920x1080`) |
|
||||
| `duration_sec` | int | | 트랙 길이 — 진행 추적용 (옵션) |
|
||||
| `style` | string | | 현재 `visualizer`만 (확장용) |
|
||||
|
||||
### 4-2. Response
|
||||
|
||||
**성공 (200):**
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"duration_ms": 12340,
|
||||
"output_path_nas": "/volume1/docker/webpage/data/videos/3/video.mp4",
|
||||
"output_bytes": 28470000,
|
||||
"encoder": "h264_nvenc",
|
||||
"preset": "p4"
|
||||
}
|
||||
```
|
||||
|
||||
**실패 (4xx/5xx):**
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": "ffmpeg returncode=1: ...",
|
||||
"stage": "ffmpeg" // path_translate | input_validation | ffmpeg | output_check
|
||||
}
|
||||
```
|
||||
|
||||
### 4-3. 경로 변환
|
||||
|
||||
Windows 서버는 `nas_path → windows_path` 변환을 환경변수 기반으로 수행:
|
||||
|
||||
```python
|
||||
# .env (Windows music_ai)
|
||||
NAS_VOLUME_PREFIX=/volume1/
|
||||
WINDOWS_DRIVE_ROOT=Z:\
|
||||
```
|
||||
|
||||
변환 로직:
|
||||
```python
|
||||
def translate_path(nas_path: str) -> str:
|
||||
# /volume1/docker/webpage/data/videos/3/cover.jpg
|
||||
# → Z:\docker\webpage\data\videos\3\cover.jpg
|
||||
if not nas_path.startswith(NAS_VOLUME_PREFIX):
|
||||
raise ValueError(f"NAS prefix 불일치: {nas_path}")
|
||||
rel = nas_path[len(NAS_VOLUME_PREFIX):] # "docker/webpage/..."
|
||||
return WINDOWS_DRIVE_ROOT + rel.replace("/", "\\")
|
||||
```
|
||||
|
||||
### 4-4. 입력 검증
|
||||
|
||||
ffmpeg 호출 전:
|
||||
- `cover_path` 변환된 Windows 경로의 파일 존재 확인 → 없으면 400 stage=input_validation
|
||||
- `audio_path` 동일
|
||||
- `output_path`의 부모 디렉토리 존재 확인 — 없으면 자동 생성
|
||||
- `resolution` 정규식 `^\d{3,4}x\d{3,4}$` 검증 → 실패 시 400
|
||||
|
||||
### 4-5. ffmpeg 명령 (NVENC)
|
||||
|
||||
```python
|
||||
def build_visualizer_cmd(cover_win, audio_win, out_win, w, h):
|
||||
return [
|
||||
"ffmpeg", "-y",
|
||||
"-hwaccel", "cuda",
|
||||
"-loop", "1", "-i", cover_win,
|
||||
"-i", audio_win,
|
||||
"-filter_complex",
|
||||
f"[0:v]scale={w}:{h},format=yuv420p[bg];"
|
||||
f"[1:a]showwaves=s={w}x200:mode=cline:colors=0xFF4444@0.8[wave];"
|
||||
f"[bg][wave]overlay=0:({h}-200)[out]",
|
||||
"-map", "[out]", "-map", "1:a",
|
||||
"-c:v", "h264_nvenc",
|
||||
"-preset", "p4", # quality preset (p1=fastest, p7=slowest/best)
|
||||
"-rc", "vbr",
|
||||
"-cq", "23", # quality (lower=better, 18-25 sane range)
|
||||
"-b:v", "0", # let CQ control bitrate
|
||||
"-pix_fmt", "yuv420p", # YouTube 호환
|
||||
"-c:a", "aac", "-b:a", "192k",
|
||||
"-shortest", out_win,
|
||||
]
|
||||
```
|
||||
|
||||
**주요 플래그 설명:**
|
||||
- `-hwaccel cuda` — CUDA 사용
|
||||
- `-c:v h264_nvenc` — NVIDIA NVENC H.264 인코더
|
||||
- `-preset p4` — 품질·속도 균형 (5070 Ti 기준 1080p 영상 ~10–20s)
|
||||
- `-rc vbr -cq 23 -b:v 0` — VBR + 일정 품질 (CQ 23 = ~CRF 23)
|
||||
- `format=yuv420p` 명시 — NVENC가 가끔 yuv444 출력하는데 YouTube 호환 X
|
||||
|
||||
### 4-6. 타임아웃 + 출력 검증
|
||||
|
||||
- ffmpeg subprocess timeout: **180초** (NAS 측 HTTP timeout 200s 미만)
|
||||
- 종료 후 출력 파일 존재 + 크기 > 1MB 검증 → 미달 시 stage=output_check 실패
|
||||
- 종료 코드 0이지만 파일 비어있는 케이스 catch
|
||||
|
||||
### 4-7. 동시 처리
|
||||
|
||||
별도 큐 없음. 동시 호출 시 ffmpeg 프로세스 병렬 실행 — RTX 5070 Ti는 NVENC 세션 5개까지 지원.
|
||||
|
||||
단일 사용자 시나리오에서 동시 인코딩은 거의 발생 안 함. 발생해도 GPU 리소스 충분.
|
||||
|
||||
### 4-8. 헬스 체크 확장
|
||||
|
||||
기존 `GET /health`에 인코더 가용성 정보 추가:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"gpu": "NVIDIA GeForce RTX 5070 Ti",
|
||||
"musicgen_loaded": true,
|
||||
"ffmpeg_path": "C:/ffmpeg/bin/ffmpeg.exe",
|
||||
"ffmpeg_nvenc": true
|
||||
}
|
||||
```
|
||||
|
||||
`ffmpeg_nvenc` 검증: 서버 시작 시 `ffmpeg -encoders | grep h264_nvenc` 한 번 실행 + 캐시.
|
||||
|
||||
---
|
||||
|
||||
## 5. NAS music-lab — `pipeline/video.py` 리팩토링
|
||||
|
||||
### 5-1. 환경변수 (필수)
|
||||
|
||||
```env
|
||||
WINDOWS_VIDEO_ENCODER_URL=http://192.168.45.59:8765
|
||||
```
|
||||
|
||||
미설정 시: `pipeline/video.py`가 기동 시 명확한 에러로 실패 (ImportError 또는 RuntimeError).
|
||||
|
||||
### 5-2. `video.generate(...)` — 새 구현
|
||||
|
||||
```python
|
||||
"""영상 비주얼 생성 — Windows GPU 서버 (NVENC) 호출."""
|
||||
import os
|
||||
import logging
|
||||
import httpx
|
||||
|
||||
from . import storage
|
||||
|
||||
logger = logging.getLogger("music-lab.video")
|
||||
|
||||
ENCODER_URL = os.getenv("WINDOWS_VIDEO_ENCODER_URL", "")
|
||||
ENCODER_TIMEOUT_S = 200 # Windows 서버 ffmpeg 180s + 마진
|
||||
|
||||
|
||||
class VideoGenerationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def generate(*, pipeline_id: int, audio_path: str, cover_path: str,
|
||||
genre: str, duration_sec: int, resolution: str = "1920x1080",
|
||||
style: str = "visualizer") -> dict:
|
||||
"""원격 Windows 서버 호출. 다운/실패 시 즉시 예외."""
|
||||
if not ENCODER_URL:
|
||||
raise VideoGenerationError(
|
||||
"WINDOWS_VIDEO_ENCODER_URL 미설정 — Windows 인코더 서버 주소 필요"
|
||||
)
|
||||
|
||||
out_path = os.path.join(storage.pipeline_dir(pipeline_id), "video.mp4")
|
||||
nas_audio = _container_to_nas(audio_path)
|
||||
nas_cover = _container_to_nas(cover_path)
|
||||
nas_output = _container_to_nas(out_path)
|
||||
|
||||
payload = {
|
||||
"cover_path_nas": nas_cover,
|
||||
"audio_path_nas": nas_audio,
|
||||
"output_path_nas": nas_output,
|
||||
"resolution": resolution,
|
||||
"duration_sec": duration_sec,
|
||||
"style": style,
|
||||
}
|
||||
|
||||
logger.info("Windows 인코더 호출: %s → %s", audio_path, out_path)
|
||||
try:
|
||||
with httpx.Client(timeout=ENCODER_TIMEOUT_S) as client:
|
||||
resp = client.post(f"{ENCODER_URL}/encode_video", json=payload)
|
||||
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as e:
|
||||
raise VideoGenerationError(f"Windows 인코더 연결 실패: {e}")
|
||||
|
||||
if resp.status_code != 200:
|
||||
try:
|
||||
detail = resp.json()
|
||||
except Exception:
|
||||
detail = {"error": resp.text[:300]}
|
||||
raise VideoGenerationError(
|
||||
f"Windows 인코더 오류 ({resp.status_code}): "
|
||||
f"{detail.get('stage','?')} — {detail.get('error','?')}"
|
||||
)
|
||||
|
||||
data = resp.json()
|
||||
if not data.get("ok"):
|
||||
raise VideoGenerationError(f"Windows 인코더 응답 ok=false: {data}")
|
||||
|
||||
return {
|
||||
"url": storage.media_url(pipeline_id, "video.mp4"),
|
||||
"used_fallback": False,
|
||||
"duration_sec": duration_sec,
|
||||
"encode_duration_ms": data.get("duration_ms"),
|
||||
"encoder": data.get("encoder", "h264_nvenc"),
|
||||
}
|
||||
|
||||
|
||||
def _container_to_nas(container_path: str) -> str:
|
||||
""" /app/data/videos/3/cover.jpg → /volume1/docker/webpage/data/videos/3/cover.jpg
|
||||
/app/data/abc.mp3 → /volume1/docker/webpage/data/music/abc.mp3
|
||||
"""
|
||||
nas_videos_root = os.getenv("NAS_VIDEOS_ROOT", "/volume1/docker/webpage/data/videos")
|
||||
nas_music_root = os.getenv("NAS_MUSIC_ROOT", "/volume1/docker/webpage/data/music")
|
||||
if container_path.startswith("/app/data/videos/"):
|
||||
return container_path.replace("/app/data/videos/", nas_videos_root + "/", 1)
|
||||
if container_path.startswith("/app/data/"):
|
||||
# 음악 파일 마운트가 /app/data 직접이라 서브디렉토리 없음 → music root에 직접
|
||||
rel = container_path[len("/app/data/"):]
|
||||
return nas_music_root + "/" + rel
|
||||
return container_path # fallback (shouldn't happen)
|
||||
```
|
||||
|
||||
### 5-3. 제거 항목
|
||||
|
||||
- `subprocess.run(...)` ffmpeg 호출 — 완전 제거
|
||||
- `VIDEO_TIMEOUT_S = 600` — 사용 안 함 (`ENCODER_TIMEOUT_S`로 대체)
|
||||
- `_build_visualizer_cmd` — 제거 (Windows 서버로 이전)
|
||||
- `subprocess.TimeoutExpired` 예외 처리 — 제거
|
||||
|
||||
### 5-4. 환경변수 (NAS music-lab)
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml music-lab service environment
|
||||
WINDOWS_VIDEO_ENCODER_URL: ${WINDOWS_VIDEO_ENCODER_URL}
|
||||
NAS_VIDEOS_ROOT: ${NAS_VIDEOS_ROOT:-/volume1/docker/webpage/data/videos}
|
||||
NAS_MUSIC_ROOT: ${NAS_MUSIC_ROOT:-/volume1/docker/webpage/data/music}
|
||||
```
|
||||
|
||||
NAS `.env` 추가:
|
||||
```env
|
||||
WINDOWS_VIDEO_ENCODER_URL=http://192.168.45.59:8765
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 에러 응답 매트릭스
|
||||
|
||||
| 상황 | NAS 측 결과 | 사용자 경험 |
|
||||
|------|------------|-------------|
|
||||
| Windows PC 꺼짐 | `VideoGenerationError("연결 실패")` | 진행 카드 `failed`, 텔레그램에 명확한 에러 |
|
||||
| Windows ffmpeg 실패 | `VideoGenerationError("Windows 인코더 오류 500: ffmpeg — ...")` | 동일 |
|
||||
| 입력 파일 NAS에 없음 | Windows가 400 응답 | "input_validation: cover not found" 메시지 |
|
||||
| 출력 파일이 비어있음 | Windows가 500 응답 | "output_check: file empty" |
|
||||
| 타임아웃 (180s+) | Windows가 504 응답 또는 connection close | "타임아웃 — GPU 부하 또는 입력 손상" |
|
||||
| WINDOWS_VIDEO_ENCODER_URL 미설정 | 즉시 `VideoGenerationError` | 환경 미설정 안내 |
|
||||
|
||||
모두 pipeline state `failed`로 전이. 재생성 5회 한도 적용.
|
||||
|
||||
---
|
||||
|
||||
## 7. 헬스 모니터링
|
||||
|
||||
NAS music-lab 시작 시 1회 `GET {ENCODER_URL}/health` 호출 → 결과를 로그에 출력:
|
||||
- 성공 + `ffmpeg_nvenc=true` → 인코더 사용 가능
|
||||
- 실패 → 경고 로그 (구동은 계속, 호출 시점에 명확한 에러)
|
||||
|
||||
---
|
||||
|
||||
## 8. 테스트 전략
|
||||
|
||||
### 8-1. NAS music-lab 단위 테스트
|
||||
|
||||
`music-lab/tests/test_video_thumb.py` — 기존 ffmpeg 테스트를 HTTP mock 기반으로 교체:
|
||||
|
||||
```python
|
||||
@respx.mock
|
||||
def test_generate_video_calls_remote_encoder(monkeypatch):
|
||||
monkeypatch.setenv("WINDOWS_VIDEO_ENCODER_URL", "http://192.168.45.59:8765")
|
||||
monkeypatch.setattr(video, "ENCODER_URL", "http://192.168.45.59:8765")
|
||||
respx.post("http://192.168.45.59:8765/encode_video").mock(
|
||||
return_value=Response(200, json={
|
||||
"ok": True, "duration_ms": 12000,
|
||||
"output_path_nas": "/volume1/...",
|
||||
"encoder": "h264_nvenc", "preset": "p4"
|
||||
})
|
||||
)
|
||||
out = video.generate(...)
|
||||
assert out["url"].endswith("/video.mp4")
|
||||
assert out["encode_duration_ms"] == 12000
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_generate_video_raises_on_connection_error(monkeypatch):
|
||||
monkeypatch.setattr(video, "ENCODER_URL", "http://192.168.45.59:8765")
|
||||
respx.post("http://192.168.45.59:8765/encode_video").mock(
|
||||
side_effect=httpx.ConnectError("Connection refused")
|
||||
)
|
||||
with pytest.raises(video.VideoGenerationError) as exc:
|
||||
video.generate(...)
|
||||
assert "연결 실패" in str(exc.value)
|
||||
|
||||
|
||||
def test_generate_video_no_url_configured(monkeypatch):
|
||||
monkeypatch.setattr(video, "ENCODER_URL", "")
|
||||
with pytest.raises(video.VideoGenerationError) as exc:
|
||||
video.generate(...)
|
||||
assert "WINDOWS_VIDEO_ENCODER_URL" in str(exc.value)
|
||||
```
|
||||
|
||||
기존 `test_generate_video_calls_ffmpeg` / `test_generate_video_failure_marks_failed` 제거.
|
||||
|
||||
### 8-2. Windows `music_ai` 단위 테스트
|
||||
|
||||
`music_ai/tests/test_video_encoder.py` (신규):
|
||||
|
||||
```python
|
||||
@patch("subprocess.run")
|
||||
def test_translate_path():
|
||||
assert video_encoder.translate_path("/volume1/docker/webpage/data/x.jpg") == r"Z:\docker\webpage\data\x.jpg"
|
||||
|
||||
def test_translate_path_rejects_bad_prefix():
|
||||
with pytest.raises(ValueError):
|
||||
video_encoder.translate_path("/something/else/x.jpg")
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_encode_endpoint_success(mock_run, client, tmp_path):
|
||||
# mock paths exist + ffmpeg succeeds
|
||||
...
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_encode_endpoint_input_missing(mock_run, client):
|
||||
# 입력 파일 안 보이면 400
|
||||
...
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_encode_endpoint_ffmpeg_fails(mock_run, client, tmp_path):
|
||||
# ffmpeg returncode=1 → 500 stage=ffmpeg
|
||||
...
|
||||
```
|
||||
|
||||
### 8-3. 통합 테스트
|
||||
|
||||
기존 `test_pipeline_flow.py`는 `cover.generate`를 mock하므로 영향 없음. video도 같이 mock — 변경 없음.
|
||||
|
||||
### 8-4. 수동 E2E
|
||||
|
||||
- [ ] Windows PC에서 `music_ai` 서버 시작 → `curl http://192.168.45.59:8765/health` → `ffmpeg_nvenc: true` 확인
|
||||
- [ ] NAS에서 `curl -X POST http://192.168.45.59:8765/encode_video -d '{...}'` 직접 호출 → 200 응답 + Z:\에 video.mp4 생성 확인
|
||||
- [ ] 진행 탭에서 새 파이프라인 시작 → 영상 단계가 10–20초 안에 완료 → 텔레그램 알림 도착
|
||||
- [ ] Windows PC 꺼두고 새 파이프라인 시작 → 영상 단계 즉시 실패 → 진행 카드 failed + 명확한 에러 메시지
|
||||
|
||||
---
|
||||
|
||||
## 9. Windows PC 사전 준비
|
||||
|
||||
사용자가 Windows PC에서 1회 수행할 작업:
|
||||
|
||||
1. **ffmpeg + NVENC 빌드 설치**
|
||||
- https://www.gyan.dev/ffmpeg/builds/ → "release full" 다운로드
|
||||
- 압축 해제 → `C:\ffmpeg\bin\ffmpeg.exe`
|
||||
- PATH 환경변수에 `C:\ffmpeg\bin` 추가
|
||||
- 검증: `ffmpeg -version` 동작, `ffmpeg -encoders | findstr h264_nvenc` 결과 출력
|
||||
|
||||
2. **NVIDIA 드라이버** — 이미 MusicGen용으로 설치돼 있음
|
||||
|
||||
3. **SMB 마운트 확인** — `Z:\docker\webpage\` 접근 가능해야 함
|
||||
|
||||
4. **방화벽** — 포트 8765 LAN 인바운드 허용 (이미 MusicGen용으로 설정돼 있음)
|
||||
|
||||
5. **`music_ai/.env`에 추가**:
|
||||
```env
|
||||
NAS_VOLUME_PREFIX=/volume1/
|
||||
WINDOWS_DRIVE_ROOT=Z:\
|
||||
FFMPEG_PATH=C:\ffmpeg\bin\ffmpeg.exe
|
||||
```
|
||||
|
||||
6. **`music_ai/start.bat` 재시작** — 새 endpoint 활성화
|
||||
|
||||
---
|
||||
|
||||
## 10. 산출물
|
||||
|
||||
| 영역 | 파일 |
|
||||
|------|------|
|
||||
| Windows | `music_ai/video_encoder.py` (신규) |
|
||||
| Windows | `music_ai/server.py` (수정 — `/encode_video` endpoint 등록, `/health` 확장) |
|
||||
| Windows | `music_ai/.env.example` (수정 — 새 변수 문서화) |
|
||||
| Windows | `music_ai/tests/test_video_encoder.py` (신규) |
|
||||
| NAS | `music-lab/app/pipeline/video.py` (재작성) |
|
||||
| NAS | `music-lab/tests/test_video_thumb.py` (수정 — HTTP mock 기반) |
|
||||
| Infra | `web-backend/docker-compose.yml` (env 3개 추가) |
|
||||
| Infra | NAS `.env` (사용자 수동, 1개 추가) |
|
||||
|
||||
---
|
||||
|
||||
## 11. 후속
|
||||
|
||||
- (P3) 영상 인코딩 진행률 실시간 보고 — Windows에서 ffmpeg progress 파싱 후 진행 탭 카드에 표시 (현재는 단순 "running")
|
||||
- (P3) Windows 서버 다중 큐 — 동시 요청 시 GPU 부하 추적 + 큐잉
|
||||
- (P4) 인코딩 옵션을 youtube_setup `visual_defaults`로 추가 — preset(p1~p7), CQ, 해상도 옵션 노출
|
||||
- (P4) Shorts 전용 1080×1920 인코딩 프로파일
|
||||
|
||||
---
|
||||
## 11. 후속
|
||||
|
||||
- (P3) 영상 인코딩 진행률 실시간 보고 — Windows에서 ffmpeg progress 파싱 후 진행 탭 카드에 표시 (현재는 단순 "running")
|
||||
- (P3) Windows 서버 다중 큐 — 동시 요청 시 GPU 부하 추적 + 큐잉
|
||||
- (P4) 인코딩 옵션을 youtube_setup `visual_defaults`로 추가 — preset(p1~p7), CQ, 해상도 옵션 노출
|
||||
- (P4) Shorts 전용 1080×1920 인코딩 프로파일
|
||||
|
||||
---
|
||||
@@ -0,0 +1,505 @@
|
||||
# 배치 음악 생성 + 자동 영상 파이프라인 설계
|
||||
|
||||
> 작성일: 2026-05-10
|
||||
> 관련: `2026-05-09-essential-mix-pipeline-design.md` (영상 파이프라인 베이스)
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경
|
||||
|
||||
현재 Create 탭은 사용자가 모든 파라미터(genre/mood/instruments/BPM/key/scale/duration/prompt) 수동 입력 후 1트랙 생성. 1시간+ mix 영상 만들려면 동일 장르 트랙 10개를 일일이 만들어야 함.
|
||||
|
||||
목표: **장르 1개만 입력 → 10트랙 자동 생성 → 자동 컴파일 → 자동 영상 파이프라인 시작 → 텔레그램 승인만 하면 발행 완료**.
|
||||
|
||||
전체 흐름:
|
||||
```
|
||||
[사용자] Create 탭 → 배치 모드 → 장르 + 트랙 수 선택 → 생성 시작
|
||||
↓ Suno API 순차 호출 (트랙당 ~1-2분)
|
||||
↓ Track 1: "{Genre} Mix Track 1", 랜덤 mood/instr/BPM/key
|
||||
↓ Track 2: "{Genre} Mix Track 2", ...
|
||||
↓ ... Track 10
|
||||
↓ 모두 완료 → compile_job 자동 생성 (acrossfade 3s)
|
||||
↓ compile 완료 → video_pipeline 자동 시작 (cover step)
|
||||
↓ 텔레그램에 "🎵 [{Genre} Mix] 커버 검토" 알림
|
||||
[사용자] 5번 승인으로 영상 발행
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 비목표
|
||||
|
||||
- 병렬 음악 생성 — VRAM 부담 회피, 순차로 단순하게
|
||||
- 트랙별 prompt 자동 작성(Claude) — Suno는 genre+mood+instruments만으로도 충분
|
||||
- 트랙별 길이 가변 — 모든 트랙 동일 `target_duration_sec` (default 180s)
|
||||
- 사용자가 진행 중 트랙 prompt 편집 — 한 번 시작하면 끝까지
|
||||
|
||||
---
|
||||
|
||||
## 3. 사용자 흐름
|
||||
|
||||
### 3-1. Create 탭의 신규 "배치 생성" 섹션
|
||||
|
||||
```
|
||||
┌─ 🎲 배치 생성 (장르 + 자동 영상까지) ─────────────────┐
|
||||
│ │
|
||||
│ 장르 [▼ lo-fi ] │
|
||||
│ 트랙 수 [● 1 — 10] (10) │
|
||||
│ 트랙당 길이 [● 60 — 300s] (180s) │
|
||||
│ ☑ 모든 트랙 생성 후 자동 영상 파이프라인 시작 │
|
||||
│ │
|
||||
│ 예상 시간: 약 15-25분 (트랙당 1-2분 × 10) │
|
||||
│ 예상 비용: ~$0.10 (Suno 10트랙 + DALL·E + Claude) │
|
||||
│ │
|
||||
│ [🎵 배치 생성 시작] │
|
||||
│ │
|
||||
│ ── 진행 상태 ────────────────────────────────────── │
|
||||
│ 배치 #3 — lo-fi · 7/10 완료 · 2:43 경과 │
|
||||
│ ✓ Track 1: Lo-Fi Mix Track 1 (chill, piano+synth) │
|
||||
│ ✓ Track 2: Lo-Fi Mix Track 2 (relaxing, piano+drums) │
|
||||
│ ... │
|
||||
│ ⏳ Track 8: 생성 중... │
|
||||
│ ○ Track 9: 대기 │
|
||||
│ ○ Track 10: 대기 │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3-2. 완료 후
|
||||
|
||||
10트랙 모두 Library에 저장됨. compile_job_id가 자동 생성되고 영상 파이프라인이 cover step부터 시작 → 텔레그램 알림. 진행 탭에 카드 1장 추가.
|
||||
|
||||
---
|
||||
|
||||
## 4. 데이터 모델
|
||||
|
||||
### 4-1. 신규 테이블 `music_batch_jobs`
|
||||
|
||||
```sql
|
||||
CREATE TABLE music_batch_jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
genre TEXT NOT NULL,
|
||||
count INTEGER NOT NULL, -- 1-10
|
||||
target_duration_sec INTEGER NOT NULL DEFAULT 180,
|
||||
auto_pipeline INTEGER NOT NULL DEFAULT 1, -- 0/1 boolean
|
||||
completed INTEGER NOT NULL DEFAULT 0,
|
||||
track_ids_json TEXT NOT NULL DEFAULT '[]',
|
||||
current_track_index INTEGER NOT NULL DEFAULT 0, -- 진행 중 트랙 (1..count)
|
||||
current_track_status TEXT, -- queued | generating | failed
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
-- queued: 시작 전
|
||||
-- generating: 트랙 생성 중
|
||||
-- generated: 모든 트랙 생성 완료 (compile 시작 전)
|
||||
-- compiling: compile 진행 중
|
||||
-- piped: 영상 파이프라인 시작됨 (=cover_pending 상태)
|
||||
-- failed: 어느 단계에서 실패
|
||||
-- cancelled: 사용자 취소
|
||||
error TEXT,
|
||||
compile_job_id INTEGER,
|
||||
pipeline_id INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
`init_db()`에 `CREATE TABLE IF NOT EXISTS` 추가.
|
||||
|
||||
### 4-2. 헬퍼 함수 (`db.py` 추가)
|
||||
|
||||
- `create_batch_job(genre, count, target_duration_sec, auto_pipeline) -> int`
|
||||
- `get_batch_job(id) -> dict | None`
|
||||
- `update_batch_job(id, **fields)` — allowlist 검증
|
||||
- `list_batch_jobs(active_only=False) -> list[dict]`
|
||||
- `append_batch_track(batch_id, track_id)` — 완료된 트랙 ID 추가, completed++
|
||||
|
||||
---
|
||||
|
||||
## 5. 백엔드 — 랜덤 풀 + 배치 실행
|
||||
|
||||
### 5-1. `app/random_pools.py` (신규)
|
||||
|
||||
장르별 음악적으로 어울리는 랜덤 풀 정의:
|
||||
|
||||
```python
|
||||
"""장르별 음악 파라미터 랜덤 풀."""
|
||||
import random
|
||||
|
||||
POOLS = {
|
||||
"lo-fi": {
|
||||
"moods": ["chill", "relaxing", "dreamy", "melancholic", "mellow", "nostalgic", "peaceful"],
|
||||
"instruments_pool": ["piano", "synth", "drums", "vinyl", "rhodes", "soft bass", "ambient pads"],
|
||||
"instruments_count": (3, 4),
|
||||
"bpm": (70, 90),
|
||||
"keys": ["C", "D", "F", "G", "A"],
|
||||
"scales": ["minor", "major"],
|
||||
"prompt_modifiers": ["cozy bedroom vibes", "rainy night", "late night study", "cafe ambience"],
|
||||
},
|
||||
"phonk": {
|
||||
"moods": ["dark", "aggressive", "moody", "intense", "hypnotic"],
|
||||
"instruments_pool": ["808 bass", "hi-hat", "synth lead", "vocal chops", "bass drops", "trap drums"],
|
||||
"instruments_count": (3, 4),
|
||||
"bpm": (130, 160),
|
||||
"keys": ["C", "D", "F", "G"],
|
||||
"scales": ["minor"],
|
||||
"prompt_modifiers": ["drift atmosphere", "dark neon", "midnight drive"],
|
||||
},
|
||||
"ambient": {
|
||||
"moods": ["peaceful", "meditative", "ethereal", "spacious", "dreamy"],
|
||||
"instruments_pool": ["pad synths", "atmospheric guitar", "soft strings", "field recordings", "drone bass"],
|
||||
"instruments_count": (2, 3),
|
||||
"bpm": (50, 75),
|
||||
"keys": ["C", "D", "E", "G", "A"],
|
||||
"scales": ["major", "minor"],
|
||||
"prompt_modifiers": ["misty mountain morning", "deep space", "still water", "forest dawn"],
|
||||
},
|
||||
"pop": {
|
||||
"moods": ["uplifting", "happy", "energetic", "romantic", "catchy"],
|
||||
"instruments_pool": ["acoustic guitar", "piano", "drums", "bass", "synth", "vocals harmonies"],
|
||||
"instruments_count": (3, 5),
|
||||
"bpm": (95, 130),
|
||||
"keys": ["C", "D", "E", "F", "G", "A"],
|
||||
"scales": ["major"],
|
||||
"prompt_modifiers": ["radio-ready", "summer vibe", "feel-good"],
|
||||
},
|
||||
"default": { # 알 수 없는 장르 fallback
|
||||
"moods": ["chill", "relaxing", "uplifting", "mellow"],
|
||||
"instruments_pool": ["piano", "synth", "drums", "guitar", "bass", "strings"],
|
||||
"instruments_count": (3, 4),
|
||||
"bpm": (80, 110),
|
||||
"keys": ["C", "D", "F", "G", "A"],
|
||||
"scales": ["minor", "major"],
|
||||
"prompt_modifiers": [""],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def randomize(genre: str, rng: random.Random | None = None) -> dict:
|
||||
"""랜덤 음악 파라미터 1세트 생성."""
|
||||
rng = rng or random.Random()
|
||||
pool = POOLS.get(genre.lower(), POOLS["default"])
|
||||
n_instr = rng.randint(*pool["instruments_count"])
|
||||
instruments = rng.sample(pool["instruments_pool"], min(n_instr, len(pool["instruments_pool"])))
|
||||
return {
|
||||
"moods": [rng.choice(pool["moods"])],
|
||||
"instruments": instruments,
|
||||
"bpm": rng.randint(*pool["bpm"]),
|
||||
"key": rng.choice(pool["keys"]),
|
||||
"scale": rng.choice(pool["scales"]),
|
||||
"prompt_modifier": rng.choice(pool["prompt_modifiers"]),
|
||||
}
|
||||
```
|
||||
|
||||
향후(P3): 장르별 풀을 `youtube_setup`/별도 테이블로 옮겨 SetupTab에서 편집 가능하게.
|
||||
|
||||
### 5-2. `app/batch_generator.py` (신규) — 순차 실행 오케스트레이터
|
||||
|
||||
```python
|
||||
"""배치 음악 생성 + 자동 컴파일·영상 파이프라인."""
|
||||
import asyncio
|
||||
import logging
|
||||
import json
|
||||
|
||||
from . import db
|
||||
from .suno_provider import run_suno_generation
|
||||
from .random_pools import randomize
|
||||
|
||||
logger = logging.getLogger("music-lab.batch")
|
||||
|
||||
POLL_INTERVAL_S = 5
|
||||
TRACK_GEN_TIMEOUT_S = 240 # 트랙당 최대 4분
|
||||
|
||||
|
||||
async def run_batch(batch_id: int) -> None:
|
||||
"""1) genre로 N트랙 순차 Suno 생성
|
||||
2) 모두 완료 후 compile_job 자동 생성·실행
|
||||
3) compile 완료 후 영상 파이프라인 시작 (cover step)
|
||||
"""
|
||||
job = db.get_batch_job(batch_id)
|
||||
if not job:
|
||||
return
|
||||
genre = job["genre"]
|
||||
count = job["count"]
|
||||
duration = job["target_duration_sec"]
|
||||
auto_pipe = bool(job["auto_pipeline"])
|
||||
|
||||
db.update_batch_job(batch_id, status="generating")
|
||||
|
||||
track_ids: list[int] = []
|
||||
for i in range(1, count + 1):
|
||||
title = f"{genre.title()} Mix Track {i}"
|
||||
params = randomize(genre)
|
||||
|
||||
db.update_batch_job(batch_id,
|
||||
current_track_index=i,
|
||||
current_track_status="generating")
|
||||
|
||||
# Suno 호출 (기존 task 패턴 활용)
|
||||
task_id = _start_suno(title=title, genre=genre,
|
||||
duration_sec=duration, **params)
|
||||
track_id = await _wait_for_track(task_id, timeout=TRACK_GEN_TIMEOUT_S)
|
||||
|
||||
if track_id:
|
||||
track_ids.append(track_id)
|
||||
db.append_batch_track(batch_id, track_id)
|
||||
else:
|
||||
logger.warning("배치 %d 트랙 %d 실패 — 계속 진행", batch_id, i)
|
||||
db.update_batch_job(batch_id, current_track_status="failed")
|
||||
# 정책: 실패한 트랙은 skip하고 계속 (나머지 9개라도 만든다)
|
||||
|
||||
if not track_ids:
|
||||
db.update_batch_job(batch_id, status="failed",
|
||||
error="모든 트랙 생성 실패")
|
||||
return
|
||||
|
||||
db.update_batch_job(batch_id, status="generated")
|
||||
|
||||
if not auto_pipe:
|
||||
return # 음악만 만들고 종료
|
||||
|
||||
# === 자동 compile ===
|
||||
db.update_batch_job(batch_id, status="compiling")
|
||||
compile_id = db.create_compile_job(
|
||||
title=f"{genre.title()} Mix",
|
||||
track_ids=track_ids,
|
||||
crossfade_sec=3,
|
||||
)
|
||||
db.update_batch_job(batch_id, compile_job_id=compile_id)
|
||||
|
||||
# 기존 compiler 호출 (동기 → asyncio.to_thread)
|
||||
from . import compiler
|
||||
await asyncio.to_thread(compiler.run, compile_id)
|
||||
|
||||
job_after = db.get_compile_job(compile_id)
|
||||
if not job_after or job_after.get("status") not in ("done", "succeeded"):
|
||||
db.update_batch_job(batch_id, status="failed",
|
||||
error=f"compile 실패 (status={job_after.get('status') if job_after else 'unknown'})")
|
||||
return
|
||||
|
||||
# === 자동 영상 파이프라인 ===
|
||||
pipeline_id = db.create_pipeline(compile_job_id=compile_id)
|
||||
db.update_batch_job(batch_id, pipeline_id=pipeline_id, status="piped")
|
||||
|
||||
from .pipeline import orchestrator
|
||||
await orchestrator.run_step(pipeline_id, "cover")
|
||||
```
|
||||
|
||||
- `_start_suno(...)` — 기존 `run_suno_generation` 호출, task_id 반환
|
||||
- `_wait_for_track(task_id, timeout)` — task 완료 폴링, 성공 시 music_library의 새 track id 반환
|
||||
|
||||
### 5-3. 변경되는 기존 모듈
|
||||
|
||||
`app/main.py`에 신규 endpoint 3개 + BackgroundTask. 변경 없는 기존 endpoint들은 그대로.
|
||||
|
||||
`db.py`에 헬퍼 함수 5개 추가 + `init_db()`에 `music_batch_jobs` CREATE 추가.
|
||||
|
||||
---
|
||||
|
||||
## 6. API 엔드포인트
|
||||
|
||||
### 6-1. `POST /api/music/generate-batch`
|
||||
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"genre": "lo-fi",
|
||||
"count": 10,
|
||||
"target_duration_sec": 180,
|
||||
"auto_pipeline": true
|
||||
}
|
||||
```
|
||||
|
||||
Validation:
|
||||
- `count` 1-10
|
||||
- `target_duration_sec` 60-300
|
||||
- `genre` 필수
|
||||
|
||||
Response 201:
|
||||
```json
|
||||
{
|
||||
"id": 3,
|
||||
"status": "queued",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
배치 작업은 BackgroundTask로 실행 (~15-25분 소요).
|
||||
|
||||
### 6-2. `GET /api/music/generate-batch/{id}`
|
||||
|
||||
진행 상태 조회. 응답 예:
|
||||
```json
|
||||
{
|
||||
"id": 3,
|
||||
"genre": "lo-fi",
|
||||
"count": 10,
|
||||
"completed": 7,
|
||||
"current_track_index": 8,
|
||||
"current_track_status": "generating",
|
||||
"status": "generating",
|
||||
"track_ids": [12, 13, 14, 15, 16, 17, 18],
|
||||
"tracks": [
|
||||
{"id": 12, "title": "Lo-Fi Mix Track 1", ...},
|
||||
...
|
||||
],
|
||||
"compile_job_id": null,
|
||||
"pipeline_id": null,
|
||||
"created_at": "2026-05-10T17:00:00",
|
||||
"updated_at": "2026-05-10T17:08:30"
|
||||
}
|
||||
```
|
||||
|
||||
`tracks` 필드는 LEFT JOIN으로 채워짐 (각 트랙 메타 포함).
|
||||
|
||||
### 6-3. `GET /api/music/generate-batch?status=active`
|
||||
|
||||
전체 배치 목록. `active`면 queued/generating/compiling/piped 만.
|
||||
|
||||
---
|
||||
|
||||
## 7. 프론트엔드 — Create 탭 배치 섹션
|
||||
|
||||
### 7-1. `MusicStudio.jsx` Create 영역에 신규 collapsible
|
||||
|
||||
Create form 위 또는 옆에 새 섹션 (`<details>` 또는 토글):
|
||||
|
||||
```jsx
|
||||
<details className="ms-batch-section" open={batchOpen}>
|
||||
<summary onClick={...}>🎲 배치 생성 (1-10트랙 + 자동 영상)</summary>
|
||||
|
||||
<div className="ms-batch-form">
|
||||
<label>장르
|
||||
<select value={batchGenre} onChange={...}>
|
||||
<option value="lo-fi">Lo-Fi</option>
|
||||
<option value="phonk">Phonk</option>
|
||||
<option value="ambient">Ambient</option>
|
||||
<option value="pop">Pop</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>트랙 수: {batchCount}
|
||||
<input type="range" min={1} max={10} value={batchCount} onChange={...}/>
|
||||
</label>
|
||||
|
||||
<label>트랙당 길이: {batchDuration}초
|
||||
<input type="range" min={60} max={300} step={10} value={batchDuration} onChange={...}/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" checked={autoPipeline} onChange={...}/>
|
||||
모든 트랙 생성 후 자동 영상 파이프라인 시작
|
||||
</label>
|
||||
|
||||
<p className="ms-batch-estimate">
|
||||
예상: 약 {batchCount * 1.5 | 0}-{batchCount * 2}분 · 비용 ~${(batchCount * 0.005 + (autoPipeline ? 0.05 : 0)).toFixed(2)}
|
||||
</p>
|
||||
|
||||
<button className="button primary" onClick={startBatch} disabled={generating}>
|
||||
🎵 배치 생성 시작
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{currentBatch && <BatchProgress batch={currentBatch} />}
|
||||
</details>
|
||||
```
|
||||
|
||||
### 7-2. 신규 컴포넌트 `BatchProgress.jsx`
|
||||
|
||||
```jsx
|
||||
export default function BatchProgress({ batch }) {
|
||||
return (
|
||||
<div className="ms-batch-progress">
|
||||
<div className="ms-batch-header">
|
||||
배치 #{batch.id} — {batch.genre} ·
|
||||
{' '}{batch.completed}/{batch.count} 완료 ·
|
||||
{' '}status: <strong>{batch.status}</strong>
|
||||
</div>
|
||||
<ol className="ms-batch-tracks">
|
||||
{Array.from({ length: batch.count }, (_, i) => i + 1).map(n => {
|
||||
const completed = n <= batch.completed;
|
||||
const current = n === batch.current_track_index && batch.status === 'generating';
|
||||
const track = (batch.tracks || []).find(t => t._batch_index === n);
|
||||
return (
|
||||
<li key={n} className={completed ? 'done' : current ? 'current' : 'pending'}>
|
||||
{completed ? '✓' : current ? '⏳' : '○'}
|
||||
{' '}Track {n}: {track ? track.title : (current ? '생성 중...' : '대기')}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
{batch.compile_job_id && <div>📀 컴파일 #{batch.compile_job_id}</div>}
|
||||
{batch.pipeline_id && (
|
||||
<div>
|
||||
🎬 영상 파이프라인 #{batch.pipeline_id} —
|
||||
<a href={`#youtube-pipeline-${batch.pipeline_id}`}> 진행 탭에서 확인</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 7-3. 폴링
|
||||
|
||||
배치 시작 시 5초 간격 `getBatchJob(id)` 호출. status가 `piped`/`failed`/`cancelled`되면 폴링 중지.
|
||||
|
||||
### 7-4. `api.js` 헬퍼
|
||||
|
||||
```javascript
|
||||
export const startBatchGen = (payload) => apiPost('/api/music/generate-batch', payload);
|
||||
export const getBatchJob = (id) => apiGet(`/api/music/generate-batch/${id}`);
|
||||
export const listBatchJobs = (status='all') => apiGet(`/api/music/generate-batch?status=${status}`);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 에러 처리
|
||||
|
||||
| 시나리오 | 동작 |
|
||||
|---------|------|
|
||||
| Suno API 트랙 1개 실패 | 로그 + skip + 다음 트랙 진행. 최종 track_ids에 누락. |
|
||||
| 모든 트랙 실패 | status=failed, error 기록 |
|
||||
| compile 실패 | status=failed, compile_job_id 보존 |
|
||||
| 영상 파이프라인 cover step 실패 | pipeline 자체에서 failed로 마크. batch는 piped 상태 그대로 (파이프라인 측에서 처리) |
|
||||
| count > 10 또는 < 1 | 400 |
|
||||
| genre 누락 | 400 |
|
||||
| Suno API key 미설정 | 400 ("SUNO_API_KEY 미설정") |
|
||||
|
||||
---
|
||||
|
||||
## 9. 테스트 전략
|
||||
|
||||
### 9-1. 단위 테스트
|
||||
|
||||
- `random_pools.randomize(genre)` — 각 장르별 결과가 풀 안에 있는지, 시드 고정 시 재현 가능
|
||||
- `db.create_batch_job` / `update_batch_job` / `append_batch_track` — 정상 흐름
|
||||
- `_wait_for_track` — task 성공/실패/timeout mock
|
||||
|
||||
### 9-2. 통합 테스트
|
||||
|
||||
- `POST /api/music/generate-batch` 호출 → 201 반환 + 배치 row 생성
|
||||
- `GET /api/music/generate-batch/{id}` 응답 schema
|
||||
- `run_batch` mocked Suno + mocked compiler + mocked orchestrator → 전체 흐름 happy path
|
||||
|
||||
### 9-3. 수동 E2E
|
||||
|
||||
- Create 탭 → 배치 생성 → 장르 선택 → 시작 → 진행 표시 확인
|
||||
- 10트랙 완료 → Library에 10개 추가 확인 → compile_job 자동 생성 확인 → 진행 탭에 새 카드 등장 확인
|
||||
|
||||
---
|
||||
|
||||
## 10. 산출물
|
||||
|
||||
| 영역 | 파일 |
|
||||
|------|------|
|
||||
| Spec/Plan | 본 문서 + plan |
|
||||
| NAS music-lab | `db.py` (테이블/헬퍼), `random_pools.py` (신규), `batch_generator.py` (신규), `main.py` (3 endpoints) |
|
||||
| Frontend | `MusicStudio.jsx` (Create 배치 섹션), `BatchProgress.jsx` (신규), `MusicStudio.css`, `api.js` 헬퍼 |
|
||||
| 테스트 | NAS 단위 + 통합, 수동 E2E |
|
||||
|
||||
---
|
||||
|
||||
## 11. 후속 (P3)
|
||||
|
||||
- 장르별 풀 SetupTab에서 편집 가능
|
||||
- 트랙별 prompt에 시나리오/카페 분위기 등 자동 추가 (트랙간 다양성 증대)
|
||||
- 배치 일시정지/재개
|
||||
- 한 배치 안에서 Track-N별 재생성 (실패한 트랙만)
|
||||
- 트랙 길이 가변 (랜덤 분포)
|
||||
358
docs/superpowers/specs/2026-05-15-insta-agent-design.md
Normal file
358
docs/superpowers/specs/2026-05-15-insta-agent-design.md
Normal file
@@ -0,0 +1,358 @@
|
||||
# insta-agent 설계 — blog-lab 폐기, 인스타 카드 피드 파이프라인 신설
|
||||
|
||||
작성일: 2026-05-15
|
||||
상태: 사용자 승인 대기 → writing-plans 진입 예정
|
||||
|
||||
---
|
||||
|
||||
## 1. 목적·배경
|
||||
|
||||
기존 `blog-lab` 서비스(네이버 블로그 마케팅 수익화)를 폐기하고, 인스타그램 프로페셔널 계정에 올릴 카드 형식 피드(1080×1350, 10페이지)를 자동 생산하는 `insta-lab` 서비스로 대체한다.
|
||||
|
||||
핵심 가치 제안:
|
||||
- 매일 경제·심리학·연예 등 카테고리에서 화제 키워드를 자동 발견
|
||||
- 사용자가 키워드 1개를 선택하면 10페이지 카드 카피 + PNG 자동 생성
|
||||
- 텔레그램으로 카드 묶음 미디어 그룹 + 추천 캡션·해시태그 푸시
|
||||
- 사용자는 카드 다운로드 → 인스타 수동 업로드 (Graph API 미사용)
|
||||
|
||||
블로그 발행 자동화의 운영 부담(네이버 SEO, 브랜드커넥트 링크 관리, 커미션 추적)을 제거하고 카드 콘텐츠 생산에 집중한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 스코프
|
||||
|
||||
### 포함
|
||||
|
||||
- 신규 컨테이너 `insta-lab` (포트 18700 재활용)
|
||||
- 신규 에이전트 `insta-agent` (`agent-office/app/agents/insta.py`)
|
||||
- 뉴스 수집 → 키워드 추출 → 카드 카피 생성 → 카드 PNG 렌더 → 텔레그램 푸시 파이프라인
|
||||
- HTML/CSS 카드 템플릿 골격 (사용자가 디자인 직접 수정)
|
||||
- 카드 슬레이트·기사·키워드·자산 5테이블 (`insta.db`)
|
||||
- nginx 라우팅 변경 (`/api/blog-marketing/` 제거 → `/api/insta/`)
|
||||
- CLAUDE.md (workspace + web-backend) 갱신
|
||||
|
||||
### 제외
|
||||
|
||||
- 인스타그램 Graph API 자동 발행 (수동 업로드 사용)
|
||||
- 카드 디자인 비주얼 완성 (사용자가 직접 작업)
|
||||
- blog_marketing.db 데이터 마이그레이션 (clean slate)
|
||||
- 다국어 번역, A/B 테스트, 성과 추적
|
||||
|
||||
---
|
||||
|
||||
## 3. 서비스 구성·폐기 범위
|
||||
|
||||
### 폐기
|
||||
|
||||
| 대상 | 처리 |
|
||||
|------|------|
|
||||
| `blog-lab/` 디렉토리 | git rm 통째로 삭제 |
|
||||
| `blog_marketing.db` | 운영·로컬 모두 삭제 (clean slate) |
|
||||
| `agent-office/app/agents/blog.py` | 삭제 |
|
||||
| `service_proxy.py`의 blog_* 함수 | 삭제 |
|
||||
| `agent-office`의 blog 라우팅·텔레그램 명령 | 삭제 |
|
||||
| docker-compose의 `blog-lab` 서비스 정의 | 교체 |
|
||||
| nginx의 `/api/blog-marketing/` location | 교체 |
|
||||
| 환경변수 `BLOG_DATA_PATH` | 제거 |
|
||||
|
||||
### 신규
|
||||
|
||||
| 대상 | 비고 |
|
||||
|------|------|
|
||||
| `insta-lab/` 디렉토리 | 신규 생성 |
|
||||
| `insta-lab` 컨테이너 (포트 18700) | blog-lab 자리 재활용 |
|
||||
| `agents/insta.py` | 신규 에이전트 |
|
||||
| nginx `/api/insta/` → `insta-lab:8000` | 신규 |
|
||||
| 환경변수 `INSTA_DATA_PATH`, `CARD_TEMPLATE_DIR` | 신규 |
|
||||
|
||||
### 재사용 자산 (코드 패턴 차용)
|
||||
|
||||
- `naver_search.py` — 엔드포인트만 `news.json`으로 교체
|
||||
- `generation_tasks` 테이블 + BackgroundTask 폴링 패턴
|
||||
- `prompt_templates` 테이블 + DB 저장 프롬프트 패턴
|
||||
- agent-office의 텔레그램 인라인 키보드·승인 패턴 (`realestate_message.py` 참고)
|
||||
|
||||
---
|
||||
|
||||
## 4. 데이터 흐름
|
||||
|
||||
### 일일 사이클
|
||||
|
||||
```
|
||||
[09:30 매일 cron — agent-office 스케줄러]
|
||||
1. 뉴스 수집 ─ 카테고리별 시드 키워드로 NAVER news.json 검색
|
||||
─ 카테고리당 상위 30건 메타 + 본문 일부 → news_articles
|
||||
2. 키워드 추출 ─ 카테고리당 빈도 상위 + Claude Haiku 정제
|
||||
─ trending_keywords (score 내림차순)
|
||||
3. 텔레그램 푸시 ─ 카테고리별 후보 5개씩 인라인 키보드
|
||||
─ 사용자 선택 대기
|
||||
|
||||
[사용자가 텔레그램 인라인 버튼 선택]
|
||||
4. 카피 생성 ─ Claude로 10페이지 카피 (1=훅/커버, 2~9=본문 8장, 10=요약/CTA)
|
||||
─ card_slates 저장 (status='draft')
|
||||
5. 카드 렌더 ─ Jinja → HTML 1080×1350 → Playwright headless 스크린샷 10장
|
||||
─ /app/data/insta_cards/{slate_id}/01.png ~ 10.png
|
||||
6. 텔레그램 ─ 미디어 그룹 10장 + 추천 캡션·해시태그
|
||||
─ 사용자 다운로드 후 인스타 수동 업로드
|
||||
```
|
||||
|
||||
### 자동 모드 (옵션)
|
||||
|
||||
- agent-office의 `agent_config.custom_config.auto_select`(bool) 플래그로 제어
|
||||
- `auto_select=true` 설정 시 키워드 추출 직후 카테고리당 score 1위 키워드를 자동 선택해 4~6 단계까지 즉시 진행
|
||||
- 사용자가 텔레그램에서 결과만 확인 (인라인 후보 푸시 단계 skip)
|
||||
|
||||
---
|
||||
|
||||
## 5. 컴포넌트
|
||||
|
||||
### insta-lab (FastAPI 서비스)
|
||||
|
||||
```
|
||||
insta-lab/
|
||||
├── Dockerfile # python:3.12-slim + playwright install chromium --with-deps
|
||||
├── requirements.txt
|
||||
├── pytest.ini
|
||||
├── tests/
|
||||
└── app/
|
||||
├── main.py # FastAPI 라우터
|
||||
├── config.py # NAVER_*, ANTHROPIC_API_KEY, INSTA_DATA_PATH, CARD_TEMPLATE_DIR
|
||||
├── db.py # 6테이블 init + CRUD
|
||||
├── news_collector.py # 네이버 뉴스 API + 본문 정리
|
||||
├── keyword_extractor.py # 빈도 + LLM 정제
|
||||
├── card_writer.py # Claude 10페이지 카피 생성
|
||||
├── card_renderer.py # Jinja → Playwright 스크린샷
|
||||
└── templates/ # 사용자가 직접 수정 (rsync로 NAS 배포)
|
||||
└── default/
|
||||
└── card.html.j2
|
||||
```
|
||||
|
||||
### agent-office 변경
|
||||
|
||||
```
|
||||
agent-office/app/agents/insta.py (신규)
|
||||
- on_schedule: 09:30 → news collect → keyword extract → 텔레그램 후보 푸시
|
||||
- on_command: extract / render <keyword> / list_categories
|
||||
- on_callback: 텔레그램 inline button "render_<keyword_id>" → 카피·렌더·푸시
|
||||
|
||||
agent-office/app/service_proxy.py
|
||||
- blog_* 함수 모두 제거
|
||||
- insta_* 함수 신규 (collect, extract, list_keywords, create_slate, render_slate, get_slate, get_asset)
|
||||
|
||||
agent-office/app/telegram/agent_registry.py
|
||||
- blog 명령 등록 제거 → insta 명령 등록
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. DB 스키마 (insta.db)
|
||||
|
||||
| 테이블 | 핵심 컬럼 | 설명 |
|
||||
|--------|----------|------|
|
||||
| `news_articles` | id PK, category, title, link UNIQUE, summary, pub_date, fetched_at | 일일 수집 기사 메타 |
|
||||
| `trending_keywords` | id PK, keyword, category, score REAL, articles_count, suggested_at, used INTEGER | 카테고리별 화제 키워드 (used=1이면 이미 슬레이트 생성됨) |
|
||||
| `card_slates` | id PK, keyword, category, status (draft/rendered/sent/failed), cover_copy TEXT, body_copies TEXT(JSON 8개), cta_copy TEXT, suggested_caption TEXT, hashtags TEXT(JSON), created_at | 10페이지 카피 묶음 |
|
||||
| `card_assets` | id PK, slate_id FK→card_slates(id), page_index INTEGER 1~10, file_path, file_hash, created_at | 렌더된 PNG 자산 |
|
||||
| `generation_tasks` | id TEXT PK, type, status, progress, message, result_id INTEGER, error TEXT, params TEXT, created_at, updated_at | blog-lab 패턴 그대로 (collect/extract/write/render 통합) |
|
||||
| `prompt_templates` | id PK, name UNIQUE, description, template TEXT, updated_at | `slate_writer`, `keyword_extractor` 두 개 시드 |
|
||||
|
||||
**인덱스**:
|
||||
- `idx_na_category_fetched` ON news_articles(category, fetched_at DESC)
|
||||
- `idx_tk_score` ON trending_keywords(category, score DESC)
|
||||
- `idx_cs_created` ON card_slates(created_at DESC)
|
||||
- `idx_ca_slate` ON card_assets(slate_id, page_index)
|
||||
|
||||
---
|
||||
|
||||
## 7. 카드 렌더 (Playwright)
|
||||
|
||||
### 템플릿
|
||||
|
||||
`templates/default/card.html.j2` — Jinja 변수:
|
||||
|
||||
| 변수 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| `page_type` | str | "cover" / "body" / "cta" |
|
||||
| `headline` | str | 페이지 헤드라인 |
|
||||
| `body` | str | 본문 (markdown-lite 허용 — 줄바꿈 보존) |
|
||||
| `accent_color` | str | hex (예: "#FF5733") |
|
||||
| `page_no` | int | 1~10 |
|
||||
| `total_pages` | int | 10 |
|
||||
|
||||
컨테이너 CSS: `width: 1080px; height: 1350px; overflow: hidden;`
|
||||
|
||||
### 렌더 로직 (card_renderer.py)
|
||||
|
||||
1. Playwright async chromium browser 1회 launch
|
||||
2. browser.new_context(viewport={"width": 1080, "height": 1350}) → page
|
||||
3. 10번 반복:
|
||||
- Jinja 렌더 → temp HTML 파일 저장
|
||||
- page.goto(`file://...`)
|
||||
- page.screenshot(path=f"{page_no:02}.png", omit_background=False)
|
||||
4. browser.close
|
||||
|
||||
### Dockerfile
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.12-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
fonts-noto-cjk fonts-noto-cjk-extra \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN playwright install chromium --with-deps
|
||||
COPY app ./app
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
이미지 사이즈 +500MB 예상. NAS Celeron J4025에서 카드 10장 렌더 ≤ 30초 목표.
|
||||
|
||||
---
|
||||
|
||||
## 8. API (insta-lab)
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/insta/status` | 서비스 상태 (NAVER/ANTHROPIC 키 여부) |
|
||||
| POST | `/api/insta/news/collect` | 뉴스 수집 수동 트리거 → BackgroundTask |
|
||||
| GET | `/api/insta/news/articles` | 수집 기사 목록 (category, days 필터) |
|
||||
| POST | `/api/insta/keywords/extract` | 키워드 추출 수동 트리거 → BackgroundTask |
|
||||
| GET | `/api/insta/keywords` | 트렌딩 키워드 (category, used 필터) |
|
||||
| POST | `/api/insta/slates` | 슬레이트 생성 (keyword, category) → BackgroundTask |
|
||||
| GET | `/api/insta/slates` | 슬레이트 목록 |
|
||||
| GET | `/api/insta/slates/{id}` | 슬레이트 상세 (카피 + 자산 경로) |
|
||||
| POST | `/api/insta/slates/{id}/render` | 카드 렌더 재시도 |
|
||||
| GET | `/api/insta/slates/{id}/assets/{page}` | 카드 PNG 다운로드 (1~10) |
|
||||
| DELETE | `/api/insta/slates/{id}` | 삭제 (slate + assets) |
|
||||
| GET | `/api/insta/tasks/{task_id}` | BackgroundTask 상태 폴링 |
|
||||
| GET/PUT | `/api/insta/templates/prompts/{name}` | 프롬프트 템플릿 조회·수정 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 키워드 추출 알고리즘
|
||||
|
||||
```python
|
||||
def extract_keywords(category: str, articles: list[Article]) -> list[Keyword]:
|
||||
# 1. 빈도 기반 후보 추출
|
||||
# - 명사 추출 (간단: 한글 2~6자 정규식 + 불용어 제거)
|
||||
# - 카테고리 시드 키워드와 코사인 유사도 ≥ 0.3 이상만
|
||||
raw_freq = count_nouns(articles)
|
||||
candidates = top_n(raw_freq, n=20)
|
||||
|
||||
# 2. Claude Haiku로 정제
|
||||
# - 시스템 프롬프트: "{category} 인스타 카드용 키워드"
|
||||
# - 입력: 후보 20개 + 각 후보가 등장한 기사 제목 3개
|
||||
# - 출력 JSON: [{"keyword": str, "score": 0~1, "reason": str}]
|
||||
refined = claude_haiku_refine(category, candidates, articles)
|
||||
|
||||
# 3. score 내림차순 → 상위 5개 trending_keywords로 저장
|
||||
return refined[:5]
|
||||
```
|
||||
|
||||
- `score`는 LLM이 평가한 "카드 콘텐츠 적합도" (호기심 유발성 + 시의성 + 구체성)
|
||||
- 시드 키워드는 `prompt_templates.name='category_seeds'`에서 카테고리별 JSON으로 관리
|
||||
|
||||
---
|
||||
|
||||
## 10. 카드 카피 생성 (slate_writer)
|
||||
|
||||
Claude 호출 1회로 10페이지 카피 생성:
|
||||
|
||||
```
|
||||
시스템 프롬프트 (DB 저장, 사용자가 수정 가능):
|
||||
- 너는 인스타그램 카드 뉴스 카피라이터다.
|
||||
- {category} 카테고리, 키워드: {keyword}
|
||||
- 출력은 JSON 객체:
|
||||
{
|
||||
"cover_copy": {"headline": str, "body": str, "accent_color": "#hex"},
|
||||
"body_copies": [
|
||||
{"headline": str, "body": str},
|
||||
... (8개)
|
||||
],
|
||||
"cta_copy": {"headline": str, "body": str, "cta": str},
|
||||
"suggested_caption": str,
|
||||
"hashtags": ["#tag1", ...]
|
||||
}
|
||||
|
||||
입력:
|
||||
- 키워드 + 관련 기사 제목·요약 5건
|
||||
```
|
||||
|
||||
`accent_color`는 카테고리별 기본값(경제=#0F62FE, 심리학=#A66CFF, 연예=#FF5C8A) 사용, LLM이 더 어울리면 override.
|
||||
|
||||
---
|
||||
|
||||
## 11. 에러 처리
|
||||
|
||||
| 단계 | 실패 시 |
|
||||
|------|---------|
|
||||
| 뉴스 수집 | 카테고리별 try/except, 한 카테고리 빈 결과여도 다른 카테고리 진행. 모두 실패 시 텔레그램 알림 |
|
||||
| 키워드 추출 | LLM 실패 시 빈도 기반 결과만 사용 (degrade). LLM 타임아웃 60s |
|
||||
| 카피 생성 | LLM 실패 시 BackgroundTask `failed`, 텔레그램 알림. JSON 파싱 실패 시 1회 retry |
|
||||
| 카드 렌더 | Playwright 크래시 시 retry 1회. 실패 시 slate.status='failed' + 텔레그램 알림. 일부 페이지만 실패 시 해당 페이지만 재렌더 가능 |
|
||||
| 텔레그램 미디어 그룹 | 텔레그램 API 10MB/장 제한 → PNG quality 90, 평균 < 500KB 예상. 초과 시 압축 후 재시도 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 테스트
|
||||
|
||||
- pytest 단위 테스트:
|
||||
- `news_collector` mocked HTTP, JSON 파싱 검증
|
||||
- `keyword_extractor` 빈도 추출 단위 + Claude mock
|
||||
- `card_writer` Claude mock, JSON 스키마 검증
|
||||
- `card_renderer` 작은 fixture HTML로 PNG 1장 생성 (실제 Playwright 통합 테스트 1건)
|
||||
- agent-office 통합: `agents/insta.py` mocked service_proxy로 on_schedule·on_command·on_callback 분기 검증
|
||||
|
||||
---
|
||||
|
||||
## 13. 운영·환경
|
||||
|
||||
### 환경변수 (insta-lab)
|
||||
|
||||
| 변수 | 기본값 | 설명 |
|
||||
|------|--------|------|
|
||||
| `NAVER_CLIENT_ID` | (필수) | 네이버 검색 API 키 |
|
||||
| `NAVER_CLIENT_SECRET` | (필수) | 네이버 검색 API 시크릿 |
|
||||
| `ANTHROPIC_API_KEY` | (필수) | Claude API 키 |
|
||||
| `INSTA_DATA_PATH` | `./data/insta` | DB + 카드 PNG 저장 경로 |
|
||||
| `CARD_TEMPLATE_DIR` | `/app/app/templates` | HTML/CSS 템플릿 디렉토리 |
|
||||
| `CORS_ALLOW_ORIGINS` | `*` | CORS 설정 |
|
||||
|
||||
### docker-compose.yml 변경
|
||||
|
||||
- `blog-lab` 서비스 블록 → `insta-lab` 서비스 블록 (포트 18700:8000 그대로)
|
||||
- 볼륨: `./data/insta:/app/data/insta`
|
||||
|
||||
### nginx default.conf 변경
|
||||
|
||||
```
|
||||
location /api/blog-marketing/ { # 제거
|
||||
...
|
||||
}
|
||||
|
||||
location /api/insta/ { # 신규
|
||||
proxy_pass http://insta-lab:8000;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### CLAUDE.md 갱신
|
||||
|
||||
- workspace/CLAUDE.md: blog-lab 표 행 제거 → insta-lab 추가, `/api/blog-marketing/` 행 제거 → `/api/insta/` 추가, 컨테이너 이름·역할 업데이트
|
||||
- web-backend/CLAUDE.md: 9.x 섹션 blog-lab 통째로 → insta-lab 섹션, 4·5 표 갱신
|
||||
|
||||
---
|
||||
|
||||
## 14. 완료 정의
|
||||
|
||||
- [ ] blog-lab 디렉토리·DB 삭제, 컨테이너에서 더 이상 빌드 안 됨
|
||||
- [ ] insta-lab 컨테이너 빌드 및 헬스체크 통과
|
||||
- [ ] `POST /api/insta/news/collect` → news_articles에 카테고리당 30건 저장 확인
|
||||
- [ ] `POST /api/insta/keywords/extract` → trending_keywords 카테고리당 5개 저장
|
||||
- [ ] `POST /api/insta/slates` → 카피 생성 + 카드 PNG 10장 렌더 (수동 호출)
|
||||
- [ ] agent-office의 insta-agent 09:30 cron 등록, 텔레그램 인라인 키보드 후보 푸시 작동
|
||||
- [ ] 텔레그램 인라인 버튼 클릭 → 미디어 그룹 10장 발송 성공
|
||||
- [ ] CLAUDE.md 양쪽 갱신 후 커밋
|
||||
- [ ] pytest 전체 통과
|
||||
251
docs/superpowers/specs/2026-05-16-insta-trends-design.md
Normal file
251
docs/superpowers/specs/2026-05-16-insta-trends-design.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# insta-lab Trends 탭 설계 — 외부 트렌드 수집 + 카테고리 가중치
|
||||
|
||||
작성일: 2026-05-16
|
||||
상태: 사용자 승인 대기 → writing-plans 진입 예정
|
||||
연관 문서: `2026-05-15-insta-agent-design.md` (insta-lab 기본 설계)
|
||||
|
||||
## ⚠️ 변경 이력
|
||||
|
||||
- **2026-05-17**: 본문에 `google_trends` source로 기재된 모든 항목은 **실제 구현에서 `youtube_trending`으로 교체됨**. Google Trends 비공식 endpoint 두 가지(`trendingsearches/daily/rss?geo=KR`, `/trends/api/dailytrends?...`)가 모두 404로 폐기되어 운영 호출이 빈 결과로 끝나는 문제 확인 → YouTube Data API v3 `videos.list?chart=mostPopular®ionCode=KR`로 source 대체. 이후 spec 본문을 읽을 때는 `google_trends` → `youtube_trending`, "Google Trends" → "YouTube 인기"로 치환 해석. 사유와 source 교체 시 동시 갱신 체크리스트: `feedback_external_data_sources.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. 목적·배경
|
||||
|
||||
insta-lab 운영 첫 사이클(2026-05-16 머지·배포 완료)에서 다음 두 가지 한계가 드러남:
|
||||
|
||||
1. **키워드 발견 소스가 사용자 시드 키워드에만 의존** — 진짜 "지금 뜨고 있는" 화제를 잡지 못함. 카테고리당 5개 시드를 고정해두고 거기에 매칭되는 기사만 모음.
|
||||
2. **계정 정체성을 시스템이 모름** — 사용자가 "내 인스타 계정은 경제 위주"라고 정해도 시스템은 모든 카테고리를 균등하게 처리.
|
||||
|
||||
이 spec은 두 한계를 해소하기 위해:
|
||||
- 외부 트렌드 소스(NAVER 인기 + Google Trends)를 추가해 "발견" 단계를 보강
|
||||
- 계정 카테고리 가중치 모델을 도입해 자동 추출 알고리즘이 계정 정체성을 반영
|
||||
|
||||
---
|
||||
|
||||
## 2. 스코프
|
||||
|
||||
### 포함
|
||||
|
||||
- 신규 백엔드 모듈 `trend_collector.py` (NAVER 인기 + Google Trends 두 source)
|
||||
- 신규 백엔드 모듈 변경: `keyword_extractor.py`에 가중치 기반 `extract_with_weights()` 추가
|
||||
- DB 마이그레이션: `trending_keywords` 테이블에 `source` 컬럼 추가, `account_preferences` 신규 테이블
|
||||
- 신규 API 4개 (`POST /trends/collect`, `GET /trends`, `GET/PUT /preferences`)
|
||||
- 09:00 매일 cron 추가 (트렌드 수집), 09:30 cron 가중치 적용
|
||||
- 프론트엔드: InstaCards 페이지에 탭 네비게이션 추가, Trends 탭 신규 3개 패널
|
||||
|
||||
### 제외
|
||||
|
||||
- pytrends 외 외부 SaaS 트렌드 API (BuzzSumo 등)
|
||||
- 트렌드 시계열 차트
|
||||
- 카테고리 자동 학습 (사용자 카드 생성 이력에서 선호도 추론)
|
||||
- 트렌드 알림 (특정 키워드 등장 시 push)
|
||||
|
||||
---
|
||||
|
||||
## 3. 데이터 소스
|
||||
|
||||
### 3-1. NAVER 인기 (source = 'naver_popular')
|
||||
- NAVER news.json API 재사용. 카테고리당 시드 키워드로 `sort=sim` (정확도 정렬 = 인기 시그널) 30건 수집
|
||||
- 응답 기사 묶음에서 빈도어 추출 → 카테고리 매핑 (기존 keyword_extractor의 `_count_nouns` + `_top_candidates` 재사용)
|
||||
- 상위 N개를 `trending_keywords` 테이블에 source='naver_popular'로 저장
|
||||
|
||||
### 3-2. Google Trends (source = 'google_trends')
|
||||
- 라이브러리: `pytrends` (PyPI, MIT)
|
||||
- `TrendReq(hl='ko-KR', tz=540).trending_searches(pn='south_korea')` 호출 → 일일 트렌딩 키워드 리스트
|
||||
- 각 키워드에 대해 Claude Haiku 1회 호출로 카테고리 분류 (`economy` / `psychology` / `celebrity` / 사용자 추가 카테고리 / `uncategorized`)
|
||||
- LLM 분류 비용 절감을 위해 분류 결과를 1일 캐시 — `trend_collector` 모듈 레벨 `_category_cache: dict[str, tuple[str, float]]` (keyword → (category, expires_ts)), 컨테이너 lifetime 동안 유효. 같은 키워드 재요청 시 cache hit. 캐시는 영속화하지 않음 (재시작 시 첫 호출은 LLM 재분류)
|
||||
- `trending_keywords` 테이블에 source='google_trends', score=traffic 정규화값
|
||||
|
||||
### 3-3. 통합 저장
|
||||
|
||||
기존 `trending_keywords` 스키마에 한 컬럼 추가:
|
||||
|
||||
```sql
|
||||
ALTER TABLE trending_keywords ADD COLUMN source TEXT NOT NULL DEFAULT 'manual';
|
||||
-- 기존 row 모두 'manual'로 마킹됨 (시드 키워드에서 추출된 것)
|
||||
-- 신규 source: 'naver_popular' | 'google_trends'
|
||||
```
|
||||
|
||||
`source`별 추가 인덱스:
|
||||
```sql
|
||||
CREATE INDEX idx_tk_source ON trending_keywords(source, suggested_at DESC);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 카테고리 가중치 모델
|
||||
|
||||
### 4-1. 신규 테이블 `account_preferences`
|
||||
|
||||
```sql
|
||||
CREATE TABLE account_preferences (
|
||||
category TEXT PRIMARY KEY,
|
||||
weight REAL NOT NULL DEFAULT 1.0,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
```
|
||||
|
||||
- 초기 시드: `economy=1.0`, `psychology=1.0`, `celebrity=1.0` (균등)
|
||||
- 사용자는 0~10 자유 범위 (UI는 0~100 정수%로 노출, 백엔드에서 0~1 정규화)
|
||||
- 합계 강제 없음. 알고리즘 내부에서 비율 정규화
|
||||
- 카테고리 추가 자유. 단 추가 시 `prompt_templates.category_seeds`에도 시드 키워드 함께 정의해야 자동 추출에 반영됨 (UI에서 안내)
|
||||
|
||||
### 4-2. 가중치 기반 추출 알고리즘
|
||||
|
||||
기존 `keyword_extractor.extract_for_category(category, limit)` 유지. 신규:
|
||||
|
||||
```python
|
||||
def extract_with_weights(weights: dict[str, float], total_limit: int) -> list[Keyword]:
|
||||
"""카테고리 가중치 비율대로 키워드를 분배 추출."""
|
||||
if not weights or sum(weights.values()) == 0:
|
||||
# fallback: 균등 가중치
|
||||
cats = list(DEFAULT_CATEGORY_SEEDS.keys())
|
||||
weights = {c: 1.0 for c in cats}
|
||||
|
||||
total_weight = sum(weights.values())
|
||||
saved = []
|
||||
for category, w in weights.items():
|
||||
if w <= 0:
|
||||
continue
|
||||
per_cat = round(total_limit * w / total_weight)
|
||||
if per_cat <= 0:
|
||||
continue
|
||||
saved.extend(extract_for_category(category, limit=per_cat))
|
||||
return saved
|
||||
```
|
||||
|
||||
- `total_limit` 기본 15 (3 카테고리 × 5 시드 시절 합계와 동일)
|
||||
- weight=0 카테고리는 skip (분류는 유지하되 자동 추출에서 제외하고 싶을 때)
|
||||
|
||||
---
|
||||
|
||||
## 5. API (insta-lab)
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| POST | `/api/insta/trends/collect` | 두 source 모두 수집 (BackgroundTask) → `{task_id}` |
|
||||
| GET | `/api/insta/trends` | 트렌드 조회. query: `source` (`naver_popular`/`google_trends`/`all`), `category`, `days` (default 1, 의미: `suggested_at >= now() - days*24h`). 정렬 `suggested_at DESC, score DESC` |
|
||||
| GET | `/api/insta/preferences` | 가중치 조회 → `{categories: [{category, weight, updated_at}]}` |
|
||||
| PUT | `/api/insta/preferences` | body `{categories: {economy: 0.6, ...}}` → upsert |
|
||||
|
||||
기존 `/api/insta/keywords`는 source 필터 추가 (`?source=manual` 등). 미지정 시 모든 source 반환 (default behavior 유지).
|
||||
|
||||
---
|
||||
|
||||
## 6. 스케줄러 변경 (agent-office InstaAgent)
|
||||
|
||||
기존:
|
||||
- 09:30 — 키워드 추출 → 텔레그램 푸시
|
||||
|
||||
신규:
|
||||
- **09:00 — 외부 트렌드 수집** (NAVER 인기 + Google Trends) — `_run_insta_trends_collect()` 신규 cron
|
||||
- **09:30 — 키워드 추출** (기존 + 가중치 적용) — InstaAgent가 `get_preferences()` 호출 후 `extract_with_weights()` 사용
|
||||
|
||||
수동 트리거: InstaAgent에 `on_command("collect_trends", {})` 신규 액션. 텔레그램에서 `/insta collect_trends` 슬래시 명령 또는 Insta 페이지 버튼에서 호출.
|
||||
|
||||
---
|
||||
|
||||
## 7. 프론트엔드 변경 (web-ui InstaCards.jsx)
|
||||
|
||||
### 7-1. 탭 네비게이션
|
||||
|
||||
기존 5개 패널을 두 탭으로 재구성:
|
||||
|
||||
| 탭 | 패널 |
|
||||
|----|------|
|
||||
| **Cards** (기본) | Trigger, Trending Keywords, Slates, SlateDetail, PromptEditor (기존 그대로) |
|
||||
| **Trends** (신규) | AccountFocusPanel, ExternalTrendsPanel, PreferenceImpactPanel |
|
||||
|
||||
탭 컴포넌트: `<TabBar>` 단순 buttons (`activeTab` state), URL에 `?tab=trends` 쿼리로 deep-link 지원.
|
||||
|
||||
### 7-2. AccountFocusPanel
|
||||
- 카테고리별 가중치 슬라이더 (0~100 정수%) + 우측 막대 차트 (분포 시각화)
|
||||
- **+ 카테고리 추가** 버튼 → 모달로 카테고리명 + 시드 키워드 N개 입력 (시드는 category_seeds 프롬프트 템플릿에 머지)
|
||||
- **저장** 버튼 → `PUT /preferences` (debounce 1초)
|
||||
|
||||
### 7-3. ExternalTrendsPanel
|
||||
- 상단: **🔄 수동 수집** 버튼 + "마지막 수집: HH:MM" 라벨 + 진행 task box
|
||||
- 두 컬럼 (반응형 → 모바일은 세로):
|
||||
- **🔥 NAVER 인기** — 카테고리별 그룹핑, 각 카드: keyword + score + 카테고리 배지
|
||||
- **🌐 Google Trends** — 단순 리스트, 각 카드: keyword + 카테고리 배지 + traffic
|
||||
- 각 카드 우측에 **🎴** 버튼 → 즉시 `POST /slates` (기존 흐름)
|
||||
- 색상 매핑: economy=#0F62FE, psychology=#A66CFF, celebrity=#FF5C8A, custom=#6B7280
|
||||
|
||||
### 7-4. PreferenceImpactPanel (작은 박스)
|
||||
- "현재 가중치 기준 다음 자동 추출 결과 미리보기: economy 3 / psychology 2 / celebrity 0"
|
||||
- 가중치 슬라이더 변경 시 즉시 클라이언트에서 계산해 갱신
|
||||
- 컴팩트 1줄 표시
|
||||
|
||||
### 7-5. 신규 API 헬퍼 (src/api.js)
|
||||
|
||||
```js
|
||||
export function getInstaTrends({ source, category, days = 1 } = {}) { ... }
|
||||
export function instaCollectTrends() { ... }
|
||||
export function getInstaPreferences() { ... }
|
||||
export function putInstaPreferences(categories) { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 에러 처리
|
||||
|
||||
| 상황 | 처리 |
|
||||
|------|------|
|
||||
| pytrends rate limit / 차단 | try/except → 빈 결과로 graceful degrade. NAVER 인기는 정상 수집 |
|
||||
| LLM 분류 실패 | `uncategorized` 카테고리로 폴백, 사용자가 UI에서 수동 재분류 가능 |
|
||||
| 가중치 합계 0 | 균등 가중치 (1/N)로 폴백, 로그 warning |
|
||||
| 카테고리 추가했는데 시드 없음 | 자동 추출에서 자연스럽게 skip (NAVER 검색에 시드 필요), UI에서 "시드 키워드 추가 필요" 경고 |
|
||||
| Google Trends 한국 region 부재 | hl='ko-KR' + pn='south_korea' 명시. 실패 시 빈 결과 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 테스트
|
||||
|
||||
### insta-lab pytest
|
||||
- `test_trend_collector.py` (4): `fetch_naver_popular` mocked, `fetch_google_trends` pytrends mocked, 카테고리 매핑, 캐시 hit
|
||||
- `test_extract_with_weights.py` (3): 균등 가중치, 한쪽 0 가중치, fallback 빈 가중치
|
||||
- `test_preferences_crud.py` (2): GET 기본값, PUT upsert
|
||||
- `test_main_trends.py` (3): 신규 4개 엔드포인트 통합
|
||||
|
||||
### agent-office pytest
|
||||
- `test_insta_agent_trends.py` (2): `on_schedule_trends` mocked, weight-applied extract
|
||||
|
||||
---
|
||||
|
||||
## 10. 마이그레이션 절차
|
||||
|
||||
1. `db.init_db()`에 `ALTER TABLE trending_keywords ADD COLUMN source ...` 추가 — `PRAGMA table_info`로 컬럼 존재 여부 확인 후 idempotent하게 실행
|
||||
2. `account_preferences` 테이블 신규 생성
|
||||
3. 초기 시드: 기존 카테고리 economy/psychology/celebrity 모두 weight=1.0
|
||||
4. 기존 `trending_keywords` row는 자동으로 source='manual' (컬럼 DEFAULT)
|
||||
5. `requirements.txt`에 `pytrends>=4.9` 추가
|
||||
6. 배포 후 사용자가 Trends 탭에서 가중치 조정 (필수 아님, 균등이 디폴트 동작)
|
||||
|
||||
---
|
||||
|
||||
## 11. 운영 영향
|
||||
|
||||
| 항목 | 영향 |
|
||||
|------|------|
|
||||
| Anthropic 토큰 비용 | +미미 (Google Trends 1회당 ~20 키워드 × Haiku 분류 1콜 ≈ 600 토큰/일) |
|
||||
| DB 크기 | +미미 (트렌드 row 일일 ~50개, 카테고리당 30 + Google 20) |
|
||||
| NAS CPU | +낮음 (pytrends + NAVER API 호출만, LLM은 외부) |
|
||||
| 카드 생성 흐름 | 변경 없음. 트렌드는 "발견" 단계만 보강 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 완료 정의
|
||||
|
||||
- [ ] `trending_keywords.source` 컬럼 마이그레이션 적용, 기존 row 모두 'manual'로 표시됨
|
||||
- [ ] `account_preferences` 테이블 생성, 초기 3개 카테고리 weight=1.0
|
||||
- [ ] `POST /api/insta/trends/collect` 호출 시 NAVER 인기 + Google Trends 모두 수집되어 DB 저장
|
||||
- [ ] `GET /api/insta/trends?source=google_trends` 결과 카테고리 분류됨
|
||||
- [ ] `PUT /api/insta/preferences` 후 09:30 cron이 가중치 비율대로 추출
|
||||
- [ ] 09:00 cron 등록, 매일 자동 트렌드 수집
|
||||
- [ ] Insta 페이지에 Cards/Trends 탭 전환 작동
|
||||
- [ ] Trends 탭의 AccountFocusPanel에서 가중치 변경·저장 가능
|
||||
- [ ] ExternalTrendsPanel에서 NAVER 인기 + Google Trends 한 눈에 표시, 각 카드 생성 트리거 작동
|
||||
- [ ] PreferenceImpactPanel 미리보기 갱신
|
||||
- [ ] insta-lab pytest 전체 통과 (기존 21 + 신규 12 = 33)
|
||||
- [ ] agent-office pytest 전체 통과
|
||||
@@ -0,0 +1,294 @@
|
||||
# insta-lab Design Importer — Claude Vision으로 이미지 디자인 → Jinja HTML 자동 생성
|
||||
|
||||
작성일: 2026-05-17
|
||||
상태: 사용자 승인 대기 → writing-plans 진입 예정
|
||||
연관 문서: `2026-05-15-insta-agent-design.md`, `2026-05-16-insta-trends-design.md`, `feedback_external_data_sources.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 목적·배경
|
||||
|
||||
insta-lab의 카드 렌더는 현재 `templates/default/card.html.j2` 한 골격만 사용 (단순 그라데이션 + Noto Sans KR). 사용자가 직접 디자인한 10장 카드 이미지(`templates/minimal/pages/insta_card_*.png`)를 이미 NAS에 배포한 상태인데, 이 이미지들이 카드 렌더에 반영되지 않음.
|
||||
|
||||
이 spec은 사용자가 만든 디자인 이미지를 **카드 렌더 파이프라인에 통합**하는 메커니즘을 정의한다. 핵심은 Claude Vision으로 10장 PNG를 분석해 페이지별 텍스트 영역·색·폰트·레이아웃을 도출하고, 이를 그대로 모방한 단일 Jinja2 HTML 파일을 자동 생성하는 것이다. 생성된 HTML은 동적 카피(headline, body, cta)를 사용자 디자인 위에 layer로 얹어 일관된 시각 + 동적 텍스트를 동시에 확보한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 스코프
|
||||
|
||||
### 포함
|
||||
|
||||
- 신규 백엔드 모듈 `insta-lab/app/design_importer.py` — 10장 PNG → Claude Sonnet Vision → `card.html.j2` 생성
|
||||
- CLI 진입점 `python -m app.design_importer <theme_name>` (운영자가 한 번씩 실행)
|
||||
- 환경변수 `INSTA_DEFAULT_THEME` 신규 (default="default") — 모든 슬레이트가 이 theme 사용
|
||||
- `card_renderer.render_slate`에 theme 전달 (기존 `template` 인자 활용, 호출자만 변경)
|
||||
- pytest: Vision 호출 mock + 출력 HTML 파싱 검증
|
||||
|
||||
### 제외 (후속)
|
||||
|
||||
- API endpoint `POST /api/insta/templates/import` — UI에서 트리거 가능
|
||||
- `card_slates.theme` 컬럼 — 슬레이트별 다른 theme 선택
|
||||
- 다중 theme 비교/A·B 테스트 UI
|
||||
- 자동 theme 추천 (트렌드 카테고리별 다른 theme)
|
||||
|
||||
---
|
||||
|
||||
## 3. 데이터·디렉토리 구조
|
||||
|
||||
```
|
||||
insta-lab/app/templates/
|
||||
├── default/ # 기존 — 폴백 / 초기 골격
|
||||
│ ├── card.html.j2
|
||||
│ └── .gitkeep
|
||||
└── <theme_name>/ # 사용자 디자인 1세트 (반복 가능)
|
||||
├── pages/ # 사용자가 git commit으로 업로드
|
||||
│ ├── insta_card_start.png # 의미 있는 이름 권장 (Claude가 페이지 의도 파악에 활용)
|
||||
│ ├── insta_card_keyword.png
|
||||
│ ├── ... (총 10장)
|
||||
│ └── README.md (선택, 디자인 의도 메모)
|
||||
└── card.html.j2 # design_importer가 자동 생성
|
||||
```
|
||||
|
||||
**파일명 컨벤션**:
|
||||
- 페이지 번호 매핑은 사용자가 제공하지 않음. design_importer가 다음 순서로 자동 매핑:
|
||||
1. 파일명에 `cover` > `start` > `intro` 키워드 포함 (우선순위 순서) → page 1 (커버). 여러 파일이 매치되면 가장 앞 키워드를 가진 파일만 선택, 나머지는 본문 풀로
|
||||
2. 파일명에 `cta` > `outro` > `finish` > `end` 키워드 포함 (우선순위 순서) → page 10. 동일하게 첫 매치만 page 10, 나머지는 본문 풀로
|
||||
3. 남은 8장은 알파벳 정렬 순으로 page 2~9 (본문)
|
||||
- **현재 운영 케이스**: `insta_card_start.png`(start=1순위) → page 1, `insta_card_cta.png`(cta=1순위) → page 10, `insta_card_finish.png`는 finish=3순위인데 cta가 이미 page 10이므로 본문 풀로 떨어져 알파벳 순에 따라 page 2~9 어딘가 배치됨
|
||||
- 사용자가 매핑을 override하려면 `pages/_order.json` 파일에 `{"insta_card_start.png": 1, "insta_card_finish.png": 10, ...}` 명시 가능 (충돌·의도 명시 시 강력 권장)
|
||||
- 매핑이 의도와 어긋나면 importer 실행 결과 dict의 `page_mapping` 필드로 확인 후 `_order.json` 추가하고 재실행
|
||||
|
||||
---
|
||||
|
||||
## 4. 핵심 모듈 `design_importer.py`
|
||||
|
||||
### 4-1. Public API
|
||||
|
||||
```python
|
||||
def import_design_theme(theme_name: str) -> dict:
|
||||
"""templates/<theme>/pages/*.png 10장 → Claude Sonnet Vision → card.html.j2 생성.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"theme_name": str,
|
||||
"html_path": str,
|
||||
"page_mapping": {filename: page_no, ...},
|
||||
"analysis_summary": str, # Claude가 도출한 디자인 분석 짧은 요약
|
||||
"tokens_used": int,
|
||||
}
|
||||
|
||||
Raises:
|
||||
ValueError: pages/ 폴더에 PNG 10장 미만이거나 매핑 실패
|
||||
anthropic.APIError: Vision 호출 실패 (retry 1회 후)
|
||||
"""
|
||||
```
|
||||
|
||||
### 4-2. 처리 흐름
|
||||
|
||||
1. `templates/<theme>/pages/` 폴더 스캔 → PNG 10장 검증 (10장 정확히)
|
||||
2. 파일명 → 페이지 매핑 결정 (3장 규칙 + 선택적 `_order.json` override)
|
||||
3. 각 PNG base64 인코딩
|
||||
4. Claude Sonnet(`claude-sonnet-4-6`) Vision 호출 1회:
|
||||
- 시스템 프롬프트: 디자이너 역할 + 출력 형식 명세
|
||||
- 사용자 메시지: 10장 이미지 + 페이지 매핑 정보 + 변수 명세 (`page_no`, `headline`, `body`, `cta`)
|
||||
- 출력 요청: 단일 Jinja2 HTML 파일 (page_no 분기 + 텍스트 영역 절대 위치 CSS + `background-image: url('pages/{{filename}}')`)
|
||||
5. 응답 HTML 파싱 + Jinja Environment로 sanity render 1회 (분기·문법 검증)
|
||||
6. `templates/<theme>/card.html.j2`에 저장
|
||||
7. dict 반환
|
||||
|
||||
### 4-3. Vision 프롬프트 스킴 (placeholder 텍스트 마스킹 포함)
|
||||
|
||||
**중요 제약**: 사용자 PNG에는 **placeholder 텍스트가 이미 박혀있다**. 동적 카피(headline, body, cta)로 교체해야 하며 원본 placeholder 텍스트는 보이면 안 된다. 따라서 단순히 텍스트 layer를 얹는 것만으로는 부족하고, 원본 텍스트가 있던 영역을 그 영역의 **배경색으로 덮은 후** 그 위에 새 텍스트를 그려야 한다.
|
||||
|
||||
시스템 프롬프트 (요약):
|
||||
```
|
||||
너는 인스타그램 카드 뉴스 디자인을 모방하는 프론트엔드 디자이너다.
|
||||
입력: 10장의 카드 디자인 이미지 (각 1080×1350, placeholder 텍스트 포함) + 페이지 번호 매핑.
|
||||
출력: 단일 Jinja2 HTML 파일.
|
||||
|
||||
요구사항:
|
||||
- 컨테이너 width 1080px, height 1350px
|
||||
- background-image로 해당 페이지 PNG를 url('pages/{{filename}}')로 로드
|
||||
- 각 페이지에서 placeholder 텍스트가 있는 영역을 식별하고, 다음 두 layer를 그 위에 그린다:
|
||||
(a) 마스킹 박스: position: absolute로 텍스트 영역과 같은 좌표·크기.
|
||||
background는 PNG의 그 영역 주변 픽셀 색 (보통 카드 배경색)에서 추출.
|
||||
placeholder가 완전히 가려지도록 padding 8px 정도 여유.
|
||||
(b) 동적 텍스트 layer: 마스킹 박스와 동일 좌표.
|
||||
font-size·font-weight·color는 원본 placeholder 폰트 스타일을 그대로 모방.
|
||||
`{{ headline }}`, `{{ body }}`, `{{ cta }}` (page_no=10에서만) Jinja 변수 사용.
|
||||
- 페이지 종류별 영역 추정:
|
||||
· page 1 (cover): 메인 헤드라인 1개 영역. 보통 화면 상단 1/3 또는 중앙
|
||||
· page 2~9 (body): 헤드라인 1개 + 본문 1개 영역 (보통 헤드라인 상단, 본문 그 아래)
|
||||
· page 10 (cta): 헤드라인 1개 + 본문 1개 + CTA 강조 텍스트 1개 영역
|
||||
- page_no 1~10 분기: {% if page_no == N %}...{% endif %} 구조
|
||||
- 폰트는 Noto Sans KR (Google Fonts CDN), letter-spacing -0.02em
|
||||
- 텍스트 영역은 word-wrap: break-word + overflow: hidden으로 길이 초과 시도 마스킹 박스 밖으로 새지 않게
|
||||
- 출력은 <!DOCTYPE html>로 시작하는 완전한 HTML 본문만 (```html 코드펜스·설명 텍스트 금지)
|
||||
```
|
||||
|
||||
사용자 메시지에 각 이미지 + filename + page_no 매핑 포함.
|
||||
|
||||
**시각 품질 보장 절차** (importer 운영 후 사용자 검증):
|
||||
1. 첫 import 후 1개 슬레이트 생성해서 PNG 10장 육안 확인
|
||||
2. placeholder 텍스트가 비치거나 마스킹 박스가 어색하면 — `card.html.j2`를 직접 수정해서 영역 좌표·색 fine-tune (백업 자동 보존)
|
||||
3. 새 디자인을 import할 일 있을 때까지는 수동 수정본 그대로 사용
|
||||
|
||||
### 4-4. 캐시 / 재실행 정책
|
||||
|
||||
- 이미 `card.html.j2`가 존재하면 덮어쓰기 (사용자 명시적 재import 의도)
|
||||
- 백업: 기존 HTML이 있으면 `card.html.j2.bak.YYYYMMDD-HHMMSS`로 rename 후 새 파일 작성
|
||||
- 분석 결과 캐시 X (재실행할 때마다 최신 결과)
|
||||
|
||||
---
|
||||
|
||||
## 5. CLI 진입점
|
||||
|
||||
```bash
|
||||
# 컨테이너 내부에서 실행
|
||||
docker exec insta-lab python -m app.design_importer <theme_name>
|
||||
|
||||
# 결과 stdout (예시)
|
||||
{
|
||||
"theme_name": "minimal",
|
||||
"html_path": "/app/app/templates/minimal/card.html.j2",
|
||||
"page_mapping": {
|
||||
"insta_card_start.png": 1,
|
||||
"insta_card_keyword.png": 2,
|
||||
...
|
||||
"insta_card_cta.png": 10
|
||||
},
|
||||
"analysis_summary": "미니멀 카드 — 흰 배경 + 검정 헤드라인 + 회색 본문...",
|
||||
"tokens_used": 15234
|
||||
}
|
||||
```
|
||||
|
||||
`__main__` 가드: argparse로 `theme_name` 위치 인자 + `--force` (기존 HTML 백업 없이 덮어쓰기) 옵션. 실패 시 exit 1.
|
||||
|
||||
---
|
||||
|
||||
## 6. 카드 렌더 통합
|
||||
|
||||
### 6-1. 환경변수 추가 (`config.py`)
|
||||
|
||||
```python
|
||||
INSTA_DEFAULT_THEME = os.getenv("INSTA_DEFAULT_THEME", "default")
|
||||
```
|
||||
|
||||
### 6-2. `main.py:_bg_create_slate` 호출 변경
|
||||
|
||||
기존:
|
||||
```python
|
||||
await card_renderer.render_slate(sid)
|
||||
```
|
||||
|
||||
신규:
|
||||
```python
|
||||
template_path = f"{INSTA_DEFAULT_THEME}/card.html.j2"
|
||||
await card_renderer.render_slate(sid, template=template_path)
|
||||
```
|
||||
|
||||
`card_renderer.render_slate`는 이미 `template` 인자를 받으며 default 값이 `"default/card.html.j2"`. 변경 없음.
|
||||
|
||||
### 6-3. `card_renderer` 폴백 가드
|
||||
|
||||
`render_slate` 시작부에 template 파일 존재 확인 추가:
|
||||
```python
|
||||
template_full = Path(_resolve_template_dir()) / template
|
||||
if not template_full.exists():
|
||||
logger.warning("Template %s 없음, default로 폴백", template)
|
||||
template = "default/card.html.j2"
|
||||
```
|
||||
|
||||
→ env에 `INSTA_DEFAULT_THEME=minimal` 설정했는데 `minimal/card.html.j2`가 아직 import 안 됐으면 자동 default 폴백.
|
||||
|
||||
### 6-4. 운영 활성화 절차
|
||||
|
||||
```bash
|
||||
# 1. 이미지 commit + push (이미 완료 — minimal/pages/ 10장)
|
||||
# 2. NAS 머지 후 design_importer 실행
|
||||
docker exec insta-lab python -m app.design_importer minimal
|
||||
|
||||
# 3. NAS .env에 추가
|
||||
echo "INSTA_DEFAULT_THEME=minimal" >> /volume1/docker/webpage/.env
|
||||
|
||||
# 4. 컨테이너 재시작 (env 재로드)
|
||||
docker compose restart insta-lab
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 에러 처리
|
||||
|
||||
| 상황 | 처리 |
|
||||
|------|------|
|
||||
| `pages/` 폴더 없음 또는 PNG 10장 미만 | ValueError + 어떤 파일이 빠졌는지 명시. 모든 이미지가 1080×1350인지도 검증 (Pillow로 size 체크) |
|
||||
| Vision 호출 실패 (network, rate limit) | retry 1회 (5초 대기), 그래도 실패 시 anthropic.APIError 전파 |
|
||||
| Vision 응답이 HTML이 아님 / Jinja 문법 깨짐 | Jinja Environment로 sanity render 시도 → 실패 시 raw 응답을 `card.html.j2.error.txt`에 저장 + ValueError 전파 (운영자가 수동 수정 가능) |
|
||||
| Vision 응답이 max_tokens(16K) 초과 → 잘림 | 응답 끝이 닫힌 `</html>` 없으면 잘렸다고 판단, max_tokens 24K로 retry 1회 |
|
||||
| 이미지 base64 인코딩 실패 (파일 깨짐) | 어느 파일이 문제인지 로그 + ValueError |
|
||||
| `_order.json` 형식 깨짐 | log warning + 자동 매핑 규칙으로 폴백 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 테스트
|
||||
|
||||
### `insta-lab/tests/test_design_importer.py` (~6 케이스)
|
||||
|
||||
1. `test_auto_page_mapping_with_cover_and_cta`: 의미 이름 파일 10개 → cover→1, cta→10, 나머지 알파벳 순
|
||||
2. `test_explicit_order_json_overrides`: `_order.json` 있으면 그것 우선
|
||||
3. `test_validates_exactly_ten_pngs`: 9장 또는 11장이면 ValueError
|
||||
4. `test_validates_image_dimensions`: 1080×1350 아닌 이미지 있으면 ValueError + 어떤 파일인지
|
||||
5. `test_import_generates_html_via_mocked_claude`: Anthropic Vision mock, 응답 HTML이 Jinja 렌더 가능한 형식인지 검증
|
||||
6. `test_import_falls_back_on_jinja_parse_failure`: mock이 깨진 HTML 반환 시 ValueError + `.error.txt` 저장
|
||||
|
||||
### `insta-lab/tests/test_card_renderer.py` (기존, 보강 1개)
|
||||
|
||||
7. `test_render_falls_back_to_default_when_theme_html_missing`: `template="ghost/card.html.j2"` 지정 시 파일 없어도 default로 폴백 + 정상 PNG 생성
|
||||
|
||||
---
|
||||
|
||||
## 9. 운영 영향
|
||||
|
||||
| 항목 | 영향 |
|
||||
|------|------|
|
||||
| Anthropic 토큰 비용 | +1회당 ~15K 토큰 (이미지 10장 × ~1K + 프롬프트 + HTML 출력). Claude Sonnet 단가 기준 ~$0.05/import. 자주 실행 X |
|
||||
| 빌드 시간 | 영향 없음 (코드 변경만, 의존성 추가 없음) |
|
||||
| 카드 렌더 시간 | 영향 없음 (Playwright는 background-image까지 wait_until="networkidle"로 처리) |
|
||||
| 디스크 | 사용자 디자인 PNG 12MB (이미 push됨) + 자동 생성 HTML ~10KB |
|
||||
| 운영 중 카드 품질 | env `INSTA_DEFAULT_THEME=minimal` 설정 후 다음 슬레이트부터 사용자 디자인 적용. 기존 슬레이트는 default 그대로 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 마이그레이션 절차
|
||||
|
||||
배포 후 사용자가 운영 NAS에서 수동 실행:
|
||||
|
||||
1. PR 머지 → webhook으로 `design_importer.py` 코드 배포 + minimal/ 디렉토리는 이미 배포됨
|
||||
2. SSH NAS:
|
||||
```bash
|
||||
docker exec insta-lab python -m app.design_importer minimal
|
||||
```
|
||||
3. 결과 JSON에서 `html_path`와 `page_mapping` 확인. 매핑이 의도와 다르면 `pages/_order.json`로 override 후 재실행
|
||||
4. `.env`에 `INSTA_DEFAULT_THEME=minimal` 추가
|
||||
5. `docker compose restart insta-lab` (env 재로드)
|
||||
6. 새 슬레이트 1개 만들어서 시각 검증 (Insta 페이지 Trends 탭 또는 수동 트리거)
|
||||
|
||||
생성된 `card.html.j2`가 마음에 안 들면:
|
||||
- `pages/_order.json`으로 페이지 순서 조정 후 importer 재실행
|
||||
- 또는 자동 생성 HTML을 사용자가 직접 수정 (importer 재실행 안 함)
|
||||
- 백업본 `card.html.j2.bak.YYYYMMDD-HHMMSS`로 롤백 가능
|
||||
|
||||
---
|
||||
|
||||
## 11. 완료 정의
|
||||
|
||||
- [ ] `insta-lab/app/design_importer.py` 작성, CLI `python -m app.design_importer` 작동
|
||||
- [ ] `_resolve_page_mapping` + 의미 이름 기반 자동 매핑 + `_order.json` override
|
||||
- [ ] Vision 호출 mock 기반 pytest 6 케이스 PASS
|
||||
- [ ] `card_renderer.render_slate`에 theme 폴백 가드 추가, 테스트 1 케이스 PASS
|
||||
- [ ] `insta-lab/app/config.py`에 `INSTA_DEFAULT_THEME` 추가
|
||||
- [ ] `insta-lab/app/main.py:_bg_create_slate`가 `INSTA_DEFAULT_THEME` 사용
|
||||
- [ ] `docker-compose.yml` insta-lab 환경변수에 `INSTA_DEFAULT_THEME=${INSTA_DEFAULT_THEME:-default}` 추가
|
||||
- [ ] CLAUDE.md 9.x insta-lab 섹션에 design_importer + INSTA_DEFAULT_THEME 항목 추가
|
||||
- [ ] 운영 NAS에서 `docker exec insta-lab python -m app.design_importer minimal` 실행 → `card.html.j2` 생성 확인
|
||||
- [ ] `.env` 설정 + 새 슬레이트 1개 생성 → 시각적으로 minimal 디자인 반영 확인
|
||||
26
insta-lab/Dockerfile
Normal file
26
insta-lab/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
FROM python:3.12-slim-bookworm
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Korean fonts + Chromium runtime deps (Debian 12 / bookworm)
|
||||
# `playwright install --with-deps`를 쓰지 않는 이유: 그 명령은 Ubuntu 패키지명을
|
||||
# 사용해 Debian에서 ttf-ubuntu-font-family / ttf-unifont 등 없는 패키지를 시도
|
||||
# → apt 실패. 대신 Chromium이 실제 필요로 하는 라이브러리만 명시 설치.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
fonts-noto-cjk fonts-noto-cjk-extra \
|
||||
libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \
|
||||
libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 \
|
||||
libxfixes3 libxrandr2 libgbm1 libxshmfence1 libpango-1.0-0 \
|
||||
libcairo2 libasound2 libatspi2.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
# --timeout 600 --retries 5: NAS 느린 네트워크/CPU에서 pip 다운로드 timeout 방지
|
||||
RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt
|
||||
RUN playwright install chromium
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
0
insta-lab/app/__init__.py
Normal file
0
insta-lab/app/__init__.py
Normal file
108
insta-lab/app/card_renderer.py
Normal file
108
insta-lab/app/card_renderer.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Jinja → HTML → Playwright headless screenshot."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
from .config import CARDS_DIR, CARD_TEMPLATE_DIR
|
||||
from . import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_template_dir() -> str:
|
||||
"""Prefer config CARD_TEMPLATE_DIR if it exists; else fall back to in-repo templates/."""
|
||||
if os.path.isdir(CARD_TEMPLATE_DIR):
|
||||
return CARD_TEMPLATE_DIR
|
||||
return os.path.join(os.path.dirname(__file__), "templates")
|
||||
|
||||
|
||||
def _env() -> Environment:
|
||||
return Environment(
|
||||
loader=FileSystemLoader(_resolve_template_dir()),
|
||||
autoescape=select_autoescape(["html", "j2"]),
|
||||
)
|
||||
|
||||
|
||||
def _slate_dir(slate_id: int) -> str:
|
||||
out = os.path.join(CARDS_DIR, str(slate_id))
|
||||
os.makedirs(out, exist_ok=True)
|
||||
return out
|
||||
|
||||
|
||||
def _build_pages(slate: dict) -> List[dict]:
|
||||
cover = json.loads(slate["cover_copy"] or "{}")
|
||||
bodies = json.loads(slate["body_copies"] or "[]")
|
||||
cta = json.loads(slate["cta_copy"] or "{}")
|
||||
accent = cover.get("accent_color") or "#0F62FE"
|
||||
pages: List[dict] = []
|
||||
pages.append({
|
||||
"page_type": "cover", "page_no": 1, "total_pages": 10,
|
||||
"headline": cover.get("headline", ""), "body": cover.get("body", ""),
|
||||
"accent_color": accent, "cta": "",
|
||||
})
|
||||
for i, b in enumerate(bodies[:8]):
|
||||
pages.append({
|
||||
"page_type": "body", "page_no": i + 2, "total_pages": 10,
|
||||
"headline": b.get("headline", ""), "body": b.get("body", ""),
|
||||
"accent_color": accent, "cta": "",
|
||||
})
|
||||
pages.append({
|
||||
"page_type": "cta", "page_no": 10, "total_pages": 10,
|
||||
"headline": cta.get("headline", ""), "body": cta.get("body", ""),
|
||||
"accent_color": accent, "cta": cta.get("cta", ""),
|
||||
})
|
||||
return pages
|
||||
|
||||
|
||||
async def render_slate(slate_id: int, template: str = "default/card.html.j2") -> List[str]:
|
||||
slate = db.get_card_slate(slate_id)
|
||||
if not slate:
|
||||
raise ValueError(f"slate {slate_id} not found")
|
||||
env = _env()
|
||||
|
||||
# template 파일이 없으면 default로 폴백 (INSTA_DEFAULT_THEME가 import 안 된 theme이면 안전)
|
||||
template_full = Path(_resolve_template_dir()) / template
|
||||
if not template_full.exists():
|
||||
logger.warning("Template '%s' 없음 → 'default/card.html.j2'로 폴백", template)
|
||||
template = "default/card.html.j2"
|
||||
|
||||
tmpl = env.get_template(template)
|
||||
pages = _build_pages(slate)
|
||||
out_dir = _slate_dir(slate_id)
|
||||
paths: List[str] = []
|
||||
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch()
|
||||
try:
|
||||
ctx = await browser.new_context(viewport={"width": 1080, "height": 1350})
|
||||
page = await ctx.new_page()
|
||||
for spec in pages:
|
||||
html_str = tmpl.render(**spec)
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False, encoding="utf-8") as f:
|
||||
f.write(html_str)
|
||||
html_path = f.name
|
||||
try:
|
||||
await page.goto(f"file://{html_path}", wait_until="networkidle")
|
||||
out_path = os.path.join(out_dir, f"{spec['page_no']:02d}.png")
|
||||
await page.screenshot(path=out_path, full_page=False, omit_background=False)
|
||||
with open(out_path, "rb") as fp:
|
||||
file_hash = hashlib.md5(fp.read()).hexdigest()
|
||||
db.add_card_asset(slate_id, spec["page_no"], out_path, file_hash)
|
||||
paths.append(out_path)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(html_path)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
await browser.close()
|
||||
return paths
|
||||
100
insta-lab/app/card_writer.py
Normal file
100
insta-lab/app/card_writer.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Claude로 10페이지 카드 카피를 한 번에 생성."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from anthropic import Anthropic
|
||||
|
||||
from .config import ANTHROPIC_API_KEY, ANTHROPIC_MODEL_SONNET
|
||||
from . import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_ACCENT_BY_CATEGORY = {
|
||||
"economy": "#0F62FE",
|
||||
"psychology": "#A66CFF",
|
||||
"celebrity": "#FF5C8A",
|
||||
}
|
||||
|
||||
DEFAULT_PROMPT = """너는 인스타그램 카드 뉴스 카피라이터다.
|
||||
카테고리: {category}
|
||||
키워드: {keyword}
|
||||
참고 기사:
|
||||
{articles}
|
||||
|
||||
10페이지 인스타 카드용 카피를 다음 JSON 한 객체로만 출력해라 (코드펜스 금지):
|
||||
{{
|
||||
"cover_copy": {{"headline": "<훅 한 줄>", "body": "<서브카피 1~2줄>", "accent_color": "#hex"}},
|
||||
"body_copies": [
|
||||
{{"headline": "<포인트 헤드라인>", "body": "<2~4문장 본문>"}},
|
||||
... (총 8개)
|
||||
],
|
||||
"cta_copy": {{"headline": "<요약 한 줄>", "body": "<마무리 1~2줄>", "cta": "팔로우/저장 등"}},
|
||||
"suggested_caption": "<인스타 캡션 본문>",
|
||||
"hashtags": ["#태그1", "#태그2", ...]
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def _client() -> Anthropic:
|
||||
return Anthropic(api_key=ANTHROPIC_API_KEY)
|
||||
|
||||
|
||||
def _strip_codefence(s: str) -> str:
|
||||
s = s.strip()
|
||||
if s.startswith("```"):
|
||||
s = re.sub(r"^```(?:json)?\s*|\s*```$", "", s).strip()
|
||||
return s
|
||||
|
||||
|
||||
def _load_prompt() -> str:
|
||||
pt = db.get_prompt_template("slate_writer")
|
||||
if pt and pt.get("template"):
|
||||
return pt["template"]
|
||||
return DEFAULT_PROMPT
|
||||
|
||||
|
||||
def write_slate(keyword: str, category: str,
|
||||
articles: Optional[list] = None) -> int:
|
||||
"""Claude로 10페이지 카피 생성 후 card_slates에 저장. slate_id 반환."""
|
||||
if articles is None:
|
||||
articles = db.list_news_articles(category=category, days=2)
|
||||
article_text = "\n".join(
|
||||
f"- {a['title']}: {a.get('summary', '')[:120]}" for a in articles[:8]
|
||||
) or "(참고 기사 없음)"
|
||||
|
||||
prompt = _load_prompt().format(category=category, keyword=keyword, articles=article_text)
|
||||
msg = _client().messages.create(
|
||||
model=ANTHROPIC_MODEL_SONNET,
|
||||
max_tokens=4000,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
raw = msg.content[0].text
|
||||
cleaned = _strip_codefence(raw)
|
||||
try:
|
||||
data: Dict[str, Any] = json.loads(cleaned)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning("slate JSON parse failed: %s", e)
|
||||
raise ValueError(f"Invalid JSON from LLM: {e}") from e
|
||||
|
||||
body_copies = data.get("body_copies") or []
|
||||
if len(body_copies) != 8:
|
||||
raise ValueError(f"body_copies must have 8 items, got {len(body_copies)}")
|
||||
|
||||
cover = data.get("cover_copy") or {}
|
||||
if not cover.get("accent_color"):
|
||||
cover["accent_color"] = DEFAULT_ACCENT_BY_CATEGORY.get(category, "#222831")
|
||||
|
||||
sid = db.add_card_slate({
|
||||
"keyword": keyword,
|
||||
"category": category,
|
||||
"status": "draft",
|
||||
"cover_copy": cover,
|
||||
"body_copies": body_copies,
|
||||
"cta_copy": data.get("cta_copy") or {},
|
||||
"suggested_caption": data.get("suggested_caption") or "",
|
||||
"hashtags": data.get("hashtags") or [],
|
||||
})
|
||||
return sid
|
||||
27
insta-lab/app/config.py
Normal file
27
insta-lab/app/config.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import os
|
||||
|
||||
NAVER_CLIENT_ID = os.getenv("NAVER_CLIENT_ID", "")
|
||||
NAVER_CLIENT_SECRET = os.getenv("NAVER_CLIENT_SECRET", "")
|
||||
YOUTUBE_DATA_API_KEY = os.getenv("YOUTUBE_DATA_API_KEY", "")
|
||||
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
||||
ANTHROPIC_MODEL_HAIKU = os.getenv("ANTHROPIC_MODEL_HAIKU", "claude-haiku-4-5-20251001")
|
||||
ANTHROPIC_MODEL_SONNET = os.getenv("ANTHROPIC_MODEL_SONNET", "claude-sonnet-4-6")
|
||||
|
||||
INSTA_DATA_PATH = os.getenv("INSTA_DATA_PATH", "/app/data")
|
||||
DB_PATH = os.path.join(INSTA_DATA_PATH, "insta.db")
|
||||
CARDS_DIR = os.path.join(INSTA_DATA_PATH, "insta_cards")
|
||||
CARD_TEMPLATE_DIR = os.getenv("CARD_TEMPLATE_DIR", "/app/app/templates")
|
||||
INSTA_DEFAULT_THEME = os.getenv("INSTA_DEFAULT_THEME", "default")
|
||||
|
||||
CORS_ALLOW_ORIGINS = os.getenv(
|
||||
"CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080"
|
||||
)
|
||||
|
||||
NEWS_PER_CATEGORY = int(os.getenv("NEWS_PER_CATEGORY", "30"))
|
||||
KEYWORDS_PER_CATEGORY = int(os.getenv("KEYWORDS_PER_CATEGORY", "5"))
|
||||
|
||||
DEFAULT_CATEGORY_SEEDS = {
|
||||
"economy": ["금리", "인플레이션", "환율", "주식", "부동산"],
|
||||
"psychology": ["심리학", "스트레스", "우울증", "관계", "자존감"],
|
||||
"celebrity": ["연예인", "드라마", "예능", "K-POP", "영화"],
|
||||
}
|
||||
352
insta-lab/app/db.py
Normal file
352
insta-lab/app/db.py
Normal file
@@ -0,0 +1,352 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import json
|
||||
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=120.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=120000")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS news_articles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
link TEXT NOT NULL UNIQUE,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
pub_date TEXT,
|
||||
fetched_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_na_category_fetched ON news_articles(category, fetched_at DESC)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS trending_keywords (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
keyword TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
score REAL NOT NULL DEFAULT 0,
|
||||
articles_count INTEGER NOT NULL DEFAULT 0,
|
||||
suggested_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
used INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_tk_score ON trending_keywords(category, score DESC)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS card_slates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
keyword TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
cover_copy TEXT NOT NULL DEFAULT '{}',
|
||||
body_copies TEXT NOT NULL DEFAULT '[]',
|
||||
cta_copy TEXT NOT NULL DEFAULT '{}',
|
||||
suggested_caption TEXT NOT NULL DEFAULT '',
|
||||
hashtags 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_cs_created ON card_slates(created_at DESC)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS card_assets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slate_id INTEGER NOT NULL REFERENCES card_slates(id) ON DELETE CASCADE,
|
||||
page_index INTEGER NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
file_hash TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
UNIQUE (slate_id, page_index)
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_ca_slate ON card_assets(slate_id, page_index)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS generation_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
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)")
|
||||
|
||||
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'))
|
||||
)
|
||||
""")
|
||||
|
||||
# source column for trending_keywords (idempotent ALTER)
|
||||
cols = [r[1] for r in conn.execute("PRAGMA table_info(trending_keywords)").fetchall()]
|
||||
if "source" not in cols:
|
||||
conn.execute("ALTER TABLE trending_keywords ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_tk_source ON trending_keywords(source, suggested_at DESC)")
|
||||
|
||||
# account_preferences — 카테고리 가중치
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS account_preferences (
|
||||
category TEXT PRIMARY KEY,
|
||||
weight REAL NOT NULL DEFAULT 1.0,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
# seed defaults if table empty
|
||||
existing = conn.execute("SELECT COUNT(*) FROM account_preferences").fetchone()[0]
|
||||
if existing == 0:
|
||||
for cat in ("economy", "psychology", "celebrity"):
|
||||
conn.execute(
|
||||
"INSERT INTO account_preferences(category, weight) VALUES(?,?)",
|
||||
(cat, 1.0),
|
||||
)
|
||||
|
||||
|
||||
# ── news_articles ────────────────────────────────────────────────
|
||||
def add_news_article(row: Dict[str, Any]) -> int:
|
||||
with _conn() as conn:
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO news_articles(category, title, link, summary, pub_date) VALUES(?,?,?,?,?)",
|
||||
(row["category"], row["title"], row["link"], row.get("summary", ""), row.get("pub_date")),
|
||||
)
|
||||
return cur.lastrowid
|
||||
except sqlite3.IntegrityError:
|
||||
existing = conn.execute("SELECT id FROM news_articles WHERE link=?", (row["link"],)).fetchone()
|
||||
return existing["id"] if existing else 0
|
||||
|
||||
|
||||
def list_news_articles(category: Optional[str] = None, days: int = 1) -> List[Dict[str, Any]]:
|
||||
sql = "SELECT * FROM news_articles WHERE fetched_at >= datetime('now', ?)"
|
||||
params: List[Any] = [f"-{int(days)} days"]
|
||||
if category:
|
||||
sql += " AND category=?"
|
||||
params.append(category)
|
||||
sql += " ORDER BY fetched_at DESC"
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ── trending_keywords ───────────────────────────────────────────
|
||||
def add_trending_keyword(row: Dict[str, Any]) -> int:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO trending_keywords(keyword, category, score, articles_count, source) VALUES(?,?,?,?,?)",
|
||||
(
|
||||
row["keyword"], row["category"],
|
||||
float(row.get("score", 0.0)), int(row.get("articles_count", 0)),
|
||||
row.get("source", "manual"),
|
||||
),
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def list_trending_keywords(category: Optional[str] = None, used: Optional[bool] = None) -> List[Dict[str, Any]]:
|
||||
sql = "SELECT * FROM trending_keywords WHERE 1=1"
|
||||
params: List[Any] = []
|
||||
if category:
|
||||
sql += " AND category=?"
|
||||
params.append(category)
|
||||
if used is not None:
|
||||
sql += " AND used=?"
|
||||
params.append(1 if used else 0)
|
||||
sql += " ORDER BY score DESC, suggested_at DESC"
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def mark_keyword_used(keyword_id: int) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("UPDATE trending_keywords SET used=1 WHERE id=?", (keyword_id,))
|
||||
|
||||
|
||||
def get_trending_keyword(keyword_id: int) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM trending_keywords WHERE id=?", (keyword_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
# ── card_slates ─────────────────────────────────────────────────
|
||||
def add_card_slate(row: Dict[str, Any]) -> int:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute("""
|
||||
INSERT INTO card_slates(keyword, category, status, cover_copy, body_copies, cta_copy,
|
||||
suggested_caption, hashtags)
|
||||
VALUES(?,?,?,?,?,?,?,?)
|
||||
""", (
|
||||
row["keyword"], row["category"], row.get("status", "draft"),
|
||||
json.dumps(row.get("cover_copy", {}), ensure_ascii=False),
|
||||
json.dumps(row.get("body_copies", []), ensure_ascii=False),
|
||||
json.dumps(row.get("cta_copy", {}), ensure_ascii=False),
|
||||
row.get("suggested_caption", ""),
|
||||
json.dumps(row.get("hashtags", []), ensure_ascii=False),
|
||||
))
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def update_slate_status(slate_id: int, status: str) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE card_slates SET status=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?",
|
||||
(status, slate_id),
|
||||
)
|
||||
|
||||
|
||||
def get_card_slate(slate_id: int) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM card_slates WHERE id=?", (slate_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def list_card_slates(limit: int = 50) -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM card_slates ORDER BY created_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def delete_card_slate(slate_id: int) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("DELETE FROM card_slates WHERE id=?", (slate_id,))
|
||||
|
||||
|
||||
# ── card_assets ─────────────────────────────────────────────────
|
||||
def add_card_asset(slate_id: int, page_index: int, file_path: str, file_hash: str = "") -> int:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute("""
|
||||
INSERT INTO card_assets(slate_id, page_index, file_path, file_hash)
|
||||
VALUES(?,?,?,?)
|
||||
ON CONFLICT(slate_id, page_index) DO UPDATE SET
|
||||
file_path=excluded.file_path, file_hash=excluded.file_hash
|
||||
""", (slate_id, page_index, file_path, file_hash))
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def list_card_assets(slate_id: int) -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM card_assets WHERE slate_id=? ORDER BY page_index ASC",
|
||||
(slate_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ── generation_tasks ────────────────────────────────────────────
|
||||
def create_task(task_type: str, params: Dict[str, Any]) -> str:
|
||||
tid = uuid.uuid4().hex
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO generation_tasks(id, type, params) VALUES(?,?,?)",
|
||||
(tid, task_type, json.dumps(params, ensure_ascii=False)),
|
||||
)
|
||||
return tid
|
||||
|
||||
|
||||
def update_task(task_id: str, status: str, progress: int = 0, 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 dict(row) if row else None
|
||||
|
||||
|
||||
# ── prompt_templates ────────────────────────────────────────────
|
||||
def upsert_prompt_template(name: str, template: str, description: str = "") -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("""
|
||||
INSERT INTO prompt_templates(name, description, template)
|
||||
VALUES(?,?,?)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
template=excluded.template,
|
||||
description=excluded.description,
|
||||
updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||
""", (name, description, template))
|
||||
|
||||
|
||||
def get_prompt_template(name: str) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM prompt_templates WHERE name=?", (name,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
# ── external trends ─────────────────────────────────────────────
|
||||
def add_external_trend(row: Dict[str, Any]) -> int:
|
||||
"""`source` 필수 — naver_popular | google_trends. trending_keywords에 인서트."""
|
||||
if "source" not in row:
|
||||
raise ValueError("add_external_trend requires 'source' field")
|
||||
return add_trending_keyword(row)
|
||||
|
||||
|
||||
def list_trends(source: Optional[str] = None, category: Optional[str] = None,
|
||||
days: int = 1) -> List[Dict[str, Any]]:
|
||||
sql = "SELECT * FROM trending_keywords WHERE suggested_at >= datetime('now', ?)"
|
||||
params: List[Any] = [f"-{int(days)} days"]
|
||||
if source and source != "all":
|
||||
sql += " AND source=?"
|
||||
params.append(source)
|
||||
if category:
|
||||
sql += " AND category=?"
|
||||
params.append(category)
|
||||
sql += " ORDER BY suggested_at DESC, score DESC"
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ── account_preferences ─────────────────────────────────────────
|
||||
def get_preferences() -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT category, weight, updated_at FROM account_preferences ORDER BY category ASC"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def upsert_preferences(weights: Dict[str, float]) -> None:
|
||||
"""전체 upsert. 기존에 있던 카테고리는 weight 갱신, 신규는 INSERT.
|
||||
명시되지 않은 기존 카테고리는 그대로 둔다 (삭제 X). 삭제 필요 시 별도 API로."""
|
||||
with _conn() as conn:
|
||||
for cat, w in weights.items():
|
||||
conn.execute("""
|
||||
INSERT INTO account_preferences(category, weight)
|
||||
VALUES(?,?)
|
||||
ON CONFLICT(category) DO UPDATE SET
|
||||
weight=excluded.weight,
|
||||
updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||
""", (cat, float(w)))
|
||||
309
insta-lab/app/design_importer.py
Normal file
309
insta-lab/app/design_importer.py
Normal file
@@ -0,0 +1,309 @@
|
||||
"""사용자 디자인 PNG 10장 → Claude Sonnet Vision → Jinja card.html.j2 자동 생성.
|
||||
|
||||
CLI (이 phase 이후 추가): python -m app.design_importer <theme_name>
|
||||
"""
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from anthropic import Anthropic
|
||||
from jinja2 import BaseLoader, Environment, TemplateSyntaxError
|
||||
from PIL import Image
|
||||
|
||||
from .config import ANTHROPIC_API_KEY, ANTHROPIC_MODEL_SONNET
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"_resolve_page_mapping",
|
||||
"_validate_images",
|
||||
"_call_vision",
|
||||
"_validate_html_template",
|
||||
"import_design_theme",
|
||||
]
|
||||
|
||||
# 페이지 1 (커버) 키워드 우선순위 — 먼저 매치된 키워드를 가진 첫 파일만 page 1
|
||||
_COVER_KEYWORDS = ("cover", "start", "intro")
|
||||
# 페이지 10 (CTA) 키워드 우선순위
|
||||
_CTA_KEYWORDS = ("cta", "outro", "finish", "end")
|
||||
|
||||
# 인스타그램 카드 규격 (세로형 4:5 비율)
|
||||
_EXPECTED_SIZE = (1080, 1350)
|
||||
|
||||
|
||||
def _resolve_page_mapping(pages_dir: Path) -> Dict[str, int]:
|
||||
"""templates/<theme>/pages/ 안의 PNG 10장을 page 1~10에 매핑.
|
||||
|
||||
우선순위:
|
||||
1. `_order.json` 있으면 그 매핑 그대로 사용 (검증 통과 시 반환)
|
||||
2. 자동 매핑:
|
||||
- _COVER_KEYWORDS 우선순위 순서로 가장 앞 키워드를 가진 첫 PNG → page 1
|
||||
- _CTA_KEYWORDS 우선순위 순서로 가장 앞 키워드를 가진 첫 PNG → page 10
|
||||
- 남은 8장은 알파벳 정렬 → page 2~9
|
||||
"""
|
||||
pages_dir = Path(pages_dir)
|
||||
pngs = sorted([p.name for p in pages_dir.glob("*.png")])
|
||||
if len(pngs) != 10:
|
||||
raise ValueError(
|
||||
f"{pages_dir}에 PNG 10장 필요, 발견 {len(pngs)}장: {pngs}"
|
||||
)
|
||||
|
||||
order_path = pages_dir / "_order.json"
|
||||
if order_path.exists():
|
||||
try:
|
||||
mapping = json.loads(order_path.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
logger.warning("_order.json 파싱 실패, 자동 매핑으로 폴백: %s", e)
|
||||
else:
|
||||
if set(mapping.keys()) == set(pngs) and set(mapping.values()) == set(range(1, 11)):
|
||||
return {k: int(v) for k, v in mapping.items()}
|
||||
logger.warning(
|
||||
"_order.json 형식 오류 (파일 누락·page 중복), 자동 매핑으로 폴백"
|
||||
)
|
||||
|
||||
return _build_mapping(pngs)
|
||||
|
||||
|
||||
def _pick_by_keywords(names: List[str], keywords: tuple) -> str | None:
|
||||
"""names 중 keywords의 우선순위에 따라 첫 매치 파일명 반환 (없으면 None)."""
|
||||
lower_names = [(n, n.lower()) for n in names]
|
||||
for kw in keywords:
|
||||
for orig, low in lower_names:
|
||||
if kw in low:
|
||||
return orig
|
||||
return None
|
||||
|
||||
|
||||
def _build_mapping(pngs: List[str]) -> Dict[str, int]:
|
||||
"""자동 매핑 알고리즘 본체."""
|
||||
mapping: Dict[str, int] = {}
|
||||
remaining = list(pngs)
|
||||
|
||||
cover = _pick_by_keywords(remaining, _COVER_KEYWORDS)
|
||||
if cover:
|
||||
mapping[cover] = 1
|
||||
remaining.remove(cover)
|
||||
|
||||
cta = _pick_by_keywords(remaining, _CTA_KEYWORDS)
|
||||
if cta:
|
||||
mapping[cta] = 10
|
||||
remaining.remove(cta)
|
||||
|
||||
remaining_sorted = sorted(remaining)
|
||||
free_pages = sorted(set(range(1, 11)) - set(mapping.values()))
|
||||
for name, page in zip(remaining_sorted, free_pages):
|
||||
mapping[name] = page
|
||||
|
||||
return mapping
|
||||
|
||||
|
||||
_EXPECTED_RATIO = 1080 / 1350 # 4:5 = 0.8
|
||||
_RATIO_TOLERANCE = 0.02 # ±2% (1122/1402 ≈ 0.80028도 통과)
|
||||
|
||||
|
||||
def _validate_images(pages_dir: Path) -> None:
|
||||
"""모든 PNG가 4:5 종횡비(1080x1350 권장)에 가까운지 검증.
|
||||
|
||||
Vision은 base64로 원본을 분석하고 Playwright는 background-size: cover로
|
||||
1080x1350 컨테이너에 fit하므로 절대 사이즈는 유연. 단 종횡비가 어긋나면
|
||||
카드가 늘어나거나 잘리므로 ±2% 허용 범위 내에서만 통과.
|
||||
|
||||
early-exit 하지 않고 전체 파일을 검사한 뒤 한 메시지에 모아 raise.
|
||||
"""
|
||||
pages_dir = Path(pages_dir)
|
||||
bad = []
|
||||
for png_path in sorted(pages_dir.glob("*.png")):
|
||||
with Image.open(png_path) as img:
|
||||
w, h = img.size
|
||||
if h == 0:
|
||||
bad.append((png_path.name, img.size))
|
||||
continue
|
||||
ratio = w / h
|
||||
if abs(ratio - _EXPECTED_RATIO) > _RATIO_TOLERANCE:
|
||||
bad.append((png_path.name, img.size))
|
||||
if bad:
|
||||
msg = "; ".join(f"{n}: {s[0]}x{s[1]}" for n, s in bad)
|
||||
raise ValueError(
|
||||
f"카드 디자인은 4:5 비율(1080x1350 권장)이어야 함. 잘못된 파일: {msg}"
|
||||
)
|
||||
|
||||
|
||||
# ── Vision 호출 + HTML 생성 ───────────────────────────────────────────────────
|
||||
|
||||
_VISION_SYSTEM_PROMPT = """너는 인스타그램 카드 뉴스 디자인을 모방하는 프론트엔드 디자이너다.
|
||||
|
||||
입력: 10장의 카드 디자인 이미지 (각 1080×1350, placeholder 텍스트가 박혀있음) + 파일명 → 페이지 번호 매핑.
|
||||
출력: 단일 Jinja2 HTML 파일 본문 (코드펜스·설명 텍스트 금지).
|
||||
|
||||
핵심 제약 — placeholder 텍스트 마스킹:
|
||||
PNG에는 디자인 placeholder 텍스트가 이미 그려져 있다. 동적 카피로 교체할 때
|
||||
원본 텍스트가 비치면 안 된다. 각 텍스트 영역마다 두 layer를 그려라:
|
||||
(a) 마스킹 박스: position: absolute로 placeholder 영역과 같은 좌표.
|
||||
background는 그 영역 주변 픽셀 색 (카드 배경색)에서 추출. padding 8px 여유.
|
||||
(b) 동적 텍스트 layer: 마스킹 박스와 동일 좌표.
|
||||
font-size·font-weight·color는 원본 placeholder의 스타일을 모방.
|
||||
{{ headline }} / {{ body }} / {{ cta }} Jinja 변수 사용.
|
||||
|
||||
페이지 종류별 영역 가이드:
|
||||
- page 1 (cover): 메인 headline 1개 영역
|
||||
- page 2~9 (body): headline 영역 + body 영역
|
||||
- page 10 (cta): headline + body + cta 영역
|
||||
|
||||
요구사항:
|
||||
- 컨테이너 width 1080px, height 1350px
|
||||
- 각 페이지마다 `background-image: url('pages/{{filename}}')`로 사용자 PNG 로드
|
||||
- page_no 1~10 분기: {% if page_no == N %}...{% endif %} 구조
|
||||
- 폰트는 Noto Sans KR (Google Fonts CDN). letter-spacing -0.02em, line-height 1.3 기본
|
||||
- 텍스트 영역은 word-wrap: break-word + overflow: hidden (동적 카피가 길어도 마스킹 박스 밖으로 안 새도록)
|
||||
- HTML <head>에 <style>로 모든 CSS 인라인. <link> 외부 stylesheet 금지
|
||||
- 출력은 <!DOCTYPE html>로 시작하는 완전한 HTML 문서
|
||||
"""
|
||||
|
||||
|
||||
def _call_vision(images_with_pages: List[Tuple[str, int, bytes]],
|
||||
theme_name: str) -> Dict[str, Any]:
|
||||
"""Claude Sonnet Vision 호출. images_with_pages: [(filename, page_no, png_bytes), ...].
|
||||
|
||||
Returns: {"html": str, "tokens": int, "summary": str}
|
||||
"""
|
||||
if not ANTHROPIC_API_KEY:
|
||||
raise RuntimeError("ANTHROPIC_API_KEY 미설정 — design_importer 사용 불가")
|
||||
|
||||
client = Anthropic(api_key=ANTHROPIC_API_KEY)
|
||||
content: List[Dict[str, Any]] = []
|
||||
for filename, page_no, png_bytes in sorted(images_with_pages, key=lambda x: x[1]):
|
||||
content.append({
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": base64.b64encode(png_bytes).decode("ascii"),
|
||||
},
|
||||
})
|
||||
content.append({
|
||||
"type": "text",
|
||||
"text": f"위 이미지 = '{filename}' = page {page_no}",
|
||||
})
|
||||
content.append({
|
||||
"type": "text",
|
||||
"text": (
|
||||
f"theme 이름: '{theme_name}'. 위 10장 디자인을 모방한 단일 Jinja2 HTML을 출력해라."
|
||||
),
|
||||
})
|
||||
|
||||
msg = client.messages.create(
|
||||
model=ANTHROPIC_MODEL_SONNET,
|
||||
max_tokens=16000,
|
||||
system=_VISION_SYSTEM_PROMPT,
|
||||
messages=[{"role": "user", "content": content}],
|
||||
)
|
||||
raw = msg.content[0].text.strip()
|
||||
# 코드펜스 자르기
|
||||
if raw.startswith("```"):
|
||||
raw = re.sub(r"^```(?:html)?\s*|\s*```$", "", raw).strip()
|
||||
summary = raw[:200].replace("\n", " ") # 첫 200자만 분석 요약으로
|
||||
return {
|
||||
"html": raw,
|
||||
"tokens": msg.usage.input_tokens + msg.usage.output_tokens,
|
||||
"summary": summary,
|
||||
}
|
||||
|
||||
|
||||
def _validate_html_template(html: str) -> None:
|
||||
"""Jinja2 Environment로 sanity render. 문법 오류면 TemplateSyntaxError 전파."""
|
||||
env = Environment(loader=BaseLoader())
|
||||
env.from_string(html) # 파싱만으로도 syntax error 검출
|
||||
|
||||
|
||||
def import_design_theme(theme_dir: str) -> Dict[str, Any]:
|
||||
"""templates/<theme>/pages/*.png 10장 → Vision → card.html.j2 생성.
|
||||
|
||||
Args:
|
||||
theme_dir: theme 디렉토리 절대 경로 (예: /app/app/templates/minimal)
|
||||
Returns:
|
||||
{theme_name, html_path, page_mapping, analysis_summary, tokens_used}
|
||||
"""
|
||||
theme_path = Path(theme_dir)
|
||||
theme_name = theme_path.name
|
||||
pages_dir = theme_path / "pages"
|
||||
|
||||
# 1. 매핑 + 검증
|
||||
mapping = _resolve_page_mapping(pages_dir)
|
||||
_validate_images(pages_dir)
|
||||
|
||||
# 2. Vision 호출
|
||||
images_with_pages = []
|
||||
for filename, page_no in mapping.items():
|
||||
png_bytes = (pages_dir / filename).read_bytes()
|
||||
images_with_pages.append((filename, page_no, png_bytes))
|
||||
|
||||
vision_result = _call_vision(images_with_pages, theme_name)
|
||||
html = vision_result["html"]
|
||||
|
||||
# 3. Jinja sanity
|
||||
html_path = theme_path / "card.html.j2"
|
||||
try:
|
||||
_validate_html_template(html)
|
||||
except TemplateSyntaxError as e:
|
||||
error_path = theme_path / "card.html.j2.error.txt"
|
||||
error_path.write_text(html, encoding="utf-8")
|
||||
raise ValueError(
|
||||
f"Vision 응답이 Jinja 문법 오류: {e}. 원본 HTML은 {error_path}에 저장됨"
|
||||
)
|
||||
|
||||
# 4. 백업 + 저장
|
||||
if html_path.exists():
|
||||
ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
backup_path = theme_path / f"card.html.j2.bak.{ts}"
|
||||
html_path.rename(backup_path)
|
||||
logger.info("기존 HTML 백업: %s", backup_path)
|
||||
|
||||
html_path.write_text(html, encoding="utf-8")
|
||||
|
||||
return {
|
||||
"theme_name": theme_name,
|
||||
"html_path": str(html_path),
|
||||
"page_mapping": mapping,
|
||||
"analysis_summary": vision_result["summary"],
|
||||
"tokens_used": vision_result["tokens"],
|
||||
}
|
||||
|
||||
|
||||
# ── CLI entrypoint ───────────────────────────────────────────────────────────
|
||||
|
||||
def main_cli():
|
||||
"""CLI: python -m app.design_importer <theme_name> [--templates-dir PATH]"""
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="design_importer",
|
||||
description="사용자 카드 디자인 PNG 10장을 Claude Vision으로 분석해 card.html.j2 생성",
|
||||
)
|
||||
parser.add_argument("theme_name", help="templates/<theme_name>/ 디렉토리명")
|
||||
parser.add_argument(
|
||||
"--templates-dir",
|
||||
default="/app/app/templates",
|
||||
help="templates 루트 디렉토리 (기본 컨테이너 내부 경로)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
theme_dir = Path(args.templates_dir) / args.theme_name
|
||||
if not theme_dir.is_dir():
|
||||
print(f"ERROR: theme 디렉토리 없음: {theme_dir}")
|
||||
raise SystemExit(1)
|
||||
|
||||
try:
|
||||
result = import_design_theme(str(theme_dir))
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}")
|
||||
raise SystemExit(1)
|
||||
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main_cli()
|
||||
102
insta-lab/app/keyword_extractor.py
Normal file
102
insta-lab/app/keyword_extractor.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""키워드 추출 — 한글 명사 빈도 + Claude Haiku 정제."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections import Counter
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from anthropic import Anthropic
|
||||
|
||||
from .config import ANTHROPIC_API_KEY, ANTHROPIC_MODEL_HAIKU, KEYWORDS_PER_CATEGORY
|
||||
from . import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_NOUN_RE = re.compile(r"[가-힣]{2,6}")
|
||||
_STOPWORDS = {
|
||||
"있다", "없다", "이다", "되다", "그리고", "하지만", "통해", "위해", "오늘", "이번",
|
||||
"지난", "관련", "대해", "또한", "다만", "한편", "최근", "앞서", "현재", "진행",
|
||||
"발생", "결과", "이상", "이하", "여러", "다양", "방법", "경우", "이유", "필요",
|
||||
}
|
||||
|
||||
|
||||
def _count_nouns(text: str) -> Dict[str, int]:
|
||||
tokens = _NOUN_RE.findall(text or "")
|
||||
return Counter(tokens)
|
||||
|
||||
|
||||
def _top_candidates(counts: Dict[str, int], n: int = 20) -> List[tuple]:
|
||||
filtered = [(k, c) for k, c in counts.items() if k not in _STOPWORDS]
|
||||
return sorted(filtered, key=lambda x: x[1], reverse=True)[:n]
|
||||
|
||||
|
||||
def _refine_with_llm(category: str, candidates: List[tuple], articles: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Claude Haiku로 후보 정제. JSON 리스트 [{keyword, score(0~1), reason}] 반환."""
|
||||
if not ANTHROPIC_API_KEY:
|
||||
return [{"keyword": k, "score": min(1.0, c / 10), "reason": "freq"} for k, c in candidates[:KEYWORDS_PER_CATEGORY]]
|
||||
|
||||
client = Anthropic(api_key=ANTHROPIC_API_KEY)
|
||||
titles = [a["title"] for a in articles[:15]]
|
||||
prompt = f"""너는 인스타그램 카드 뉴스 큐레이터다.
|
||||
카테고리: {category}
|
||||
빈도 상위 후보: {[k for k, _ in candidates]}
|
||||
관련 기사 제목 일부:
|
||||
{chr(10).join('- ' + t for t in titles)}
|
||||
|
||||
이 후보 중에서 인스타 카드 콘텐츠로 적합한 키워드를 score 내림차순으로 최대 {KEYWORDS_PER_CATEGORY}개 골라.
|
||||
출력 형식 (JSON 배열만):
|
||||
[{{"keyword": "...", "score": 0.0~1.0, "reason": "..."}}]
|
||||
"""
|
||||
msg = client.messages.create(
|
||||
model=ANTHROPIC_MODEL_HAIKU,
|
||||
max_tokens=600,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
text = msg.content[0].text.strip()
|
||||
if text.startswith("```"):
|
||||
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text).strip()
|
||||
try:
|
||||
return json.loads(text)
|
||||
except Exception:
|
||||
logger.warning("LLM refine JSON parse failed, falling back to freq")
|
||||
return [{"keyword": k, "score": min(1.0, c / 10), "reason": "freq-fallback"} for k, c in candidates[:KEYWORDS_PER_CATEGORY]]
|
||||
|
||||
|
||||
def extract_for_category(category: str, limit: int = KEYWORDS_PER_CATEGORY) -> List[Dict[str, Any]]:
|
||||
"""카테고리 기사들에서 키워드를 뽑아 DB에 저장하고 결과 반환."""
|
||||
articles = db.list_news_articles(category=category, days=2)
|
||||
text_blob = "\n".join((a["title"] + " " + a.get("summary", "")) for a in articles)
|
||||
counts = _count_nouns(text_blob)
|
||||
candidates = _top_candidates(counts, n=20)
|
||||
refined = _refine_with_llm(category, candidates, articles)[:limit]
|
||||
|
||||
saved: List[Dict[str, Any]] = []
|
||||
for kw in refined:
|
||||
kid = db.add_trending_keyword({
|
||||
"keyword": kw["keyword"],
|
||||
"category": category,
|
||||
"score": float(kw.get("score", 0.0)),
|
||||
"articles_count": sum(1 for a in articles if kw["keyword"] in a["title"]),
|
||||
})
|
||||
saved.append({"id": kid, **kw, "category": category})
|
||||
return saved
|
||||
|
||||
|
||||
def extract_with_weights(weights: Dict[str, float], total_limit: int) -> List[Dict[str, Any]]:
|
||||
"""카테고리 가중치 비율대로 키워드를 분배 추출."""
|
||||
from .config import DEFAULT_CATEGORY_SEEDS
|
||||
if not weights or sum(weights.values()) == 0:
|
||||
cats = list(DEFAULT_CATEGORY_SEEDS.keys())
|
||||
weights = {c: 1.0 for c in cats}
|
||||
|
||||
total_weight = sum(weights.values())
|
||||
out: List[Dict[str, Any]] = []
|
||||
for category, w in weights.items():
|
||||
if w <= 0:
|
||||
continue
|
||||
per_cat = round(total_limit * w / total_weight)
|
||||
if per_cat <= 0:
|
||||
continue
|
||||
out.extend(extract_for_category(category, limit=per_cat))
|
||||
return out
|
||||
306
insta-lab/app/main.py
Normal file
306
insta-lab/app/main.py
Normal file
@@ -0,0 +1,306 @@
|
||||
"""FastAPI entrypoint for insta-lab."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, BackgroundTasks, Body, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .config import (
|
||||
CORS_ALLOW_ORIGINS, NAVER_CLIENT_ID, ANTHROPIC_API_KEY,
|
||||
INSTA_DATA_PATH, DB_PATH, DEFAULT_CATEGORY_SEEDS, KEYWORDS_PER_CATEGORY,
|
||||
INSTA_DEFAULT_THEME,
|
||||
)
|
||||
from . import db, news_collector, keyword_extractor, card_writer, card_renderer, trend_collector
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
app = FastAPI()
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[o.strip() for o in CORS_ALLOW_ORIGINS.split(",")],
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
|
||||
allow_headers=["Content-Type"],
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
os.makedirs(INSTA_DATA_PATH, exist_ok=True)
|
||||
db.init_db()
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/insta/status")
|
||||
def status():
|
||||
return {
|
||||
"ok": True,
|
||||
"naver_api": bool(NAVER_CLIENT_ID),
|
||||
"anthropic_api": bool(ANTHROPIC_API_KEY),
|
||||
}
|
||||
|
||||
|
||||
# ── News ─────────────────────────────────────────────────────────
|
||||
class CollectRequest(BaseModel):
|
||||
categories: Optional[list[str]] = None
|
||||
|
||||
|
||||
def _seeds_for(category: str) -> list[str]:
|
||||
pt = db.get_prompt_template("category_seeds")
|
||||
if pt and pt.get("template"):
|
||||
try:
|
||||
data = json.loads(pt["template"])
|
||||
if category in data:
|
||||
return list(data[category])
|
||||
except Exception:
|
||||
pass
|
||||
return list(DEFAULT_CATEGORY_SEEDS.get(category, []))
|
||||
|
||||
|
||||
async def _bg_collect(task_id: str, categories: list[str]):
|
||||
try:
|
||||
db.update_task(task_id, "processing", 10, "수집 중")
|
||||
total = 0
|
||||
for cat in categories:
|
||||
seeds = _seeds_for(cat)
|
||||
if not seeds:
|
||||
continue
|
||||
total += news_collector.collect_for_category(cat, seeds)
|
||||
db.update_task(task_id, "succeeded", 100, f"{total}건 수집", result_id=total)
|
||||
except Exception as e:
|
||||
logger.exception("collect failed")
|
||||
db.update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
|
||||
@app.post("/api/insta/news/collect")
|
||||
def collect_news(req: CollectRequest, bg: BackgroundTasks):
|
||||
cats = req.categories or list(DEFAULT_CATEGORY_SEEDS.keys())
|
||||
tid = db.create_task("news_collect", {"categories": cats})
|
||||
bg.add_task(_bg_collect, tid, cats)
|
||||
return {"task_id": tid, "categories": cats}
|
||||
|
||||
|
||||
@app.get("/api/insta/news/articles")
|
||||
def list_articles(category: Optional[str] = None, days: int = Query(7, ge=1, le=90)):
|
||||
return {"items": db.list_news_articles(category=category, days=days)}
|
||||
|
||||
|
||||
# ── Keywords ─────────────────────────────────────────────────────
|
||||
class ExtractRequest(BaseModel):
|
||||
categories: Optional[list[str]] = None
|
||||
|
||||
|
||||
async def _bg_extract(task_id: str, categories: Optional[list[str]] = None):
|
||||
try:
|
||||
db.update_task(task_id, "processing", 10, "추출 중")
|
||||
prefs_rows = db.get_preferences()
|
||||
weights = {p["category"]: p["weight"] for p in prefs_rows}
|
||||
if categories:
|
||||
# 사용자가 카테고리 명시한 경우만 그 서브셋으로 균등 가중치 (override)
|
||||
weights = {c: 1.0 for c in categories}
|
||||
total = KEYWORDS_PER_CATEGORY * max(1, len([w for w in weights.values() if w > 0]))
|
||||
keyword_extractor.extract_with_weights(weights, total_limit=total)
|
||||
db.update_task(task_id, "succeeded", 100, "완료", result_id=0)
|
||||
except Exception as e:
|
||||
logger.exception("extract failed")
|
||||
db.update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
|
||||
@app.post("/api/insta/keywords/extract")
|
||||
def extract_keywords(req: ExtractRequest, bg: BackgroundTasks):
|
||||
cats = req.categories or list(DEFAULT_CATEGORY_SEEDS.keys())
|
||||
tid = db.create_task("keyword_extract", {"categories": cats})
|
||||
bg.add_task(_bg_extract, tid, cats)
|
||||
return {"task_id": tid, "categories": cats}
|
||||
|
||||
|
||||
@app.get("/api/insta/keywords")
|
||||
def list_keywords(
|
||||
category: Optional[str] = None,
|
||||
used: Optional[bool] = None,
|
||||
source: Optional[str] = None,
|
||||
):
|
||||
if source:
|
||||
return {"items": db.list_trends(source=source, category=category, days=30)}
|
||||
return {"items": db.list_trending_keywords(category=category, used=used)}
|
||||
|
||||
|
||||
# ── Slates ───────────────────────────────────────────────────────
|
||||
class SlateRequest(BaseModel):
|
||||
keyword: str
|
||||
category: str
|
||||
keyword_id: Optional[int] = None
|
||||
|
||||
|
||||
async def _bg_create_slate(task_id: str, keyword: str, category: str, keyword_id: Optional[int]):
|
||||
try:
|
||||
db.update_task(task_id, "processing", 30, "카피 생성 중")
|
||||
sid = card_writer.write_slate(keyword=keyword, category=category)
|
||||
db.update_task(task_id, "processing", 70, "카드 렌더 중")
|
||||
await card_renderer.render_slate(sid, template=f"{INSTA_DEFAULT_THEME}/card.html.j2")
|
||||
db.update_slate_status(sid, "rendered")
|
||||
if keyword_id:
|
||||
db.mark_keyword_used(keyword_id)
|
||||
db.update_task(task_id, "succeeded", 100, "완료", result_id=sid)
|
||||
except Exception as e:
|
||||
logger.exception("create slate failed")
|
||||
db.update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
|
||||
@app.post("/api/insta/slates")
|
||||
def create_slate(req: SlateRequest, bg: BackgroundTasks):
|
||||
tid = db.create_task("slate_create", req.dict())
|
||||
bg.add_task(_bg_create_slate, tid, req.keyword, req.category, req.keyword_id)
|
||||
return {"task_id": tid}
|
||||
|
||||
|
||||
@app.get("/api/insta/slates")
|
||||
def list_slates(limit: int = Query(50, ge=1, le=500)):
|
||||
return {"items": db.list_card_slates(limit=limit)}
|
||||
|
||||
|
||||
@app.get("/api/insta/slates/{slate_id}")
|
||||
def get_slate(slate_id: int):
|
||||
s = db.get_card_slate(slate_id)
|
||||
if not s:
|
||||
raise HTTPException(404, "slate not found")
|
||||
s["assets"] = db.list_card_assets(slate_id)
|
||||
for k in ("cover_copy", "body_copies", "cta_copy", "hashtags"):
|
||||
if isinstance(s.get(k), str):
|
||||
try:
|
||||
s[k] = json.loads(s[k])
|
||||
except Exception:
|
||||
pass
|
||||
return s
|
||||
|
||||
|
||||
async def _bg_render(task_id: str, slate_id: int):
|
||||
try:
|
||||
db.update_task(task_id, "processing", 30, "재렌더 중")
|
||||
await card_renderer.render_slate(slate_id, template=f"{INSTA_DEFAULT_THEME}/card.html.j2")
|
||||
db.update_slate_status(slate_id, "rendered")
|
||||
db.update_task(task_id, "succeeded", 100, "완료", result_id=slate_id)
|
||||
except Exception as e:
|
||||
logger.exception("render failed")
|
||||
db.update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
|
||||
@app.post("/api/insta/slates/{slate_id}/render")
|
||||
def render_slate_endpoint(slate_id: int, bg: BackgroundTasks):
|
||||
if not db.get_card_slate(slate_id):
|
||||
raise HTTPException(404, "slate not found")
|
||||
tid = db.create_task("slate_render", {"slate_id": slate_id})
|
||||
bg.add_task(_bg_render, tid, slate_id)
|
||||
return {"task_id": tid}
|
||||
|
||||
|
||||
@app.get("/api/insta/slates/{slate_id}/assets/{page}")
|
||||
def get_asset(slate_id: int, page: int):
|
||||
if not (1 <= page <= 10):
|
||||
raise HTTPException(400, "page must be 1..10")
|
||||
assets = db.list_card_assets(slate_id)
|
||||
match = next((a for a in assets if a["page_index"] == page), None)
|
||||
if not match:
|
||||
raise HTTPException(404, "asset not found")
|
||||
return FileResponse(match["file_path"], media_type="image/png")
|
||||
|
||||
|
||||
@app.delete("/api/insta/slates/{slate_id}")
|
||||
def delete_slate(slate_id: int):
|
||||
if not db.get_card_slate(slate_id):
|
||||
raise HTTPException(404)
|
||||
for a in db.list_card_assets(slate_id):
|
||||
try:
|
||||
os.unlink(a["file_path"])
|
||||
except OSError:
|
||||
pass
|
||||
db.delete_card_slate(slate_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Tasks ────────────────────────────────────────────────────────
|
||||
@app.get("/api/insta/tasks/{task_id}")
|
||||
def get_task_status(task_id: str):
|
||||
t = db.get_task(task_id)
|
||||
if not t:
|
||||
raise HTTPException(404)
|
||||
return t
|
||||
|
||||
|
||||
# ── Prompt Templates ─────────────────────────────────────────────
|
||||
class TemplateBody(BaseModel):
|
||||
template: str
|
||||
description: str = ""
|
||||
|
||||
|
||||
@app.get("/api/insta/templates/prompts/{name}")
|
||||
def get_prompt(name: str):
|
||||
pt = db.get_prompt_template(name)
|
||||
if not pt:
|
||||
raise HTTPException(404)
|
||||
return pt
|
||||
|
||||
|
||||
@app.put("/api/insta/templates/prompts/{name}")
|
||||
def upsert_prompt(name: str, body: TemplateBody):
|
||||
db.upsert_prompt_template(name, body.template, body.description)
|
||||
return db.get_prompt_template(name)
|
||||
|
||||
|
||||
# ── Trends ───────────────────────────────────────────────────────
|
||||
class TrendsCollectRequest(BaseModel):
|
||||
categories: Optional[list[str]] = None
|
||||
|
||||
|
||||
async def _bg_collect_trends(task_id: str, categories: list[str]):
|
||||
try:
|
||||
db.update_task(task_id, "processing", 10, "외부 트렌드 수집 중")
|
||||
result = trend_collector.collect_all(categories)
|
||||
msg = f"naver:{result['naver_popular']}, youtube:{result['youtube_trending']}"
|
||||
db.update_task(task_id, "succeeded", 100, msg, result_id=sum(result.values()))
|
||||
except Exception as e:
|
||||
logger.exception("trends collect failed")
|
||||
db.update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
|
||||
@app.post("/api/insta/trends/collect")
|
||||
def collect_trends(req: TrendsCollectRequest, bg: BackgroundTasks):
|
||||
cats = req.categories or list(DEFAULT_CATEGORY_SEEDS.keys())
|
||||
tid = db.create_task("trends_collect", {"categories": cats})
|
||||
bg.add_task(_bg_collect_trends, tid, cats)
|
||||
return {"task_id": tid, "categories": cats}
|
||||
|
||||
|
||||
@app.get("/api/insta/trends")
|
||||
def list_trends_endpoint(
|
||||
source: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
days: int = Query(1, ge=1, le=90),
|
||||
):
|
||||
return {"items": db.list_trends(source=source, category=category, days=days)}
|
||||
|
||||
|
||||
# ── Preferences ──────────────────────────────────────────────────
|
||||
class PreferencesBody(BaseModel):
|
||||
categories: dict[str, float]
|
||||
|
||||
|
||||
@app.get("/api/insta/preferences")
|
||||
def get_preferences_endpoint():
|
||||
return {"categories": db.get_preferences()}
|
||||
|
||||
|
||||
@app.put("/api/insta/preferences")
|
||||
def put_preferences_endpoint(body: PreferencesBody):
|
||||
db.upsert_preferences(body.categories)
|
||||
return {"categories": db.get_preferences()}
|
||||
82
insta-lab/app/news_collector.py
Normal file
82
insta-lab/app/news_collector.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""NAVER 뉴스 검색 API 연동 — 카테고리별 시드 키워드로 일일 수집."""
|
||||
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from .config import NAVER_CLIENT_ID, NAVER_CLIENT_SECRET, NEWS_PER_CATEGORY
|
||||
from . import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NEWS_URL = "https://openapi.naver.com/v1/search/news.json"
|
||||
_HEADERS = {
|
||||
"X-Naver-Client-Id": NAVER_CLIENT_ID,
|
||||
"X-Naver-Client-Secret": NAVER_CLIENT_SECRET,
|
||||
}
|
||||
_TAG_RE = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
def _clean(text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
no_tag = _TAG_RE.sub("", text)
|
||||
return html.unescape(no_tag).strip()
|
||||
|
||||
|
||||
def search_news(keyword: str, display: int = 30, sort: str = "date") -> List[Dict[str, Any]]:
|
||||
"""NAVER news.json 단일 호출.
|
||||
|
||||
Returns: list of {title, link, summary, pub_date}
|
||||
"""
|
||||
resp = requests.get(
|
||||
NEWS_URL,
|
||||
headers=_HEADERS,
|
||||
params={"query": keyword, "display": display, "sort": sort},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return [
|
||||
{
|
||||
"title": _clean(item.get("title", "")),
|
||||
"link": item.get("link") or item.get("originallink", ""),
|
||||
"summary": _clean(item.get("description", "")),
|
||||
"pub_date": item.get("pubDate", ""),
|
||||
}
|
||||
for item in data.get("items", [])
|
||||
]
|
||||
|
||||
|
||||
def collect_for_category(category: str,
|
||||
seed_keywords: List[str],
|
||||
per_keyword: Optional[int] = None) -> int:
|
||||
"""카테고리에 대해 시드 키워드 각각으로 검색 후 DB에 삽입.
|
||||
UNIQUE(link)가 중복 삽입을 막음. 시도된 기사 수(중복 포함) 반환.
|
||||
"""
|
||||
per_kw = per_keyword if per_keyword is not None else max(1, NEWS_PER_CATEGORY // max(1, len(seed_keywords)))
|
||||
seen_links = set()
|
||||
attempted = 0
|
||||
for kw in seed_keywords:
|
||||
try:
|
||||
items = search_news(kw, display=per_kw)
|
||||
except Exception as e:
|
||||
logger.warning("search_news failed kw=%s err=%s", kw, e)
|
||||
continue
|
||||
for item in items:
|
||||
link = item["link"]
|
||||
if not link or link in seen_links:
|
||||
continue
|
||||
seen_links.add(link)
|
||||
db.add_news_article({
|
||||
"category": category,
|
||||
"title": item["title"],
|
||||
"link": link,
|
||||
"summary": item["summary"],
|
||||
"pub_date": item["pub_date"],
|
||||
})
|
||||
attempted += 1
|
||||
return attempted
|
||||
0
insta-lab/app/templates/__init__.py
Normal file
0
insta-lab/app/templates/__init__.py
Normal file
0
insta-lab/app/templates/default/.gitkeep
Normal file
0
insta-lab/app/templates/default/.gitkeep
Normal file
55
insta-lab/app/templates/default/card.html.j2
Normal file
55
insta-lab/app/templates/default/card.html.j2
Normal file
@@ -0,0 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700;900&display=swap');
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
width: 1080px; height: 1350px;
|
||||
font-family: 'Noto Sans KR', sans-serif;
|
||||
background: #F7F7FA; color: #14171A;
|
||||
}
|
||||
.card {
|
||||
width: 1080px; height: 1350px;
|
||||
padding: 80px 72px;
|
||||
display: flex; flex-direction: column; justify-content: space-between;
|
||||
background: linear-gradient(180deg, #FFFFFF 0%, #F7F7FA 100%);
|
||||
border-top: 16px solid {{ accent_color }};
|
||||
}
|
||||
.badge {
|
||||
display: inline-block; padding: 8px 20px; border-radius: 999px;
|
||||
background: {{ accent_color }}; color: #fff;
|
||||
font-size: 28px; font-weight: 700; letter-spacing: -0.02em;
|
||||
}
|
||||
.headline {
|
||||
font-size: {{ 96 if page_type == 'cover' else 72 }}px;
|
||||
font-weight: 900; line-height: 1.15; letter-spacing: -0.04em;
|
||||
margin-top: 32px;
|
||||
}
|
||||
.body {
|
||||
font-size: 40px; font-weight: 400; line-height: 1.55;
|
||||
margin-top: 40px; color: #2A2F35;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.footer {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 28px; color: #6B7280; font-weight: 500;
|
||||
}
|
||||
.cta { font-weight: 700; color: {{ accent_color }}; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div>
|
||||
<span class="badge">{{ page_type|upper }}</span>
|
||||
<h1 class="headline">{{ headline }}</h1>
|
||||
<p class="body">{{ body }}</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<span>{{ page_no }} / {{ total_pages }}</span>
|
||||
{% if cta %}<span class="cta">{{ cta }}</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
12
insta-lab/app/templates/minimal/pages/_order.json
Normal file
12
insta-lab/app/templates/minimal/pages/_order.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"insta_card_start.png": 1,
|
||||
"insta_card_keyword.png": 2,
|
||||
"insta_card_highlight.png": 3,
|
||||
"insta_card_observation.png": 4,
|
||||
"insta_card_memo.png": 5,
|
||||
"insta_card_oneline.png": 6,
|
||||
"insta_card_checklist.png": 7,
|
||||
"insta_card_study.png": 8,
|
||||
"insta_card_cta.png": 9,
|
||||
"insta_card_finish.png": 10
|
||||
}
|
||||
BIN
insta-lab/app/templates/minimal/pages/insta_card_checklist.png
Normal file
BIN
insta-lab/app/templates/minimal/pages/insta_card_checklist.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1010 KiB |
BIN
insta-lab/app/templates/minimal/pages/insta_card_cta.png
Normal file
BIN
insta-lab/app/templates/minimal/pages/insta_card_cta.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
insta-lab/app/templates/minimal/pages/insta_card_finish.png
Normal file
BIN
insta-lab/app/templates/minimal/pages/insta_card_finish.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
insta-lab/app/templates/minimal/pages/insta_card_highlight.png
Normal file
BIN
insta-lab/app/templates/minimal/pages/insta_card_highlight.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user