Compare commits
397 Commits
cb6e2d992a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 20691b5057 | |||
| 3bf87a93fb | |||
| 4623c68d4e | |||
| f79dc87d75 | |||
| d4302acb6a | |||
| b7fd98c8c7 | |||
| 0b29283043 | |||
| 9dba1e74b0 | |||
| 4c9fe11fc9 | |||
| a356a5895f | |||
| 2e042e18c5 | |||
| 83e74ad1f4 | |||
| b70caddff1 | |||
| d6e34973a4 | |||
| 7007c90665 | |||
| ca7a502514 | |||
| dc471ecc60 | |||
| e91715bf2c | |||
| 1e4c1b42b7 | |||
| 0190a6c206 | |||
| 6ef4160da2 | |||
| 078c9f008a | |||
| 918151bda8 | |||
| 2ce6721c35 | |||
| c5303151c0 | |||
| ee61405ff1 | |||
| fef5f7a835 | |||
| e47ccdb762 | |||
| 4b6996b0f7 | |||
| 0f65aa53e4 | |||
| ea3485cde6 | |||
| d6366a38f3 | |||
| 0f8c71c552 | |||
| 1401c5703d | |||
| 92329f6fd5 | |||
| d0047c2b9d | |||
| 088944499c | |||
| a9fdbf8a93 | |||
| f46851d481 | |||
| 11b3700959 | |||
| 1db8a0063d | |||
| f017a61c79 | |||
| 1694823129 | |||
| a4614ebeae | |||
| 875e750f77 | |||
| 9cb40fb4e5 | |||
| 383f48c71e | |||
| 6be74737c2 | |||
| 3106716e70 | |||
| a126155948 | |||
| f509339cbb | |||
| e72a52a950 | |||
| eecaefc26d | |||
| b3c0683364 | |||
| 17321d948e | |||
| 8552cbc184 | |||
| b1c786e59d | |||
| b885d02ac4 | |||
| b35fab777e | |||
| 43081bea0e | |||
| bebe5797e7 | |||
| 9e1001b935 | |||
| e5465ad136 | |||
| 21d46d95dd | |||
| ac4a574ef2 | |||
| c985d2c605 | |||
| b4e873b5b0 | |||
| 6c5e93f64e | |||
| 6b7eb5a9c1 | |||
| 4b28ef3afa | |||
| 211aff1e45 | |||
| 37ca8e594e | |||
| c9a094969d | |||
| e8dbf8092a | |||
| 21cf0114f4 | |||
| 20f83cee33 | |||
| 1e77123394 | |||
| fbd8d26ec6 | |||
| 6f505b8cb1 | |||
| e1722e3963 | |||
| b1e28aa725 | |||
| 532b794c11 | |||
| e7f6edf7c5 | |||
| 42cf39d0da | |||
| 74196396c5 | |||
| 4393ba706b | |||
| 714224a9b4 | |||
| ea93dc522b | |||
| 408b6a3df7 | |||
| e6ff234031 | |||
| 912cd18e48 | |||
| a06cc424ca | |||
| e87c43a7a4 | |||
| 0c12c3527f | |||
| 5ed9d265f6 | |||
| 24229d00ae | |||
| 43f8b111ad | |||
| a9f38e1248 | |||
| 87651c9449 | |||
| a1a37ead9e | |||
| 978aa14f8b | |||
| 030365bed0 | |||
| 8c5bfa453f | |||
| 11d86450c3 | |||
| 90f6af6ab3 | |||
| 83113ab50c | |||
| 20514193e8 | |||
| 7a470aad44 | |||
| de8adaeadd | |||
| 5cde24115b | |||
| 318190c93f | |||
| 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 |
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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
47
.env.example
47
.env.example
@@ -51,9 +51,14 @@ PGID=1000
|
||||
# Windows AI Server (NAS 입장에서 바라본 Windows PC IP)
|
||||
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
|
||||
|
||||
# Admin API Key (trade/order 등 민감 엔드포인트 보호, 미설정 시 인증 비활성화)
|
||||
# Admin API Key — /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
|
||||
@@ -82,3 +87,43 @@ SUNO_API_KEY=
|
||||
|
||||
# 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
|
||||
|
||||
209
CHECK_POINT.md
Normal file
209
CHECK_POINT.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# web-backend CHECK_POINT
|
||||
|
||||
> NAS Docker 11 컨테이너(9 백엔드 + frontend + deployer). Synology Celeron J4025 (2C 2.0GHz) 18GB.
|
||||
> 2026-05-18 작성 — uvicorn CPU 폭주 진단 결과 정리.
|
||||
|
||||
## 🔴 즉시 (오늘, 총 1시간 5분)
|
||||
|
||||
### 1. 09:00 cron 5분 스태거링 ⭐ 가장 큰 효과
|
||||
|
||||
**파일**: `agent-office/app/scheduler.py:72-76`
|
||||
```python
|
||||
# 변경 전 — 09:00 동시 실행 (CPU 폭주 원인 #1)
|
||||
scheduler.add_job(_run_insta_trends_collect, "cron", hour=9, minute=0)
|
||||
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=9, minute=0)
|
||||
scheduler.add_job(_run_youtube_research, "cron", hour=9, minute=0)
|
||||
|
||||
# 변경 후 — 5분 스태거링
|
||||
scheduler.add_job(_run_insta_trends_collect, "cron", hour=9, minute=0, id="insta_trends")
|
||||
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=9, minute=5, id="lotto_curate")
|
||||
scheduler.add_job(_run_youtube_research, "cron", hour=9, minute=10, id="youtube_research")
|
||||
```
|
||||
|
||||
**파일**: `realestate-lab/app/main.py:51`
|
||||
```python
|
||||
# 변경 전
|
||||
scheduler.add_job(scheduled_collect, "cron", hour=9, minute=0, id="collect")
|
||||
|
||||
# 변경 후
|
||||
scheduler.add_job(scheduled_collect, "cron", hour=9, minute=15, id="collect")
|
||||
```
|
||||
|
||||
- [x] agent-office scheduler.py 수정 (2026-05-18)
|
||||
- [x] realestate-lab main.py 수정 (2026-05-18)
|
||||
- [ ] git commit + push (Gitea Webhook 자동 빌드)
|
||||
|
||||
---
|
||||
|
||||
### 2. insta-lab Playwright Semaphore(1) ⭐
|
||||
|
||||
**파일**: `insta-lab/app/main.py` (모듈 레벨 추가)
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
# 모듈 레벨에 한 번만 선언
|
||||
RENDER_SEMAPHORE = asyncio.Semaphore(1) # Chromium 동시 실행 1개로 제한
|
||||
|
||||
# 카드 렌더 백그라운드 함수에 감싸기
|
||||
async def _bg_render(task_id: str, slate_id: int):
|
||||
async with RENDER_SEMAPHORE:
|
||||
await card_renderer.render_slate(slate_id, ...)
|
||||
```
|
||||
|
||||
- [x] card_renderer.render_slate를 Semaphore(1)로 감쌈 (2026-05-18, lazy init)
|
||||
- [ ] 동시 2개 요청 테스트 (curl 동시 2회 → 순차 처리되는지 확인)
|
||||
|
||||
---
|
||||
|
||||
### 3. healthcheck interval 60s
|
||||
|
||||
**파일**: `docker-compose.yml` (모든 9 컨테이너)
|
||||
```yaml
|
||||
# 변경 전
|
||||
healthcheck:
|
||||
interval: 30s
|
||||
|
||||
# 변경 후
|
||||
healthcheck:
|
||||
interval: 60s
|
||||
```
|
||||
|
||||
- [x] docker-compose.yml 10개 healthcheck 일괄 변경 (9 백엔드 + frontend, 2026-05-18)
|
||||
- [ ] `docker compose up -d` 재기동
|
||||
- [ ] `docker stats` 로 CPU 5% 정도 감소 확인
|
||||
|
||||
---
|
||||
|
||||
### 4. uvicorn --workers 1 명시
|
||||
|
||||
**모든 Dockerfile CMD**:
|
||||
```dockerfile
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
```
|
||||
|
||||
영향 9 파일 (모두 2026-05-18 적용):
|
||||
- [x] lotto/Dockerfile
|
||||
- [x] stock/Dockerfile
|
||||
- [x] music-lab/Dockerfile
|
||||
- [x] insta-lab/Dockerfile
|
||||
- [x] realestate-lab/Dockerfile
|
||||
- [x] agent-office/Dockerfile
|
||||
- [x] personal/Dockerfile
|
||||
- [x] packs-lab/Dockerfile
|
||||
- [x] travel-proxy/Dockerfile
|
||||
|
||||
→ `docker compose build --no-cache` 후 재기동.
|
||||
|
||||
---
|
||||
|
||||
### 5. lotto Monte Carlo 08:05 → 08:30
|
||||
|
||||
**파일**: `lotto/app/main.py:86`
|
||||
```python
|
||||
# 변경 전 — stock 08:00과 5분 차이로 겹침
|
||||
scheduler.add_job(_run_simulation_job, "cron", hour="0,4,8,12,16,20", minute=5)
|
||||
|
||||
# 변경 후 — 25분 분리
|
||||
scheduler.add_job(_run_simulation_job, "cron", hour="0,4,8,12,16,20", minute=30)
|
||||
```
|
||||
|
||||
- [x] lotto/app/main.py 수정 (2026-05-18)
|
||||
|
||||
---
|
||||
|
||||
## 🟡 중기 (1~2주)
|
||||
|
||||
### 6. Chromium Browser Pool 재설계 (insta-lab) ✅ 2026-05-18
|
||||
- 매번 launch X → 1개 인스턴스 재사용
|
||||
- 카드 10장 렌더 시간 30% 단축 기대
|
||||
- [x] `card_renderer.py` 내부에 모듈 레벨 `_PLAYWRIGHT`/`_BROWSER` + `init_browser`/`shutdown_browser` 함수 (별도 모듈 분리 안 함, 같은 파일에 인접 배치)
|
||||
- [x] `_render_slate_locked` 본체에서 `_get_browser()` 재사용 (crashed 시 lazy 재초기화)
|
||||
- [x] `main.py` startup hook에서 `init_browser()`, shutdown hook에서 `shutdown_browser()`
|
||||
|
||||
### 7. stock 뉴스 스크랩 비동기화 — ⚠️ 보류 2026-05-18
|
||||
- **재진단**: stock은 `BackgroundScheduler` 사용 중 → main loop 블로킹 없음 (이미 별도 thread)
|
||||
- `fetch_market_news`의 4개 동기 `requests.get`은 network I/O wait라 CPU 거의 사용 안 함
|
||||
- `to_thread`로 wrap해도 BackgroundScheduler 환경에서 사실상 의미 없음
|
||||
- 진짜 효과를 보려면 AsyncIOScheduler 전환 + scraper.py 4개 fetch를 `aiohttp` 병렬로 — **큰 리팩토링 vs 효과 불명확**
|
||||
- [ ] 박재오 판단: 큰 리팩토링 진행 여부
|
||||
|
||||
### 8. realestate 수집 병렬화 ✅ 2026-05-18
|
||||
- **파일**: `realestate-lab/app/main.py:scheduled_collect`
|
||||
- `collect_all()` + `delete_old_completed_announcements()` 병렬
|
||||
- BackgroundScheduler 환경이라 `asyncio.gather` 대신 `ThreadPoolExecutor(max_workers=2)` 사용 (효과 동일)
|
||||
- 매칭은 순차 유지 (DB 일관성)
|
||||
- [x] ThreadPoolExecutor 적용
|
||||
|
||||
### 9. lotto Monte Carlo 시뮬레이션 빈도 검토
|
||||
- 현재 6회/일 (00·04·08·12·16·20)
|
||||
- 실제 필요 빈도 박재오 결정 — 3회/일(아침·점심·저녁)로 줄이면 CPU 50% 감소
|
||||
- [ ] 박재오 의사결정 후 cron 변경
|
||||
|
||||
---
|
||||
|
||||
## 🟢 장기 (1개월+)
|
||||
|
||||
### 10. 무거운 작업 Windows AI 서버로 이전 ✅ 이미 적용 상태 (2026-05-18 확인)
|
||||
- **확인 결과**: NAS `.env`가 이미 `LLM_PROVIDER=claude` + `OLLAMA_URL=http://192.168.45.59:11435`로 설정됨
|
||||
- 실 운영은 Anthropic Claude (원격 API) — NAS Celeron에서 LLM 추론 안 함
|
||||
- Ollama fallback 사용 시에도 Windows AI 서버로 통일
|
||||
- stock 외 다른 컨테이너에 ollama/qwen 호출 코드 없음
|
||||
- 결론: 코드/설정 변경 불필요
|
||||
|
||||
### 11. 컨테이너 리소스 제한 — ❌ 진행 금지 (박재오 명시 2026-05-18)
|
||||
- J4025 2C 환경에서 cpus 0.5 제한은 오히려 throughput 손해
|
||||
- 향후 작업자 무심코 도입하지 말 것
|
||||
|
||||
### 12. NAS 업그레이드 검토 — ⏸️ 보류 (박재오 명시 2026-05-18)
|
||||
- 현재: Celeron J4025 (2C 2.0GHz)
|
||||
- 대안: Ryzen N5105 (4C 2.0GHz) NAS — 4코어로 병렬성 2배
|
||||
- 자금·우선순위 결정 대기
|
||||
|
||||
---
|
||||
|
||||
## ✅ 최근 완료 (참고)
|
||||
|
||||
- 2026-05-15: insta-lab 신설 (포트 18700, Jinja2 + Playwright + Claude Sonnet)
|
||||
- 2026-05-16: insta-lab Playwright 1080×1350 PNG 렌더 완성
|
||||
- 2026-05-17: agent-office random idle 제거, ADMIN_API_KEY 강화 (stock)
|
||||
- 2026-05-17: insta-lab minimal theme + design_importer 추가
|
||||
- 2026-05-17: blog-lab 트랙 완전 폐기 (docker-compose에 없음, 위키 정정 완료)
|
||||
- 2026-05-18: 🔴 즉시 5건 일괄 적용 — 09:00 cron 스태거링(insta/lotto/youtube/realestate), lotto Monte Carlo 08:30, insta-lab Semaphore(1), healthcheck 60s, uvicorn --workers 1 명시 (사용자 push + NAS deployer 재기동 대기)
|
||||
- 2026-05-18: 🟡 중기 2건 적용 — #6 insta-lab Chromium Browser Pool (lifecycle hook), #8 realestate ThreadPoolExecutor 병렬 (collect/delete). #7 stock async는 BackgroundScheduler 사용 중이라 재진단 후 보류 (효과 미미). #9 Monte Carlo 빈도는 박재오 결정 대기.
|
||||
- 2026-05-18: 🟢 장기 진단·결정 — #10은 이미 적용 상태 확인 (LLM_PROVIDER=claude, OLLAMA_URL=Windows AI). #11 컨테이너 리소스 제한 박재오 진행 금지. #12 NAS 업그레이드 보류. web-ai V1(:8000)+V2(:8001) 4개 process 종료 — NAS API polling 부담 즉시 감소.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 진단 커맨드 (NAS bash)
|
||||
|
||||
```bash
|
||||
# 실시간 CPU 사용 (상위 15)
|
||||
top -b -n 1 | head -25
|
||||
|
||||
# 프로세스별 CPU 정렬
|
||||
ps aux --sort=-%cpu | head -15
|
||||
|
||||
# uvicorn·chromium·python 프로세스만
|
||||
ps aux | grep -E "uvicorn|chromium|python" | grep -v grep
|
||||
|
||||
# 스케줄러 실행 로그 (최근 50)
|
||||
docker logs agent-office 2>&1 | grep -E "APScheduler|executing" | tail -50
|
||||
|
||||
# insta-lab Chromium 프로세스 개수
|
||||
docker exec insta-lab ps aux | grep chromium | wc -l
|
||||
|
||||
# 컨테이너별 CPU/메모리 실시간
|
||||
docker stats --no-stream
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 참고
|
||||
|
||||
- 진단 풀 보고서: `C:\Users\jaeoh\Documents\Obsidian Vault\raw\2026-05-18-NAS-uvicorn-CPU-진단-개선안.md`
|
||||
- 위키 페이지: [[사업-개인-웹-플랫폼]] (CPU 부하 진단 섹션 + 컨테이너 표)
|
||||
- docker-compose.yml: 본 디렉토리 루트
|
||||
|
||||
## 변경 이력
|
||||
|
||||
- 2026-05-18: 페이지 신설. 즉시 5건 + 중기 4건 + 장기 3건. 진단 커맨드.
|
||||
440
CLAUDE.md
440
CLAUDE.md
@@ -7,9 +7,9 @@
|
||||
## 1. 프로젝트 개요
|
||||
|
||||
Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||
- **서비스**: lotto-lab, stock-lab, travel-album, music-lab, blog-lab, realestate-lab, deployer
|
||||
- **서비스**: lotto-lab, stock, travel-proxy, music-lab, insta-lab, realestate-lab, agent-office, personal, packs-lab, deployer (10개)
|
||||
- **프론트엔드**: 별도 레포 (React + Vite SPA), 빌드 산출물만 NAS에 배포
|
||||
- **인프라**: Docker Compose + Nginx(리버스 프록시) + Gitea Webhook 자동 배포
|
||||
- **인프라**: Docker Compose (10컨테이너) + Nginx(리버스 프록시) + Gitea Webhook 자동 배포
|
||||
|
||||
---
|
||||
|
||||
@@ -22,7 +22,7 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||
| 메모리 | 18 GB |
|
||||
| Docker | Synology Container Manager |
|
||||
| Git 서버 | Gitea (self-hosted, NAS 내부) |
|
||||
| AI 서버 | Windows PC (192.168.45.59:8000) — NVIDIA 3070 Ti + Ollama |
|
||||
| AI 서버 | Windows PC (192.168.45.59:8000) — NVIDIA RTX 5070 Ti (16GB VRAM) + Ollama |
|
||||
|
||||
---
|
||||
|
||||
@@ -31,8 +31,8 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||
```
|
||||
/volume1
|
||||
├── docker/webpage/ # 운영 런타임 (Docker Compose 실행 위치)
|
||||
│ ├── backend/ # lotto-backend 소스 (rsync 동기화)
|
||||
│ ├── stock-lab/ # stock-lab 소스 (rsync 동기화)
|
||||
│ ├── lotto/ # lotto 소스 (rsync 동기화)
|
||||
│ ├── stock/ # stock 소스 (rsync 동기화)
|
||||
│ ├── travel-proxy/ # travel-proxy 소스 (rsync 동기화)
|
||||
│ ├── deployer/ # deployer 소스 (rsync 동기화)
|
||||
│ ├── nginx/default.conf # Nginx 설정
|
||||
@@ -53,14 +53,16 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||
|
||||
| 컨테이너 | 포트 | 역할 |
|
||||
|---------|------|------|
|
||||
| `lotto-backend` | 18000 | 로또 데이터 수집·분석·추천 API |
|
||||
| `stock-lab` | 18500 | 주식 뉴스·AI 분석·KIS API 연동 |
|
||||
| `lotto` | 18000 | 로또 데이터 수집·분석·추천 API |
|
||||
| `stock` | 18500 | 주식 뉴스·AI 분석·KIS API 연동 |
|
||||
| `music-lab` | 18600 | AI 음악 생성·라이브러리 관리 API |
|
||||
| `blog-lab` | 18700 | 블로그 마케팅 수익화 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 + 썸네일 생성 |
|
||||
| `lotto-frontend` (nginx) | 8080 | 정적 SPA 서빙 + API 리버스 프록시 |
|
||||
| `frontend` (nginx) | 8080 | 정적 SPA 서빙 + API 리버스 프록시 |
|
||||
| `webpage-deployer` | 19010 | Gitea Webhook 수신 → 자동 배포 |
|
||||
|
||||
---
|
||||
@@ -69,17 +71,22 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||
|
||||
| 경로 | 프록시 대상 | 비고 |
|
||||
|------|------------|------|
|
||||
| `/api/` | `lotto-backend:8000` | lotto API (기본) |
|
||||
| `/api/` | `lotto:8000` | lotto API (기본) |
|
||||
| `/api/travel/` | `travel-proxy:8000` | travel API |
|
||||
| `/api/stock/` | `stock-lab:8000` | stock API |
|
||||
| `/api/trade/` | `stock-lab:8000` | KIS 실계좌 API |
|
||||
| `/api/portfolio` | `stock-lab:8000` | trailing slash 유무 모두 매칭 |
|
||||
| `/api/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/blog-marketing/` | `blog-lab:8000` | 블로그 마케팅 수익화 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 해시 파일 |
|
||||
@@ -128,14 +135,15 @@ docker compose up -d
|
||||
| Lotto Backend | http://localhost:18000 |
|
||||
| Travel API | http://localhost:19000 |
|
||||
| Stock Lab | http://localhost:18500 |
|
||||
| Blog Lab | http://localhost:18700 |
|
||||
| Insta Lab | http://localhost:18700 |
|
||||
| Realestate Lab | http://localhost:18800 |
|
||||
| Packs Lab | http://localhost:18950 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 서비스별 핵심 정보
|
||||
|
||||
### lotto-lab (backend/)
|
||||
### 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`
|
||||
@@ -154,12 +162,18 @@ docker compose up -d
|
||||
| `strategy_weights` | 메타 전략 가중치 (EMA + Softmax) |
|
||||
| `weekly_reports` | 주간 공략 리포트 캐시 |
|
||||
| `lotto_briefings` | AI 큐레이터 주간 브리핑 (5세트 + 내러티브 + 토큰·비용 집계) |
|
||||
| `todos` | 투두리스트 (UUID PK) |
|
||||
| `blog_posts` | 블로그 글 (tags: JSON 배열) |
|
||||
| `todos` | 투두리스트 (UUID PK) — personal 서비스로 이전됨, 레거시 테이블 유지 |
|
||||
| `blog_posts` | 블로그 글 (tags: JSON 배열) — personal 서비스로 이전됨, 레거시 테이블 유지 |
|
||||
| `weight_trials` | 주별 6일치 후보 가중치 (4 perturb + 2 dirichlet) |
|
||||
| `auto_picks` | 매일 N=5 시도 번호 + 채점 결과 |
|
||||
| `weight_base_history` | base 갱신 이력 (winner_4plus / ema_blend / unchanged / cold_start) |
|
||||
|
||||
**스케줄러 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개 교체)
|
||||
- 월요일 09:00 — weight_evolver_weekly (6개 후보 생성 + 그날 N=5 추출)
|
||||
- 매일 09:00 — weight_evolver_daily (월요일 제외, 오늘 W로 N=5 추출)
|
||||
- 토요일 22:00 — weight_evolver_eval (회고 + 다음주 base 갱신)
|
||||
|
||||
**lotto-lab API 목록**
|
||||
|
||||
@@ -189,15 +203,6 @@ docker compose up -d
|
||||
| GET | `/api/history` | 추천 이력 (limit, offset, favorite, tag, sort) |
|
||||
| PATCH | `/api/history/{id}` | 즐겨찾기·메모·태그 수정 |
|
||||
| DELETE | `/api/history/{id}` | 삭제 |
|
||||
| GET | `/api/todos` | 투두 전체 목록 |
|
||||
| POST | `/api/todos` | 투두 생성 (status: todo\|in_progress\|done) |
|
||||
| PUT | `/api/todos/{id}` | 투두 수정 |
|
||||
| DELETE | `/api/todos/done` | 완료 항목 일괄 삭제 |
|
||||
| DELETE | `/api/todos/{id}` | 투두 개별 삭제 |
|
||||
| GET | `/api/blog/posts` | 블로그 글 목록 (`{"posts": [...]}`, date DESC) |
|
||||
| POST | `/api/blog/posts` | 블로그 글 생성 (date 미입력 시 오늘) |
|
||||
| PUT | `/api/blog/posts/{id}` | 블로그 글 수정 |
|
||||
| DELETE | `/api/blog/posts/{id}` | 블로그 글 삭제 |
|
||||
| GET | `/api/lotto/curator/candidates` | 큐레이터용 후보 N세트 + 피처 |
|
||||
| GET | `/api/lotto/curator/context` | 주간 맥락(핫/콜드·직전 회차) |
|
||||
| GET | `/api/lotto/curator/usage` | 큐레이터 토큰·비용 집계 |
|
||||
@@ -205,15 +210,20 @@ docker compose up -d
|
||||
| GET | `/api/lotto/briefing/latest` | 최신 브리핑 |
|
||||
| GET | `/api/lotto/briefing/{draw_no}` | 특정 회차 브리핑 |
|
||||
| GET | `/api/lotto/briefing` | 브리핑 이력 |
|
||||
| GET | `/api/lotto/evolver/status` | weight_evolver 이번주 trials + current_base + 진행 상황 |
|
||||
| GET | `/api/lotto/evolver/history?weeks=12` | base 변경 이력 |
|
||||
| GET | `/api/lotto/evolver/trials/{week_start}` | 특정 주 6 trials + 채점 결과 |
|
||||
| POST | `/api/lotto/evolver/generate-now` | 수동 트리거 — 이번주 후보 생성 |
|
||||
| POST | `/api/lotto/evolver/evaluate-now` | 수동 회고 + 다음주 base 갱신 |
|
||||
|
||||
### stock-lab (stock-lab/)
|
||||
### 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-lab API 목록**
|
||||
**stock API 목록**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
@@ -254,10 +264,11 @@ docker compose up -d
|
||||
- 15:40 평일 — 총 자산 스냅샷 저장 (`save_daily_snapshot`)
|
||||
|
||||
### music-lab (music-lab/)
|
||||
- 듀얼 프로바이더 음악 생성 서비스 (Suno API + 로컬 MusicGen)
|
||||
- 듀얼 프로바이더 음악 생성 서비스 (Suno API + 로컬 MusicGen) + YouTube 영상 제작 + 시장 조사 트렌드
|
||||
- 생성된 오디오 파일: `/app/data/music/` (Nginx가 `/media/music/`로 직접 서빙)
|
||||
- DB: `/app/data/music.db` (music_tasks, music_library 테이블)
|
||||
- 파일 구조: `main.py`, `db.py`, `suno_provider.py`, `local_provider.py`
|
||||
- 생성된 영상 파일: `/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 구조**
|
||||
@@ -293,12 +304,51 @@ docker compose up -d
|
||||
| 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` — 생성에 사용된 프로바이더
|
||||
@@ -317,31 +367,64 @@ docker compose up -d
|
||||
- 가사 섹션 태그: `[Verse]`, `[Chorus]`, `[Bridge]`, `[Instrumental]` 등
|
||||
|
||||
### realestate-lab (realestate-lab/)
|
||||
- 공공데이터포털 API 연동: 한국부동산원 청약홈 분양정보 조회 서비스
|
||||
- 공공데이터포털 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`, `models.py`
|
||||
- 파일 구조: `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**
|
||||
- 09:00 매일 — 청약 공고 수집 + 매칭 (`scheduled_collect`)
|
||||
- 00:00 매일 — 상태 갱신 + 재매칭 (`scheduled_status_update`)
|
||||
**스케줄러 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` | 공고 목록 (region, status, house_type, matched_only, sort, page, size) |
|
||||
| GET | `/api/realestate/announcements/{id}` | 공고 상세 (주택형별 포함) |
|
||||
| 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}` | 공고 삭제 |
|
||||
| POST | `/api/realestate/collect` | 수동 수집 트리거 |
|
||||
| DELETE | `/api/realestate/announcements/closed` | status='완료' 공고 일괄 삭제 |
|
||||
| POST | `/api/realestate/collect` | 수동 수집 트리거 (collect → cleanup → match → notify 전체 흐름) |
|
||||
| GET | `/api/realestate/collect/status` | 마지막 수집 결과 |
|
||||
| GET | `/api/realestate/profile` | 내 프로필 조회 |
|
||||
| PUT | `/api/realestate/profile` | 프로필 수정 (upsert) |
|
||||
| GET | `/api/realestate/matches` | 매칭 결과 목록 |
|
||||
| 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` | 요약 (진행중 공고수, 신규 매칭수, 다가오는 일정) |
|
||||
@@ -351,6 +434,7 @@ docker compose up -d
|
||||
- 썸네일 캐시: `/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 인덱싱 + 썸네일 일괄 생성
|
||||
@@ -362,85 +446,113 @@ docker compose up -d
|
||||
| `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/regions` | 지역 GeoJSON (커스텀 지역 동적 추가 포함) |
|
||||
| GET | `/api/travel/photos` | 사진 목록 (region, page=1, size=20) |
|
||||
| POST | `/api/travel/sync` | 폴더 스캔 → DB 동기화 + 썸네일 생성 |
|
||||
| GET | `/api/travel/albums` | 앨범 목록 + 사진 수 + 커버 정보 |
|
||||
| GET | `/api/travel/albums` | 앨범 목록 + 사진 수 + 커버 + region/regionName |
|
||||
| PUT | `/api/travel/albums/{album}/cover` | 앨범 커버 지정 |
|
||||
| PUT | `/api/travel/albums/{album}/region` | 앨범 지역 변경 (region_map_extra 수정) |
|
||||
| PUT | `/api/travel/regions/{region_id}` | 커스텀 지역 이름/좌표 수정 (지도 핀 표시용) |
|
||||
|
||||
### blog-lab (blog-lab/)
|
||||
- 블로그 마케팅 수익화 서비스 (키워드 분석 → AI 글 생성 → 마케팅 강화 → 품질 리뷰 → 포스팅 → 수익 추적)
|
||||
- AI 엔진: Claude API (Anthropic, `claude-sonnet-4-20250514`)
|
||||
- 웹 검색: Naver Search API (블로그 + 쇼핑) + 상위 블로그 본문 크롤링
|
||||
- DB: `/app/data/blog_marketing.db`
|
||||
- 파일 구조: `main.py`, `db.py`, `config.py`, `naver_search.py`, `content_generator.py`, `marketer.py`, `quality_reviewer.py`, `web_crawler.py`
|
||||
### 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`
|
||||
|
||||
**파이프라인**: 리서치(+크롤링) → 작가(초안) → 마케터(링크 삽입) → 평가자(6기준 60점)
|
||||
**상태 흐름**: `draft` → `marketed` → `reviewed` → `published`
|
||||
**환경변수**
|
||||
- `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 튜닝
|
||||
|
||||
**blog_marketing.db 테이블**
|
||||
**카테고리 시드 키워드**
|
||||
- 기본 economy / psychology / celebrity 3종 (config.DEFAULT_CATEGORY_SEEDS)
|
||||
- `prompt_templates.name='category_seeds'`에 JSON으로 오버라이드 가능
|
||||
|
||||
| 테이블 | 설명 |
|
||||
|--------|------|
|
||||
| `keyword_analyses` | 키워드 분석 결과 (네이버 검색 데이터 + 경쟁도/기회 점수 + 크롤링 본문) |
|
||||
| `blog_posts` | 블로그 글 (draft → marketed → reviewed → published) |
|
||||
| `brand_links` | 브랜드커넥트 제휴 링크 (post_id/keyword_id FK) |
|
||||
| `commissions` | 포스트별 월간 클릭/구매/수익 |
|
||||
| `generation_tasks` | 비동기 작업 상태 (research/generate/market/review) |
|
||||
| `prompt_templates` | AI 프롬프트 템플릿 (DB 저장, 코드 배포 없이 수정 가능) |
|
||||
**카드 슬레이트 (`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)
|
||||
|
||||
**blog-lab API 목록**
|
||||
**스케줄러 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장, 4:5 비율 권장 1080×1350, placeholder 텍스트 박혀있는 형태) → Claude Sonnet Vision → `templates/<theme>/card.html.j2` 자동 생성
|
||||
- 파일명 자동 매핑: `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
|
||||
- 활성화: `.env`에 `INSTA_DEFAULT_THEME=<theme>` 추가 + `docker compose restart insta-lab` (테마 디렉토리에 `card.html.j2` 없으면 렌더러가 default로 폴백)
|
||||
- 토큰 비용: 1회당 ~15K tokens (~$0.05 Sonnet 기준)
|
||||
|
||||
**⚠️ 실행 위치 — 로컬 권장, NAS docker exec 금지**
|
||||
- docker-compose의 insta-lab volume은 `/app/data`만 마운트. **`/app/app/templates`는 컨테이너 ephemeral state**.
|
||||
- NAS에서 `docker exec insta-lab python -m app.design_importer <theme>`로 돌리면 `card.html.j2`가 컨테이너 안에만 생성되고 다음 image rebuild(다른 push의 webhook이라도) 때 사라짐 → 렌더러가 default로 폴백.
|
||||
- **로컬 실행** (host repo working tree에 영속화 → git push → 자동 배포):
|
||||
```bash
|
||||
cd insta-lab
|
||||
pip install anthropic Pillow jinja2 # 이미 있으면 skip
|
||||
export ANTHROPIC_API_KEY=sk-ant-...
|
||||
python -m app.design_importer <theme> --templates-dir ./app/templates
|
||||
git add app/templates/<theme>/card.html.j2
|
||||
git commit -m "feat(insta-lab): <theme> 디자인 import"
|
||||
git push # → Gitea webhook → NAS rebuild → 영구 활성화
|
||||
```
|
||||
- 응급 hotfix로 NAS에서 돌렸다면 `docker cp insta-lab:/app/app/templates/<theme>/card.html.j2 ./` 후 즉시 host repo에 commit + push 필요
|
||||
|
||||
**insta-lab API 목록**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/blog-marketing/status` | 서비스 상태 (API 키 설정 현황) |
|
||||
| POST | `/api/blog-marketing/research` | 키워드 분석 시작 (+ 상위 블로그 크롤링) |
|
||||
| GET | `/api/blog-marketing/research/history` | 분석 이력 조회 |
|
||||
| GET | `/api/blog-marketing/research/{id}` | 분석 상세 조회 |
|
||||
| DELETE | `/api/blog-marketing/research/{id}` | 분석 삭제 |
|
||||
| GET | `/api/blog-marketing/task/{task_id}` | 작업 상태 폴링 |
|
||||
| POST | `/api/blog-marketing/generate` | 작가 단계: AI 글 생성 (크롤링 참고 + 링크 반영) |
|
||||
| POST | `/api/blog-marketing/market/{post_id}` | 마케터 단계: 전환율 강화 + 링크 삽입 |
|
||||
| POST | `/api/blog-marketing/review/{post_id}` | 평가자 단계: 품질 리뷰 (6기준 × 10점, 42/60 통과) |
|
||||
| POST | `/api/blog-marketing/regenerate/{post_id}` | 피드백 기반 재생성 |
|
||||
| POST | `/api/blog-marketing/links` | 브랜드커넥트 링크 등록 |
|
||||
| GET | `/api/blog-marketing/links` | 링크 조회 (post_id, keyword_id 필터) |
|
||||
| PUT | `/api/blog-marketing/links/{id}` | 링크 수정 |
|
||||
| DELETE | `/api/blog-marketing/links/{id}` | 링크 삭제 |
|
||||
| GET | `/api/blog-marketing/posts` | 포스트 목록 (status 필터) |
|
||||
| GET | `/api/blog-marketing/posts/{id}` | 포스트 상세 |
|
||||
| PUT | `/api/blog-marketing/posts/{id}` | 포스트 수정 |
|
||||
| DELETE | `/api/blog-marketing/posts/{id}` | 포스트 삭제 |
|
||||
| POST | `/api/blog-marketing/posts/{id}/publish` | 발행 (네이버 URL 등록) |
|
||||
| GET | `/api/blog-marketing/commissions` | 수익 내역 조회 |
|
||||
| POST | `/api/blog-marketing/commissions` | 수익 기록 추가 |
|
||||
| PUT | `/api/blog-marketing/commissions/{id}` | 수익 기록 수정 |
|
||||
| DELETE | `/api/blog-marketing/commissions/{id}` | 수익 기록 삭제 |
|
||||
| GET | `/api/blog-marketing/dashboard` | 대시보드 집계 |
|
||||
|
||||
**환경변수**
|
||||
- `ANTHROPIC_API_KEY`: Claude API 키 (미설정 시 AI 생성 비활성화)
|
||||
- `NAVER_CLIENT_ID`: 네이버 검색 API 클라이언트 ID
|
||||
- `NAVER_CLIENT_SECRET`: 네이버 검색 API 시크릿
|
||||
- `BLOG_DATA_PATH`: SQLite DB 저장 경로 (기본 `./data/blog`)
|
||||
| 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-lab/music-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음)
|
||||
- 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`
|
||||
- 파일 구조: `main.py`, `db.py`, `config.py`, `models.py`, `websocket_manager.py`, `service_proxy.py`, `telegram_bot.py`, `scheduler.py`, `agents/base.py`, `agents/stock.py`, `agents/music.py`, `agents/realestate.py`, `telegram/realestate_message.py`
|
||||
|
||||
**에이전트 FSM 상태**: idle → working → waiting (승인 대기) → reporting → break (휴식)
|
||||
|
||||
**환경변수**
|
||||
- `STOCK_LAB_URL`: stock-lab 내부 URL (기본 `http://stock-lab:8000`)
|
||||
- `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
|
||||
@@ -449,8 +561,23 @@ docker compose up -d
|
||||
- `CONVERSATION_MODEL`: 대화 모델 (기본 `claude-haiku-4-5-20251001`)
|
||||
- `CONVERSATION_HISTORY_LIMIT`: 이력 주입 수 (기본 20)
|
||||
- `CONVERSATION_RATE_PER_MIN`: 채팅당 분당 최대 메시지 (기본 6)
|
||||
- `LOTTO_BACKEND_URL`: 기본 `http://lotto-backend:8000`
|
||||
- `LOTTO_BACKEND_URL`: 기본 `http://lotto:8000`
|
||||
- `LOTTO_CURATOR_MODEL`: 기본 `claude-sonnet-4-5`
|
||||
- `YOUTUBE_DATA_API_KEY`: YouTube Data API v3 키 (미설정 시 YouTube trending 수집 skip)
|
||||
- `LOTTO_SIGNAL_WINDOW`: baseline 윈도우 크기 (기본 8)
|
||||
- `LOTTO_Z_NORMAL`: normal fire 임계치 (기본 1.5)
|
||||
- `LOTTO_Z_URGENT`: urgent fire 임계치 (기본 2.5)
|
||||
- `LOTTO_THROTTLE_HOURS`: 같은 메트릭 재발화 throttle (기본 6시간)
|
||||
- `LOTTO_URGENT_DAILY_MAX`: urgent 하루 cap (기본 3통)
|
||||
|
||||
**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가 응답
|
||||
@@ -463,6 +590,24 @@ docker compose up -d
|
||||
- 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`)
|
||||
- 09:15 매일 — 로또 light_check (시뮬·전략 가중치 평가)
|
||||
- 매 4시간 :15 — 로또 sim_check (00/04/08/12/16/20시)
|
||||
- 일/수 21:15 — 로또 deep_check (큐레이션 후 confidence 포함 평가)
|
||||
- 09:25 매일 — 로또 daily_digest (지난 24h 발화 텔레그램 1통)
|
||||
- 토요일 22:15 — 로또 weight_evolver 주간 텔레그램 리포트
|
||||
|
||||
**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 목록**
|
||||
|
||||
@@ -478,9 +623,103 @@ docker compose up -d
|
||||
| GET | `/api/agent-office/tasks/{id}` | 작업 상세 |
|
||||
| POST | `/api/agent-office/command` | 에이전트에 명령 전송 |
|
||||
| POST | `/api/agent-office/approve` | 작업 승인/거부 |
|
||||
| POST | `/api/agent-office/telegram/webhook` | 텔레그램 Webhook 수신 |
|
||||
| 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` | 마지막 수집 작업 상태 |
|
||||
| GET | `/api/agent-office/lotto/signals?days=7` | 로또 능동 시그널 이력 (모든 fire_level) |
|
||||
| GET | `/api/agent-office/lotto/baselines` | 로또 메트릭별 baseline μ/σ + 윈도우 상태 |
|
||||
| POST | `/api/agent-office/lotto/signal-check?source=light` | 로또 시그널 평가 수동 트리거 (light/sim/deep) |
|
||||
|
||||
### 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` 사용)
|
||||
@@ -493,12 +732,13 @@ docker compose up -d
|
||||
## 10. 주의사항
|
||||
|
||||
- **Nginx trailing slash**: `/api/portfolio`는 trailing slash 없이도 매칭되도록 두 location 블록으로 처리
|
||||
- **라우트 순서**: `DELETE /api/todos/done`은 `DELETE /api/todos/{id}` 보다 **반드시 먼저** 등록 (FastAPI prefix 매칭 순서)
|
||||
- **라우트 순서**: `DELETE /api/todos/done`은 `DELETE /api/todos/{id}` 보다 **반드시 먼저** 등록 (personal 서비스, FastAPI prefix 매칭 순서)
|
||||
- **PUID/PGID**: travel-proxy는 NAS 파일 권한을 위해 PUID/PGID를 환경변수로 주입
|
||||
- **캐시 전략**: `index.html`은 `no-store`, `assets/`는 1년 장기 캐시(immutable)
|
||||
- **Frontend 배포**: git push로 자동 배포되지 않음. 로컬 빌드 후 NAS에 수동 업로드
|
||||
- **.env 파일**: 절대 커밋 금지. `.env.example`만 레포에 포함
|
||||
- **공휴일 목록**: `stock-lab/app/holidays.json` 매년 수동 갱신 필요 (KRX 기준)
|
||||
- **공휴일 목록**: `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슬레이트만 렌더하도록 직렬화됨
|
||||
|
||||
156
README.md
156
README.md
@@ -1,7 +1,7 @@
|
||||
# web-backend
|
||||
|
||||
Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
|
||||
로또 분석, 주식 포트폴리오, AI 음악 생성, 블로그 마케팅, 부동산 청약, AI 에이전트 오피스, 여행 앨범을 하나의 Docker Compose 스택으로 운영한다.
|
||||
로또 분석, 주식 포트폴리오, AI 음악 생성, 인스타 카드 피드, 부동산 청약, AI 에이전트 오피스, 여행 앨범, 개인 서비스(포트폴리오·블로그·투두), NAS 자료 다운로드 자동화를 하나의 Docker Compose 스택으로 운영한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -9,33 +9,37 @@ Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ lotto-frontend (Nginx:8080) │
|
||||
│ frontend (Nginx:8080) │
|
||||
│ ├── 정적 SPA 서빙 (React + Vite) │
|
||||
│ └── API 리버스 프록시 │
|
||||
│ ├── /api/ → lotto-backend:8000 (로또·블로그·투두)│
|
||||
│ ├── /api/stock/, /trade/ → stock-lab:8000 │
|
||||
│ ├── /api/portfolio → stock-lab:8000 │
|
||||
│ ├── /api/music/ → music-lab:8000 │
|
||||
│ ├── /api/blog-marketing/ → blog-lab:8000 │
|
||||
│ ├── /api/realestate/ → realestate-lab:8000 │
|
||||
│ ├── /api/agent-office/ → agent-office:8000 (+ WebSocket) │
|
||||
│ ├── /api/travel/ → travel-proxy:8000 │
|
||||
│ ├── /media/music/… (nginx 직접 서빙, 생성 오디오) │
|
||||
│ ├── /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 │
|
||||
│ └── /webhook → deployer:9000 │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 컨테이너 | 포트 | 역할 |
|
||||
|---------|------|------|
|
||||
| `lotto-backend` | 18000 | 로또 데이터 수집·분석·추천 + 블로그·투두 API |
|
||||
| `stock-lab` | 18500 | 주식 뉴스·AI 요약·KIS 실계좌·포트폴리오·자산 추적 |
|
||||
| `music-lab` | 18600 | AI 음악 생성 (Suno + 로컬 MusicGen 듀얼 프로바이더) |
|
||||
| `blog-lab` | 18700 | 블로그 마케팅 수익화 (키워드→글 생성→리뷰→발행) |
|
||||
| `realestate-lab` | 18800 | 청약 공고 자동 수집·프로필 매칭 |
|
||||
| `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 + 온디맨드 썸네일 |
|
||||
| `lotto-frontend` | 8080 | SPA 서빙 + 리버스 프록시 |
|
||||
| `frontend` | 8080 | SPA 서빙 + 리버스 프록시 |
|
||||
| `webpage-deployer` | 19010 | Gitea Webhook → 자동 배포 |
|
||||
|
||||
---
|
||||
@@ -44,12 +48,14 @@ Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
|
||||
|
||||
```
|
||||
web-backend/
|
||||
├── backend/ # lotto-backend (로또·블로그·투두)
|
||||
├── stock-lab/ # 주식·포트폴리오
|
||||
├── music-lab/ # AI 음악 생성
|
||||
├── blog-lab/ # 블로그 마케팅 파이프라인
|
||||
├── realestate-lab/ # 청약 자동 수집·매칭
|
||||
├── 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 수신 → 자동 배포
|
||||
├── nginx/default.conf # 리버스 프록시 + SPA + 캐시
|
||||
@@ -74,12 +80,14 @@ 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 |
|
||||
| blog-lab | http://localhost:18700 |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
@@ -99,7 +107,7 @@ curl http://localhost:18500/health
|
||||
- 09:10 / 21:10 — 당첨번호 동기화 + 추천 채점
|
||||
- 00:05, 04:05, 08:05, 12:05, 16:05, 20:05 — 몬테카를로 시뮬레이션 (후보 20,000 → 상위 100 → best_picks 20쌍 교체)
|
||||
|
||||
### 2. stock-lab (`/api/stock/`, `/api/trade/`, `/api/portfolio`)
|
||||
### 2. stock (`/api/stock/`, `/api/trade/`, `/api/portfolio`)
|
||||
|
||||
주식 뉴스 스크래핑 + LLM 요약 + KIS 실계좌 연동 + 포트폴리오·자산 스냅샷.
|
||||
|
||||
@@ -123,20 +131,23 @@ curl http://localhost:18500/health
|
||||
- **라이브러리**: 생성 파일은 `/app/data/music/`에 저장되고 Nginx가 `/media/music/`으로 직접 서빙
|
||||
- **가사 도구**: 저장·편집·타임스탬프 기반 가라오케 동기
|
||||
|
||||
### 4. blog-lab (`/api/blog-marketing/`)
|
||||
### 4. insta-lab (`/api/insta/`)
|
||||
|
||||
블로그 마케팅 수익화 4단계 파이프라인 (`draft → marketed → reviewed → published`).
|
||||
인스타그램 카드 피드 자동 생성 — 뉴스 모니터링 → 키워드 추출 → 10페이지 카드 카피·PNG 렌더 → 텔레그램 푸시 → 사용자 수동 업로드.
|
||||
|
||||
```
|
||||
리서치(Naver Search + 상위 블로그 본문 크롤링)
|
||||
→ 작가(AI 초안 생성)
|
||||
→ 마케터(전환율 강화 + 브랜드 링크 삽입)
|
||||
→ 평가자(6기준×10점, 42/60 통과 시 published)
|
||||
NAVER 뉴스 + YouTube 인기 (외부 트렌드)
|
||||
→ 카테고리별 빈도 + Claude Haiku 정제 → 트렌딩 키워드
|
||||
→ 사용자가 키워드 선택
|
||||
→ Claude Sonnet으로 10페이지 카피 추론 (커버 1 + 본문 8 + CTA 1)
|
||||
→ Jinja2 + Playwright 1080×1350 PNG 10장 렌더
|
||||
→ 텔레그램 미디어 그룹 + 추천 캡션·해시태그
|
||||
```
|
||||
|
||||
- **AI 엔진**: Claude API (`claude-sonnet-4-20250514`)
|
||||
- **키워드 분석**: 네이버 검색(블로그+쇼핑) API + 경쟁도/기회 점수
|
||||
- **수익 추적**: 포스트별 월간 클릭/구매/수익 기록
|
||||
- **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에 저장 → 코드 배포 없이 수정 가능
|
||||
|
||||
### 5. realestate-lab (`/api/realestate/`)
|
||||
@@ -152,7 +163,7 @@ curl http://localhost:18500/health
|
||||
|
||||
AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 4명의 에이전트가 실제 작업을 수행한다.
|
||||
|
||||
- **아키텍처**: stock-lab / music-lab / blog-lab / realestate-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음)
|
||||
- **아키텍처**: 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)
|
||||
- **텔레그램 연동**: 양방향 알림 + 인라인 키보드 승인
|
||||
@@ -165,31 +176,40 @@ AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 4명의 에
|
||||
|---------|--------|-----|----------|
|
||||
| 📈 **주식 트레이더** (`stock`) | 08:00 매일 | — | 뉴스 요약 (LLM) → 텔레그램 아침 브리핑, 종목 알람 등록 |
|
||||
| 🎵 **음악 프로듀서** (`music`) | 수동 트리거 | ✅ 작곡 | 프롬프트 수신 → 승인 → Suno API 작곡 → 트랙 푸시 |
|
||||
| ✍️ **블로그 마케터** (`blog`) | 10:00 매일 | ✅ 발행 | 트렌드 키워드 1개 선택 → 리서치→작가→마케터→평가 자동 실행 → 점수·본문을 텔레그램 승인 요청 → 승인 시 `published` 전환, 거절 시 재생성 |
|
||||
| 🏢 **청약 애널리스트** (`realestate`) | 09:15 매일 | — | realestate-lab 수집 트리거 → 신규 매칭 상위 5건 + 대시보드 요약을 텔레그램 리포트 (읽음 처리 자동) |
|
||||
| 🎴 **인스타 큐레이터** (`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`
|
||||
**Blog** — `research {keyword}`, `add_trend_keyword`, `list_trend_keywords`
|
||||
**Insta** — `extract`, `render <keyword_id>`, `collect_trends`
|
||||
**Realestate** — `fetch_matches`, `dashboard`
|
||||
**YouTube** — `research {countries: [...]}`
|
||||
|
||||
#### 스케줄러 잡
|
||||
|
||||
- 08:00 — Stock: 뉴스 요약
|
||||
- 09:15 — Realestate: 매칭 리포트
|
||||
- 10:00 — Blog: 자동 파이프라인 (리서치→생성→리뷰→승인 대기)
|
||||
- 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 + 온디맨드 썸네일 생성.
|
||||
여행 사진 API + SQLite 인덱스 + 온디맨드 썸네일 + 지역 관리.
|
||||
|
||||
- 원본: `/data/travel/` (RO 마운트)
|
||||
- 썸네일: 480×480 Pillow 리사이징, `/data/thumbs/` 영구 캐시 (tmp → rename 원자성 보장)
|
||||
- 메모리 캐시: 앨범 스캔 결과 TTL 300초
|
||||
- 메타: `region_map.json`, `regions.geojson`
|
||||
- DB: `/data/thumbs/travel.db` (photos, album_covers 테이블)
|
||||
- 메타: `region_map.json` (RO) + `region_map_extra.json` (RW 오버라이드) + `regions.geojson`
|
||||
- 지역 관리: 앨범 지역 변경, 커스텀 지역 생성, 지도 핀 좌표 지정
|
||||
- 데이터 흐름: 수동 sync → 폴더 스캔 → SQLite 인덱싱 + 썸네일 일괄 생성
|
||||
|
||||
### 8. deployer (`/webhook`)
|
||||
|
||||
@@ -221,7 +241,7 @@ Gitea Webhook 수신 → NAS 자동 배포.
|
||||
| 공동 출현 | 15% | 번호 쌍 동시 출현 빈도 |
|
||||
| 다양성 | 10% | 연속번호·범위·구간 커버리지 |
|
||||
|
||||
### LLM 요약 provider 추상화 (stock-lab)
|
||||
### LLM 요약 provider 추상화 (stock)
|
||||
|
||||
`ai_summarizer.py`는 provider 분리 구조. `summarize_news(articles)` 시그니처는 provider와 무관하게 고정.
|
||||
|
||||
@@ -229,7 +249,7 @@ Gitea Webhook 수신 → NAS 자동 배포.
|
||||
- `_summarize_with_ollama`: Ollama `/api/generate` (타임아웃 180s, qwen3:14b 첫 로드 대응)
|
||||
- 실패 시 `LLMError` (구 `OllamaError` alias 유지)
|
||||
|
||||
### 총 자산 스냅샷 (stock-lab)
|
||||
### 총 자산 스냅샷 (stock)
|
||||
|
||||
평일 15:40 자동 실행 → `holidays.json`으로 공휴일 스킵 → 포트폴리오 현재가 조회 + 예수금 합계 → `asset_snapshots` upsert (date UNIQUE).
|
||||
|
||||
@@ -262,12 +282,15 @@ git push → Gitea → X-Gitea-Signature (HMAC SHA256)
|
||||
|
||||
| DB | 소유 서비스 | 주요 테이블 |
|
||||
|----|------------|-----------|
|
||||
| `lotto.db` | lotto-backend | draws, recommendations, simulation_runs/candidates, best_picks, purchase_history, strategy_performance/weights, weekly_reports, todos, blog_posts |
|
||||
| `stock.db` | stock-lab | articles, portfolio, broker_cash, asset_snapshots, sell_history |
|
||||
| `music.db` | music-lab | music_tasks, music_library (provider, lyrics, image_url, suno_id, file_hash, cover_images, wav_url, video_url, stem_urls) |
|
||||
| `blog_marketing.db` | blog-lab | keyword_analyses, blog_posts, brand_links, commissions, generation_tasks, prompt_templates |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -288,33 +311,50 @@ PGID=1000
|
||||
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
|
||||
WEBHOOK_SECRET=your_secret_here
|
||||
|
||||
# LLM (stock-lab, blog-lab, agent-office 공통)
|
||||
# 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
|
||||
|
||||
# blog-lab
|
||||
# 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_LAB_URL=http://stock-lab:8000
|
||||
STOCK_URL=http://stock:8000
|
||||
MUSIC_LAB_URL=http://music-lab:8000
|
||||
BLOG_LAB_URL=http://blog-lab:8000
|
||||
INSTA_LAB_URL=http://insta-lab:8000
|
||||
REALESTATE_LAB_URL=http://realestate-lab:8000
|
||||
|
||||
# personal (포트폴리오 편집 인증)
|
||||
PORTFOLIO_EDIT_PASSWORD=
|
||||
```
|
||||
|
||||
---
|
||||
@@ -339,7 +379,7 @@ REALESTATE_LAB_URL=http://realestate-lab:8000
|
||||
- **라우트 순서** — `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 기준)
|
||||
- **공휴일 목록** — `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`
|
||||
|
||||
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`
|
||||
@@ -7,4 +7,4 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
from .stock import StockAgent
|
||||
from .music import MusicAgent
|
||||
from .blog import BlogAgent
|
||||
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["blog"] = BlogAgent()
|
||||
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)
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
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")
|
||||
VALID_STATES = ("idle", "working", "waiting", "reporting")
|
||||
|
||||
class BaseAgent:
|
||||
agent_id: str = ""
|
||||
@@ -14,7 +11,6 @@ class BaseAgent:
|
||||
state: str = "idle"
|
||||
state_detail: str = ""
|
||||
_idle_since: float = 0.0
|
||||
_break_until: float = 0.0
|
||||
_ws_manager = None
|
||||
|
||||
def __init__(self):
|
||||
@@ -32,9 +28,6 @@ class BaseAgent:
|
||||
|
||||
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})")
|
||||
|
||||
@@ -48,19 +41,6 @@ class BaseAgent:
|
||||
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
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
|
||||
from .base import BaseAgent
|
||||
from ..db import (
|
||||
create_task, update_task_status, approve_task, reject_task,
|
||||
get_task, get_agent_config, add_log,
|
||||
)
|
||||
from .. import service_proxy
|
||||
from .. import telegram_bot
|
||||
|
||||
|
||||
DEFAULT_TREND_KEYWORDS = [
|
||||
"다이어트 식단", "재택근무 꿀템", "캠핑 장비 추천",
|
||||
"홈트레이닝", "제주도 여행", "에어프라이어 레시피",
|
||||
]
|
||||
|
||||
|
||||
class BlogAgent(BaseAgent):
|
||||
"""블로그 마케팅 에이전트.
|
||||
|
||||
매일 10:00 자동 실행: 키워드 1개 리서치 → 글 생성 → 마케터 → 평가자
|
||||
→ 평가 점수와 요약을 텔레그램 승인 요청으로 푸시
|
||||
→ 승인 시 `published` 상태로 전환, 거절 시 재생성
|
||||
"""
|
||||
|
||||
agent_id = "blog"
|
||||
display_name = "블로그 마케터"
|
||||
|
||||
async def on_schedule(self) -> None:
|
||||
if self.state not in ("idle", "break"):
|
||||
return
|
||||
|
||||
config = get_agent_config(self.agent_id) or {}
|
||||
custom = config.get("custom_config", {}) or {}
|
||||
keywords = custom.get("trend_keywords") or DEFAULT_TREND_KEYWORDS
|
||||
if not keywords:
|
||||
return
|
||||
|
||||
import random
|
||||
keyword = random.choice(keywords)
|
||||
|
||||
task_id = create_task(
|
||||
self.agent_id,
|
||||
"auto_blog_pipeline",
|
||||
{"keyword": keyword},
|
||||
requires_approval=True,
|
||||
)
|
||||
await self.transition("working", f"리서치: {keyword}", task_id)
|
||||
asyncio.create_task(self._run_pipeline(task_id, keyword))
|
||||
|
||||
async def _await_task(self, step: str, task_id: str, timeout_sec: int = 240) -> Optional[int]:
|
||||
"""blog-lab BackgroundTask 완료 폴링. 완료 시 result_id 반환."""
|
||||
attempts = max(1, timeout_sec // 5)
|
||||
for _ in range(attempts):
|
||||
await asyncio.sleep(5)
|
||||
status = await service_proxy.blog_task_status(task_id)
|
||||
s = status.get("status")
|
||||
if s == "succeeded":
|
||||
return status.get("result_id")
|
||||
if s == "failed":
|
||||
raise Exception(f"{step} failed: {status.get('error')}")
|
||||
raise Exception(f"{step} timeout ({timeout_sec}s 내 완료되지 않음)")
|
||||
|
||||
async def _run_pipeline(self, task_id: str, keyword: str) -> None:
|
||||
try:
|
||||
# 1) 리서치
|
||||
research = await service_proxy.blog_research(keyword)
|
||||
keyword_id = await self._await_task("research", research.get("task_id"), 180)
|
||||
if not keyword_id:
|
||||
raise Exception("research succeeded but result_id missing")
|
||||
|
||||
# 2) 작가 단계 (비동기)
|
||||
await self.transition("working", f"글 생성: {keyword}", task_id)
|
||||
gen = await service_proxy.blog_generate(keyword_id)
|
||||
post_id = await self._await_task("generate", gen.get("task_id"), 300)
|
||||
if not post_id:
|
||||
raise Exception("generate succeeded but post_id missing")
|
||||
|
||||
# 3) 마케터 단계 (비동기)
|
||||
await self.transition("working", "링크 삽입 중", task_id)
|
||||
mkt = await service_proxy.blog_market(post_id)
|
||||
await self._await_task("market", mkt.get("task_id"), 180)
|
||||
|
||||
# 4) 평가자 단계 (비동기)
|
||||
await self.transition("working", "품질 리뷰 중", task_id)
|
||||
rev = await service_proxy.blog_review(post_id)
|
||||
await self._await_task("review", rev.get("task_id"), 180)
|
||||
|
||||
post_after = await service_proxy.blog_get_post(post_id)
|
||||
score = post_after.get("review_score")
|
||||
passed = (score or 0) >= 42
|
||||
|
||||
title = post_after.get("title", "(제목 없음)")
|
||||
excerpt = (post_after.get("body") or "")[:300]
|
||||
|
||||
update_task_status(task_id, "pending", {
|
||||
"keyword": keyword,
|
||||
"post_id": post_id,
|
||||
"score": score,
|
||||
"passed": passed,
|
||||
"title": title,
|
||||
})
|
||||
|
||||
await self.transition("waiting", f"승인 대기 · {score}/60", task_id)
|
||||
|
||||
detail = (
|
||||
f"키워드: {keyword}\n"
|
||||
f"제목: {title}\n"
|
||||
f"평가 점수: {score}/60 ({'통과' if passed else '미통과'})\n\n"
|
||||
f"{excerpt}..."
|
||||
)
|
||||
await telegram_bot.send_approval_request(
|
||||
self.agent_id, task_id,
|
||||
"✍️ [블로그 에이전트] 발행 승인 요청", detail,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
add_log(self.agent_id, f"Blog pipeline failed: {e}", "error", task_id)
|
||||
update_task_status(task_id, "failed", {"error": str(e), "keyword": keyword})
|
||||
await self.transition("idle", f"오류: {e}")
|
||||
await telegram_bot.send_task_result(
|
||||
self.agent_id, "✍️ [블로그 에이전트] 파이프라인 실패",
|
||||
f"키워드: {keyword}\n오류: {e}",
|
||||
)
|
||||
|
||||
async def on_command(self, command: str, params: dict) -> dict:
|
||||
if command == "research":
|
||||
keyword = (params.get("keyword") or "").strip()
|
||||
if not keyword:
|
||||
return {"ok": False, "message": "keyword 필수"}
|
||||
task_id = create_task(
|
||||
self.agent_id, "auto_blog_pipeline",
|
||||
{"keyword": keyword}, requires_approval=True,
|
||||
)
|
||||
await self.transition("working", f"리서치: {keyword}", task_id)
|
||||
asyncio.create_task(self._run_pipeline(task_id, keyword))
|
||||
return {"ok": True, "task_id": task_id, "message": f"파이프라인 시작: {keyword}"}
|
||||
|
||||
if command == "add_trend_keyword":
|
||||
keyword = (params.get("keyword") or "").strip()
|
||||
if not keyword:
|
||||
return {"ok": False, "message": "keyword 필수"}
|
||||
config = get_agent_config(self.agent_id) or {}
|
||||
custom = config.get("custom_config", {}) or {}
|
||||
kws = list(custom.get("trend_keywords") or [])
|
||||
if keyword not in kws:
|
||||
kws.append(keyword)
|
||||
from ..db import update_agent_config
|
||||
update_agent_config(self.agent_id, custom_config={**custom, "trend_keywords": kws})
|
||||
return {"ok": True, "keywords": kws}
|
||||
|
||||
if command == "list_trend_keywords":
|
||||
config = get_agent_config(self.agent_id) or {}
|
||||
custom = config.get("custom_config", {}) or {}
|
||||
return {"ok": True, "keywords": custom.get("trend_keywords") or DEFAULT_TREND_KEYWORDS}
|
||||
|
||||
return {"ok": False, "message": f"Unknown command: {command}"}
|
||||
|
||||
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
|
||||
task = get_task(task_id)
|
||||
if not task:
|
||||
return
|
||||
result = task.get("result_data") or {}
|
||||
post_id = result.get("post_id")
|
||||
|
||||
if not approved:
|
||||
reject_task(task_id)
|
||||
await self.transition("idle", "발행 거절됨")
|
||||
await telegram_bot.send_task_result(
|
||||
self.agent_id, "✍️ [블로그 에이전트] 발행 취소",
|
||||
f"키워드: {result.get('keyword', '')}\n사용자가 거절했습니다.",
|
||||
)
|
||||
return
|
||||
|
||||
approve_task(task_id, via="telegram")
|
||||
await self.transition("reporting", "발행 중...", task_id)
|
||||
|
||||
try:
|
||||
if post_id:
|
||||
await service_proxy.blog_publish(int(post_id))
|
||||
update_task_status(task_id, "succeeded", {**result, "published": True})
|
||||
await telegram_bot.send_task_result(
|
||||
self.agent_id, "✍️ [블로그 에이전트] 발행 완료",
|
||||
f"키워드: {result.get('keyword', '')}\n제목: {result.get('title', '')}\n"
|
||||
f"점수: {result.get('score')}/60",
|
||||
)
|
||||
await self.transition("idle", "발행 완료")
|
||||
except Exception as e:
|
||||
add_log(self.agent_id, f"Blog publish failed: {e}", "error", task_id)
|
||||
update_task_status(task_id, "failed", {**result, "publish_error": str(e)})
|
||||
await self.transition("idle", f"발행 오류: {e}")
|
||||
75
agent-office/app/agents/classify_intent.py
Normal file
75
agent-office/app/agents/classify_intent.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""텔레그램 사용자 응답 자연어 분류 — 화이트리스트 우선, 모호 시 LLM."""
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger("agent-office.classify_intent")
|
||||
|
||||
CLAUDE_HAIKU_DEFAULT = "claude-haiku-4-5-20251001"
|
||||
|
||||
APPROVE_WORDS = {
|
||||
"승인", "시작", "진행", "ok", "okay", "agree",
|
||||
"네", "예", "좋아", "좋아요", "go", "yes", "y",
|
||||
}
|
||||
REJECT_WORDS = {"반려", "거절", "취소", "no", "nope", "n"}
|
||||
|
||||
|
||||
def _get_api_key() -> str:
|
||||
return os.getenv("ANTHROPIC_API_KEY", "")
|
||||
|
||||
|
||||
def _get_model() -> str:
|
||||
return os.getenv("CLAUDE_HAIKU_MODEL", CLAUDE_HAIKU_DEFAULT)
|
||||
|
||||
|
||||
def classify(text: str) -> tuple[str, str | None]:
|
||||
"""returns (intent, feedback) — intent ∈ {approve, reject, unclear}"""
|
||||
if not text:
|
||||
return ("unclear", None)
|
||||
t = text.strip().lower()
|
||||
if t in APPROVE_WORDS:
|
||||
return ("approve", None)
|
||||
if t in REJECT_WORDS:
|
||||
return ("reject", None)
|
||||
# 반려 단어로 시작 + 추가 텍스트
|
||||
for w in REJECT_WORDS:
|
||||
if t.startswith(w):
|
||||
rest = text.strip()[len(w):].lstrip(" ,.-:").strip()
|
||||
if rest:
|
||||
return ("reject", rest)
|
||||
# 승인 단어로 시작 (긍정 의도면 추가 텍스트 무시)
|
||||
for w in APPROVE_WORDS:
|
||||
if t.startswith(w + " ") or t == w:
|
||||
return ("approve", None)
|
||||
return _llm_classify(text)
|
||||
|
||||
|
||||
def _llm_classify(text: str) -> tuple[str, str | None]:
|
||||
api_key = _get_api_key()
|
||||
if not api_key:
|
||||
return ("unclear", None)
|
||||
prompt = (
|
||||
"사용자 응답을 분류하세요. JSON으로만 응답.\n"
|
||||
f'응답: "{text}"\n\n'
|
||||
'출력: {"intent":"approve|reject|unclear","feedback":"반려면 수정 방향, 아니면 빈 문자열"}'
|
||||
)
|
||||
try:
|
||||
resp = httpx.post(
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
headers={"x-api-key": api_key, "anthropic-version": "2023-06-01"},
|
||||
json={"model": _get_model(), "max_tokens": 200,
|
||||
"messages": [{"role": "user", "content": prompt}]},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
text_out = resp.json()["content"][0]["text"]
|
||||
start = text_out.find("{")
|
||||
end = text_out.rfind("}") + 1
|
||||
if start < 0 or end <= start:
|
||||
return ("unclear", None)
|
||||
data = json.loads(text_out[start:end])
|
||||
return (data.get("intent", "unclear"), data.get("feedback") or None)
|
||||
except (httpx.HTTPError, httpx.TimeoutException, KeyError, ValueError, json.JSONDecodeError) as e:
|
||||
logger.warning("LLM 분류 실패: %s", e)
|
||||
return ("unclear", None)
|
||||
194
agent-office/app/agents/insta.py
Normal file
194
agent-office/app/agents/insta.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""인스타 카드 에이전트 — 매일 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__)
|
||||
|
||||
# 텔레그램 후보 푸시 시 "확실한 것만" 보내기 위한 최소 신뢰도 (키워드 score 0~1)
|
||||
KEYWORD_MIN_SCORE = 0.7
|
||||
|
||||
|
||||
def _dedup_and_filter_keywords(
|
||||
keywords: List[Dict[str, Any]], min_score: float = KEYWORD_MIN_SCORE,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""score >= min_score 인 키워드만 남기고, 동일 keyword 중복 제거(최고 score 유지).
|
||||
결과는 score 내림차순. 텔레그램 후보 푸시 전 정리용."""
|
||||
best: Dict[str, Dict[str, Any]] = {}
|
||||
for k in keywords:
|
||||
if float(k.get("score", 0)) < min_score:
|
||||
continue
|
||||
name = str(k.get("keyword", "")).strip()
|
||||
if not name:
|
||||
continue
|
||||
if name not in best or k["score"] > best[name]["score"]:
|
||||
best[name] = k
|
||||
return sorted(best.values(), key=lambda k: -k["score"])
|
||||
|
||||
|
||||
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 != "idle":
|
||||
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:
|
||||
# 중복 제거 + 신뢰도(score) 임계값 이상만 — "확실한 것만" 정리해서 전송
|
||||
filtered = _dedup_and_filter_keywords(keywords)
|
||||
if not filtered:
|
||||
await messaging.send_raw(
|
||||
f"📰 [인스타 큐레이터] 오늘은 확실한 추천 키워드가 없습니다 (신뢰도 {KEYWORD_MIN_SCORE:.1f}+ 기준)."
|
||||
)
|
||||
return
|
||||
by_cat: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for k in filtered:
|
||||
by_cat.setdefault(k["category"], []).append(k)
|
||||
rows: List[List[Dict[str, Any]]] = []
|
||||
text_lines = [f"📰 <b>[인스타 큐레이터]</b> 오늘의 키워드 후보 (신뢰도 {KEYWORD_MIN_SCORE:.1f}+)"]
|
||||
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
|
||||
@@ -8,7 +8,7 @@ class LottoAgent(BaseAgent):
|
||||
display_name = "로또 큐레이터"
|
||||
|
||||
async def on_schedule(self) -> None:
|
||||
if self.state not in ("idle", "break"):
|
||||
if self.state != "idle":
|
||||
return
|
||||
await self._run(source="auto")
|
||||
|
||||
@@ -17,21 +17,244 @@ class LottoAgent(BaseAgent):
|
||||
return await self._run(source="manual")
|
||||
if action == "status":
|
||||
return {"ok": True, "message": f"{self.state}: {self.state_detail}"}
|
||||
if action in ("signal_check", "light_check", "sim_check", "deep_check"):
|
||||
source = action.replace("_check", "") if action != "signal_check" else "light"
|
||||
return await self.run_signal_check(source=source)
|
||||
if action == "daily_digest":
|
||||
return await self.run_daily_digest()
|
||||
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_signal_check(self, source: str = "light") -> dict:
|
||||
"""비-LLM 시그널 평가. task_id wrap 적용."""
|
||||
from ..curator.signal_runner import run_signal_check
|
||||
from ..config import (
|
||||
LOTTO_Z_NORMAL, LOTTO_Z_URGENT,
|
||||
LOTTO_THROTTLE_HOURS, LOTTO_URGENT_DAILY_MAX,
|
||||
)
|
||||
from ..db import (
|
||||
create_task, update_task_status, add_log,
|
||||
get_last_signal_notification, get_recent_urgent_count,
|
||||
mark_signal_notified,
|
||||
)
|
||||
from ..notifiers.telegram_lotto import send_urgent_signal
|
||||
from ..service_proxy import lotto_latest_draw
|
||||
|
||||
if self.state not in ("idle", "reporting"):
|
||||
return {"ok": False, "message": f"busy ({self.state})"}
|
||||
|
||||
task_id = create_task("lotto", "signal_check", {"source": source})
|
||||
try:
|
||||
curate_result = None
|
||||
current_draw_no = await lotto_latest_draw()
|
||||
|
||||
if source == "deep":
|
||||
from ..curator.pipeline import curate_weekly
|
||||
cw = await curate_weekly(source="signal_deep")
|
||||
curate_result = {"confidence": cw.get("confidence")}
|
||||
if cw.get("draw_no"):
|
||||
current_draw_no = cw.get("draw_no")
|
||||
|
||||
outcome = await run_signal_check(
|
||||
source=source,
|
||||
z_normal=LOTTO_Z_NORMAL,
|
||||
z_urgent=LOTTO_Z_URGENT,
|
||||
curate_result=curate_result,
|
||||
current_draw_no=current_draw_no,
|
||||
)
|
||||
|
||||
# urgent 텔레그램 + throttle (기존 동작 유지)
|
||||
if outcome["overall_fire"] == "urgent":
|
||||
if get_recent_urgent_count(hours=24) >= LOTTO_URGENT_DAILY_MAX:
|
||||
add_log("lotto", "urgent daily cap 도달 → normal로 강등", level="warning", task_id=task_id)
|
||||
else:
|
||||
blocked = False
|
||||
for r in outcome["results"]:
|
||||
if r["fire_level"] in ("normal", "urgent"):
|
||||
if get_last_signal_notification(
|
||||
metric=r["metric"], fire_level=r["fire_level"],
|
||||
hours=LOTTO_THROTTLE_HOURS,
|
||||
):
|
||||
blocked = True
|
||||
break
|
||||
if not blocked:
|
||||
from datetime import datetime, timezone
|
||||
event = {
|
||||
"fire_level": "urgent",
|
||||
"triggered_at": datetime.now(timezone.utc).isoformat(),
|
||||
"results": outcome["results"],
|
||||
}
|
||||
await send_urgent_signal(event)
|
||||
for r in outcome["results"]:
|
||||
if r["fire_level"] in ("normal", "urgent"):
|
||||
mark_signal_notified(r["signal_id"])
|
||||
add_log("lotto", f"urgent 텔레그램 발송 ({len(outcome['results'])}개 시그널)", task_id=task_id)
|
||||
|
||||
fired_metrics = [
|
||||
r["metric"] for r in outcome["results"]
|
||||
if r["fire_level"] not in ("noop", "warmup")
|
||||
]
|
||||
update_task_status(task_id, "succeeded", result_data={
|
||||
"source": source,
|
||||
"overall_fire": outcome["overall_fire"],
|
||||
"n_results": len(outcome["results"]),
|
||||
"fired_metrics": fired_metrics,
|
||||
})
|
||||
add_log("lotto", f"signal_check({source}) → {outcome['overall_fire']} results={len(outcome['results'])}", task_id=task_id)
|
||||
return {"ok": True, **outcome}
|
||||
except Exception as e:
|
||||
update_task_status(task_id, "failed", result_data={"error": str(e)})
|
||||
add_log("lotto", f"signal_check 예외: {e}", level="error", task_id=task_id)
|
||||
return {"ok": False, "message": f"{type(e).__name__}: {e}"}
|
||||
|
||||
async def run_daily_digest(self) -> dict:
|
||||
"""일일 요약 — 지난 24h normal/urgent 발화 텔레그램 1통. task_id wrap."""
|
||||
from ..db import (
|
||||
create_task, update_task_status, add_log,
|
||||
get_recent_lotto_signals, get_signals_history, get_baseline,
|
||||
)
|
||||
from ..notifiers.telegram_lotto import send_signal_summary
|
||||
|
||||
task_id = create_task("lotto", "daily_digest", {})
|
||||
try:
|
||||
sigs = get_recent_lotto_signals(hours=24, min_fire="normal")
|
||||
total_24h = get_signals_history(days=1)
|
||||
evaluated = len(total_24h)
|
||||
|
||||
trend = {}
|
||||
try:
|
||||
cache = get_baseline("drift_weights_cache")
|
||||
if cache and isinstance(cache["window_values"], list) and len(cache["window_values"]) >= 2:
|
||||
prev_w = cache["window_values"][-2]
|
||||
curr_w = cache["window_values"][-1]
|
||||
trend = {
|
||||
k: curr_w.get(k, 0.0) - prev_w.get(k, 0.0)
|
||||
for k in (set(prev_w) | set(curr_w))
|
||||
}
|
||||
except Exception as e:
|
||||
add_log("lotto", f"weights_trend 계산 실패: {e}", level="warning", task_id=task_id)
|
||||
|
||||
digest = {
|
||||
"evaluated": evaluated,
|
||||
"fired": len(sigs),
|
||||
"signals": sigs,
|
||||
"weights_trend": trend,
|
||||
}
|
||||
await send_signal_summary(digest)
|
||||
update_task_status(task_id, "succeeded", result_data={
|
||||
"evaluated": evaluated,
|
||||
"fired": len(sigs),
|
||||
"signals_count": len(sigs),
|
||||
})
|
||||
add_log("lotto", f"daily_digest 발송: 평가 {evaluated} / 발화 {len(sigs)}", task_id=task_id)
|
||||
return {"ok": True, **digest}
|
||||
except Exception as e:
|
||||
update_task_status(task_id, "failed", result_data={"error": str(e)})
|
||||
add_log("lotto", f"daily_digest 예외: {e}", level="error", task_id=task_id)
|
||||
return {"ok": False, "message": f"{type(e).__name__}: {e}"}
|
||||
|
||||
async def run_weekly_evolution_report(self) -> dict:
|
||||
"""토 22:15 — lotto-lab evaluate-now 트리거 후 텔레그램 리포트. task_id wrap."""
|
||||
from ..service_proxy import lotto_evolver_evaluate, lotto_evolver_status
|
||||
from ..notifiers.telegram_lotto import send_evolution_report
|
||||
from ..db import create_task, update_task_status, add_log
|
||||
|
||||
task_id = create_task("lotto", "weekly_evolution_report", {})
|
||||
try:
|
||||
eval_result = await lotto_evolver_evaluate()
|
||||
status = await lotto_evolver_status()
|
||||
current_base = status.get("current_base") or [0.2] * 5
|
||||
await send_evolution_report(eval_result, current_base)
|
||||
|
||||
winner = eval_result.get("winner") or {}
|
||||
update_task_status(task_id, "succeeded", result_data={
|
||||
"draw_no": eval_result.get("draw_no"),
|
||||
"update_reason": eval_result.get("update_reason"),
|
||||
"winner_day_of_week": winner.get("day_of_week"),
|
||||
"winner_max_correct": winner.get("max_correct"),
|
||||
})
|
||||
add_log("lotto", f"weekly_evolution_report 발송: draw={eval_result.get('draw_no')} reason={eval_result.get('update_reason')}", task_id=task_id)
|
||||
return {"ok": True, **eval_result}
|
||||
except Exception as e:
|
||||
update_task_status(task_id, "failed", result_data={"error": str(e)})
|
||||
add_log("lotto", f"weekly_evolution_report 예외: {e}", level="error", task_id=task_id)
|
||||
return {"ok": False, "message": f"{type(e).__name__}: {e}"}
|
||||
|
||||
async def sync_evolver_activity(self) -> dict:
|
||||
"""매일 09:30 — lotto-lab evolver 상태 polling → agent_office.db에 task+log 거울. 멱등."""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from ..service_proxy import lotto_evolver_status
|
||||
from ..db import (
|
||||
create_task, update_task_status, add_log,
|
||||
get_tasks_by_agent_date_kind,
|
||||
)
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
today_kst = datetime.now(KST).date()
|
||||
# created_at은 UTC로 저장되므로 idempotency guard는 UTC 날짜 기준
|
||||
today_utc_iso = datetime.now(timezone.utc).date().isoformat()
|
||||
dow = today_kst.weekday()
|
||||
if dow == 6:
|
||||
dow = 5
|
||||
|
||||
try:
|
||||
status = await lotto_evolver_status()
|
||||
except Exception as e:
|
||||
add_log("lotto", f"sync_evolver_activity: lotto-lab status fetch 실패: {e}", level="warning")
|
||||
return {"ok": False, "reason": "status_fetch_failed", "error": str(e)}
|
||||
|
||||
results = {"created": []}
|
||||
|
||||
today_trial = next((t for t in status.get("trials", []) if t.get("day_of_week") == dow), None)
|
||||
if today_trial and today_trial.get("picks"):
|
||||
if not get_tasks_by_agent_date_kind("lotto", today_utc_iso, "evolver_apply"):
|
||||
tid = create_task("lotto", "evolver_apply", {
|
||||
"date": today_utc_iso,
|
||||
"trial_id": today_trial["id"],
|
||||
"day_of_week": dow,
|
||||
"weight": today_trial["weight"],
|
||||
})
|
||||
update_task_status(tid, "succeeded", result_data={
|
||||
"n_picks": len(today_trial["picks"]),
|
||||
"meta_scores": [p.get("meta_score") for p in today_trial["picks"]],
|
||||
})
|
||||
add_log("lotto", f"evolver_apply: 오늘({dow}) W로 {len(today_trial['picks'])}세트 추출", task_id=tid)
|
||||
results["created"].append("evolver_apply")
|
||||
|
||||
if today_kst.weekday() == 0 and len(status.get("trials", [])) == 6:
|
||||
if not get_tasks_by_agent_date_kind("lotto", today_utc_iso, "evolver_generate"):
|
||||
tid = create_task("lotto", "evolver_generate", {"week_start": status.get("week_start")})
|
||||
update_task_status(tid, "succeeded", result_data={
|
||||
"trials_count": 6,
|
||||
"candidates_per_source": {"perturb": 4, "dirichlet": 2},
|
||||
})
|
||||
add_log("lotto", f"evolver_generate: {status.get('week_start')} 주의 6 trials 생성", task_id=tid)
|
||||
results["created"].append("evolver_generate")
|
||||
|
||||
return {"ok": True, **results}
|
||||
|
||||
async def _run(self, source: str) -> dict:
|
||||
task_id = create_task(self.agent_id, "curate_weekly", {"source": source})
|
||||
await self.transition("working", "후보 수집 및 AI 큐레이션 중...", task_id)
|
||||
try:
|
||||
result = await curate_weekly(source=source)
|
||||
update_task_status(task_id, "succeeded", result_data=result)
|
||||
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, **result}
|
||||
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)
|
||||
|
||||
@@ -1,89 +1,68 @@
|
||||
import asyncio
|
||||
from .base import BaseAgent
|
||||
from ..db import create_task, update_task_status, add_log
|
||||
from .. import service_proxy
|
||||
from .. import telegram_bot
|
||||
from ..telegram import messaging
|
||||
from ..telegram.realestate_message import format_realestate_matches, build_match_keyboard
|
||||
|
||||
|
||||
class RealestateAgent(BaseAgent):
|
||||
"""부동산 청약 에이전트.
|
||||
|
||||
매일 09:15 자동 실행: realestate-lab의 수집을 트리거하고
|
||||
신규 매칭 결과를 텔레그램으로 푸시 (승인 없는 리포트형).
|
||||
realestate-lab이 신규 매칭 발견 시 /realestate/notify로 push해 트리거됨.
|
||||
on_new_matches가 메인 진입점. on_schedule은 사용하지 않음(cron 폐기).
|
||||
"""
|
||||
|
||||
agent_id = "realestate"
|
||||
display_name = "청약 애널리스트"
|
||||
|
||||
async def on_schedule(self) -> None:
|
||||
if self.state not in ("idle", "break"):
|
||||
return
|
||||
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, "daily_match_report", {})
|
||||
await self.transition("working", "청약 공고 수집 중", task_id)
|
||||
task_id = create_task(self.agent_id, "notify_matches", {"count": len(matches)})
|
||||
|
||||
try:
|
||||
collect = await service_proxy.realestate_collect()
|
||||
new_count = collect.get("new_count", 0) or 0
|
||||
text = format_realestate_matches(matches)
|
||||
keyboard = build_match_keyboard(matches)
|
||||
await self.transition("reporting", f"매칭 {len(matches)}건 알림", task_id)
|
||||
|
||||
await self.transition("working", "신규 매칭 조회 중", task_id)
|
||||
matches = await service_proxy.realestate_matches(limit=20)
|
||||
dashboard = await service_proxy.realestate_dashboard()
|
||||
|
||||
await self.transition("reporting", "리포트 전송 중", task_id)
|
||||
|
||||
if not matches:
|
||||
body = (
|
||||
f"수집된 신규 공고: {new_count}건\n"
|
||||
f"진행 중 공고: {dashboard.get('active_count', 0)}건\n"
|
||||
f"신규 매칭: 없음"
|
||||
)
|
||||
else:
|
||||
lines = [
|
||||
f"📌 수집 {new_count}건 / 매칭 {len(matches)}건",
|
||||
"",
|
||||
]
|
||||
for m in matches[:5]:
|
||||
title = m.get("title") or m.get("announcement_title") or "(제목 없음)"
|
||||
region = m.get("region") or ""
|
||||
score = m.get("match_score") or m.get("score") or ""
|
||||
lines.append(f"• [{region}] {title} (매칭 {score})")
|
||||
if len(matches) > 5:
|
||||
lines.append(f"… 외 {len(matches) - 5}건")
|
||||
body = "\n".join(lines)
|
||||
|
||||
tg = await telegram_bot.send_task_result(
|
||||
self.agent_id,
|
||||
"🏢 [청약 에이전트] 오늘의 매칭 리포트",
|
||||
body,
|
||||
)
|
||||
|
||||
# 확인한 매칭 read 처리
|
||||
for m in matches[:5]:
|
||||
mid = m.get("id")
|
||||
if mid:
|
||||
try:
|
||||
await service_proxy.realestate_mark_read(int(mid))
|
||||
except Exception:
|
||||
pass
|
||||
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", {
|
||||
"new_count": new_count,
|
||||
"match_count": len(matches),
|
||||
"telegram_sent": tg.get("ok", False),
|
||||
"sent": len(matches),
|
||||
"telegram_message_id": tg.get("message_id"),
|
||||
})
|
||||
await self.transition("idle", f"매칭 {len(matches)}건")
|
||||
|
||||
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"Realestate report failed: {e}", "error", task_id)
|
||||
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":
|
||||
await self.on_schedule()
|
||||
return {"ok": True, "message": "매칭 리포트 시작"}
|
||||
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:
|
||||
|
||||
@@ -44,14 +44,23 @@ class StockAgent(BaseAgent):
|
||||
display_name = "주식 트레이더"
|
||||
|
||||
async def on_schedule(self) -> None:
|
||||
if self.state not in ("idle", "break"):
|
||||
if self.state != "idle":
|
||||
return
|
||||
|
||||
task_id = create_task(self.agent_id, "news_summary", {"limit": 15})
|
||||
await self.transition("working", "AI 뉴스 요약 생성 중...", task_id)
|
||||
await self.transition("working", "최신 뉴스 수집 중...", task_id)
|
||||
|
||||
try:
|
||||
# AI 요약 호출 (뉴스 수집 + LLM 처리는 stock-lab이 담당)
|
||||
# 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", "뉴스 요약 전송 중...")
|
||||
@@ -110,7 +119,232 @@ class StockAgent(BaseAgent):
|
||||
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 != "idle":
|
||||
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 != "idle":
|
||||
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(
|
||||
|
||||
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
|
||||
@@ -1,9 +1,9 @@
|
||||
import os
|
||||
|
||||
# Service URLs (Docker internal network)
|
||||
STOCK_LAB_URL = os.getenv("STOCK_LAB_URL", "http://localhost:18500")
|
||||
STOCK_URL = os.getenv("STOCK_URL", "http://localhost:18500")
|
||||
MUSIC_LAB_URL = os.getenv("MUSIC_LAB_URL", "http://localhost:18600")
|
||||
BLOG_LAB_URL = os.getenv("BLOG_LAB_URL", "http://localhost:18700")
|
||||
INSTA_LAB_URL = os.getenv("INSTA_LAB_URL", "http://localhost:18700")
|
||||
REALESTATE_LAB_URL = os.getenv("REALESTATE_LAB_URL", "http://localhost:18800")
|
||||
|
||||
# Telegram
|
||||
@@ -26,11 +26,21 @@ CORS_ALLOW_ORIGINS = os.getenv(
|
||||
"CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080"
|
||||
)
|
||||
|
||||
# Idle break threshold (seconds)
|
||||
IDLE_BREAK_THRESHOLD = int(os.getenv("IDLE_BREAK_THRESHOLD", "300")) # 5 min
|
||||
BREAK_DURATION_MIN = int(os.getenv("BREAK_DURATION_MIN", "60")) # 1 min
|
||||
BREAK_DURATION_MAX = int(os.getenv("BREAK_DURATION_MAX", "180")) # 3 min
|
||||
|
||||
# Lotto Curator
|
||||
LOTTO_BACKEND_URL = os.getenv("LOTTO_BACKEND_URL", "http://lotto-backend:8000")
|
||||
LOTTO_BACKEND_URL = os.getenv("LOTTO_BACKEND_URL", "http://lotto:8000")
|
||||
LOTTO_CURATOR_MODEL = os.getenv("LOTTO_CURATOR_MODEL", "claude-sonnet-4-5")
|
||||
|
||||
# Lotto Active Signals
|
||||
LOTTO_SIGNAL_WINDOW = int(os.getenv("LOTTO_SIGNAL_WINDOW", "8"))
|
||||
LOTTO_Z_NORMAL = float(os.getenv("LOTTO_Z_NORMAL", "1.5"))
|
||||
LOTTO_Z_URGENT = float(os.getenv("LOTTO_Z_URGENT", "2.5"))
|
||||
LOTTO_DIGEST_HOUR = int(os.getenv("LOTTO_DIGEST_HOUR", "9"))
|
||||
LOTTO_DIGEST_MIN = int(os.getenv("LOTTO_DIGEST_MIN", "25"))
|
||||
LOTTO_THROTTLE_HOURS = int(os.getenv("LOTTO_THROTTLE_HOURS", "6"))
|
||||
LOTTO_URGENT_DAILY_MAX = int(os.getenv("LOTTO_URGENT_DAILY_MAX", "3"))
|
||||
|
||||
# Tarot Lab
|
||||
TAROT_MODEL = os.getenv("TAROT_MODEL", "claude-sonnet-4-6")
|
||||
TAROT_COST_INPUT_PER_M = float(os.getenv("TAROT_COST_INPUT_PER_M", "3.0"))
|
||||
TAROT_COST_OUTPUT_PER_M = float(os.getenv("TAROT_COST_OUTPUT_PER_M", "15.0"))
|
||||
TAROT_TIMEOUT_SEC = int(os.getenv("TAROT_TIMEOUT_SEC", "60"))
|
||||
|
||||
@@ -9,6 +9,7 @@ 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"
|
||||
@@ -36,12 +37,12 @@ async def _call_claude(user_text: str, feedback: str = "") -> tuple[dict, dict]:
|
||||
user_text = f"이전 응답이 다음 이유로 거절됨: {feedback}\n올바른 스키마로 다시 응답.\n\n{user_text}"
|
||||
payload = {
|
||||
"model": LOTTO_CURATOR_MODEL,
|
||||
"max_tokens": 4096,
|
||||
"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=120) as client:
|
||||
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()
|
||||
@@ -68,16 +69,19 @@ async def _call_claude(user_text: str, feedback: str = "") -> tuple[dict, dict]:
|
||||
|
||||
|
||||
async def curate_weekly(source: str = "auto") -> Dict[str, Any]:
|
||||
cand_resp = await service_proxy.lotto_candidates(n=20)
|
||||
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]
|
||||
@@ -101,8 +105,14 @@ async def curate_weekly(source: str = "auto") -> Dict[str, Any]:
|
||||
|
||||
payload = {
|
||||
"draw_no": draw_no,
|
||||
"picks": [p.model_dump() for p in validated.picks],
|
||||
"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"],
|
||||
@@ -118,4 +128,5 @@ async def curate_weekly(source: str = "auto") -> Dict[str, Any]:
|
||||
"draw_no": draw_no,
|
||||
"confidence": validated.confidence,
|
||||
"tokens": {"input": usage_total["input"], "output": usage_total["output"]},
|
||||
"payload": payload, # 텔레그램 알림용
|
||||
}
|
||||
|
||||
@@ -2,31 +2,49 @@
|
||||
import json
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """당신은 로또 번호 큐레이터입니다. 주어진 후보 20세트 중 5세트를 다음 규칙으로 선별합니다.
|
||||
SYSTEM_PROMPT = """당신은 로또 번호 큐레이터입니다.
|
||||
주어진 후보 30세트 중 4계층(코어 5, 보너스 5, 확장 5, 풀 5) 총 20세트를 선별합니다.
|
||||
|
||||
선별 규칙:
|
||||
- 5세트의 리스크 분포는 안정 2 · 균형 2 · 공격 1 을 권장(유연 ±1).
|
||||
- 홀짝 비율, 저/고 구간, 연속번호 포함 여부가 세트끼리 겹치지 않도록 다양성을 확보.
|
||||
- hot_number_count=0 이고 cold_number_count=0 인 '중립형' 세트를 최소 1개 포함.
|
||||
- 후보에 없는 번호 조합은 절대 사용 금지. numbers 필드는 반드시 candidates 중 하나와 정확히 일치해야 함.
|
||||
- 각 세트 reason은 한국어 40자 이내 한 줄. 해당 세트의 features 값과 context 값만 근거로.
|
||||
계층별 큐레이션 규칙:
|
||||
- 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: 특별한 주의사항 없으면 빈 문자열.
|
||||
- headline: 한 줄, 이번 주 추첨 전망 + 조정 사유.
|
||||
- summary_3lines: 정확히 3개 항목.
|
||||
- hot_cold_comment: hot/cold 번호 한 줄 논평.
|
||||
- warnings: 주의사항 없으면 빈 문자열.
|
||||
- retrospective: 회고 한 줄 또는 빈 문자열.
|
||||
|
||||
출력은 반드시 JSON 하나, 그 외 어떤 텍스트도 금지. 스키마:
|
||||
{
|
||||
"picks": [
|
||||
{"numbers":[int,int,int,int,int,int], "risk_tag":"안정"|"균형"|"공격", "reason": str}
|
||||
],
|
||||
"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
|
||||
"warnings": str,
|
||||
"retrospective": str
|
||||
},
|
||||
"confidence": int (0~100)
|
||||
}
|
||||
@@ -36,11 +54,11 @@ narrative 규칙:
|
||||
def build_user_message(draw_no: int, candidates: list, context: dict) -> str:
|
||||
payload = {
|
||||
"draw_no": draw_no,
|
||||
"context": context,
|
||||
"context": context, # hot_numbers, cold_numbers, last_draw_summary, my_recent_performance, retrospective
|
||||
"candidates": candidates,
|
||||
}
|
||||
return (
|
||||
f"이번 회차: {draw_no}\n"
|
||||
f"아래 데이터로 5세트를 큐레이션하고 위 스키마로만 응답하세요.\n\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),
|
||||
},
|
||||
}
|
||||
@@ -17,25 +17,42 @@ class Pick(BaseModel):
|
||||
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):
|
||||
picks: List[Pick]
|
||||
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)
|
||||
if len(out.picks) != 5:
|
||||
raise ValueError("picks must have exactly 5 sets")
|
||||
candidate_set = {tuple(sorted(c)) for c in candidate_numbers}
|
||||
for p in out.picks:
|
||||
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
|
||||
|
||||
185
agent-office/app/curator/signal_runner.py
Normal file
185
agent-office/app/curator/signal_runner.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""LottoAgent 능동 시그널 — DB I/O + cron 진입점 + 평가 orchestration."""
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .. import db
|
||||
from .. import service_proxy
|
||||
from . import signals
|
||||
|
||||
logger = logging.getLogger("agent-office.lotto-signals")
|
||||
|
||||
# 회차 단위 메트릭 (window push 시 last_pushed_draw_no 비교)
|
||||
DRAW_SCOPED_METRICS = {"drift", "confidence"}
|
||||
|
||||
|
||||
def _load_baseline(metric: str) -> signals.AdaptiveBaseline:
|
||||
row = db.get_baseline(metric)
|
||||
if row is None:
|
||||
return signals.AdaptiveBaseline(window=[], window_max=8)
|
||||
return signals.AdaptiveBaseline(
|
||||
window=list(row["window_values"]),
|
||||
window_max=8,
|
||||
last_pushed_draw_no=row.get("last_pushed_draw_no"),
|
||||
)
|
||||
|
||||
|
||||
def _save_baseline(metric: str, bl: signals.AdaptiveBaseline) -> None:
|
||||
db.upsert_baseline(
|
||||
metric=metric,
|
||||
window_values=bl.window,
|
||||
mu=bl.mu,
|
||||
sigma=bl.sigma,
|
||||
last_pushed_draw_no=bl.last_pushed_draw_no,
|
||||
)
|
||||
|
||||
|
||||
def evaluate_metric_and_persist(
|
||||
source: str,
|
||||
metric: str,
|
||||
value: float,
|
||||
draw_no: Optional[int],
|
||||
z_normal: float,
|
||||
z_urgent: float,
|
||||
push_to_window: bool,
|
||||
payload: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""단일 메트릭 평가 → lotto_signals INSERT → baseline 갱신.
|
||||
|
||||
회차 단위 메트릭(drift, confidence)은 같은 draw_no에서 window push 생략.
|
||||
"""
|
||||
bl = _load_baseline(metric)
|
||||
|
||||
# 회차 가드
|
||||
do_push = push_to_window
|
||||
if metric in DRAW_SCOPED_METRICS and draw_no is not None:
|
||||
if bl.last_pushed_draw_no == draw_no:
|
||||
do_push = False
|
||||
|
||||
# 평가는 push 전 baseline 기준
|
||||
z, fire = bl.evaluate(value=value, z_normal=z_normal, z_urgent=z_urgent)
|
||||
|
||||
if do_push:
|
||||
bl.push(value=value, draw_no=draw_no)
|
||||
_save_baseline(metric, bl)
|
||||
else:
|
||||
# cold start에서도 baseline row를 만들어 두려면 upsert 필요
|
||||
_save_baseline(metric, bl)
|
||||
|
||||
sid = db.insert_lotto_signal(
|
||||
source=source,
|
||||
metric=metric,
|
||||
value=value,
|
||||
baseline_mu=bl.mu if bl.size > 0 else None,
|
||||
baseline_sigma=bl.sigma if bl.size >= 2 else None,
|
||||
z_score=z,
|
||||
fire_level=fire,
|
||||
payload=payload,
|
||||
)
|
||||
return {
|
||||
"signal_id": sid,
|
||||
"metric": metric,
|
||||
"value": value,
|
||||
"baseline_mu": bl.mu if bl.size > 0 else None,
|
||||
"baseline_sigma": bl.sigma if bl.size >= 2 else None,
|
||||
"z_score": z,
|
||||
"fire_level": fire,
|
||||
"payload": payload or {},
|
||||
}
|
||||
|
||||
|
||||
# ---------- Service proxy thin wrappers (monkeypatch 대상) ----------
|
||||
|
||||
async def _fetch_best_picks() -> List[Dict[str, Any]]:
|
||||
return await service_proxy.lotto_best()
|
||||
|
||||
|
||||
async def _fetch_strategy_weights() -> Dict[str, float]:
|
||||
return await service_proxy.lotto_strategy_weights()
|
||||
|
||||
|
||||
# ---------- Orchestrator ----------
|
||||
|
||||
async def run_signal_check(
|
||||
source: str,
|
||||
z_normal: float = 1.5,
|
||||
z_urgent: float = 2.5,
|
||||
curate_result: Optional[Dict[str, Any]] = None,
|
||||
current_draw_no: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""cron 진입점. source ∈ {'light', 'sim', 'deep'}.
|
||||
|
||||
light/sim: Sim Consensus + Strategy Drift 평가
|
||||
deep: 위 2종 + Confidence (curate_result 필요)
|
||||
"""
|
||||
results: List[Dict[str, Any]] = []
|
||||
|
||||
# --- Sim Consensus ---
|
||||
try:
|
||||
best = await _fetch_best_picks()
|
||||
v = signals.sim_consensus_score(best)
|
||||
results.append(
|
||||
evaluate_metric_and_persist(
|
||||
source=source, metric="sim_signal",
|
||||
value=v, draw_no=None,
|
||||
z_normal=z_normal, z_urgent=z_urgent,
|
||||
push_to_window=True,
|
||||
payload={"top_count": min(len(best), 10)},
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"sim_consensus 평가 실패: {e}")
|
||||
|
||||
# --- Strategy Drift (회차 단위) ---
|
||||
try:
|
||||
w_curr = await _fetch_strategy_weights()
|
||||
# weights 캐시: lotto_baselines의 별도 metric 'drift_weights_cache'에 prev/curr 2개 보관
|
||||
prev_payload_row = db.get_baseline("drift_weights_cache")
|
||||
w_prev = prev_payload_row["window_values"] if prev_payload_row else None
|
||||
|
||||
if w_prev and isinstance(w_prev, list) and len(w_prev) > 0 and isinstance(w_prev[0], dict):
|
||||
prev_dict = w_prev[-1]
|
||||
drift_value = signals.strategy_drift_score(prev_dict, w_curr)
|
||||
results.append(
|
||||
evaluate_metric_and_persist(
|
||||
source=source, metric="drift",
|
||||
value=drift_value, draw_no=current_draw_no,
|
||||
z_normal=z_normal, z_urgent=z_urgent,
|
||||
push_to_window=True,
|
||||
payload={"weights_now": w_curr, "weights_prev": prev_dict},
|
||||
)
|
||||
)
|
||||
# weights 캐시 갱신 (최대 2개 FIFO)
|
||||
cache_window = (w_prev or []) + [w_curr]
|
||||
if len(cache_window) > 2:
|
||||
cache_window = cache_window[-2:]
|
||||
db.upsert_baseline(
|
||||
metric="drift_weights_cache",
|
||||
window_values=cache_window,
|
||||
mu=0.0, sigma=0.0,
|
||||
last_pushed_draw_no=current_draw_no,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"strategy_drift 평가 실패: {e}")
|
||||
|
||||
# --- Confidence (deep_check + curate_result 필수) ---
|
||||
if source == "deep" and curate_result is not None:
|
||||
try:
|
||||
cv = signals.confidence_score(curate_result)
|
||||
if cv is not None:
|
||||
results.append(
|
||||
evaluate_metric_and_persist(
|
||||
source=source, metric="confidence",
|
||||
value=cv, draw_no=current_draw_no,
|
||||
z_normal=z_normal, z_urgent=z_urgent,
|
||||
push_to_window=True,
|
||||
payload={"draw_no": current_draw_no},
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"confidence 평가 실패: {e}")
|
||||
|
||||
overall = signals.decide_overall_fire(
|
||||
[{"metric": r["metric"], "z": r["z_score"], "fire": r["fire_level"]} for r in results]
|
||||
)
|
||||
return {"overall_fire": overall, "results": results}
|
||||
150
agent-office/app/curator/signals.py
Normal file
150
agent-office/app/curator/signals.py
Normal file
@@ -0,0 +1,150 @@
|
||||
# agent-office/app/curator/signals.py
|
||||
"""LottoAgent 능동 모니터링 — 시그널 평가 & adaptive baseline (순수 함수).
|
||||
|
||||
DB I/O 없음. 입력은 모두 dict/list, 출력도 dict/list.
|
||||
signal_runner.py에서 DB 연동 + cron 진입점 담당.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from statistics import mean, stdev
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
# ---------- Metric: Sim Consensus ----------
|
||||
|
||||
def _normalize_columns(picks: List[Dict[str, Any]]) -> List[List[float]]:
|
||||
"""20개 후보의 5종 점수 컬럼별 min-max normalize → 후보별 5종 정규화 점수."""
|
||||
if not picks:
|
||||
return []
|
||||
n_metrics = len(picks[0]["scores"])
|
||||
columns = [[p["scores"][k] for p in picks] for k in range(n_metrics)]
|
||||
norms_per_col = []
|
||||
for col in columns:
|
||||
lo, hi = min(col), max(col)
|
||||
rng = hi - lo
|
||||
if rng == 0:
|
||||
# 모두 0이면 0.0(기하평균 페널티), 모두 동일한 양수면 0.5(타이 처리)
|
||||
fallback = 0.0 if lo == 0 else 0.5
|
||||
norms_per_col.append([fallback] * len(col))
|
||||
else:
|
||||
norms_per_col.append([(v - lo) / rng for v in col])
|
||||
return [
|
||||
[norms_per_col[k][i] for k in range(n_metrics)]
|
||||
for i in range(len(picks))
|
||||
]
|
||||
|
||||
|
||||
def _geomean(values: List[float]) -> float:
|
||||
"""기하평균. 0이 하나라도 있으면 0 (한 차원이 0인 후보 강하게 페널티)."""
|
||||
if not values:
|
||||
return 0.0
|
||||
if any(v <= 0 for v in values):
|
||||
return 0.0
|
||||
log_sum = sum(math.log(v) for v in values)
|
||||
return math.exp(log_sum / len(values))
|
||||
|
||||
|
||||
def sim_consensus_score(best_picks: List[Dict[str, Any]]) -> float:
|
||||
"""top-10 후보의 기하평균 consensus 평균."""
|
||||
if not best_picks:
|
||||
return 0.0
|
||||
normalized = _normalize_columns(best_picks)
|
||||
consensus = [_geomean(scores) for scores in normalized]
|
||||
consensus.sort(reverse=True)
|
||||
top = consensus[:10] if len(consensus) >= 10 else consensus
|
||||
return mean(top) if top else 0.0
|
||||
|
||||
|
||||
# ---------- Metric: Strategy Drift ----------
|
||||
|
||||
def strategy_drift_score(prev: Dict[str, float], curr: Dict[str, float]) -> float:
|
||||
"""가중치 변화 절댓값 합. 신규/소멸 전략도 가산."""
|
||||
keys = set(prev) | set(curr)
|
||||
return sum(abs(curr.get(k, 0.0) - prev.get(k, 0.0)) for k in keys)
|
||||
|
||||
|
||||
# ---------- Metric: Confidence ----------
|
||||
|
||||
def confidence_score(curate_result: Dict[str, Any]) -> Optional[float]:
|
||||
"""큐레이션 결과의 confidence를 0~1로 clamp. 없으면 None."""
|
||||
if "confidence" not in curate_result:
|
||||
return None
|
||||
v = float(curate_result["confidence"])
|
||||
return max(0.0, min(1.0, v))
|
||||
|
||||
|
||||
# ---------- Adaptive Baseline ----------
|
||||
|
||||
@dataclass
|
||||
class AdaptiveBaseline:
|
||||
window: List[float] = field(default_factory=list)
|
||||
window_max: int = 8
|
||||
last_pushed_draw_no: Optional[int] = None
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return len(self.window)
|
||||
|
||||
@property
|
||||
def mu(self) -> float:
|
||||
return mean(self.window) if self.window else 0.0
|
||||
|
||||
@property
|
||||
def sigma(self) -> float:
|
||||
return stdev(self.window) if len(self.window) >= 2 else 0.0
|
||||
|
||||
def push(self, value: float, draw_no: Optional[int] = None) -> None:
|
||||
"""FIFO push. window_max 초과 시 가장 오래된 값 제거."""
|
||||
self.window.append(float(value))
|
||||
if len(self.window) > self.window_max:
|
||||
self.window = self.window[-self.window_max:]
|
||||
if draw_no is not None:
|
||||
self.last_pushed_draw_no = draw_no
|
||||
|
||||
def evaluate(self, value: float, z_normal: float, z_urgent: float) -> Tuple[Optional[float], str]:
|
||||
"""z-score 계산 + fire_level 판정.
|
||||
|
||||
Returns:
|
||||
(z_score, fire_level) — z_score는 cold start/warmup이면 None.
|
||||
fire_level ∈ {'warmup', 'noop', 'normal', 'urgent'}
|
||||
|
||||
NOTE: z_score is None when sigma==0 (degenerate window) or warmup.
|
||||
Callers must treat None as "signal present but unquantified" — do not
|
||||
compare None with thresholds directly.
|
||||
"""
|
||||
if self.size < 4:
|
||||
return None, "warmup"
|
||||
|
||||
z_normal_eff = 2.0 if self.size < self.window_max else z_normal
|
||||
z_urgent_eff = z_urgent
|
||||
|
||||
if self.sigma == 0:
|
||||
return (None, "urgent") if value > self.mu else (None, "noop")
|
||||
|
||||
z = (value - self.mu) / self.sigma
|
||||
if z >= z_urgent_eff:
|
||||
return z, "urgent"
|
||||
if z >= z_normal_eff:
|
||||
return z, "normal"
|
||||
return z, "noop"
|
||||
|
||||
|
||||
# ---------- Combined fire decision ----------
|
||||
|
||||
def decide_overall_fire(signal_results: List[Dict[str, Any]]) -> str:
|
||||
"""3종 시그널을 종합해 전체 fire_level 결정.
|
||||
|
||||
Args:
|
||||
signal_results: [{"metric": str, "z": float|None, "fire": str}, ...]
|
||||
Returns:
|
||||
'noop' | 'normal' | 'urgent'
|
||||
"""
|
||||
fires = [s for s in signal_results if s["fire"] in ("normal", "urgent")]
|
||||
if any(s["fire"] == "urgent" for s in fires):
|
||||
return "urgent"
|
||||
if len(fires) >= 2:
|
||||
return "urgent"
|
||||
if len(fires) == 1:
|
||||
return "normal"
|
||||
return "noop"
|
||||
@@ -9,9 +9,10 @@ from .config import DB_PATH
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH, timeout=10)
|
||||
conn = 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
|
||||
|
||||
|
||||
@@ -86,6 +87,77 @@ def init_db() -> None:
|
||||
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
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS lotto_signals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
triggered_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
source TEXT NOT NULL,
|
||||
metric TEXT NOT NULL,
|
||||
value REAL NOT NULL,
|
||||
baseline_mu REAL,
|
||||
baseline_sigma REAL,
|
||||
z_score REAL,
|
||||
fire_level TEXT NOT NULL,
|
||||
notified_at TEXT,
|
||||
payload TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_ls_triggered
|
||||
ON lotto_signals(triggered_at DESC)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_ls_fire
|
||||
ON lotto_signals(fire_level, notified_at)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS lotto_baselines (
|
||||
metric TEXT PRIMARY KEY,
|
||||
window_values TEXT NOT NULL DEFAULT '[]',
|
||||
mu REAL NOT NULL DEFAULT 0.0,
|
||||
sigma REAL NOT NULL DEFAULT 0.0,
|
||||
last_pushed_draw_no INTEGER,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS tarot_readings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
spread_type TEXT NOT NULL,
|
||||
category TEXT,
|
||||
question TEXT,
|
||||
cards TEXT NOT NULL,
|
||||
interpretation_json TEXT,
|
||||
summary TEXT,
|
||||
model TEXT,
|
||||
tokens_in INTEGER,
|
||||
tokens_out INTEGER,
|
||||
cost_usd REAL,
|
||||
confidence TEXT,
|
||||
favorite INTEGER NOT NULL DEFAULT 0,
|
||||
note TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_tarot_created
|
||||
ON tarot_readings(created_at DESC)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_tarot_favorite
|
||||
ON tarot_readings(favorite, created_at DESC)
|
||||
""")
|
||||
# Seed default agent configs
|
||||
for agent_id, name in [
|
||||
("stock", "주식 트레이더"),
|
||||
@@ -93,6 +165,7 @@ def init_db() -> None:
|
||||
("blog", "블로그 마케터"),
|
||||
("realestate", "청약 애널리스트"),
|
||||
("lotto", "로또 큐레이터"),
|
||||
("youtube", "YouTube 리서치"),
|
||||
]:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO agent_config(agent_id, display_name) VALUES(?,?)",
|
||||
@@ -190,12 +263,24 @@ def get_task(task_id: str) -> Optional[Dict[str, Any]]:
|
||||
return _task_to_dict(r) if r else None
|
||||
|
||||
|
||||
def get_agent_tasks(agent_id: str, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
def get_agent_tasks(
|
||||
agent_id: str,
|
||||
limit: int = 20,
|
||||
task_type: Optional[str] = None,
|
||||
days: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
sql = "SELECT * FROM agent_tasks WHERE agent_id=?"
|
||||
params: List[Any] = [agent_id]
|
||||
if task_type is not None:
|
||||
sql += " AND task_type=?"
|
||||
params.append(task_type)
|
||||
if days is not None and days > 0:
|
||||
sql += " AND created_at >= datetime('now', ?)"
|
||||
params.append(f"-{int(days)} days")
|
||||
sql += " ORDER BY created_at DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM agent_tasks WHERE agent_id=? ORDER BY created_at DESC LIMIT ?",
|
||||
(agent_id, limit),
|
||||
).fetchall()
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
return [_task_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
@@ -501,3 +586,324 @@ def get_activity_feed(limit: int = 50, offset: int = 0) -> dict:
|
||||
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"],
|
||||
}
|
||||
|
||||
|
||||
# --- lotto_signals / lotto_baselines CRUD ---
|
||||
|
||||
def insert_lotto_signal(
|
||||
source: str,
|
||||
metric: str,
|
||||
value: float,
|
||||
baseline_mu: Optional[float],
|
||||
baseline_sigma: Optional[float],
|
||||
z_score: Optional[float],
|
||||
fire_level: str,
|
||||
payload: Optional[Dict[str, Any]] = None,
|
||||
) -> int:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO lotto_signals
|
||||
(source, metric, value, baseline_mu, baseline_sigma, z_score, fire_level, payload)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
source, metric, value,
|
||||
baseline_mu, baseline_sigma, z_score, fire_level,
|
||||
json.dumps(payload or {}, ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def mark_signal_notified(signal_id: int) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE lotto_signals SET notified_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
|
||||
(signal_id,),
|
||||
)
|
||||
|
||||
|
||||
def get_recent_lotto_signals(hours: int = 24, min_fire: str = "normal") -> List[Dict[str, Any]]:
|
||||
"""지난 N시간 발화 시그널. min_fire='normal'이면 normal+urgent."""
|
||||
levels = ("urgent",) if min_fire == "urgent" else ("normal", "urgent")
|
||||
placeholders = ",".join("?" * len(levels))
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT * FROM lotto_signals
|
||||
WHERE triggered_at >= datetime('now', ?)
|
||||
AND fire_level IN ({placeholders})
|
||||
ORDER BY triggered_at DESC
|
||||
""",
|
||||
(f"-{int(hours)} hours", *levels),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_signals_history(days: int = 7) -> List[Dict[str, Any]]:
|
||||
"""차트/이력 페이지용 — 모든 fire_level 포함."""
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM lotto_signals
|
||||
WHERE triggered_at >= datetime('now', ?)
|
||||
ORDER BY triggered_at DESC
|
||||
""",
|
||||
(f"-{int(days)} days",),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_recent_urgent_count(hours: int = 24) -> int:
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS c FROM lotto_signals
|
||||
WHERE triggered_at >= datetime('now', ?)
|
||||
AND fire_level = 'urgent'
|
||||
AND notified_at IS NOT NULL
|
||||
""",
|
||||
(f"-{int(hours)} hours",),
|
||||
).fetchone()
|
||||
return int(row["c"]) if row else 0
|
||||
|
||||
|
||||
def get_last_signal_notification(metric: str, fire_level: str, hours: int) -> Optional[str]:
|
||||
"""같은 metric+fire_level이 hours 내에 알림 발송된 마지막 시각. throttle용."""
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT notified_at FROM lotto_signals
|
||||
WHERE metric = ?
|
||||
AND fire_level = ?
|
||||
AND notified_at IS NOT NULL
|
||||
AND notified_at >= datetime('now', ?)
|
||||
ORDER BY notified_at DESC LIMIT 1
|
||||
""",
|
||||
(metric, fire_level, f"-{int(hours)} hours"),
|
||||
).fetchone()
|
||||
return row["notified_at"] if row else None
|
||||
|
||||
|
||||
def get_baseline(metric: str) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM lotto_baselines WHERE metric = ?",
|
||||
(metric,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
d = dict(row)
|
||||
d["window_values"] = json.loads(d["window_values"])
|
||||
return d
|
||||
|
||||
|
||||
def upsert_baseline(
|
||||
metric: str,
|
||||
window_values: List[float],
|
||||
mu: float,
|
||||
sigma: float,
|
||||
last_pushed_draw_no: Optional[int],
|
||||
) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO lotto_baselines
|
||||
(metric, window_values, mu, sigma, last_pushed_draw_no, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
ON CONFLICT(metric) DO UPDATE SET
|
||||
window_values = excluded.window_values,
|
||||
mu = excluded.mu,
|
||||
sigma = excluded.sigma,
|
||||
last_pushed_draw_no = excluded.last_pushed_draw_no,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
metric,
|
||||
json.dumps(window_values),
|
||||
mu, sigma, last_pushed_draw_no,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_all_baselines() -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM lotto_baselines ORDER BY metric").fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["window_values"] = json.loads(d["window_values"])
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def get_tasks_by_agent_date_kind(agent_id: str, date_iso: str, task_type: str) -> List[Dict[str, Any]]:
|
||||
"""같은 (agent, date, task_type)으로 이미 생성된 task 조회. 멱등 guard."""
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM agent_tasks
|
||||
WHERE agent_id = ? AND task_type = ?
|
||||
AND substr(created_at, 1, 10) = ?
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(agent_id, task_type, date_iso),
|
||||
).fetchall()
|
||||
return [_task_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
# --- tarot_readings CRUD ---
|
||||
|
||||
def save_tarot_reading(data: Dict[str, Any]) -> int:
|
||||
interp = data.get("interpretation_json") or {}
|
||||
summary = interp.get("summary", "") if isinstance(interp, dict) else ""
|
||||
with _conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO tarot_readings
|
||||
(spread_type, category, question, cards, interpretation_json,
|
||||
summary, model, tokens_in, tokens_out, cost_usd, confidence)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
data["spread_type"],
|
||||
data.get("category"),
|
||||
data.get("question"),
|
||||
json.dumps(data.get("cards") or [], ensure_ascii=False),
|
||||
json.dumps(interp, ensure_ascii=False) if interp else None,
|
||||
summary,
|
||||
data.get("model"),
|
||||
data.get("tokens_in"),
|
||||
data.get("tokens_out"),
|
||||
data.get("cost_usd"),
|
||||
data.get("confidence"),
|
||||
),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def get_tarot_reading(reading_id: int) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
r = conn.execute("SELECT * FROM tarot_readings WHERE id=?", (reading_id,)).fetchone()
|
||||
return _tarot_row_to_dict(r) if r else None
|
||||
|
||||
|
||||
def list_tarot_readings(
|
||||
page: int = 1, size: int = 20,
|
||||
favorite: Optional[bool] = None,
|
||||
spread_type: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
wheres, params = [], []
|
||||
if favorite is not None:
|
||||
wheres.append("favorite=?")
|
||||
params.append(1 if favorite else 0)
|
||||
if spread_type:
|
||||
wheres.append("spread_type=?")
|
||||
params.append(spread_type)
|
||||
if category:
|
||||
wheres.append("category=?")
|
||||
params.append(category)
|
||||
where_sql = ("WHERE " + " AND ".join(wheres)) if wheres else ""
|
||||
offset = (page - 1) * size
|
||||
with _conn() as conn:
|
||||
total = conn.execute(
|
||||
f"SELECT COUNT(*) c FROM tarot_readings {where_sql}", params
|
||||
).fetchone()["c"]
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM tarot_readings {where_sql} ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
params + [size, offset],
|
||||
).fetchall()
|
||||
return {
|
||||
"items": [_tarot_row_to_dict(r) for r in rows],
|
||||
"page": page, "size": size, "total": int(total),
|
||||
}
|
||||
|
||||
|
||||
def update_tarot_reading(reading_id: int, **kwargs) -> None:
|
||||
sets, vals = [], []
|
||||
if "favorite" in kwargs and kwargs["favorite"] is not None:
|
||||
sets.append("favorite=?")
|
||||
vals.append(1 if kwargs["favorite"] else 0)
|
||||
if "note" in kwargs and kwargs["note"] is not None:
|
||||
sets.append("note=?")
|
||||
vals.append(kwargs["note"])
|
||||
if not sets:
|
||||
return
|
||||
vals.append(reading_id)
|
||||
with _conn() as conn:
|
||||
conn.execute(f"UPDATE tarot_readings SET {','.join(sets)} WHERE id=?", vals)
|
||||
|
||||
|
||||
def delete_tarot_reading(reading_id: int) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("DELETE FROM tarot_readings WHERE id=?", (reading_id,))
|
||||
|
||||
|
||||
def _tarot_row_to_dict(r) -> Dict[str, Any]:
|
||||
try:
|
||||
interp = json.loads(r["interpretation_json"]) if r["interpretation_json"] else None
|
||||
except (ValueError, TypeError):
|
||||
interp = None
|
||||
try:
|
||||
cards = json.loads(r["cards"]) if r["cards"] else []
|
||||
except (ValueError, TypeError):
|
||||
cards = []
|
||||
return {
|
||||
"id": r["id"],
|
||||
"created_at": r["created_at"],
|
||||
"spread_type": r["spread_type"],
|
||||
"category": r["category"],
|
||||
"question": r["question"],
|
||||
"cards": cards,
|
||||
"interpretation_json": interp,
|
||||
"summary": r["summary"],
|
||||
"model": r["model"],
|
||||
"tokens_in": r["tokens_in"],
|
||||
"tokens_out": r["tokens_out"],
|
||||
"cost_usd": r["cost_usd"],
|
||||
"confidence": r["confidence"],
|
||||
"favorite": int(r["favorite"]),
|
||||
"note": r["note"],
|
||||
}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import os
|
||||
import json
|
||||
from typing import Optional
|
||||
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .config import CORS_ALLOW_ORIGINS
|
||||
from .db import init_db, get_all_agents, get_agent_config, update_agent_config, get_agent_tasks, get_pending_approvals, get_task, get_logs, get_activity_feed
|
||||
from .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
|
||||
from .routers import tarot as tarot_router
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(notify_router.router)
|
||||
app.include_router(tarot_router.router)
|
||||
|
||||
_cors_origins = CORS_ALLOW_ORIGINS.split(",")
|
||||
app.add_middleware(
|
||||
@@ -102,8 +107,15 @@ def update_agent(agent_id: str, body: AgentConfigUpdate):
|
||||
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)}
|
||||
def agent_tasks(
|
||||
agent_id: str,
|
||||
limit: int = 20,
|
||||
task_type: Optional[str] = None,
|
||||
days: Optional[int] = None,
|
||||
):
|
||||
tasks_list = get_agent_tasks(agent_id, limit=limit, task_type=task_type, days=days)
|
||||
# Backward compat: 기존 client는 'tasks', 신규 client는 'items' 사용
|
||||
return {"tasks": tasks_list, "items": tasks_list}
|
||||
|
||||
@app.get("/api/agent-office/agents/{agent_id}/logs")
|
||||
def agent_logs(agent_id: str, limit: int = 50):
|
||||
@@ -180,3 +192,75 @@ def conversation_stats(days: int = 7):
|
||||
@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
|
||||
|
||||
|
||||
# --- Lotto Signal Endpoints ---
|
||||
|
||||
@app.get("/api/agent-office/lotto/signals")
|
||||
async def list_lotto_signals(days: int = 7):
|
||||
"""시그널 이력 (모든 fire_level)."""
|
||||
from .db import get_signals_history
|
||||
return {"items": get_signals_history(days=days)}
|
||||
|
||||
|
||||
@app.get("/api/agent-office/lotto/baselines")
|
||||
async def list_lotto_baselines():
|
||||
"""현재 baseline μ/σ + window 상태."""
|
||||
from .db import get_all_baselines
|
||||
return {"items": get_all_baselines()}
|
||||
|
||||
|
||||
@app.post("/api/agent-office/lotto/signal-check")
|
||||
async def trigger_signal_check(source: str = "light"):
|
||||
"""수동 트리거 (디버그·테스트용). source ∈ {light, sim, deep}."""
|
||||
if source not in ("light", "sim", "deep"):
|
||||
raise HTTPException(status_code=400, detail="source must be light/sim/deep")
|
||||
agent = AGENT_REGISTRY.get("lotto")
|
||||
if not agent:
|
||||
raise HTTPException(status_code=503, detail="lotto agent not registered")
|
||||
return await agent.run_signal_check(source=source)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Literal
|
||||
|
||||
|
||||
class CommandRequest(BaseModel):
|
||||
@@ -33,3 +33,46 @@ class ComposeCommand(BaseModel):
|
||||
style: Optional[str] = None
|
||||
model: Optional[str] = "V4"
|
||||
instrumental: Optional[bool] = False
|
||||
|
||||
|
||||
class TarotCardDraw(BaseModel):
|
||||
position: str
|
||||
card_id: str
|
||||
reversed: bool = False
|
||||
|
||||
|
||||
class TarotInterpretRequest(BaseModel):
|
||||
spread_type: Literal["one_card", "three_card"]
|
||||
category: Optional[str] = None
|
||||
question: Optional[str] = None
|
||||
cards: List[TarotCardDraw]
|
||||
cards_reference: str = Field(..., min_length=1)
|
||||
context_meta: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class TarotInterpretResponse(BaseModel):
|
||||
interpretation_json: dict
|
||||
model: str
|
||||
tokens_in: int
|
||||
tokens_out: int
|
||||
cost_usd: float
|
||||
latency_ms: int
|
||||
reroll_count: int = 0
|
||||
|
||||
|
||||
class TarotSaveRequest(BaseModel):
|
||||
spread_type: Literal["one_card", "three_card"]
|
||||
category: Optional[str] = None
|
||||
question: Optional[str] = None
|
||||
cards: List[TarotCardDraw]
|
||||
interpretation_json: dict
|
||||
model: str
|
||||
tokens_in: int
|
||||
tokens_out: int
|
||||
cost_usd: float
|
||||
confidence: Optional[str] = None
|
||||
|
||||
|
||||
class TarotPatchRequest(BaseModel):
|
||||
favorite: Optional[bool] = None
|
||||
note: Optional[str] = None
|
||||
|
||||
227
agent-office/app/notifiers/telegram_lotto.py
Normal file
227
agent-office/app/notifiers/telegram_lotto.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""로또 큐레이션·당첨 알림 — 텔레그램 푸시."""
|
||||
import logging
|
||||
from typing import Dict, Any, List
|
||||
|
||||
# 기존 에이전트들과 동일한 패턴: 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}")
|
||||
|
||||
|
||||
# ---------- 능동 시그널 알림 (urgent + digest) ----------
|
||||
|
||||
_METRIC_LABEL = {
|
||||
"sim_signal": "Sim Consensus",
|
||||
"drift": "Strategy Drift",
|
||||
"confidence": "Confidence",
|
||||
}
|
||||
|
||||
|
||||
def _format_urgent_signal(event: Dict[str, Any]) -> str:
|
||||
"""긴급 시그널 텔레그램 메시지 포맷."""
|
||||
triggered = event.get("triggered_at", "")[:19].replace("T", " ")
|
||||
results = event.get("results", [])
|
||||
fired = [r for r in results if r.get("fire_level") in ("normal", "urgent")]
|
||||
|
||||
lines = [
|
||||
"🚨 로또 능동 신호",
|
||||
"",
|
||||
f"[{triggered}]",
|
||||
f"강한 시그널 {len(fired)}종 발화:",
|
||||
]
|
||||
for r in fired:
|
||||
label = _METRIC_LABEL.get(r["metric"], r["metric"])
|
||||
v = r.get("value")
|
||||
mu = r.get("baseline_mu")
|
||||
sigma = r.get("baseline_sigma")
|
||||
z = r.get("z_score")
|
||||
v_text = f"{v:.2f}" if v is not None else "N/A"
|
||||
if mu is not None and sigma is not None and z is not None:
|
||||
lines.append(f"• {label} {v_text} (μ={mu:.2f}, σ={sigma:.2f}) z={z:.1f}")
|
||||
else:
|
||||
lines.append(f"• {label} {v_text}")
|
||||
|
||||
# drift 페이로드 — 어떤 전략이 변동했는지 한 줄
|
||||
for r in fired:
|
||||
if r["metric"] == "drift":
|
||||
wn = (r.get("payload") or {}).get("weights_now") or {}
|
||||
wp = (r.get("payload") or {}).get("weights_prev") or {}
|
||||
if wn and wp:
|
||||
diffs = {k: wn.get(k, 0) - wp.get(k, 0) for k in (set(wn) | set(wp))}
|
||||
top = sorted(diffs.items(), key=lambda kv: abs(kv[1]), reverse=True)[:2]
|
||||
detail = ", ".join(f"{k} {'+' if d>=0 else ''}{d*100:.0f}%p" for k, d in top)
|
||||
lines.append("")
|
||||
lines.append(f"요인: {detail}")
|
||||
break
|
||||
|
||||
lines.append("")
|
||||
lines.append(f"[자세히 보기] ({LOTTO_URL}/agent)")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_signal_digest(digest: Dict[str, Any]) -> str:
|
||||
"""일일 요약 메시지. 발화 0건이면 빈 문자열 (발송 skip 신호)."""
|
||||
fired = int(digest.get("fired", 0))
|
||||
if fired == 0:
|
||||
return ""
|
||||
|
||||
signals_list = digest.get("signals", [])
|
||||
evaluated = digest.get("evaluated", 0)
|
||||
|
||||
lines = [
|
||||
"📊 로또 일일 요약 (지난 24h)",
|
||||
"",
|
||||
f"평가 {evaluated}회 / 발화 {fired}회",
|
||||
]
|
||||
for s in signals_list:
|
||||
label = _METRIC_LABEL.get(s["metric"], s["metric"])
|
||||
z = s.get("z_score")
|
||||
when = (s.get("triggered_at") or "")[11:16] # HH:MM
|
||||
z_text = f"z={z:.1f}" if z is not None else "z=-"
|
||||
lines.append(f"• {label:14s} {s['fire_level']:6s} {z_text} ({when})")
|
||||
|
||||
weights_trend = digest.get("weights_trend") or {}
|
||||
if weights_trend:
|
||||
lines += ["", "전략 가중치 추세 (최근 8회 baseline):"]
|
||||
for strategy, delta in sorted(weights_trend.items(), key=lambda kv: -abs(kv[1])):
|
||||
arrow = "↑" if delta > 0.01 else ("↓" if delta < -0.01 else "→")
|
||||
lines.append(f" {strategy:12s} {arrow} {delta*100:+.0f}%")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def send_urgent_signal(event: Dict[str, Any]) -> None:
|
||||
text = _format_urgent_signal(event)
|
||||
try:
|
||||
await send_raw(text)
|
||||
except Exception as e:
|
||||
logger.warning(f"[telegram_lotto] urgent signal send failed: {e}")
|
||||
|
||||
|
||||
async def send_signal_summary(digest: Dict[str, Any]) -> None:
|
||||
text = _format_signal_digest(digest)
|
||||
if not text:
|
||||
return # 발화 0건이면 발송 skip
|
||||
try:
|
||||
await send_raw(text)
|
||||
except Exception as e:
|
||||
logger.warning(f"[telegram_lotto] digest send failed: {e}")
|
||||
|
||||
|
||||
# ---------- Weight Evolver 주간 리포트 ----------
|
||||
|
||||
_DAY_NAMES = ["월", "화", "수", "목", "금", "토"]
|
||||
_METRIC_NAMES = ["freq", "finger", "gap", "cooccur", "divers"]
|
||||
_REASON_LABEL = {
|
||||
"winner_4plus": "4개 이상 일치 → base 교체",
|
||||
"ema_blend": "3개 일치 → EMA blend (0.3)",
|
||||
"unchanged": "유효 성과 없음 → base 유지",
|
||||
"cold_start": "초기 균등 적용",
|
||||
}
|
||||
|
||||
|
||||
def _format_evolution_report(eval_result: Dict[str, Any], current_base: List[float]) -> str:
|
||||
"""주간 weight evolution 텔레그램 메시지. ok=False 또는 winner 없으면 빈 문자열."""
|
||||
if not eval_result or "winner" not in eval_result:
|
||||
return ""
|
||||
|
||||
draw_no = eval_result.get("draw_no", "?")
|
||||
winner = eval_result["winner"]
|
||||
new_base = eval_result.get("new_base") or [0.0] * 5
|
||||
reason = eval_result.get("update_reason", "")
|
||||
dow = winner.get("day_of_week", 0)
|
||||
day_name = _DAY_NAMES[dow] if 0 <= dow < len(_DAY_NAMES) else "?"
|
||||
|
||||
lines = [
|
||||
f"🧬 로또 학습 주간 리포트 ({draw_no}회차)",
|
||||
"",
|
||||
f"이번주 시도: 6일 × {winner.get('n_picks', 5)}세트",
|
||||
"",
|
||||
f"🏆 Winner: {day_name}요일",
|
||||
f" W = [" + ", ".join(
|
||||
f"{name} {w:.2f}" for name, w in zip(_METRIC_NAMES, winner["weight"])
|
||||
) + "]",
|
||||
f" 최고 적중: {winner.get('max_correct', 0)}개 일치 (max={winner.get('max_correct', 0)})",
|
||||
f" 평균 점수: {winner.get('avg_score', 0):.2f}",
|
||||
"",
|
||||
f"📊 다음주 base 변경 ({reason}):",
|
||||
]
|
||||
# 우선순위: eval_result.previous_base > current_base (eval 직후 stale) > 균등 fallback
|
||||
base_now = eval_result.get("previous_base") or current_base or [0.2] * 5
|
||||
for i, (cur, new) in enumerate(zip(base_now, new_base)):
|
||||
diff = new - cur
|
||||
if abs(diff) < 0.005:
|
||||
marker = "="
|
||||
elif diff > 0:
|
||||
marker = "+" if diff < 0.05 else "++"
|
||||
else:
|
||||
marker = "-" if diff > -0.05 else "--"
|
||||
lines.append(f" {_METRIC_NAMES[i]:8s} {cur:.2f} → {new:.2f} ({marker})")
|
||||
lines.append("")
|
||||
lines.append(f" → {_REASON_LABEL.get(reason, reason)}")
|
||||
lines.append("")
|
||||
lines.append(f"[웹에서 차트 보기] ({LOTTO_URL}/evolver)")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def send_evolution_report(eval_result: Dict[str, Any], current_base: List[float]) -> None:
|
||||
text = _format_evolution_report(eval_result, current_base)
|
||||
if not text:
|
||||
return
|
||||
try:
|
||||
await send_raw(text)
|
||||
except Exception as e:
|
||||
logger.warning(f"[telegram_lotto] evolution report send failed: {e}")
|
||||
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}
|
||||
70
agent-office/app/routers/tarot.py
Normal file
70
agent-office/app/routers/tarot.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Tarot Lab 엔드포인트 — interpret + readings CRUD."""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from ..models import (
|
||||
TarotInterpretRequest,
|
||||
TarotInterpretResponse,
|
||||
TarotSaveRequest,
|
||||
TarotPatchRequest,
|
||||
)
|
||||
from ..tarot import pipeline
|
||||
from .. import db as db_module
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/agent-office/tarot")
|
||||
|
||||
|
||||
@router.post("/interpret", response_model=TarotInterpretResponse)
|
||||
async def interpret_endpoint(req: TarotInterpretRequest):
|
||||
try:
|
||||
result = await pipeline.interpret(req)
|
||||
except pipeline.TarotError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/readings")
|
||||
async def save_reading(req: TarotSaveRequest):
|
||||
rid = db_module.save_tarot_reading(req.model_dump())
|
||||
row = db_module.get_tarot_reading(rid)
|
||||
return {"id": rid, "created_at": row["created_at"]}
|
||||
|
||||
|
||||
@router.get("/readings")
|
||||
async def list_readings(
|
||||
page: int = 1,
|
||||
size: int = 20,
|
||||
favorite: bool | None = None,
|
||||
spread_type: str | None = None,
|
||||
category: str | None = None,
|
||||
):
|
||||
return db_module.list_tarot_readings(
|
||||
page=page, size=size,
|
||||
favorite=favorite, spread_type=spread_type, category=category,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/readings/{reading_id}")
|
||||
async def get_reading(reading_id: int):
|
||||
row = db_module.get_tarot_reading(reading_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="reading not found")
|
||||
return row
|
||||
|
||||
|
||||
@router.patch("/readings/{reading_id}")
|
||||
async def patch_reading(reading_id: int, req: TarotPatchRequest):
|
||||
row = db_module.get_tarot_reading(reading_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="reading not found")
|
||||
db_module.update_tarot_reading(reading_id, **req.model_dump(exclude_none=True))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/readings/{reading_id}")
|
||||
async def delete_reading(reading_id: int):
|
||||
row = db_module.get_tarot_reading(reading_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="reading not found")
|
||||
db_module.delete_tarot_reading(reading_id)
|
||||
return {"ok": True}
|
||||
@@ -5,34 +5,116 @@ from .agents import AGENT_REGISTRY
|
||||
|
||||
scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
|
||||
|
||||
async def _check_idle_breaks():
|
||||
for agent in AGENT_REGISTRY.values():
|
||||
await agent.check_idle_break()
|
||||
|
||||
async def _run_stock_schedule():
|
||||
agent = AGENT_REGISTRY.get("stock")
|
||||
if agent:
|
||||
await agent.on_schedule()
|
||||
|
||||
async def _run_realestate_schedule():
|
||||
agent = AGENT_REGISTRY.get("realestate")
|
||||
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_blog_schedule():
|
||||
agent = AGENT_REGISTRY.get("blog")
|
||||
|
||||
async def _run_insta_trends_collect():
|
||||
agent = AGENT_REGISTRY.get("insta")
|
||||
if agent:
|
||||
await agent.on_schedule()
|
||||
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_lotto_light_check():
|
||||
agent = AGENT_REGISTRY.get("lotto")
|
||||
if agent:
|
||||
await agent.run_signal_check(source="light")
|
||||
|
||||
async def _run_lotto_sim_check():
|
||||
agent = AGENT_REGISTRY.get("lotto")
|
||||
if agent:
|
||||
await agent.run_signal_check(source="sim")
|
||||
|
||||
async def _run_lotto_deep_check():
|
||||
agent = AGENT_REGISTRY.get("lotto")
|
||||
if agent:
|
||||
await agent.run_signal_check(source="deep")
|
||||
|
||||
async def _run_lotto_daily_digest():
|
||||
agent = AGENT_REGISTRY.get("lotto")
|
||||
if agent:
|
||||
await agent.run_daily_digest()
|
||||
|
||||
async def _run_lotto_weekly_evolution_report():
|
||||
agent = AGENT_REGISTRY.get("lotto")
|
||||
if agent:
|
||||
await agent.run_weekly_evolution_report()
|
||||
|
||||
async def _run_lotto_sync_evolver_activity():
|
||||
agent = AGENT_REGISTRY.get("lotto")
|
||||
if agent:
|
||||
await agent.sync_evolver_activity()
|
||||
|
||||
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_realestate_schedule, "cron", hour=9, minute=15, id="realestate_report")
|
||||
scheduler.add_job(_run_blog_schedule, "cron", hour=10, minute=0, id="blog_pipeline")
|
||||
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=7, minute=0, id="lotto_curate")
|
||||
scheduler.add_job(_check_idle_breaks, "interval", seconds=60, id="idle_check")
|
||||
scheduler.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")
|
||||
# 외부 트렌드 수집은 장 마감 후 16:40 — 9시 주식 활발 시간대 NAS 자원 회피.
|
||||
# screener(16:30)와 10분 스태거: Celeron 2C/2.0GHz 동시 실행 시 CPU 폭주 방지 (CHECK_POINT FU-A)
|
||||
scheduler.add_job(_run_insta_trends_collect, "cron", hour=16, minute=40, id="insta_trends_collect")
|
||||
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=9, minute=5, id="lotto_curate")
|
||||
scheduler.add_job(_run_lotto_light_check, "cron", hour=9, minute=15, id="lotto_light_check")
|
||||
scheduler.add_job(_run_lotto_sim_check, "cron", minute=15, hour="0,4,8,12,16,20", id="lotto_sim_check")
|
||||
scheduler.add_job(_run_lotto_deep_check, "cron", day_of_week="sun,wed", hour=21, minute=15, id="lotto_deep_check")
|
||||
scheduler.add_job(_run_lotto_daily_digest, "cron", hour=9, minute=25, id="lotto_digest")
|
||||
scheduler.add_job(_run_lotto_weekly_evolution_report, "cron", day_of_week="sat", hour=22, minute=15, id="lotto_evolution_weekly")
|
||||
scheduler.add_job(
|
||||
_run_lotto_sync_evolver_activity,
|
||||
"cron", hour=9, minute=30,
|
||||
id="lotto_evolver_activity_sync",
|
||||
)
|
||||
scheduler.add_job(_run_youtube_research, "cron", hour=9, minute=10, 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(_poll_pipelines, "interval", seconds=30, id="pipeline_poll")
|
||||
scheduler.start()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import httpx
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .config import STOCK_LAB_URL, MUSIC_LAB_URL, BLOG_LAB_URL, REALESTATE_LAB_URL
|
||||
from .config import STOCK_URL, MUSIC_LAB_URL, INSTA_LAB_URL, REALESTATE_LAB_URL
|
||||
|
||||
_client = httpx.AsyncClient(timeout=30.0)
|
||||
|
||||
@@ -9,28 +9,82 @@ async def fetch_stock_news(limit: int = 10, category: str = None) -> List[Dict[s
|
||||
params = {"limit": limit}
|
||||
if category:
|
||||
params["category"] = category
|
||||
resp = await _client.get(f"{STOCK_LAB_URL}/api/stock/news", params=params)
|
||||
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_LAB_URL}/api/stock/indices")
|
||||
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-lab의 AI 요약 엔드포인트 호출.
|
||||
"""stock의 AI 요약 엔드포인트 호출.
|
||||
반환: {"summary": str, "tokens": {...}, "model": str, "duration_ms": int, "article_count": int}
|
||||
"""
|
||||
# stock-lab 내부 Ollama 호출이 180s까지 가능하므로 여유있게 200s
|
||||
# stock 내부 Ollama 호출이 180s까지 가능하므로 여유있게 200s
|
||||
async with httpx.AsyncClient(timeout=200.0) as client:
|
||||
resp = await client.post(
|
||||
f"{STOCK_LAB_URL}/api/stock/news/summarize",
|
||||
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()
|
||||
@@ -47,60 +101,107 @@ async def get_music_credits() -> Dict[str, Any]:
|
||||
return resp.json()
|
||||
|
||||
|
||||
# --- blog-lab ---
|
||||
# --- insta-lab ---
|
||||
|
||||
async def blog_research(keyword: str) -> Dict[str, Any]:
|
||||
"""키워드 리서치 시작 → task_id 반환"""
|
||||
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"{BLOG_LAB_URL}/api/blog-marketing/research",
|
||||
json={"keyword": keyword},
|
||||
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 blog_task_status(task_id: str) -> Dict[str, Any]:
|
||||
resp = await _client.get(f"{BLOG_LAB_URL}/api/blog-marketing/task/{task_id}")
|
||||
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 blog_generate(keyword_id: int) -> Dict[str, Any]:
|
||||
resp = await _client.post(
|
||||
f"{BLOG_LAB_URL}/api/blog-marketing/generate",
|
||||
json={"keyword_id": keyword_id},
|
||||
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()
|
||||
|
||||
|
||||
async def blog_market(post_id: int) -> Dict[str, Any]:
|
||||
resp = await _client.post(f"{BLOG_LAB_URL}/api/blog-marketing/market/{post_id}")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def blog_review(post_id: int) -> Dict[str, Any]:
|
||||
resp = await _client.post(f"{BLOG_LAB_URL}/api/blog-marketing/review/{post_id}")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def blog_publish(post_id: int, url: str = "") -> Dict[str, Any]:
|
||||
resp = await _client.post(
|
||||
f"{BLOG_LAB_URL}/api/blog-marketing/posts/{post_id}/publish",
|
||||
json={"url": url},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def blog_get_post(post_id: int) -> Dict[str, Any]:
|
||||
resp = await _client.get(f"{BLOG_LAB_URL}/api/blog-marketing/posts/{post_id}")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
# --- realestate-lab ---
|
||||
|
||||
async def realestate_collect() -> Dict[str, Any]:
|
||||
@@ -112,13 +213,15 @@ async def realestate_collect() -> Dict[str, Any]:
|
||||
|
||||
|
||||
async def realestate_matches(limit: int = 20) -> List[Dict[str, Any]]:
|
||||
resp = await _client.get(
|
||||
f"{REALESTATE_LAB_URL}/api/realestate/matches",
|
||||
params={"limit": limit, "unread_only": True},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data if isinstance(data, list) else data.get("matches", [])
|
||||
"""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]:
|
||||
@@ -133,6 +236,16 @@ async def realestate_mark_read(match_id: int) -> Dict[str, Any]:
|
||||
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]:
|
||||
@@ -154,3 +267,130 @@ async def lotto_save_briefing(payload: dict) -> Dict[str, Any]:
|
||||
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
|
||||
|
||||
|
||||
async def lotto_best() -> List[Dict[str, Any]]:
|
||||
"""GET /api/lotto/best — best_picks 20개 (numbers + scores 5종)."""
|
||||
from .config import LOTTO_BACKEND_URL
|
||||
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/best")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
items = data.get("items") if isinstance(data, dict) else data
|
||||
return items or []
|
||||
|
||||
|
||||
async def lotto_strategy_weights() -> Dict[str, float]:
|
||||
"""GET /api/lotto/strategy/weights — 전략별 가중치 dict."""
|
||||
from .config import LOTTO_BACKEND_URL
|
||||
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/strategy/weights")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
weights = data.get("weights") if isinstance(data, dict) else data
|
||||
if isinstance(weights, list):
|
||||
return {item["strategy"]: float(item["weight"]) for item in weights}
|
||||
return {k: float(v) for k, v in (weights or {}).items()}
|
||||
|
||||
|
||||
async def lotto_latest_draw() -> Optional[int]:
|
||||
"""GET /api/lotto/latest — 최신 회차 번호만 반환."""
|
||||
from .config import LOTTO_BACKEND_URL
|
||||
try:
|
||||
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/latest")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
# /api/lotto/latest 응답 키: {"drawNo": N, ...}
|
||||
# 하위 호환을 위해 drawNo, draw_no, drwNo, draw 순서로 시도
|
||||
for key in ("drawNo", "draw_no", "drwNo", "draw"):
|
||||
if isinstance(data, dict) and data.get(key):
|
||||
return int(data[key])
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def lotto_evolver_status() -> Dict[str, Any]:
|
||||
"""GET /api/lotto/evolver/status — 이번주 trials + 다음주 base 정보."""
|
||||
from .config import LOTTO_BACKEND_URL
|
||||
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/evolver/status")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def lotto_evolver_evaluate() -> Dict[str, Any]:
|
||||
"""POST /api/lotto/evolver/evaluate-now — 회고 트리거 (텔레그램 리포트용)."""
|
||||
from .config import LOTTO_BACKEND_URL
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
resp = await client.post(f"{LOTTO_BACKEND_URL}/api/lotto/evolver/evaluate-now")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
1
agent-office/app/tarot/__init__.py
Normal file
1
agent-office/app/tarot/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tarot Lab — Claude Sonnet 기반 evidence·interactions 해석 파이프라인."""
|
||||
139
agent-office/app/tarot/pipeline.py
Normal file
139
agent-office/app/tarot/pipeline.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Tarot 파이프라인 — Claude Sonnet 호출 + 파싱 폴백 + reroll 1회."""
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
|
||||
import httpx
|
||||
|
||||
from ..config import (
|
||||
ANTHROPIC_API_KEY,
|
||||
TAROT_MODEL,
|
||||
TAROT_COST_INPUT_PER_M,
|
||||
TAROT_COST_OUTPUT_PER_M,
|
||||
TAROT_TIMEOUT_SEC,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger("agent-office.tarot")
|
||||
from ..models import TarotInterpretRequest
|
||||
from .prompt import SYSTEM_PROMPT, build_user_message
|
||||
from .schema import validate_interpretation
|
||||
|
||||
|
||||
API_URL = "https://api.anthropic.com/v1/messages"
|
||||
|
||||
|
||||
class TarotError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def calc_cost(tokens_in: int, tokens_out: int) -> float:
|
||||
return (
|
||||
tokens_in / 1_000_000 * TAROT_COST_INPUT_PER_M
|
||||
+ tokens_out / 1_000_000 * TAROT_COST_OUTPUT_PER_M
|
||||
)
|
||||
|
||||
|
||||
def _strip_codeblock(text: str) -> str:
|
||||
t = text.strip()
|
||||
if t.startswith("```"):
|
||||
t = t.strip("`")
|
||||
if t.startswith("json"):
|
||||
t = t[4:]
|
||||
t = t.strip()
|
||||
return t
|
||||
|
||||
|
||||
def _extract_json(raw: str) -> dict:
|
||||
cleaned = _strip_codeblock(raw)
|
||||
try:
|
||||
return json.loads(cleaned)
|
||||
except json.JSONDecodeError:
|
||||
start, end = cleaned.find("{"), cleaned.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
try:
|
||||
return json.loads(cleaned[start : end + 1])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
async def _call_claude(user_text: str, feedback: str = "") -> tuple[dict, dict, str]:
|
||||
if not ANTHROPIC_API_KEY:
|
||||
raise TarotError("ANTHROPIC_API_KEY missing")
|
||||
if feedback:
|
||||
user_text = f"이전 응답이 다음 이유로 거절됨: {feedback}\n올바른 스키마(시스템 지침)로 다시 응답.\n\n{user_text}"
|
||||
payload = {
|
||||
"model": TAROT_MODEL,
|
||||
"max_tokens": 1400, # 응답 시간 단축 — 3-card spread evidence·interactions 포함 충분
|
||||
"system": [{"type": "text", "text": SYSTEM_PROMPT,
|
||||
"cache_control": {"type": "ephemeral"}}],
|
||||
"messages": [{"role": "user", "content": [{"type": "text", "text": user_text}]}],
|
||||
}
|
||||
headers = {
|
||||
"x-api-key": ANTHROPIC_API_KEY,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"anthropic-beta": "prompt-caching-2024-07-31",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
started = time.monotonic()
|
||||
async with httpx.AsyncClient(timeout=TAROT_TIMEOUT_SEC) 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)
|
||||
raw_text = "".join(
|
||||
b.get("text", "") for b in resp.get("content", []) if b.get("type") == "text"
|
||||
)
|
||||
usage = resp.get("usage", {}) or {}
|
||||
tokens_in = int(usage.get("input_tokens", 0) or 0)
|
||||
tokens_out = int(usage.get("output_tokens", 0) or 0)
|
||||
logger.info("tarot claude call: latency=%dms, in=%d, out=%d", latency_ms, tokens_in, tokens_out)
|
||||
parsed = _extract_json(raw_text)
|
||||
meta = {
|
||||
"tokens_in": tokens_in,
|
||||
"tokens_out": tokens_out,
|
||||
"latency_ms": latency_ms,
|
||||
}
|
||||
return parsed, meta, raw_text
|
||||
|
||||
|
||||
async def interpret(req: TarotInterpretRequest) -> Dict[str, Any]:
|
||||
user_text = build_user_message(
|
||||
question=req.question or "",
|
||||
category=req.category or "",
|
||||
spread_type=req.spread_type,
|
||||
cards_reference=req.cards_reference,
|
||||
context_meta=req.context_meta or {},
|
||||
spread_count=len(req.cards),
|
||||
)
|
||||
|
||||
total_in, total_out, total_latency = 0, 0, 0
|
||||
last_error = ""
|
||||
for attempt in range(2):
|
||||
try:
|
||||
parsed, meta, _raw = await _call_claude(user_text, feedback=last_error)
|
||||
except httpx.HTTPError as e:
|
||||
raise TarotError(f"Claude HTTP error: {e}") from e
|
||||
except json.JSONDecodeError as e:
|
||||
last_error = f"JSON 파싱 실패: {e}"
|
||||
continue
|
||||
total_in += meta["tokens_in"]
|
||||
total_out += meta["tokens_out"]
|
||||
total_latency += meta["latency_ms"]
|
||||
|
||||
ok, err = validate_interpretation(parsed, req.spread_type)
|
||||
if ok:
|
||||
return {
|
||||
"interpretation_json": parsed,
|
||||
"model": TAROT_MODEL,
|
||||
"tokens_in": total_in,
|
||||
"tokens_out": total_out,
|
||||
"cost_usd": calc_cost(total_in, total_out),
|
||||
"latency_ms": total_latency,
|
||||
"reroll_count": attempt,
|
||||
}
|
||||
last_error = err
|
||||
|
||||
raise TarotError(f"검증 실패 (reroll 2회): {last_error}")
|
||||
108
agent-office/app/tarot/prompt.py
Normal file
108
agent-office/app/tarot/prompt.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Tarot 프롬프트 — SYSTEM + build_user_message."""
|
||||
|
||||
SYSTEM_PROMPT = """당신은 라이더-웨이트(RWS) 타로 덱의 전통 상징체계에 정통한 타로 리더입니다.
|
||||
사용자의 질문, 카테고리, 뽑힌 카드 각각의 정·역방향과 위치를 받아 근거 기반으로 해석합니다.
|
||||
|
||||
# 해석 원칙
|
||||
1. 데이터 우선: "참고 카드 정보" 블록의 키워드·기본의미·상징만을 1차 근거로 사용.
|
||||
외부 변형 의미·다른 덱 해석은 사용하지 않음.
|
||||
2. 위치 의미 결합: 카드의 의미와 위치(과거/현재/미래 또는 오늘)를 명시적으로 결합해서 해석. evidence에 근거 기록.
|
||||
3. 카드 간 상호작용 분석 (3장 스프레드):
|
||||
- 시너지: 같은 슈트, 같은 원소, 메이저 비율, 정·역 흐름
|
||||
- 충돌·전환: 슈트 충돌(컵-소드, 완드-펜타클), 정→역 전환, 메이저↔마이너 전환
|
||||
4. 자기 성찰 톤: 운명론 단정 금지. "…할 가능성이 있어 보입니다" 같은 표현.
|
||||
5. 카테고리 컨텍스트: 동일 카드라도 카테고리에 따라 강조점이 달라야 함.
|
||||
6. 질문 직접 응답: 사용자 질문을 evidence·advice에서 인용·반영.
|
||||
|
||||
# 응답 형식 (strict JSON only — 코드블록 없이 raw JSON)
|
||||
{
|
||||
"summary": "전체 흐름 한 단락 (3~4문장)",
|
||||
"cards": [
|
||||
{
|
||||
"position": "<위치 라벨>",
|
||||
"card": "<card_id>",
|
||||
"reversed": <bool>,
|
||||
"interpretation": "3~4문장",
|
||||
"evidence": {
|
||||
"card_meaning_used": "참고 카드 정보에서 인용한 키워드·상징",
|
||||
"position_logic": "왜 이 위치에 이렇게 적용되는지 (1~2문장)",
|
||||
"category_lens": "카테고리 관점에서 부각되는 면 (1문장)"
|
||||
},
|
||||
"advice": "1문장"
|
||||
}
|
||||
],
|
||||
"interactions": [
|
||||
{ "type": "synergy"|"conflict"|"transition",
|
||||
"between": ["<card_id>", "<card_id>"],
|
||||
"explanation": "1~2문장" }
|
||||
],
|
||||
"advice": "2문장. interactions를 1개 이상 참조할 것.",
|
||||
"warning": "역방향·충돌 경계 (없으면 null)",
|
||||
"confidence": "high"|"medium"|"low"
|
||||
}
|
||||
|
||||
# confidence 판정 기준
|
||||
- high: 3장 모두 한 방향 서사 또는 명확한 전환
|
||||
- medium: 2장 일관, 1장 별도 신호
|
||||
- low: 카드 간 의미 충돌이 커서 명확한 흐름 잡기 어려움
|
||||
|
||||
# 금지사항
|
||||
- 참고 카드 정보에 없는 상징 도입 금지
|
||||
- 역방향 카드를 정방향처럼 다루지 말 것
|
||||
- "신비롭게 들리는" 문구로 채우지 말 것 — evidence에 인용·근거 명시
|
||||
- JSON 외 텍스트 금지
|
||||
"""
|
||||
|
||||
|
||||
SPREAD_NAMES = {
|
||||
"one_card": "오늘의 카드",
|
||||
"three_card": "3장 스프레드 (과거·현재·미래)",
|
||||
}
|
||||
|
||||
|
||||
def build_user_message(
|
||||
question: str,
|
||||
category: str,
|
||||
spread_type: str,
|
||||
cards_reference: str,
|
||||
context_meta: dict,
|
||||
spread_count: int,
|
||||
) -> str:
|
||||
q = question or "(질문 없음)"
|
||||
cat = category or "일반"
|
||||
spread_name = SPREAD_NAMES.get(spread_type, spread_type)
|
||||
|
||||
meta_lines = []
|
||||
if context_meta:
|
||||
if "major_minor_ratio" in context_meta:
|
||||
meta_lines.append(f"- 메이저:마이너 비율: {context_meta['major_minor_ratio']}")
|
||||
if "element_distribution" in context_meta:
|
||||
ed = context_meta["element_distribution"]
|
||||
meta_lines.append(
|
||||
f"- 원소 분포: 공기 {ed.get('air',0)}, 물 {ed.get('water',0)}, 불 {ed.get('fire',0)}, 흙 {ed.get('earth',0)}"
|
||||
)
|
||||
if "orientation_flow" in context_meta:
|
||||
meta_lines.append(f"- 정역 흐름: {context_meta['orientation_flow']}")
|
||||
meta_block = "\n".join(meta_lines) if meta_lines else "(추가 컨텍스트 없음)"
|
||||
|
||||
return f"""# 질문
|
||||
{q}
|
||||
|
||||
# 카테고리
|
||||
{cat}
|
||||
|
||||
# 스프레드
|
||||
{spread_name} ({spread_count}장)
|
||||
|
||||
# 뽑힌 카드와 참고 카드 정보
|
||||
{cards_reference}
|
||||
|
||||
## 추가 컨텍스트
|
||||
{meta_block}
|
||||
|
||||
# 작업
|
||||
위 정보만을 근거로 사용해, 시스템 지침의 JSON 형식으로 응답하세요.
|
||||
- 각 카드의 evidence.card_meaning_used에는 위 "참고 카드 정보"에서 발췌한 키워드·의미를 그대로 인용.
|
||||
- interactions는 3장 간 슈트·원소·정역방향 패턴을 분석해 최소 1개 이상 도출 (1장 스프레드면 빈 배열 허용).
|
||||
- confidence는 카드 흐름의 일관성에 따라 정직하게 판정.
|
||||
"""
|
||||
36
agent-office/app/tarot/schema.py
Normal file
36
agent-office/app/tarot/schema.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Tarot 응답 스키마 검증 — 누락·빈 필드 reroll 트리거."""
|
||||
|
||||
VALID_CONFIDENCE = {"high", "medium", "low"}
|
||||
|
||||
|
||||
def validate_interpretation(parsed: dict, spread_type: str) -> tuple[bool, str]:
|
||||
if not isinstance(parsed, dict):
|
||||
return False, "응답이 dict가 아님"
|
||||
for k in ("summary", "cards", "interactions", "advice", "confidence"):
|
||||
if k not in parsed:
|
||||
return False, f"필수 필드 누락: {k}"
|
||||
if parsed.get("confidence") not in VALID_CONFIDENCE:
|
||||
return False, f"confidence 값 비정상: {parsed.get('confidence')}"
|
||||
cards = parsed.get("cards")
|
||||
if not isinstance(cards, list) or not cards:
|
||||
return False, "cards가 빈 배열"
|
||||
for i, c in enumerate(cards):
|
||||
if not isinstance(c, dict):
|
||||
return False, f"cards[{i}] dict 아님"
|
||||
for k in ("position", "card", "reversed", "interpretation", "advice", "evidence"):
|
||||
if k not in c:
|
||||
return False, f"cards[{i}].{k} 누락"
|
||||
ev = c["evidence"]
|
||||
if not isinstance(ev, dict):
|
||||
return False, f"cards[{i}].evidence dict 아님"
|
||||
for k in ("card_meaning_used", "position_logic", "category_lens"):
|
||||
if k not in ev:
|
||||
return False, f"cards[{i}].evidence.{k} 누락"
|
||||
if not isinstance(ev[k], str) or not ev[k].strip():
|
||||
return False, f"cards[{i}].evidence.{k} 빈 문자열"
|
||||
interactions = parsed.get("interactions")
|
||||
if not isinstance(interactions, list):
|
||||
return False, "interactions가 list 아님"
|
||||
if spread_type == "three_card" and len(interactions) == 0:
|
||||
return False, "three_card는 interactions 1개 이상 필요"
|
||||
return True, ""
|
||||
@@ -15,6 +15,11 @@ AGENT_META = {
|
||||
"emoji": "🎱",
|
||||
"display_name": "로또 큐레이터",
|
||||
},
|
||||
"realestate": {
|
||||
"display_name": "청약 애널리스트",
|
||||
"emoji": "🏢",
|
||||
"color": "#f43f5e",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -103,6 +103,34 @@ def _build_messages(history: list, user_text: str) -> list:
|
||||
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:
|
||||
|
||||
@@ -8,14 +8,22 @@ from .client import _enabled, api_call
|
||||
from .formatter import MessageKind, format_agent_message
|
||||
|
||||
|
||||
async def send_raw(text: str, reply_markup: Optional[dict] = None, chat_id: Optional[str] = None) -> dict:
|
||||
"""가장 저수준. 원문 텍스트 그대로 전송. chat_id 생략 시 기본 TELEGRAM_CHAT_ID로."""
|
||||
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": "HTML",
|
||||
"parse_mode": parse_mode,
|
||||
}
|
||||
if reply_markup:
|
||||
payload["reply_markup"] = reply_markup
|
||||
|
||||
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},
|
||||
]],
|
||||
}
|
||||
@@ -50,6 +50,10 @@ AGENT_COMMAND_MAP = {
|
||||
"credits": ("credits", {}),
|
||||
# compose는 인자 필요 — 아래 특수 케이스에서 처리
|
||||
},
|
||||
"realestate": {
|
||||
"matches": ("fetch_matches", {}),
|
||||
"dashboard": ("dashboard", {}),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -84,4 +88,8 @@ HELP_TEXT = """<b>🤖 Agent Office 텔레그램 명령</b>
|
||||
<b>🎵 음악 프로듀서</b>
|
||||
/music credits — Suno 크레딧 조회
|
||||
/music compose <프롬프트> — 작곡 시작
|
||||
|
||||
<b>🏢 청약 애널리스트</b>
|
||||
/realestate matches — 신규 매칭 조회 후 알림 전송
|
||||
/realestate dashboard — 청약 현황 요약
|
||||
"""
|
||||
|
||||
@@ -30,8 +30,16 @@ async def handle_webhook(data: dict, agent_dispatcher=None) -> Optional[dict]:
|
||||
|
||||
|
||||
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
|
||||
@@ -60,11 +68,80 @@ async def _handle_callback(callback_query: dict) -> Optional[dict]:
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
@@ -3,3 +3,7 @@ uvicorn[standard]==0.30.6
|
||||
apscheduler==3.10.4
|
||||
websockets>=12.0
|
||||
httpx>=0.27
|
||||
respx>=0.21
|
||||
pytest-asyncio>=0.23
|
||||
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)
|
||||
@@ -1,60 +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, CuratorOutput
|
||||
from app.curator.schema import validate_response
|
||||
|
||||
|
||||
CANDIDATE_NUMBERS = [
|
||||
[1, 2, 3, 4, 5, 6],
|
||||
[7, 8, 9, 10, 11, 12],
|
||||
[13, 14, 15, 16, 17, 18],
|
||||
[19, 20, 21, 22, 23, 24],
|
||||
[25, 26, 27, 28, 29, 30],
|
||||
[31, 32, 33, 34, 35, 36],
|
||||
]
|
||||
def _pick(nums, role="안정"):
|
||||
return {"numbers": nums, "risk_tag": role, "reason": "x"}
|
||||
|
||||
|
||||
def _valid_payload():
|
||||
def _make_payload(core, bonus, ext, pool):
|
||||
return {
|
||||
"picks": [
|
||||
{"numbers": s, "risk_tag": "안정", "reason": "test"}
|
||||
for s in CANDIDATE_NUMBERS[:5]
|
||||
],
|
||||
"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": ["a", "b", "c"],
|
||||
"hot_cold_comment": "hc", "warnings": "",
|
||||
"headline": "h",
|
||||
"summary_3lines": ["1", "2", "3"],
|
||||
"retrospective": "지난주 평균 1.8",
|
||||
},
|
||||
"confidence": 80,
|
||||
"confidence": 70,
|
||||
}
|
||||
|
||||
|
||||
def test_valid_payload_passes():
|
||||
result = validate_response(_valid_payload(), CANDIDATE_NUMBERS)
|
||||
assert isinstance(result, CuratorOutput)
|
||||
assert len(result.picks) == 5
|
||||
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_rejects_number_out_of_candidates():
|
||||
bad = _valid_payload()
|
||||
bad["picks"][0]["numbers"] = [40, 41, 42, 43, 44, 45] # valid numbers but not in candidates
|
||||
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(bad, CANDIDATE_NUMBERS)
|
||||
|
||||
|
||||
def test_rejects_wrong_pick_count():
|
||||
bad = _valid_payload()
|
||||
bad["picks"] = bad["picks"][:3]
|
||||
with pytest.raises(ValueError, match="exactly 5"):
|
||||
validate_response(bad, CANDIDATE_NUMBERS)
|
||||
|
||||
|
||||
def test_rejects_duplicate_numbers_within_set():
|
||||
bad = _valid_payload()
|
||||
bad["picks"][0]["numbers"] = [1, 1, 2, 3, 4, 5]
|
||||
with pytest.raises(ValueError):
|
||||
validate_response(bad, CANDIDATE_NUMBERS)
|
||||
|
||||
|
||||
def test_rejects_invalid_risk_tag():
|
||||
bad = _valid_payload()
|
||||
bad["picks"][0]["risk_tag"] = "미친"
|
||||
with pytest.raises(ValueError):
|
||||
validate_response(bad, CANDIDATE_NUMBERS)
|
||||
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()
|
||||
55
agent-office/tests/test_insta_keyword_filter.py
Normal file
55
agent-office/tests/test_insta_keyword_filter.py
Normal file
@@ -0,0 +1,55 @@
|
||||
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 app.agents.insta import _dedup_and_filter_keywords, KEYWORD_MIN_SCORE
|
||||
|
||||
|
||||
def test_filters_below_threshold():
|
||||
"""score < 임계값(0.7) 키워드는 제외."""
|
||||
kws = [
|
||||
{"id": 1, "keyword": "금리인하", "category": "경제", "score": 0.9},
|
||||
{"id": 2, "keyword": "환율", "category": "경제", "score": 0.6}, # 컷
|
||||
{"id": 3, "keyword": "반도체", "category": "경제", "score": 0.71},
|
||||
]
|
||||
out = _dedup_and_filter_keywords(kws, min_score=0.7)
|
||||
kept = {k["keyword"] for k in out}
|
||||
assert kept == {"금리인하", "반도체"}
|
||||
|
||||
|
||||
def test_dedup_keeps_highest_score():
|
||||
"""동일 keyword 중복 시 최고 score 1개만 유지."""
|
||||
kws = [
|
||||
{"id": 1, "keyword": "AI", "category": "경제", "score": 0.75},
|
||||
{"id": 2, "keyword": "AI", "category": "기술", "score": 0.92}, # 같은 키워드, 더 높음
|
||||
]
|
||||
out = _dedup_and_filter_keywords(kws, min_score=0.7)
|
||||
assert len(out) == 1
|
||||
assert out[0]["id"] == 2
|
||||
assert out[0]["score"] == 0.92
|
||||
|
||||
|
||||
def test_sorted_by_score_desc():
|
||||
kws = [
|
||||
{"id": 1, "keyword": "a", "category": "c", "score": 0.72},
|
||||
{"id": 2, "keyword": "b", "category": "c", "score": 0.95},
|
||||
{"id": 3, "keyword": "c", "category": "c", "score": 0.80},
|
||||
]
|
||||
out = _dedup_and_filter_keywords(kws, min_score=0.7)
|
||||
assert [k["keyword"] for k in out] == ["b", "c", "a"]
|
||||
|
||||
|
||||
def test_empty_when_all_below_threshold():
|
||||
kws = [{"id": 1, "keyword": "x", "category": "c", "score": 0.4}]
|
||||
assert _dedup_and_filter_keywords(kws, min_score=0.7) == []
|
||||
|
||||
|
||||
def test_default_threshold_is_0_7():
|
||||
assert KEYWORD_MIN_SCORE == 0.7
|
||||
87
agent-office/tests/test_lotto_evolution_format.py
Normal file
87
agent-office/tests/test_lotto_evolution_format.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from app.notifiers.telegram_lotto import _format_evolution_report
|
||||
|
||||
|
||||
def test_evolution_report_winner_4plus():
|
||||
eval_result = {
|
||||
"ok": True,
|
||||
"draw_no": 1225,
|
||||
"week_start": "2026-05-18",
|
||||
"winner": {
|
||||
"day_of_week": 3,
|
||||
"weight": [0.18, 0.32, 0.20, 0.22, 0.08],
|
||||
"avg_score": 0.42,
|
||||
"max_correct": 4,
|
||||
"n_picks": 5,
|
||||
},
|
||||
"new_base": [0.18, 0.32, 0.20, 0.22, 0.08],
|
||||
"previous_base": [0.20, 0.20, 0.20, 0.20, 0.20],
|
||||
"update_reason": "winner_4plus",
|
||||
"per_day": [
|
||||
{"day_of_week": 0, "avg_score": 0.20, "max_correct": 2},
|
||||
{"day_of_week": 3, "avg_score": 0.42, "max_correct": 4},
|
||||
],
|
||||
}
|
||||
current_base = [0.20, 0.20, 0.20, 0.20, 0.20]
|
||||
text = _format_evolution_report(eval_result, current_base)
|
||||
assert "🧬" in text
|
||||
assert "1225" in text
|
||||
assert "목요일" in text or "Winner" in text
|
||||
assert "4개 일치" in text or "max=4" in text
|
||||
assert "winner_4plus" in text
|
||||
|
||||
|
||||
def test_evolution_report_unchanged():
|
||||
eval_result = {
|
||||
"ok": True,
|
||||
"draw_no": 1226,
|
||||
"week_start": "2026-05-25",
|
||||
"winner": {
|
||||
"day_of_week": 1,
|
||||
"weight": [0.21, 0.19, 0.20, 0.20, 0.20],
|
||||
"avg_score": 0.10,
|
||||
"max_correct": 2,
|
||||
"n_picks": 5,
|
||||
},
|
||||
"new_base": [0.20, 0.20, 0.20, 0.20, 0.20],
|
||||
"update_reason": "unchanged",
|
||||
"per_day": [],
|
||||
}
|
||||
current_base = [0.20, 0.20, 0.20, 0.20, 0.20]
|
||||
text = _format_evolution_report(eval_result, current_base)
|
||||
assert "unchanged" in text or "유지" in text
|
||||
assert "2개 일치" in text or "max=2" in text
|
||||
|
||||
|
||||
def test_evolution_report_empty_returns_empty():
|
||||
"""evaluate가 ok=False면 빈 문자열 (발송 skip)."""
|
||||
text = _format_evolution_report({"ok": False, "reason": "no_trials"}, [0.2]*5)
|
||||
assert text == ""
|
||||
|
||||
|
||||
def test_evolution_report_uses_previous_base_for_diff():
|
||||
"""previous_base와 new_base 차이가 메시지 diff에 정확히 반영됨."""
|
||||
eval_result = {
|
||||
"ok": True,
|
||||
"draw_no": 1227,
|
||||
"winner": {
|
||||
"day_of_week": 0,
|
||||
"weight": [0.30, 0.20, 0.20, 0.20, 0.10],
|
||||
"avg_score": 0.50,
|
||||
"max_correct": 4,
|
||||
"n_picks": 5,
|
||||
},
|
||||
"new_base": [0.30, 0.20, 0.20, 0.20, 0.10],
|
||||
"previous_base": [0.20, 0.20, 0.20, 0.20, 0.20],
|
||||
"update_reason": "winner_4plus",
|
||||
}
|
||||
# current_base는 stale (post-update 값) — previous_base가 우선 적용되어야 함
|
||||
text = _format_evolution_report(eval_result, [0.30, 0.20, 0.20, 0.20, 0.10])
|
||||
# freq: 0.20 → 0.30 (+0.10 = "++")
|
||||
# divers: 0.20 → 0.10 (-0.10 = "--")
|
||||
assert "0.20 → 0.30" in text # freq 증가
|
||||
assert "0.20 → 0.10" in text # divers 감소
|
||||
assert "(++)" in text or "(+)" in text # freq marker
|
||||
assert "(--)" in text or "(-)" in text # divers marker
|
||||
116
agent-office/tests/test_lotto_signal_runner.py
Normal file
116
agent-office/tests/test_lotto_signal_runner.py
Normal file
@@ -0,0 +1,116 @@
|
||||
import gc
|
||||
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 app.curator import signal_runner
|
||||
from app import db
|
||||
|
||||
db.DB_PATH = _TMP # patch frozen module-level DB_PATH (import order safety)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db():
|
||||
gc.collect()
|
||||
if os.path.exists(_TMP):
|
||||
os.remove(_TMP)
|
||||
db.init_db()
|
||||
yield
|
||||
gc.collect()
|
||||
if os.path.exists(_TMP):
|
||||
try:
|
||||
os.remove(_TMP)
|
||||
except PermissionError:
|
||||
pass # Windows: WAL-mode file locked; DB is ephemeral anyway
|
||||
|
||||
|
||||
def test_evaluate_and_persist_cold_start():
|
||||
"""첫 호출은 warmup으로 기록되고 baseline에 값이 들어간다."""
|
||||
result = signal_runner.evaluate_metric_and_persist(
|
||||
source="light",
|
||||
metric="sim_signal",
|
||||
value=1.5,
|
||||
draw_no=None,
|
||||
z_normal=1.5,
|
||||
z_urgent=2.5,
|
||||
push_to_window=True,
|
||||
)
|
||||
assert result["fire_level"] == "warmup"
|
||||
assert result["z_score"] is None
|
||||
|
||||
bl = db.get_baseline("sim_signal")
|
||||
assert bl is not None
|
||||
assert bl["window_values"] == [1.5]
|
||||
|
||||
|
||||
def test_evaluate_after_window_filled_normal_fire():
|
||||
"""8회 push 후 정상 운영, 평균 대비 z≥1.5면 normal."""
|
||||
for v in [1.0, 1.1, 0.9, 1.0, 1.0, 1.1, 0.9, 1.0]:
|
||||
signal_runner.evaluate_metric_and_persist(
|
||||
source="sim",
|
||||
metric="sim_signal",
|
||||
value=v,
|
||||
draw_no=None,
|
||||
z_normal=1.5,
|
||||
z_urgent=2.5,
|
||||
push_to_window=True,
|
||||
)
|
||||
|
||||
result = signal_runner.evaluate_metric_and_persist(
|
||||
source="sim",
|
||||
metric="sim_signal",
|
||||
value=1.12,
|
||||
draw_no=None,
|
||||
z_normal=1.5,
|
||||
z_urgent=2.5,
|
||||
push_to_window=True,
|
||||
)
|
||||
assert result["fire_level"] in ("normal", "urgent")
|
||||
assert result["z_score"] is not None and result["z_score"] >= 1.5
|
||||
|
||||
|
||||
def test_evaluate_drift_skips_same_draw_push():
|
||||
"""drift는 회차 단위. 같은 회차에서 두 번 호출하면 두 번째는 window push X."""
|
||||
signal_runner.evaluate_metric_and_persist(
|
||||
source="sim", metric="drift", value=0.05, draw_no=1100,
|
||||
z_normal=1.5, z_urgent=2.5, push_to_window=True,
|
||||
)
|
||||
bl_before = db.get_baseline("drift")
|
||||
assert bl_before["window_values"] == [0.05]
|
||||
assert bl_before["last_pushed_draw_no"] == 1100
|
||||
|
||||
signal_runner.evaluate_metric_and_persist(
|
||||
source="sim", metric="drift", value=0.08, draw_no=1100,
|
||||
z_normal=1.5, z_urgent=2.5, push_to_window=True,
|
||||
)
|
||||
bl_after = db.get_baseline("drift")
|
||||
assert bl_after["window_values"] == [0.05]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_signal_check_aggregates_three_metrics(monkeypatch):
|
||||
"""run_signal_check이 3종 메트릭 모두 평가하고 overall fire를 반환."""
|
||||
async def fake_lotto_best():
|
||||
return [{"numbers": [1,2,3,4,5,6], "scores": [10,10,10,10,10]}] * 20
|
||||
|
||||
async def fake_lotto_strategy_weights():
|
||||
return {"gap_focus": 0.4, "hot_focus": 0.3, "pair_bias": 0.3}
|
||||
|
||||
monkeypatch.setattr(signal_runner, "_fetch_best_picks", fake_lotto_best)
|
||||
monkeypatch.setattr(signal_runner, "_fetch_strategy_weights", fake_lotto_strategy_weights)
|
||||
|
||||
out = await signal_runner.run_signal_check(source="light", curate_result=None, current_draw_no=1101)
|
||||
assert "overall_fire" in out
|
||||
assert "results" in out
|
||||
assert any(r["metric"] == "sim_signal" for r in out["results"])
|
||||
# light_check는 confidence 평가 안 함
|
||||
assert not any(r["metric"] == "confidence" for r in out["results"])
|
||||
130
agent-office/tests/test_lotto_signals.py
Normal file
130
agent-office/tests/test_lotto_signals.py
Normal file
@@ -0,0 +1,130 @@
|
||||
# agent-office/tests/test_lotto_signals.py
|
||||
import pytest
|
||||
|
||||
from app.curator import signals
|
||||
|
||||
|
||||
def test_sim_consensus_top10_geomean():
|
||||
"""top-10 consensus 평균이 기하평균 기반인지."""
|
||||
best_picks = [
|
||||
{"scores": [10, 10, 10, 10, 10]}, # high & uniform
|
||||
{"scores": [9, 9, 9, 9, 9]},
|
||||
{"scores": [8, 8, 8, 8, 8]},
|
||||
{"scores": [7, 7, 7, 7, 7]},
|
||||
{"scores": [6, 6, 6, 6, 6]},
|
||||
{"scores": [5, 5, 5, 5, 5]},
|
||||
{"scores": [4, 4, 4, 4, 4]},
|
||||
{"scores": [3, 3, 3, 3, 3]},
|
||||
{"scores": [2, 2, 2, 2, 2]},
|
||||
{"scores": [1, 1, 1, 1, 1]}, # top 10
|
||||
{"scores": [0, 0, 0, 0, 0]}, # bottom 10
|
||||
] * 1 + [{"scores": [0, 0, 0, 0, 0]}] * 10
|
||||
result = signals.sim_consensus_score(best_picks)
|
||||
assert 0.0 <= result <= 1.0
|
||||
assert result > 0.4
|
||||
|
||||
|
||||
def test_sim_consensus_geomean_penalizes_imbalance():
|
||||
"""5종 중 한 종만 폭주하는 outlier 후보는 균형 후보보다 작아야 한다."""
|
||||
balanced = [{"scores": [5, 5, 5, 5, 5]}] * 20
|
||||
imbalanced = [{"scores": [25, 0, 0, 0, 0]}] * 20
|
||||
s_balanced = signals.sim_consensus_score(balanced)
|
||||
s_imbalanced = signals.sim_consensus_score(imbalanced)
|
||||
assert s_imbalanced < s_balanced
|
||||
|
||||
|
||||
def test_strategy_drift_score():
|
||||
"""drift = 전략별 가중치 변화 절댓값 합."""
|
||||
w_prev = {"gap_focus": 0.30, "hot_focus": 0.25, "pair_bias": 0.45}
|
||||
w_curr = {"gap_focus": 0.40, "hot_focus": 0.20, "pair_bias": 0.40}
|
||||
result = signals.strategy_drift_score(w_prev, w_curr)
|
||||
assert abs(result - 0.20) < 1e-9
|
||||
|
||||
|
||||
def test_strategy_drift_new_strategy_appears():
|
||||
"""이전에 없던 전략이 등장하면 그 가중치 전체가 drift에 가산."""
|
||||
w_prev = {"gap_focus": 0.5, "hot_focus": 0.5}
|
||||
w_curr = {"gap_focus": 0.4, "hot_focus": 0.4, "newbie": 0.2}
|
||||
result = signals.strategy_drift_score(w_prev, w_curr)
|
||||
assert abs(result - 0.4) < 1e-9
|
||||
|
||||
|
||||
def test_confidence_score_passthrough():
|
||||
"""confidence는 큐레이션 결과의 값 그대로 (0~1 clamp 확인)."""
|
||||
assert signals.confidence_score({"confidence": 0.85}) == 0.85
|
||||
assert signals.confidence_score({"confidence": 1.2}) == 1.0
|
||||
assert signals.confidence_score({"confidence": -0.1}) == 0.0
|
||||
assert signals.confidence_score({}) is None
|
||||
|
||||
|
||||
def test_adaptive_baseline_cold_start():
|
||||
"""window 크기 < 4 → warmup, z=None."""
|
||||
bl = signals.AdaptiveBaseline(window=[1.0, 1.1, 0.9], window_max=8)
|
||||
z, fire = bl.evaluate(value=1.5, z_normal=1.5, z_urgent=2.5)
|
||||
assert fire == "warmup"
|
||||
assert z is None
|
||||
|
||||
|
||||
def test_adaptive_baseline_preparing():
|
||||
"""window 4~7 → 보수적 임계치 z=2.0."""
|
||||
bl = signals.AdaptiveBaseline(window=[1.0, 1.0, 1.0, 1.0], window_max=8)
|
||||
z, fire = bl.evaluate(value=3.0, z_normal=1.5, z_urgent=2.5)
|
||||
assert fire in ("normal", "urgent")
|
||||
|
||||
|
||||
def test_adaptive_baseline_normal_window_full():
|
||||
"""window 8 풀, value가 평균보다 1.5σ 이상이면 normal."""
|
||||
bl = signals.AdaptiveBaseline(
|
||||
window=[1.0, 1.1, 0.9, 1.0, 1.0, 1.1, 0.9, 1.0],
|
||||
window_max=8,
|
||||
)
|
||||
z, fire = bl.evaluate(value=1.12, z_normal=1.5, z_urgent=2.5)
|
||||
assert fire == "normal"
|
||||
assert z is not None and z >= 1.5
|
||||
|
||||
|
||||
def test_adaptive_baseline_urgent():
|
||||
"""z >= 2.5 → urgent."""
|
||||
bl = signals.AdaptiveBaseline(
|
||||
window=[1.0, 1.1, 0.9, 1.0, 1.0, 1.1, 0.9, 1.0],
|
||||
window_max=8,
|
||||
)
|
||||
z, fire = bl.evaluate(value=2.0, z_normal=1.5, z_urgent=2.5)
|
||||
assert fire == "urgent"
|
||||
|
||||
|
||||
def test_adaptive_baseline_push_updates_window():
|
||||
"""push 시 FIFO 동작."""
|
||||
bl = signals.AdaptiveBaseline(window=[1, 2, 3, 4, 5, 6, 7, 8], window_max=8)
|
||||
bl.push(9.0)
|
||||
assert bl.window == [2, 3, 4, 5, 6, 7, 8, 9.0]
|
||||
|
||||
|
||||
def test_decide_fire_level_two_normals_escalate():
|
||||
sigs = [
|
||||
{"metric": "sim", "z": 1.6, "fire": "normal"},
|
||||
{"metric": "drift", "z": 1.7, "fire": "normal"},
|
||||
{"metric": "conf", "z": 0.5, "fire": "noop"},
|
||||
]
|
||||
assert signals.decide_overall_fire(sigs) == "urgent"
|
||||
|
||||
|
||||
def test_decide_fire_level_single_normal():
|
||||
sigs = [
|
||||
{"metric": "sim", "z": 1.6, "fire": "normal"},
|
||||
{"metric": "drift", "z": 0.3, "fire": "noop"},
|
||||
]
|
||||
assert signals.decide_overall_fire(sigs) == "normal"
|
||||
|
||||
|
||||
def test_decide_fire_level_single_urgent():
|
||||
sigs = [
|
||||
{"metric": "sim", "z": 3.0, "fire": "urgent"},
|
||||
{"metric": "drift", "z": 0.2, "fire": "noop"},
|
||||
]
|
||||
assert signals.decide_overall_fire(sigs) == "urgent"
|
||||
|
||||
|
||||
def test_decide_fire_level_all_noop():
|
||||
sigs = [{"metric": "sim", "z": 0.5, "fire": "noop"}]
|
||||
assert signals.decide_overall_fire(sigs) == "noop"
|
||||
154
agent-office/tests/test_lotto_task_wrap.py
Normal file
154
agent-office/tests/test_lotto_task_wrap.py
Normal file
@@ -0,0 +1,154 @@
|
||||
# agent-office/tests/test_lotto_task_wrap.py
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import gc
|
||||
|
||||
_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 app import db
|
||||
db.DB_PATH = _TMP
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db():
|
||||
# Re-patch DB_PATH at the start of every test (cross-file isolation)
|
||||
db.DB_PATH = _TMP
|
||||
gc.collect()
|
||||
if os.path.exists(_TMP):
|
||||
os.remove(_TMP)
|
||||
db.init_db()
|
||||
yield
|
||||
gc.collect()
|
||||
if os.path.exists(_TMP):
|
||||
try:
|
||||
os.remove(_TMP)
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_signal_check_creates_task_row(monkeypatch):
|
||||
"""run_signal_check이 agent_tasks에 row를 만들고 result_data를 저장."""
|
||||
from app.agents.lotto import LottoAgent
|
||||
from app.curator import signal_runner
|
||||
|
||||
async def fake_run_signal_check(**kwargs):
|
||||
return {
|
||||
"overall_fire": "normal",
|
||||
"results": [
|
||||
{"signal_id": 1, "metric": "sim_signal",
|
||||
"value": 0.6, "z_score": 1.7, "fire_level": "normal",
|
||||
"baseline_mu": 0.5, "baseline_sigma": 0.05, "payload": {}},
|
||||
],
|
||||
}
|
||||
monkeypatch.setattr(signal_runner, "run_signal_check", fake_run_signal_check)
|
||||
|
||||
from app import service_proxy
|
||||
async def fake_latest():
|
||||
return 1226
|
||||
monkeypatch.setattr(service_proxy, "lotto_latest_draw", fake_latest)
|
||||
|
||||
from app.notifiers import telegram_lotto
|
||||
async def fake_send(_event): pass
|
||||
monkeypatch.setattr(telegram_lotto, "send_urgent_signal", fake_send)
|
||||
|
||||
agent = LottoAgent()
|
||||
result = await agent.run_signal_check(source="light")
|
||||
assert result["ok"] is True
|
||||
|
||||
tasks = db.get_agent_tasks("lotto", task_type="signal_check", days=1)
|
||||
assert len(tasks) == 1
|
||||
t = tasks[0]
|
||||
assert t["status"] == "succeeded"
|
||||
assert t["result_data"]["source"] == "light"
|
||||
assert t["result_data"]["overall_fire"] == "normal"
|
||||
assert "sim_signal" in t["result_data"]["fired_metrics"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_signal_check_failure_marks_task_failed(monkeypatch):
|
||||
from app.agents.lotto import LottoAgent
|
||||
from app.curator import signal_runner
|
||||
from app import service_proxy
|
||||
|
||||
async def boom(**kwargs):
|
||||
raise RuntimeError("boom")
|
||||
monkeypatch.setattr(signal_runner, "run_signal_check", boom)
|
||||
|
||||
async def fake_latest():
|
||||
return 1226
|
||||
monkeypatch.setattr(service_proxy, "lotto_latest_draw", fake_latest)
|
||||
|
||||
agent = LottoAgent()
|
||||
result = await agent.run_signal_check(source="sim")
|
||||
assert result["ok"] is False
|
||||
|
||||
tasks = db.get_agent_tasks("lotto", task_type="signal_check", days=1)
|
||||
assert len(tasks) == 1
|
||||
assert tasks[0]["status"] == "failed"
|
||||
assert "boom" in tasks[0]["result_data"]["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_daily_digest_creates_task(monkeypatch):
|
||||
"""run_daily_digest이 agent_tasks에 task 생성 + result_data 저장."""
|
||||
from app.agents.lotto import LottoAgent
|
||||
from app.notifiers import telegram_lotto
|
||||
|
||||
async def fake_send(_d): pass
|
||||
monkeypatch.setattr(telegram_lotto, "send_signal_summary", fake_send)
|
||||
|
||||
agent = LottoAgent()
|
||||
result = await agent.run_daily_digest()
|
||||
assert result["ok"] is True
|
||||
|
||||
tasks = db.get_agent_tasks("lotto", task_type="daily_digest", days=1)
|
||||
assert len(tasks) == 1
|
||||
assert tasks[0]["status"] == "succeeded"
|
||||
assert "fired" in tasks[0]["result_data"]
|
||||
assert "evaluated" in tasks[0]["result_data"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_weekly_evolution_report_creates_task(monkeypatch):
|
||||
"""run_weekly_evolution_report이 task 생성 + result_data 저장."""
|
||||
from app.agents.lotto import LottoAgent
|
||||
from app import service_proxy
|
||||
from app.notifiers import telegram_lotto
|
||||
|
||||
async def fake_eval():
|
||||
return {
|
||||
"ok": True, "draw_no": 1225,
|
||||
"winner": {"day_of_week": 3, "weight": [0.18, 0.32, 0.20, 0.22, 0.08],
|
||||
"avg_score": 0.42, "max_correct": 4, "n_picks": 5},
|
||||
"new_base": [0.18, 0.32, 0.20, 0.22, 0.08],
|
||||
"previous_base": [0.2] * 5,
|
||||
"update_reason": "winner_4plus",
|
||||
}
|
||||
async def fake_status():
|
||||
return {"current_base": [0.2] * 5}
|
||||
async def fake_send(_e, _b): pass
|
||||
|
||||
monkeypatch.setattr(service_proxy, "lotto_evolver_evaluate", fake_eval)
|
||||
monkeypatch.setattr(service_proxy, "lotto_evolver_status", fake_status)
|
||||
monkeypatch.setattr(telegram_lotto, "send_evolution_report", fake_send)
|
||||
|
||||
agent = LottoAgent()
|
||||
result = await agent.run_weekly_evolution_report()
|
||||
assert result["ok"] is True
|
||||
|
||||
tasks = db.get_agent_tasks("lotto", task_type="weekly_evolution_report", days=1)
|
||||
assert len(tasks) == 1
|
||||
r = tasks[0]["result_data"]
|
||||
assert tasks[0]["status"] == "succeeded"
|
||||
assert r["draw_no"] == 1225
|
||||
assert r["update_reason"] == "winner_4plus"
|
||||
assert r["winner_day_of_week"] == 3
|
||||
assert r["winner_max_correct"] == 4
|
||||
49
agent-office/tests/test_lotto_telegram_signal.py
Normal file
49
agent-office/tests/test_lotto_telegram_signal.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from app.notifiers.telegram_lotto import (
|
||||
_format_urgent_signal,
|
||||
_format_signal_digest,
|
||||
)
|
||||
|
||||
|
||||
def test_urgent_signal_format_basic():
|
||||
event = {
|
||||
"fire_level": "urgent",
|
||||
"triggered_at": "2026-05-20T07:18:00.000Z",
|
||||
"results": [
|
||||
{"metric": "sim_signal", "value": 1.84, "z_score": 3.9,
|
||||
"baseline_mu": 1.02, "baseline_sigma": 0.21, "payload": {},
|
||||
"fire_level": "urgent"},
|
||||
{"metric": "drift", "value": 0.18, "z_score": 3.0,
|
||||
"baseline_mu": 0.06, "baseline_sigma": 0.04, "fire_level": "normal",
|
||||
"payload": {"weights_now": {"gap_focus": 0.5, "hot_focus": 0.5},
|
||||
"weights_prev": {"gap_focus": 0.3, "hot_focus": 0.7}}},
|
||||
],
|
||||
}
|
||||
text = _format_urgent_signal(event)
|
||||
assert "🚨" in text
|
||||
assert "Sim Consensus" in text
|
||||
assert "z=3.9" in text
|
||||
assert "Strategy Drift" in text
|
||||
|
||||
|
||||
def test_signal_digest_format_with_signals():
|
||||
digest = {
|
||||
"evaluated": 6,
|
||||
"fired": 2,
|
||||
"signals": [
|
||||
{"metric": "sim_signal", "fire_level": "normal", "z_score": 1.7,
|
||||
"triggered_at": "2026-05-20T16:18:00Z", "payload": {}},
|
||||
{"metric": "confidence", "fire_level": "normal", "z_score": 1.6,
|
||||
"triggered_at": "2026-05-20T09:05:00Z", "payload": {}},
|
||||
],
|
||||
"weights_trend": {"gap_focus": +0.12, "hot_focus": -0.02, "pair_bias": -0.08},
|
||||
}
|
||||
text = _format_signal_digest(digest)
|
||||
assert "📊" in text
|
||||
assert "지난 24h" in text
|
||||
assert "z=1.7" in text
|
||||
|
||||
|
||||
def test_signal_digest_empty_returns_empty_string():
|
||||
"""발화 0건이면 빈 문자열 → 발송 자체 skip 가능."""
|
||||
text = _format_signal_digest({"evaluated": 6, "fired": 0, "signals": [], "weights_trend": {}})
|
||||
assert text == ""
|
||||
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
|
||||
123
agent-office/tests/test_sync_evolver_activity.py
Normal file
123
agent-office/tests/test_sync_evolver_activity.py
Normal file
@@ -0,0 +1,123 @@
|
||||
# agent-office/tests/test_sync_evolver_activity.py
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import gc
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
_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 app import db
|
||||
db.DB_PATH = _TMP
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db():
|
||||
# Re-patch DB_PATH at the start of every test (cross-file isolation)
|
||||
db.DB_PATH = _TMP
|
||||
gc.collect()
|
||||
if os.path.exists(_TMP):
|
||||
os.remove(_TMP)
|
||||
db.init_db()
|
||||
yield
|
||||
gc.collect()
|
||||
if os.path.exists(_TMP):
|
||||
try:
|
||||
os.remove(_TMP)
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
|
||||
def _today_dow_clamped():
|
||||
"""오늘의 weekday() (일요일=6은 5로 clamp)."""
|
||||
KST = timezone(timedelta(hours=9))
|
||||
dow = datetime.now(KST).weekday()
|
||||
return 5 if dow == 6 else dow
|
||||
|
||||
|
||||
def _fake_status_with_picks(dow_with_picks):
|
||||
async def fake():
|
||||
return {
|
||||
"week_start": "2026-05-18",
|
||||
"current_base": [0.2] * 5,
|
||||
"trials": [
|
||||
{
|
||||
"id": 100 + i,
|
||||
"day_of_week": i,
|
||||
"weight": [0.2] * 5,
|
||||
"source": "perturb",
|
||||
"picks": ([
|
||||
{"id": j, "numbers": [1,2,3,4,5,6], "meta_score": 0.5}
|
||||
for j in range(5)
|
||||
] if i == dow_with_picks else []),
|
||||
}
|
||||
for i in range(6)
|
||||
],
|
||||
}
|
||||
return fake
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_evolver_activity_creates_apply_task(monkeypatch):
|
||||
"""오늘 trial에 picks가 있으면 evolver_apply task 1개 생성."""
|
||||
from app.agents.lotto import LottoAgent
|
||||
from app import service_proxy
|
||||
|
||||
dow = _today_dow_clamped()
|
||||
monkeypatch.setattr(service_proxy, "lotto_evolver_status", _fake_status_with_picks(dow))
|
||||
|
||||
agent = LottoAgent()
|
||||
await agent.sync_evolver_activity()
|
||||
|
||||
apply_tasks = db.get_agent_tasks("lotto", task_type="evolver_apply", days=1)
|
||||
assert len(apply_tasks) == 1
|
||||
assert apply_tasks[0]["result_data"]["n_picks"] == 5
|
||||
assert apply_tasks[0]["input_data"]["day_of_week"] == dow
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_evolver_activity_idempotent(monkeypatch):
|
||||
"""같은 날 두 번 호출해도 task는 1개만 (멱등)."""
|
||||
from app.agents.lotto import LottoAgent
|
||||
from app import service_proxy
|
||||
|
||||
dow = _today_dow_clamped()
|
||||
monkeypatch.setattr(service_proxy, "lotto_evolver_status", _fake_status_with_picks(dow))
|
||||
|
||||
agent = LottoAgent()
|
||||
await agent.sync_evolver_activity()
|
||||
await agent.sync_evolver_activity()
|
||||
|
||||
apply_tasks = db.get_agent_tasks("lotto", task_type="evolver_apply", days=1)
|
||||
assert len(apply_tasks) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_evolver_activity_no_picks_no_task(monkeypatch):
|
||||
"""오늘 trial에 picks가 없으면 task 생성하지 않음."""
|
||||
from app.agents.lotto import LottoAgent
|
||||
from app import service_proxy
|
||||
|
||||
async def fake_status():
|
||||
return {
|
||||
"week_start": "2026-05-18",
|
||||
"current_base": [0.2] * 5,
|
||||
"trials": [
|
||||
{"id": 100 + i, "day_of_week": i, "weight": [0.2]*5,
|
||||
"source": "perturb", "picks": []}
|
||||
for i in range(6)
|
||||
],
|
||||
}
|
||||
monkeypatch.setattr(service_proxy, "lotto_evolver_status", fake_status)
|
||||
|
||||
agent = LottoAgent()
|
||||
await agent.sync_evolver_activity()
|
||||
|
||||
apply_tasks = db.get_agent_tasks("lotto", task_type="evolver_apply", days=1)
|
||||
assert len(apply_tasks) == 0
|
||||
70
agent-office/tests/test_tarot_db.py
Normal file
70
agent-office/tests/test_tarot_db.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db as db_module
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(monkeypatch, tmp_path):
|
||||
db_file = tmp_path / "test_tarot.db"
|
||||
monkeypatch.setattr(db_module, "DB_PATH", str(db_file))
|
||||
db_module.init_db()
|
||||
yield
|
||||
if db_file.exists():
|
||||
db_file.unlink()
|
||||
|
||||
|
||||
def test_save_and_get_tarot_reading():
|
||||
rid = db_module.save_tarot_reading({
|
||||
"spread_type": "three_card",
|
||||
"category": "연애",
|
||||
"question": "Q",
|
||||
"cards": [{"position": "과거", "card_id": "the-fool", "reversed": False}],
|
||||
"interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "A", "warning": None, "confidence": "medium"},
|
||||
"model": "claude-sonnet-4-6",
|
||||
"tokens_in": 100, "tokens_out": 200, "cost_usd": 0.005,
|
||||
"confidence": "medium",
|
||||
})
|
||||
assert rid > 0
|
||||
row = db_module.get_tarot_reading(rid)
|
||||
assert row["id"] == rid
|
||||
assert row["category"] == "연애"
|
||||
assert row["interpretation_json"]["summary"] == "S"
|
||||
assert row["favorite"] == 0
|
||||
|
||||
|
||||
def test_list_tarot_readings_filters_and_pagination():
|
||||
for cat in ["연애", "연애", "재물"]:
|
||||
db_module.save_tarot_reading({
|
||||
"spread_type": "three_card", "category": cat, "question": "Q",
|
||||
"cards": [], "interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "", "warning": None, "confidence": "low"},
|
||||
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": "low",
|
||||
})
|
||||
res = db_module.list_tarot_readings(page=1, size=10, category="연애")
|
||||
assert res["total"] == 2
|
||||
assert all(r["category"] == "연애" for r in res["items"])
|
||||
|
||||
|
||||
def test_update_tarot_reading_favorite_and_note():
|
||||
rid = db_module.save_tarot_reading({
|
||||
"spread_type": "one_card", "category": None, "question": None,
|
||||
"cards": [], "interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "", "warning": None, "confidence": "high"},
|
||||
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": "high",
|
||||
})
|
||||
db_module.update_tarot_reading(rid, favorite=True, note="기억하고 싶음")
|
||||
row = db_module.get_tarot_reading(rid)
|
||||
assert row["favorite"] == 1
|
||||
assert row["note"] == "기억하고 싶음"
|
||||
|
||||
|
||||
def test_delete_tarot_reading():
|
||||
rid = db_module.save_tarot_reading({
|
||||
"spread_type": "one_card", "category": None, "question": None,
|
||||
"cards": [], "interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "", "warning": None, "confidence": "high"},
|
||||
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": "high",
|
||||
})
|
||||
db_module.delete_tarot_reading(rid)
|
||||
assert db_module.get_tarot_reading(rid) is None
|
||||
113
agent-office/tests/test_tarot_pipeline.py
Normal file
113
agent-office/tests/test_tarot_pipeline.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import json
|
||||
import pytest
|
||||
import respx
|
||||
from httpx import Response
|
||||
|
||||
from app.tarot import pipeline as p
|
||||
from app.models import TarotInterpretRequest
|
||||
|
||||
|
||||
def _valid_response_text():
|
||||
return json.dumps({
|
||||
"summary": "S",
|
||||
"cards": [
|
||||
{"position": "과거", "card": "the-fool", "reversed": False,
|
||||
"interpretation": "i", "advice": "a",
|
||||
"evidence": {"card_meaning_used": "k", "position_logic": "p", "category_lens": "c"}},
|
||||
{"position": "현재", "card": "the-lovers", "reversed": True,
|
||||
"interpretation": "i", "advice": "a",
|
||||
"evidence": {"card_meaning_used": "k", "position_logic": "p", "category_lens": "c"}},
|
||||
{"position": "미래", "card": "ten-of-cups", "reversed": False,
|
||||
"interpretation": "i", "advice": "a",
|
||||
"evidence": {"card_meaning_used": "k", "position_logic": "p", "category_lens": "c"}},
|
||||
],
|
||||
"interactions": [{"type": "synergy", "between": ["the-fool", "ten-of-cups"], "explanation": "."}],
|
||||
"advice": "A", "warning": None, "confidence": "medium",
|
||||
})
|
||||
|
||||
|
||||
def _claude_resp(text, in_tok=100, out_tok=200):
|
||||
return {
|
||||
"content": [{"type": "text", "text": text}],
|
||||
"usage": {"input_tokens": in_tok, "output_tokens": out_tok},
|
||||
}
|
||||
|
||||
|
||||
def _req():
|
||||
return TarotInterpretRequest(
|
||||
spread_type="three_card",
|
||||
category="연애",
|
||||
question="Q",
|
||||
cards=[
|
||||
{"position": "과거", "card_id": "the-fool", "reversed": False},
|
||||
{"position": "현재", "card_id": "the-lovers", "reversed": True},
|
||||
{"position": "미래", "card_id": "ten-of-cups", "reversed": False},
|
||||
],
|
||||
cards_reference="REFERENCE",
|
||||
context_meta={"major_minor_ratio": "2:1"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interpret_happy_path(monkeypatch):
|
||||
monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "sk-test")
|
||||
with respx.mock(base_url="https://api.anthropic.com") as mock:
|
||||
mock.post("/v1/messages").mock(return_value=Response(200, json=_claude_resp(_valid_response_text())))
|
||||
out = await p.interpret(_req())
|
||||
assert out["interpretation_json"]["confidence"] == "medium"
|
||||
assert out["tokens_in"] == 100
|
||||
assert out["tokens_out"] == 200
|
||||
assert out["reroll_count"] == 0
|
||||
assert out["cost_usd"] > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interpret_codeblock_strip(monkeypatch):
|
||||
monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "sk-test")
|
||||
wrapped = "```json\n" + _valid_response_text() + "\n```"
|
||||
with respx.mock(base_url="https://api.anthropic.com") as mock:
|
||||
mock.post("/v1/messages").mock(return_value=Response(200, json=_claude_resp(wrapped)))
|
||||
out = await p.interpret(_req())
|
||||
assert out["interpretation_json"]["summary"] == "S"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interpret_reroll_on_validation_fail(monkeypatch):
|
||||
monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "sk-test")
|
||||
bad = json.loads(_valid_response_text())
|
||||
bad["cards"][0]["evidence"]["card_meaning_used"] = ""
|
||||
bad_text = json.dumps(bad)
|
||||
with respx.mock(base_url="https://api.anthropic.com") as mock:
|
||||
route = mock.post("/v1/messages")
|
||||
route.side_effect = [
|
||||
Response(200, json=_claude_resp(bad_text)),
|
||||
Response(200, json=_claude_resp(_valid_response_text())),
|
||||
]
|
||||
out = await p.interpret(_req())
|
||||
assert out["reroll_count"] == 1
|
||||
assert out["interpretation_json"]["cards"][0]["evidence"]["card_meaning_used"] == "k"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interpret_raises_when_both_attempts_fail(monkeypatch):
|
||||
monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "sk-test")
|
||||
bad = json.loads(_valid_response_text())
|
||||
bad["cards"][0]["evidence"]["card_meaning_used"] = ""
|
||||
bad_text = json.dumps(bad)
|
||||
with respx.mock(base_url="https://api.anthropic.com") as mock:
|
||||
mock.post("/v1/messages").mock(return_value=Response(200, json=_claude_resp(bad_text)))
|
||||
with pytest.raises(p.TarotError):
|
||||
await p.interpret(_req())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interpret_raises_when_api_key_missing(monkeypatch):
|
||||
monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "")
|
||||
with pytest.raises(p.TarotError):
|
||||
await p.interpret(_req())
|
||||
|
||||
|
||||
def test_calc_cost():
|
||||
assert p.calc_cost(1_000_000, 0) == pytest.approx(3.0)
|
||||
assert p.calc_cost(0, 1_000_000) == pytest.approx(15.0)
|
||||
assert p.calc_cost(500_000, 500_000) == pytest.approx(9.0)
|
||||
86
agent-office/tests/test_tarot_routes.py
Normal file
86
agent-office/tests/test_tarot_routes.py
Normal file
@@ -0,0 +1,86 @@
|
||||
import json
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import db as db_module
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(monkeypatch, tmp_path):
|
||||
db_file = tmp_path / "test_routes.db"
|
||||
monkeypatch.setattr(db_module, "DB_PATH", str(db_file))
|
||||
db_module.init_db()
|
||||
from app.main import app
|
||||
yield app
|
||||
|
||||
|
||||
def test_interpret_calls_pipeline(monkeypatch, fresh_db):
|
||||
async def fake_interpret(req):
|
||||
return {
|
||||
"interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "A", "warning": None, "confidence": "high"},
|
||||
"model": "claude-sonnet-4-6", "tokens_in": 100, "tokens_out": 200,
|
||||
"cost_usd": 0.005, "latency_ms": 1234, "reroll_count": 0,
|
||||
}
|
||||
from app.tarot import pipeline
|
||||
monkeypatch.setattr(pipeline, "interpret", fake_interpret)
|
||||
client = TestClient(fresh_db)
|
||||
r = client.post("/api/agent-office/tarot/interpret", json={
|
||||
"spread_type": "one_card",
|
||||
"category": "일반",
|
||||
"question": "Q",
|
||||
"cards": [{"position": "오늘", "card_id": "the-fool", "reversed": False}],
|
||||
"cards_reference": "REF",
|
||||
"context_meta": {},
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["interpretation_json"]["confidence"] == "high"
|
||||
|
||||
|
||||
def test_save_and_list(fresh_db):
|
||||
client = TestClient(fresh_db)
|
||||
save = client.post("/api/agent-office/tarot/readings", json={
|
||||
"spread_type": "three_card", "category": "연애", "question": "Q",
|
||||
"cards": [{"position": "과거", "card_id": "the-fool", "reversed": False}],
|
||||
"interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "A", "warning": None, "confidence": "medium"},
|
||||
"model": "claude-sonnet-4-6", "tokens_in": 1, "tokens_out": 2, "cost_usd": 0.01,
|
||||
"confidence": "medium",
|
||||
})
|
||||
assert save.status_code == 200, save.text
|
||||
rid = save.json()["id"]
|
||||
lst = client.get("/api/agent-office/tarot/readings?page=1&size=10")
|
||||
assert lst.json()["total"] == 1
|
||||
assert lst.json()["items"][0]["id"] == rid
|
||||
|
||||
|
||||
def test_patch_favorite(fresh_db):
|
||||
client = TestClient(fresh_db)
|
||||
save = client.post("/api/agent-office/tarot/readings", json={
|
||||
"spread_type": "one_card", "cards": [],
|
||||
"interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "A", "warning": None, "confidence": "low"},
|
||||
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": "low",
|
||||
})
|
||||
rid = save.json()["id"]
|
||||
p = client.patch(f"/api/agent-office/tarot/readings/{rid}", json={"favorite": True})
|
||||
assert p.status_code == 200
|
||||
g = client.get(f"/api/agent-office/tarot/readings/{rid}")
|
||||
assert g.json()["favorite"] == 1
|
||||
|
||||
|
||||
def test_delete(fresh_db):
|
||||
client = TestClient(fresh_db)
|
||||
save = client.post("/api/agent-office/tarot/readings", json={
|
||||
"spread_type": "one_card", "cards": [],
|
||||
"interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "A", "warning": None, "confidence": "low"},
|
||||
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": "low",
|
||||
})
|
||||
rid = save.json()["id"]
|
||||
d = client.delete(f"/api/agent-office/tarot/readings/{rid}")
|
||||
assert d.status_code == 200
|
||||
g = client.get(f"/api/agent-office/tarot/readings/{rid}")
|
||||
assert g.status_code == 404
|
||||
|
||||
|
||||
def test_get_missing_reading_404(fresh_db):
|
||||
client = TestClient(fresh_db)
|
||||
r = client.get("/api/agent-office/tarot/readings/99999")
|
||||
assert r.status_code == 404
|
||||
75
agent-office/tests/test_tarot_schema.py
Normal file
75
agent-office/tests/test_tarot_schema.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import pytest
|
||||
|
||||
from app.tarot.schema import validate_interpretation
|
||||
|
||||
|
||||
def _valid_three():
|
||||
return {
|
||||
"summary": "S",
|
||||
"cards": [
|
||||
{"position": "과거", "card": "the-fool", "reversed": False,
|
||||
"interpretation": "...", "advice": "a",
|
||||
"evidence": {"card_meaning_used": "키워드", "position_logic": "p", "category_lens": "c"}},
|
||||
{"position": "현재", "card": "the-lovers", "reversed": True,
|
||||
"interpretation": "...", "advice": "a",
|
||||
"evidence": {"card_meaning_used": "키워드", "position_logic": "p", "category_lens": "c"}},
|
||||
{"position": "미래", "card": "ten-of-cups", "reversed": False,
|
||||
"interpretation": "...", "advice": "a",
|
||||
"evidence": {"card_meaning_used": "키워드", "position_logic": "p", "category_lens": "c"}},
|
||||
],
|
||||
"interactions": [{"type": "synergy", "between": ["the-fool", "ten-of-cups"], "explanation": "..."}],
|
||||
"advice": "A",
|
||||
"warning": None,
|
||||
"confidence": "medium",
|
||||
}
|
||||
|
||||
|
||||
def test_valid_three_card_passes():
|
||||
ok, msg = validate_interpretation(_valid_three(), "three_card")
|
||||
assert ok, msg
|
||||
|
||||
|
||||
def test_missing_evidence_fails():
|
||||
bad = _valid_three()
|
||||
del bad["cards"][0]["evidence"]
|
||||
ok, msg = validate_interpretation(bad, "three_card")
|
||||
assert not ok
|
||||
assert "evidence" in msg
|
||||
|
||||
|
||||
def test_empty_card_meaning_used_fails():
|
||||
bad = _valid_three()
|
||||
bad["cards"][0]["evidence"]["card_meaning_used"] = ""
|
||||
ok, msg = validate_interpretation(bad, "three_card")
|
||||
assert not ok
|
||||
assert "card_meaning_used" in msg
|
||||
|
||||
|
||||
def test_three_card_requires_interactions():
|
||||
bad = _valid_three()
|
||||
bad["interactions"] = []
|
||||
ok, msg = validate_interpretation(bad, "three_card")
|
||||
assert not ok
|
||||
assert "interactions" in msg
|
||||
|
||||
|
||||
def test_one_card_accepts_empty_interactions():
|
||||
one = {
|
||||
"summary": "S",
|
||||
"cards": [{"position": "오늘", "card": "the-fool", "reversed": False,
|
||||
"interpretation": "...", "advice": "a",
|
||||
"evidence": {"card_meaning_used": "k", "position_logic": "p", "category_lens": "c"}}],
|
||||
"interactions": [],
|
||||
"advice": "A",
|
||||
"warning": None,
|
||||
"confidence": "high",
|
||||
}
|
||||
ok, msg = validate_interpretation(one, "one_card")
|
||||
assert ok, msg
|
||||
|
||||
|
||||
def test_invalid_confidence_fails():
|
||||
bad = _valid_three()
|
||||
bad["confidence"] = "very high"
|
||||
ok, msg = validate_interpretation(bad, "three_card")
|
||||
assert not ok
|
||||
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
|
||||
@@ -1,4 +0,0 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
.env
|
||||
data/
|
||||
@@ -1,15 +0,0 @@
|
||||
import os
|
||||
|
||||
# Anthropic Claude API
|
||||
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
||||
CLAUDE_MODEL = os.getenv("CLAUDE_MODEL", "claude-sonnet-4-20250514")
|
||||
|
||||
# Naver Search API
|
||||
NAVER_CLIENT_ID = os.getenv("NAVER_CLIENT_ID", "")
|
||||
NAVER_CLIENT_SECRET = os.getenv("NAVER_CLIENT_SECRET", "")
|
||||
|
||||
# Database
|
||||
DB_PATH = os.getenv("BLOG_DB_PATH", "/app/data/blog_marketing.db")
|
||||
|
||||
# CORS
|
||||
CORS_ALLOW_ORIGINS = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080")
|
||||
@@ -1,172 +0,0 @@
|
||||
"""Claude API 기반 콘텐츠 생성 — 트렌드 브리프 + 블로그 글 작성."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import anthropic
|
||||
|
||||
from .config import ANTHROPIC_API_KEY, CLAUDE_MODEL
|
||||
from .db import get_template
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_client: Optional[anthropic.Anthropic] = None
|
||||
|
||||
|
||||
def _get_client() -> anthropic.Anthropic:
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
|
||||
return _client
|
||||
|
||||
|
||||
def _call_claude(prompt: str, max_tokens: int = 4096) -> str:
|
||||
"""Claude API 호출. 단일 user 메시지. 현재 날짜 시스템 프롬프트 포함."""
|
||||
client = _get_client()
|
||||
today = date.today().isoformat()
|
||||
resp = client.messages.create(
|
||||
model=CLAUDE_MODEL,
|
||||
max_tokens=max_tokens,
|
||||
system=f"현재 날짜는 {today}입니다. 모든 콘텐츠는 이 날짜 기준으로 작성하세요.",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
return resp.content[0].text
|
||||
|
||||
|
||||
def generate_trend_brief(analysis: Dict[str, Any]) -> str:
|
||||
"""키워드 분석 데이터를 바탕으로 트렌드 브리프 생성."""
|
||||
template = get_template("trend_brief")
|
||||
if not template:
|
||||
raise RuntimeError("trend_brief 템플릿이 없습니다")
|
||||
|
||||
top_blogs_text = "\n".join(
|
||||
f"- {b.get('title', '')}" for b in analysis.get("top_blogs", [])
|
||||
) or "없음"
|
||||
|
||||
top_products_text = "\n".join(
|
||||
f"- {p.get('title', '')} ({p.get('lprice', '?')}원, {p.get('mallName', '')})"
|
||||
for p in analysis.get("top_products", [])
|
||||
) or "없음"
|
||||
|
||||
prompt = template.format(
|
||||
keyword=analysis.get("keyword", ""),
|
||||
competition=analysis.get("competition", 0),
|
||||
opportunity=analysis.get("opportunity", 0),
|
||||
top_blogs=top_blogs_text,
|
||||
top_products=top_products_text,
|
||||
)
|
||||
|
||||
return _call_claude(prompt)
|
||||
|
||||
|
||||
def _parse_blog_json(raw: str, keyword: str) -> Dict[str, str]:
|
||||
"""Claude 응답에서 블로그 JSON을 파싱."""
|
||||
try:
|
||||
text = raw.strip()
|
||||
if text.startswith("```"):
|
||||
lines = text.split("\n")
|
||||
lines = [l for l in lines if not l.strip().startswith("```")]
|
||||
text = "\n".join(lines)
|
||||
result = json.loads(text)
|
||||
return {
|
||||
"title": result.get("title", ""),
|
||||
"body": result.get("body", ""),
|
||||
"excerpt": result.get("excerpt", ""),
|
||||
"tags": result.get("tags", []),
|
||||
}
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
logger.warning("Blog post JSON parse failed, using raw text")
|
||||
return {
|
||||
"title": f"{keyword} 추천 리뷰",
|
||||
"body": raw,
|
||||
"excerpt": raw[:200],
|
||||
"tags": [keyword],
|
||||
}
|
||||
|
||||
|
||||
def generate_blog_post(
|
||||
analysis: Dict[str, Any],
|
||||
trend_brief: str,
|
||||
brand_links: Optional[list] = None,
|
||||
) -> Dict[str, str]:
|
||||
"""트렌드 브리프를 바탕으로 블로그 글 작성.
|
||||
|
||||
Returns:
|
||||
{"title": str, "body": str, "excerpt": str, "tags": [...]}
|
||||
"""
|
||||
template = get_template("blog_write")
|
||||
if not template:
|
||||
raise RuntimeError("blog_write 템플릿이 없습니다")
|
||||
|
||||
top_products_text = "\n".join(
|
||||
f"- {p.get('title', '')} ({p.get('lprice', '?')}원, {p.get('mallName', '')})"
|
||||
for p in analysis.get("top_products", [])
|
||||
) or "없음"
|
||||
|
||||
# 크롤링된 블로그 본문 참고 자료
|
||||
reference_blogs_text = ""
|
||||
for blog in analysis.get("top_blogs", []):
|
||||
content = blog.get("content", "")
|
||||
if content:
|
||||
reference_blogs_text += f"\n### {blog.get('title', '제목 없음')}\n{content}\n"
|
||||
if not reference_blogs_text:
|
||||
reference_blogs_text = "없음"
|
||||
|
||||
# 브랜드커넥트 링크 정보
|
||||
brand_products_text = ""
|
||||
if brand_links:
|
||||
for link in brand_links:
|
||||
brand_products_text += (
|
||||
f"- 상품명: {link.get('product_name', '')}\n"
|
||||
f" 설명: {link.get('description', '')}\n"
|
||||
f" 링크: {link.get('url', '')}\n"
|
||||
f" 배치 힌트: {link.get('placement_hint', '자연스럽게')}\n"
|
||||
)
|
||||
if not brand_products_text:
|
||||
brand_products_text = "없음 (제휴 링크 없이 일반 리뷰로 작성)"
|
||||
|
||||
prompt = template.format(
|
||||
keyword=analysis.get("keyword", ""),
|
||||
trend_brief=trend_brief,
|
||||
top_products=top_products_text,
|
||||
reference_blogs=reference_blogs_text,
|
||||
brand_products=brand_products_text,
|
||||
)
|
||||
|
||||
# 구조화된 응답을 위한 추가 지시
|
||||
prompt += (
|
||||
"\n\n---\n"
|
||||
"응답은 반드시 아래 JSON 형식으로 해주세요 (JSON만 출력, 다른 텍스트 없이):\n"
|
||||
'{"title": "블로그 제목", "body": "HTML 본문", "excerpt": "2줄 요약", '
|
||||
'"tags": ["태그1", "태그2", ...]}'
|
||||
)
|
||||
|
||||
raw = _call_claude(prompt, max_tokens=8192)
|
||||
return _parse_blog_json(raw, analysis.get("keyword", ""))
|
||||
|
||||
|
||||
def regenerate_blog_post(
|
||||
analysis: Dict[str, Any],
|
||||
trend_brief: str,
|
||||
previous_body: str,
|
||||
feedback: str,
|
||||
) -> Dict[str, str]:
|
||||
"""피드백을 반영하여 블로그 글 재생성."""
|
||||
prompt = (
|
||||
"당신은 네이버 블로그에서 월 100만 이상 수익을 올리는 전문 블로거입니다.\n"
|
||||
f"키워드: {analysis.get('keyword', '')}\n\n"
|
||||
f"이전에 작성한 글:\n{previous_body[:3000]}\n\n"
|
||||
f"리뷰어 피드백:\n{feedback}\n\n"
|
||||
"위 피드백을 반영하여 글을 개선해주세요.\n"
|
||||
"작성 규칙: 1인칭 체험기, 2,000자 이상, 자연스러운 구어체, "
|
||||
"제품 비교표 포함, 광고 고지 문구 포함.\n"
|
||||
"HTML 형식으로 작성하되, 네이버 블로그에서 바로 붙여넣기 가능한 형태로.\n\n"
|
||||
"---\n"
|
||||
"응답은 반드시 아래 JSON 형식으로 해주세요 (JSON만 출력):\n"
|
||||
'{"title": "블로그 제목", "body": "HTML 본문", "excerpt": "2줄 요약", '
|
||||
'"tags": ["태그1", "태그2", ...]}'
|
||||
)
|
||||
raw = _call_claude(prompt, max_tokens=8192)
|
||||
return _parse_blog_json(raw, analysis.get("keyword", ""))
|
||||
@@ -1,789 +0,0 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .config import DB_PATH
|
||||
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with _conn() as conn:
|
||||
# 키워드/상품 분석 결과
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS keyword_analyses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
keyword TEXT NOT NULL,
|
||||
blog_total INTEGER NOT NULL DEFAULT 0,
|
||||
shop_total INTEGER NOT NULL DEFAULT 0,
|
||||
competition REAL NOT NULL DEFAULT 0,
|
||||
opportunity REAL NOT NULL DEFAULT 0,
|
||||
avg_price INTEGER,
|
||||
min_price INTEGER,
|
||||
max_price INTEGER,
|
||||
top_products TEXT NOT NULL DEFAULT '[]',
|
||||
top_blogs TEXT NOT NULL DEFAULT '[]',
|
||||
ai_summary TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_ka_created ON keyword_analyses(created_at DESC)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_ka_keyword ON keyword_analyses(keyword)")
|
||||
|
||||
# 블로그 포스트
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS blog_posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
keyword_id INTEGER REFERENCES keyword_analyses(id),
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
excerpt TEXT NOT NULL DEFAULT '',
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
review_score INTEGER,
|
||||
review_detail TEXT NOT NULL DEFAULT '{}',
|
||||
naver_url TEXT NOT NULL DEFAULT '',
|
||||
trend_brief TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_bp_created ON blog_posts(created_at DESC)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_bp_status ON blog_posts(status)")
|
||||
|
||||
# 수익(커미션) 추적
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS commissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
post_id INTEGER REFERENCES blog_posts(id),
|
||||
month TEXT NOT NULL,
|
||||
clicks INTEGER NOT NULL DEFAULT 0,
|
||||
purchases INTEGER NOT NULL DEFAULT 0,
|
||||
revenue INTEGER NOT NULL DEFAULT 0,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_comm_month ON commissions(month)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_comm_post ON commissions(post_id)")
|
||||
|
||||
# 비동기 작업 상태 (research / generate / review)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS generation_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL DEFAULT 'research',
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
progress INTEGER NOT NULL DEFAULT 0,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
result_id INTEGER,
|
||||
error TEXT,
|
||||
params TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_gt_created ON generation_tasks(created_at DESC)")
|
||||
|
||||
# AI 프롬프트 템플릿
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS prompt_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
template TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
|
||||
# 브랜드커넥트 제휴 링크
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS brand_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
post_id INTEGER REFERENCES blog_posts(id),
|
||||
keyword_id INTEGER REFERENCES keyword_analyses(id),
|
||||
url TEXT NOT NULL,
|
||||
product_name TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
placement_hint TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_bl_post ON brand_links(post_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_bl_keyword ON brand_links(keyword_id)")
|
||||
|
||||
# 기본 프롬프트 템플릿 시딩 (존재하지 않을 때만)
|
||||
_seed_templates(conn)
|
||||
_migrate_templates(conn)
|
||||
|
||||
|
||||
def _seed_templates(conn: sqlite3.Connection) -> None:
|
||||
"""기본 프롬프트 템플릿을 DB에 시딩."""
|
||||
templates = [
|
||||
{
|
||||
"name": "trend_brief",
|
||||
"description": "네이버 블로그 트렌드 분석 + 제목/훅 전략 브리프",
|
||||
"template": (
|
||||
"당신은 네이버 블로그 마케팅 전문가입니다.\n"
|
||||
"아래 키워드 분석 데이터를 바탕으로 블로그 포스팅 전략 브리프를 작성하세요.\n\n"
|
||||
"키워드: {keyword}\n"
|
||||
"블로그 경쟁도: {competition} (0-100, 높을수록 경쟁 치열)\n"
|
||||
"쇼핑 기회 점수: {opportunity} (0-100, 높을수록 기회 큼)\n"
|
||||
"상위 블로그 제목들: {top_blogs}\n"
|
||||
"상위 상품들: {top_products}\n\n"
|
||||
"다음을 포함해주세요:\n"
|
||||
"1. 클릭을 유도하는 제목 공식 3가지\n"
|
||||
"2. 도입부 훅 전략 (공감형, 질문형, 충격형 중 추천)\n"
|
||||
"3. 추천 해시태그 5-10개\n"
|
||||
"4. 경쟁 분석 요약 (기존 글 대비 차별화 포인트)\n"
|
||||
"5. SEO 키워드 배치 전략"
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "blog_write",
|
||||
"description": "공감형 1인칭 체험기 블로그 글 작성",
|
||||
"template": (
|
||||
"당신은 네이버 블로그에서 월 100만 이상 수익을 올리는 전문 블로거입니다.\n"
|
||||
"아래 브리프를 바탕으로 블로그 글을 작성하세요.\n\n"
|
||||
"키워드: {keyword}\n"
|
||||
"트렌드 브리프: {trend_brief}\n"
|
||||
"상위 상품 정보: {top_products}\n\n"
|
||||
"작성 규칙:\n"
|
||||
"- 1인칭 체험기 형식 (\"제가 직접 써봤는데요\")\n"
|
||||
"- 1,500자 이상\n"
|
||||
"- 자연스러운 구어체 (네이버 블로그 톤)\n"
|
||||
"- 제품 비교표 포함 (마크다운 테이블)\n"
|
||||
"- 장단점 솔직하게 작성\n"
|
||||
"- 광고 고지 문구 포함: \"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.\"\n"
|
||||
"- 추천 매트릭스 (가성비/품질/디자인 기준)\n"
|
||||
"- 자연스러운 CTA (구매 링크 유도)\n\n"
|
||||
"HTML 형식으로 작성하되, 네이버 블로그에서 바로 붙여넣기 가능한 형태로 만들어주세요."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "quality_review",
|
||||
"description": "블로그 글 품질 리뷰 (6기준 × 10점)",
|
||||
"template": (
|
||||
"당신은 블로그 콘텐츠 품질 평가 전문가입니다.\n"
|
||||
"아래 블로그 글을 6가지 기준으로 평가해주세요.\n\n"
|
||||
"제목: {title}\n"
|
||||
"본문: {body}\n\n"
|
||||
"평가 기준 (각 1-10점):\n"
|
||||
"1. 독자 공감도 (empathy): 1인칭 체험기가 자연스럽고 공감되는가?\n"
|
||||
"2. 제목 클릭 유도력 (click_appeal): 검색 결과에서 클릭하고 싶은 제목인가?\n"
|
||||
"3. 구매 전환력 (conversion): 읽고 나서 제품을 사고 싶어지는가?\n"
|
||||
"4. SEO 최적화 (seo): 키워드 배치, 소제목, 길이가 적절한가?\n"
|
||||
"5. 형식 완성도 (format): 비교표, 이미지 설명, 단락 구성이 잘 되어있는가?\n"
|
||||
"6. 링크 자연스러움 (link_natural): 제휴 링크가 광고처럼 느껴지지 않고 자연스럽게 녹아있는가? (링크가 없으면 5점 기본)\n\n"
|
||||
"JSON 형식으로 응답:\n"
|
||||
"{{\n"
|
||||
" \"scores\": {{\n"
|
||||
" \"empathy\": N,\n"
|
||||
" \"click_appeal\": N,\n"
|
||||
" \"conversion\": N,\n"
|
||||
" \"seo\": N,\n"
|
||||
" \"format\": N,\n"
|
||||
" \"link_natural\": N\n"
|
||||
" }},\n"
|
||||
" \"total\": N,\n"
|
||||
" \"pass\": true/false,\n"
|
||||
" \"feedback\": \"개선 사항 설명\"\n"
|
||||
"}}"
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "marketer_enhance",
|
||||
"description": "마케터 전환율 강화 + 제휴 링크 삽입",
|
||||
"template": (
|
||||
"당신은 네이버 블로그 수익화 전문 마케터입니다.\n"
|
||||
"아래 블로그 초안에 제휴 링크를 자연스럽게 삽입하고 전환율을 강화하세요.\n\n"
|
||||
"=== 블로그 초안 ===\n{draft_body}\n\n"
|
||||
"=== 타겟 키워드 ===\n{keyword}\n\n"
|
||||
"=== 삽입할 제휴 링크 ===\n{brand_links_info}\n\n"
|
||||
"작업 규칙:\n"
|
||||
"- 제휴 링크를 <a href=\"URL\" target=\"_blank\">상품명</a> 형태로 본문 흐름에 맞게 2~3곳 삽입\n"
|
||||
"- 결론에 CTA(Call-to-Action) 블록 추가 (\"지금 확인하기\" 등)\n"
|
||||
"- 글 맨 아래에 광고 고지 문구 자동 삽입: \"이 포스팅은 브랜드로부터 소정의 수수료를 받을 수 있습니다\"\n"
|
||||
"- 작가의 1인칭 톤과 구어체를 유지\n"
|
||||
"- 과도한 광고 느낌 없이 자연스러운 추천 흐름 유지\n"
|
||||
"- 구매 심리를 자극하는 표현 강화 (한정 수량, 가격 비교, 실사용 만족도 등)\n"
|
||||
"- 배치 힌트가 있으면 참고하되, 문맥이 더 자연스러운 위치 우선\n"
|
||||
"- 기존 본문의 구조와 길이를 크게 변경하지 않음"
|
||||
),
|
||||
},
|
||||
]
|
||||
for t in templates:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM prompt_templates WHERE name = ?", (t["name"],)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
conn.execute(
|
||||
"INSERT INTO prompt_templates (name, description, template) VALUES (?, ?, ?)",
|
||||
(t["name"], t["description"], t["template"]),
|
||||
)
|
||||
|
||||
|
||||
def _migrate_templates(conn: sqlite3.Connection) -> None:
|
||||
"""기존 템플릿을 최신 버전으로 업데이트."""
|
||||
new_blog_write = (
|
||||
"당신은 네이버 블로그에서 월 100만 이상 수익을 올리는 전문 블로거입니다.\n"
|
||||
"아래 브리프와 참고 자료를 바탕으로 블로그 글을 작성하세요.\n\n"
|
||||
"키워드: {keyword}\n"
|
||||
"트렌드 브리프: {trend_brief}\n\n"
|
||||
"=== 상위 블로그 참고 자료 ===\n"
|
||||
"{reference_blogs}\n\n"
|
||||
"=== 상위 상품 정보 ===\n"
|
||||
"{top_products}\n\n"
|
||||
"=== 제휴 상품 (브랜드커넥트 링크) ===\n"
|
||||
"{brand_products}\n\n"
|
||||
"작성 규칙:\n"
|
||||
"- 1인칭 체험기 형식 (\"제가 직접 써봤는데요\")\n"
|
||||
"- 2,000자 이상\n"
|
||||
"- 자연스러운 구어체 (네이버 블로그 톤)\n"
|
||||
"- 상위 블로그 참고하되 표절 금지 (자신만의 시각으로 재구성)\n"
|
||||
"- 제품 비교표 포함 (HTML 테이블)\n"
|
||||
"- 장단점 솔직하게 작성\n"
|
||||
"- 제휴 상품이 있으면 자연스럽게 체험 맥락에 녹여서 작성\n"
|
||||
"- 제휴 링크는 <a> 태그로 자연스럽게 삽입\n"
|
||||
"- 추천 매트릭스 (가성비/품질/디자인 기준)\n"
|
||||
"- 자연스러운 CTA (구매 링크 유도)\n\n"
|
||||
"HTML 형식으로 작성하되, 네이버 블로그에서 바로 붙여넣기 가능한 형태로 만들어주세요."
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE prompt_templates SET template = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE name = 'blog_write'",
|
||||
(new_blog_write,),
|
||||
)
|
||||
|
||||
new_quality_review = (
|
||||
"당신은 블로그 콘텐츠 품질 평가 전문가입니다.\n"
|
||||
"아래 블로그 글을 6가지 기준으로 평가해주세요.\n\n"
|
||||
"제목: {title}\n"
|
||||
"본문: {body}\n\n"
|
||||
"평가 기준 (각 1-10점):\n"
|
||||
"1. 독자 공감도 (empathy): 1인칭 체험기가 자연스럽고 공감되는가?\n"
|
||||
"2. 제목 클릭 유도력 (click_appeal): 검색 결과에서 클릭하고 싶은 제목인가?\n"
|
||||
"3. 구매 전환력 (conversion): 읽고 나서 제품을 사고 싶어지는가?\n"
|
||||
"4. SEO 최적화 (seo): 키워드 배치, 소제목, 길이가 적절한가?\n"
|
||||
"5. 형식 완성도 (format): 비교표, 이미지 설명, 단락 구성이 잘 되어있는가?\n"
|
||||
"6. 링크 자연스러움 (link_natural): 제휴 링크가 광고처럼 느껴지지 않고 자연스럽게 녹아있는가? (링크가 없으면 5점 기본)\n\n"
|
||||
"JSON 형식으로 응답:\n"
|
||||
"{{\n"
|
||||
" \"scores\": {{\n"
|
||||
" \"empathy\": N,\n"
|
||||
" \"click_appeal\": N,\n"
|
||||
" \"conversion\": N,\n"
|
||||
" \"seo\": N,\n"
|
||||
" \"format\": N,\n"
|
||||
" \"link_natural\": N\n"
|
||||
" }},\n"
|
||||
" \"total\": N,\n"
|
||||
" \"pass\": true/false,\n"
|
||||
" \"feedback\": \"개선 사항 설명\"\n"
|
||||
"}}"
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE prompt_templates SET template = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE name = 'quality_review'",
|
||||
(new_quality_review,),
|
||||
)
|
||||
|
||||
# marketer_enhance가 없으면 추가
|
||||
existing = conn.execute("SELECT id FROM prompt_templates WHERE name = 'marketer_enhance'").fetchone()
|
||||
if not existing:
|
||||
conn.execute(
|
||||
"INSERT INTO prompt_templates (name, description, template) VALUES (?, ?, ?)",
|
||||
("marketer_enhance", "마케터 전환율 강화 + 제휴 링크 삽입",
|
||||
"당신은 네이버 블로그 수익화 전문 마케터입니다.\n"
|
||||
"아래 블로그 초안에 제휴 링크를 자연스럽게 삽입하고 전환율을 강화하세요.\n\n"
|
||||
"=== 블로그 초안 ===\n{draft_body}\n\n"
|
||||
"=== 타겟 키워드 ===\n{keyword}\n\n"
|
||||
"=== 삽입할 제휴 링크 ===\n{brand_links_info}\n\n"
|
||||
"작업 규칙:\n"
|
||||
"- 제휴 링크를 <a href=\"URL\" target=\"_blank\">상품명</a> 형태로 본문 흐름에 맞게 2~3곳 삽입\n"
|
||||
"- 결론에 CTA(Call-to-Action) 블록 추가\n"
|
||||
"- 글 맨 아래에 광고 고지 문구 자동 삽입\n"
|
||||
"- 작가의 1인칭 톤과 구어체를 유지\n"
|
||||
"- 과도한 광고 느낌 없이 자연스러운 추천 흐름 유지"),
|
||||
)
|
||||
|
||||
|
||||
# ── keyword_analyses CRUD ────────────────────────────────────────────────────
|
||||
|
||||
def _ka_row_to_dict(r) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r["id"],
|
||||
"keyword": r["keyword"],
|
||||
"blog_total": r["blog_total"],
|
||||
"shop_total": r["shop_total"],
|
||||
"competition": r["competition"],
|
||||
"opportunity": r["opportunity"],
|
||||
"avg_price": r["avg_price"],
|
||||
"min_price": r["min_price"],
|
||||
"max_price": r["max_price"],
|
||||
"top_products": json.loads(r["top_products"]) if r["top_products"] else [],
|
||||
"top_blogs": json.loads(r["top_blogs"]) if r["top_blogs"] else [],
|
||||
"ai_summary": r["ai_summary"],
|
||||
"created_at": r["created_at"],
|
||||
}
|
||||
|
||||
|
||||
def add_keyword_analysis(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO keyword_analyses
|
||||
(keyword, blog_total, shop_total, competition, opportunity,
|
||||
avg_price, min_price, max_price, top_products, top_blogs, ai_summary)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
data.get("keyword", ""),
|
||||
data.get("blog_total", 0),
|
||||
data.get("shop_total", 0),
|
||||
data.get("competition", 0),
|
||||
data.get("opportunity", 0),
|
||||
data.get("avg_price"),
|
||||
data.get("min_price"),
|
||||
data.get("max_price"),
|
||||
json.dumps(data.get("top_products", []), ensure_ascii=False),
|
||||
json.dumps(data.get("top_blogs", []), ensure_ascii=False),
|
||||
data.get("ai_summary", ""),
|
||||
),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM keyword_analyses WHERE rowid = last_insert_rowid()"
|
||||
).fetchone()
|
||||
return _ka_row_to_dict(row)
|
||||
|
||||
|
||||
def get_keyword_analysis(analysis_id: int) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM keyword_analyses WHERE id = ?", (analysis_id,)
|
||||
).fetchone()
|
||||
return _ka_row_to_dict(row) if row else None
|
||||
|
||||
|
||||
def get_keyword_analyses(limit: int = 30) -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM keyword_analyses ORDER BY created_at DESC LIMIT ?", (limit,)
|
||||
).fetchall()
|
||||
return [_ka_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def delete_keyword_analysis(analysis_id: int) -> bool:
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM keyword_analyses WHERE id = ?", (analysis_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return False
|
||||
conn.execute("DELETE FROM keyword_analyses WHERE id = ?", (analysis_id,))
|
||||
return True
|
||||
|
||||
|
||||
# ── blog_posts CRUD ──────────────────────────────────────────────────────────
|
||||
|
||||
def _post_row_to_dict(r) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r["id"],
|
||||
"keyword_id": r["keyword_id"],
|
||||
"title": r["title"],
|
||||
"body": r["body"],
|
||||
"excerpt": r["excerpt"],
|
||||
"tags": json.loads(r["tags"]) if r["tags"] else [],
|
||||
"status": r["status"],
|
||||
"review_score": r["review_score"],
|
||||
"review_detail": json.loads(r["review_detail"]) if r["review_detail"] else {},
|
||||
"naver_url": r["naver_url"],
|
||||
"trend_brief": r["trend_brief"],
|
||||
"created_at": r["created_at"],
|
||||
"updated_at": r["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
def add_post(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO blog_posts
|
||||
(keyword_id, title, body, excerpt, tags, status, review_score,
|
||||
review_detail, naver_url, trend_brief)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
data.get("keyword_id"),
|
||||
data.get("title", ""),
|
||||
data.get("body", ""),
|
||||
data.get("excerpt", ""),
|
||||
json.dumps(data.get("tags", []), ensure_ascii=False),
|
||||
data.get("status", "draft"),
|
||||
data.get("review_score"),
|
||||
json.dumps(data.get("review_detail", {}), ensure_ascii=False),
|
||||
data.get("naver_url", ""),
|
||||
data.get("trend_brief", ""),
|
||||
),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM blog_posts WHERE rowid = last_insert_rowid()"
|
||||
).fetchone()
|
||||
return _post_row_to_dict(row)
|
||||
|
||||
|
||||
def get_post(post_id: int) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM blog_posts WHERE id = ?", (post_id,)
|
||||
).fetchone()
|
||||
return _post_row_to_dict(row) if row else None
|
||||
|
||||
|
||||
def get_posts(status: Optional[str] = None, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
if status:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM blog_posts WHERE status = ? ORDER BY created_at DESC LIMIT ?",
|
||||
(status, limit),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM blog_posts ORDER BY created_at DESC LIMIT ?", (limit,)
|
||||
).fetchall()
|
||||
return [_post_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def update_post(post_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
fields = []
|
||||
values = []
|
||||
for k in ("title", "body", "excerpt", "status", "naver_url", "trend_brief"):
|
||||
if k in data:
|
||||
fields.append(f"{k} = ?")
|
||||
values.append(data[k])
|
||||
if "tags" in data:
|
||||
fields.append("tags = ?")
|
||||
values.append(json.dumps(data["tags"], ensure_ascii=False))
|
||||
if "review_score" in data:
|
||||
fields.append("review_score = ?")
|
||||
values.append(data["review_score"])
|
||||
if "review_detail" in data:
|
||||
fields.append("review_detail = ?")
|
||||
values.append(json.dumps(data["review_detail"], ensure_ascii=False))
|
||||
if not fields:
|
||||
return get_post(post_id)
|
||||
fields.append("updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')")
|
||||
values.append(post_id)
|
||||
conn.execute(
|
||||
f"UPDATE blog_posts SET {', '.join(fields)} WHERE id = ?", values
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM blog_posts WHERE id = ?", (post_id,)
|
||||
).fetchone()
|
||||
return _post_row_to_dict(row) if row else None
|
||||
|
||||
|
||||
def delete_post(post_id: int) -> bool:
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM blog_posts WHERE id = ?", (post_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return False
|
||||
conn.execute("DELETE FROM blog_posts WHERE id = ?", (post_id,))
|
||||
return True
|
||||
|
||||
|
||||
# ── commissions CRUD ─────────────────────────────────────────────────────────
|
||||
|
||||
def _comm_row_to_dict(r) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r["id"],
|
||||
"post_id": r["post_id"],
|
||||
"month": r["month"],
|
||||
"clicks": r["clicks"],
|
||||
"purchases": r["purchases"],
|
||||
"revenue": r["revenue"],
|
||||
"note": r["note"],
|
||||
"created_at": r["created_at"],
|
||||
}
|
||||
|
||||
|
||||
def add_commission(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO commissions (post_id, month, clicks, purchases, revenue, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
data.get("post_id"),
|
||||
data.get("month", ""),
|
||||
data.get("clicks", 0),
|
||||
data.get("purchases", 0),
|
||||
data.get("revenue", 0),
|
||||
data.get("note", ""),
|
||||
),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM commissions WHERE rowid = last_insert_rowid()"
|
||||
).fetchone()
|
||||
return _comm_row_to_dict(row)
|
||||
|
||||
|
||||
def get_commissions(post_id: Optional[int] = None, limit: int = 100) -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
if post_id:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM commissions WHERE post_id = ? ORDER BY month DESC LIMIT ?",
|
||||
(post_id, limit),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM commissions ORDER BY month DESC LIMIT ?", (limit,)
|
||||
).fetchall()
|
||||
return [_comm_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def update_commission(comm_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
fields = []
|
||||
values = []
|
||||
for k in ("month", "clicks", "purchases", "revenue", "note"):
|
||||
if k in data:
|
||||
fields.append(f"{k} = ?")
|
||||
values.append(data[k])
|
||||
if not fields:
|
||||
return None
|
||||
values.append(comm_id)
|
||||
conn.execute(
|
||||
f"UPDATE commissions SET {', '.join(fields)} WHERE id = ?", values
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM commissions WHERE id = ?", (comm_id,)
|
||||
).fetchone()
|
||||
return _comm_row_to_dict(row) if row else None
|
||||
|
||||
|
||||
def delete_commission(comm_id: int) -> bool:
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM commissions WHERE id = ?", (comm_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return False
|
||||
conn.execute("DELETE FROM commissions WHERE id = ?", (comm_id,))
|
||||
return True
|
||||
|
||||
|
||||
# ── brand_links CRUD ────────────────────────────────────────────────────────
|
||||
|
||||
def _bl_row_to_dict(r) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r["id"],
|
||||
"post_id": r["post_id"],
|
||||
"keyword_id": r["keyword_id"],
|
||||
"url": r["url"],
|
||||
"product_name": r["product_name"],
|
||||
"description": r["description"],
|
||||
"placement_hint": r["placement_hint"],
|
||||
"created_at": r["created_at"],
|
||||
}
|
||||
|
||||
|
||||
def add_brand_link(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO brand_links (post_id, keyword_id, url, product_name, description, placement_hint)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
data.get("post_id"),
|
||||
data.get("keyword_id"),
|
||||
data.get("url", ""),
|
||||
data.get("product_name", ""),
|
||||
data.get("description", ""),
|
||||
data.get("placement_hint", ""),
|
||||
),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM brand_links WHERE rowid = last_insert_rowid()"
|
||||
).fetchone()
|
||||
return _bl_row_to_dict(row)
|
||||
|
||||
|
||||
def get_brand_links(
|
||||
post_id: Optional[int] = None,
|
||||
keyword_id: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
if post_id is not None:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM brand_links WHERE post_id = ? ORDER BY id", (post_id,)
|
||||
).fetchall()
|
||||
elif keyword_id is not None:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM brand_links WHERE keyword_id = ? ORDER BY id", (keyword_id,)
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute("SELECT * FROM brand_links ORDER BY id DESC LIMIT 100").fetchall()
|
||||
return [_bl_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def update_brand_link(link_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
fields = []
|
||||
values = []
|
||||
for k in ("post_id", "keyword_id", "url", "product_name", "description", "placement_hint"):
|
||||
if k in data:
|
||||
fields.append(f"{k} = ?")
|
||||
values.append(data[k])
|
||||
if not fields:
|
||||
row = conn.execute("SELECT * FROM brand_links WHERE id = ?", (link_id,)).fetchone()
|
||||
return _bl_row_to_dict(row) if row else None
|
||||
values.append(link_id)
|
||||
conn.execute(f"UPDATE brand_links SET {', '.join(fields)} WHERE id = ?", values)
|
||||
row = conn.execute("SELECT * FROM brand_links WHERE id = ?", (link_id,)).fetchone()
|
||||
return _bl_row_to_dict(row) if row else None
|
||||
|
||||
|
||||
def delete_brand_link(link_id: int) -> bool:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT id FROM brand_links WHERE id = ?", (link_id,)).fetchone()
|
||||
if not row:
|
||||
return False
|
||||
conn.execute("DELETE FROM brand_links WHERE id = ?", (link_id,))
|
||||
return True
|
||||
|
||||
|
||||
def link_brand_links_to_post(keyword_id: int, post_id: int) -> None:
|
||||
"""keyword_id로 등록된 링크들을 post_id에도 연결."""
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE brand_links SET post_id = ? WHERE keyword_id = ? AND post_id IS NULL",
|
||||
(post_id, keyword_id),
|
||||
)
|
||||
|
||||
|
||||
def get_dashboard_stats() -> Dict[str, Any]:
|
||||
"""대시보드 집계: 총 포스트/클릭/구매/수익 + 월별 추이."""
|
||||
with _conn() as conn:
|
||||
total_posts = conn.execute("SELECT COUNT(*) FROM blog_posts").fetchone()[0]
|
||||
published = conn.execute(
|
||||
"SELECT COUNT(*) FROM blog_posts WHERE status = 'published'"
|
||||
).fetchone()[0]
|
||||
|
||||
agg = conn.execute(
|
||||
"SELECT COALESCE(SUM(clicks),0), COALESCE(SUM(purchases),0), COALESCE(SUM(revenue),0) FROM commissions"
|
||||
).fetchone()
|
||||
|
||||
monthly = conn.execute(
|
||||
"""SELECT month, SUM(clicks) as clicks, SUM(purchases) as purchases, SUM(revenue) as revenue
|
||||
FROM commissions GROUP BY month ORDER BY month DESC LIMIT 12"""
|
||||
).fetchall()
|
||||
|
||||
top_posts = conn.execute(
|
||||
"""SELECT bp.id, bp.title, COALESCE(SUM(c.revenue),0) as total_revenue
|
||||
FROM blog_posts bp LEFT JOIN commissions c ON c.post_id = bp.id
|
||||
GROUP BY bp.id ORDER BY total_revenue DESC LIMIT 5"""
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"total_posts": total_posts,
|
||||
"published_posts": published,
|
||||
"total_clicks": agg[0],
|
||||
"total_purchases": agg[1],
|
||||
"total_revenue": agg[2],
|
||||
"monthly": [
|
||||
{"month": r["month"], "clicks": r["clicks"], "purchases": r["purchases"], "revenue": r["revenue"]}
|
||||
for r in monthly
|
||||
],
|
||||
"top_posts": [
|
||||
{"id": r["id"], "title": r["title"], "total_revenue": r["total_revenue"]}
|
||||
for r in top_posts
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── generation_tasks CRUD ────────────────────────────────────────────────────
|
||||
|
||||
def _task_row_to_dict(r) -> Dict[str, Any]:
|
||||
return {
|
||||
"task_id": r["id"],
|
||||
"type": r["type"],
|
||||
"status": r["status"],
|
||||
"progress": r["progress"],
|
||||
"message": r["message"],
|
||||
"result_id": r["result_id"],
|
||||
"error": r["error"],
|
||||
"params": json.loads(r["params"]) if r["params"] else {},
|
||||
"created_at": r["created_at"],
|
||||
"updated_at": r["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
def create_task(task_id: str, task_type: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO generation_tasks (id, type, params) VALUES (?, ?, ?)",
|
||||
(task_id, task_type, json.dumps(params, ensure_ascii=False)),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM generation_tasks WHERE id = ?", (task_id,)
|
||||
).fetchone()
|
||||
return _task_row_to_dict(row)
|
||||
|
||||
|
||||
def update_task(
|
||||
task_id: str,
|
||||
status: str,
|
||||
progress: int,
|
||||
message: str,
|
||||
result_id: Optional[int] = None,
|
||||
error: Optional[str] = None,
|
||||
) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""UPDATE generation_tasks
|
||||
SET status = ?, progress = ?, message = ?, result_id = ?, error = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||
WHERE id = ?""",
|
||||
(status, progress, message, result_id, error, task_id),
|
||||
)
|
||||
|
||||
|
||||
def get_task(task_id: str) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM generation_tasks WHERE id = ?", (task_id,)
|
||||
).fetchone()
|
||||
return _task_row_to_dict(row) if row else None
|
||||
|
||||
|
||||
# ── prompt_templates CRUD ────────────────────────────────────────────────────
|
||||
|
||||
def get_template(name: str) -> Optional[str]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT template FROM prompt_templates WHERE name = ?", (name,)
|
||||
).fetchone()
|
||||
return row["template"] if row else None
|
||||
|
||||
|
||||
def get_all_templates() -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM prompt_templates ORDER BY name").fetchall()
|
||||
return [
|
||||
{"id": r["id"], "name": r["name"], "description": r["description"],
|
||||
"template": r["template"], "updated_at": r["updated_at"]}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def update_template(name: str, template: str) -> bool:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE prompt_templates SET template = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE name = ?",
|
||||
(template, name),
|
||||
)
|
||||
return conn.execute(
|
||||
"SELECT id FROM prompt_templates WHERE name = ?", (name,)
|
||||
).fetchone() is not None
|
||||
@@ -1,440 +0,0 @@
|
||||
import os
|
||||
import uuid
|
||||
import logging
|
||||
from fastapi import FastAPI, HTTPException, BackgroundTasks, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
|
||||
from .config import CORS_ALLOW_ORIGINS, NAVER_CLIENT_ID, ANTHROPIC_API_KEY
|
||||
from .db import (
|
||||
init_db,
|
||||
get_keyword_analyses, get_keyword_analysis, delete_keyword_analysis,
|
||||
add_keyword_analysis,
|
||||
get_posts, get_post, add_post, update_post, delete_post,
|
||||
get_commissions, add_commission, update_commission, delete_commission,
|
||||
get_dashboard_stats,
|
||||
get_task, create_task, update_task,
|
||||
add_brand_link, get_brand_links, update_brand_link, delete_brand_link,
|
||||
link_brand_links_to_post,
|
||||
)
|
||||
from .naver_search import analyze_keyword_with_crawling
|
||||
from .content_generator import generate_trend_brief, generate_blog_post, regenerate_blog_post
|
||||
from .quality_reviewer import review_post
|
||||
from .marketer import enhance_for_conversion
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
_cors_origins = CORS_ALLOW_ORIGINS.split(",")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[o.strip() for o in _cors_origins],
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type"],
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
init_db()
|
||||
os.makedirs("/app/data", exist_ok=True)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/blog-marketing/status")
|
||||
def service_status():
|
||||
"""서비스 상태 및 설정 현황."""
|
||||
return {
|
||||
"ok": True,
|
||||
"naver_api": bool(NAVER_CLIENT_ID),
|
||||
"claude_api": bool(ANTHROPIC_API_KEY),
|
||||
}
|
||||
|
||||
|
||||
# ── 키워드 분석 API ──────────────────────────────────────────────────────────
|
||||
|
||||
class ResearchRequest(BaseModel):
|
||||
keyword: str
|
||||
|
||||
|
||||
def _run_research(task_id: str, keyword: str):
|
||||
"""BackgroundTask: 네이버 검색 → 키워드 분석 → DB 저장."""
|
||||
try:
|
||||
update_task(task_id, "processing", 30, "네이버 검색 중...")
|
||||
result = analyze_keyword_with_crawling(keyword)
|
||||
|
||||
update_task(task_id, "processing", 80, "분석 결과 저장 중...")
|
||||
saved = add_keyword_analysis(result)
|
||||
|
||||
update_task(task_id, "succeeded", 100, "분석 완료", result_id=saved["id"])
|
||||
except Exception as e:
|
||||
logger.exception("Research failed for keyword=%s", keyword)
|
||||
update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
|
||||
@app.post("/api/blog-marketing/research")
|
||||
def start_research(req: ResearchRequest, background_tasks: BackgroundTasks):
|
||||
"""키워드 분석 시작 (BackgroundTask). task_id 즉시 반환."""
|
||||
if not NAVER_CLIENT_ID:
|
||||
raise HTTPException(status_code=400, detail="Naver API 키가 설정되지 않았습니다")
|
||||
if not req.keyword.strip():
|
||||
raise HTTPException(status_code=400, detail="키워드를 입력하세요")
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
create_task(task_id, "research", {"keyword": req.keyword.strip()})
|
||||
background_tasks.add_task(_run_research, task_id, req.keyword.strip())
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
@app.get("/api/blog-marketing/research/history")
|
||||
def list_research(limit: int = Query(30, ge=1, le=100)):
|
||||
return {"analyses": get_keyword_analyses(limit)}
|
||||
|
||||
|
||||
@app.get("/api/blog-marketing/research/{analysis_id}")
|
||||
def get_research(analysis_id: int):
|
||||
result = get_keyword_analysis(analysis_id)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Analysis not found")
|
||||
return result
|
||||
|
||||
|
||||
@app.delete("/api/blog-marketing/research/{analysis_id}")
|
||||
def remove_research(analysis_id: int):
|
||||
if not delete_keyword_analysis(analysis_id):
|
||||
raise HTTPException(status_code=404, detail="Analysis not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── 작업 상태 폴링 API ──────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/blog-marketing/task/{task_id}")
|
||||
def get_task_status(task_id: str):
|
||||
task = get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return task
|
||||
|
||||
|
||||
# ── AI 글 생성 API ──────────────────────────────────────────────────────────
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
keyword_id: int # keyword_analyses.id
|
||||
|
||||
|
||||
class LinkRequest(BaseModel):
|
||||
url: str
|
||||
product_name: str
|
||||
keyword_id: Optional[int] = None
|
||||
post_id: Optional[int] = None
|
||||
description: str = ""
|
||||
placement_hint: str = ""
|
||||
|
||||
|
||||
def _run_generate(task_id: str, keyword_id: int):
|
||||
"""BackgroundTask: 트렌드 브리프 → 블로그 글 생성 → DB 저장."""
|
||||
try:
|
||||
analysis = get_keyword_analysis(keyword_id)
|
||||
if not analysis:
|
||||
update_task(task_id, "failed", 0, "", error="키워드 분석 결과를 찾을 수 없습니다")
|
||||
return
|
||||
|
||||
# 연결된 브랜드커넥트 링크 조회
|
||||
brand_links = get_brand_links(keyword_id=keyword_id)
|
||||
|
||||
update_task(task_id, "processing", 20, "트렌드 브리프 생성 중...")
|
||||
trend_brief = generate_trend_brief(analysis)
|
||||
|
||||
update_task(task_id, "processing", 60, "블로그 글 작성 중...")
|
||||
post_data = generate_blog_post(analysis, trend_brief, brand_links=brand_links)
|
||||
|
||||
update_task(task_id, "processing", 90, "저장 중...")
|
||||
saved = add_post({
|
||||
"keyword_id": keyword_id,
|
||||
"title": post_data["title"],
|
||||
"body": post_data["body"],
|
||||
"excerpt": post_data["excerpt"],
|
||||
"tags": post_data["tags"],
|
||||
"status": "draft",
|
||||
"trend_brief": trend_brief,
|
||||
})
|
||||
|
||||
# keyword_id에 연결된 링크를 post_id에도 연결
|
||||
link_brand_links_to_post(keyword_id=keyword_id, post_id=saved["id"])
|
||||
|
||||
update_task(task_id, "succeeded", 100, "글 생성 완료", result_id=saved["id"])
|
||||
except Exception as e:
|
||||
logger.exception("Generate failed for keyword_id=%s", keyword_id)
|
||||
update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
|
||||
@app.post("/api/blog-marketing/generate")
|
||||
def start_generate(req: GenerateRequest, background_tasks: BackgroundTasks):
|
||||
"""AI 블로그 글 생성 시작. task_id 즉시 반환."""
|
||||
if not ANTHROPIC_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Claude API 키가 설정되지 않았습니다")
|
||||
analysis = get_keyword_analysis(req.keyword_id)
|
||||
if not analysis:
|
||||
raise HTTPException(status_code=404, detail="키워드 분석 결과를 찾을 수 없습니다")
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
create_task(task_id, "generate", {"keyword_id": req.keyword_id})
|
||||
background_tasks.add_task(_run_generate, task_id, req.keyword_id)
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
# ── 품질 리뷰 API ───────────────────────────────────────────────────────────
|
||||
|
||||
def _run_review(task_id: str, post_id: int):
|
||||
"""BackgroundTask: 블로그 글 품질 리뷰."""
|
||||
try:
|
||||
post = get_post(post_id)
|
||||
if not post:
|
||||
update_task(task_id, "failed", 0, "", error="포스트를 찾을 수 없습니다")
|
||||
return
|
||||
|
||||
update_task(task_id, "processing", 50, "품질 리뷰 중...")
|
||||
result = review_post(post["title"], post["body"])
|
||||
|
||||
update_post(post_id, {
|
||||
"review_score": result["total"],
|
||||
"review_detail": result,
|
||||
"status": "reviewed" if result["pass"] else "draft",
|
||||
})
|
||||
|
||||
update_task(task_id, "succeeded", 100, "리뷰 완료", result_id=post_id)
|
||||
except Exception as e:
|
||||
logger.exception("Review failed for post_id=%s", post_id)
|
||||
update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
|
||||
@app.post("/api/blog-marketing/review/{post_id}")
|
||||
def start_review(post_id: int, background_tasks: BackgroundTasks):
|
||||
"""블로그 글 품질 리뷰 시작. task_id 즉시 반환."""
|
||||
if not ANTHROPIC_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Claude API 키가 설정되지 않았습니다")
|
||||
post = get_post(post_id)
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
create_task(task_id, "review", {"post_id": post_id})
|
||||
background_tasks.add_task(_run_review, task_id, post_id)
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
# ── 재생성 API ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _run_regenerate(task_id: str, post_id: int):
|
||||
"""BackgroundTask: 피드백 기반 블로그 글 재생성."""
|
||||
try:
|
||||
post = get_post(post_id)
|
||||
if not post:
|
||||
update_task(task_id, "failed", 0, "", error="포스트를 찾을 수 없습니다")
|
||||
return
|
||||
|
||||
analysis = get_keyword_analysis(post["keyword_id"]) if post["keyword_id"] else {}
|
||||
feedback = post.get("review_detail", {}).get("feedback", "개선이 필요합니다")
|
||||
|
||||
update_task(task_id, "processing", 50, "글 재생성 중...")
|
||||
result = regenerate_blog_post(
|
||||
analysis or {"keyword": ""},
|
||||
post.get("trend_brief", ""),
|
||||
post["body"],
|
||||
feedback,
|
||||
)
|
||||
|
||||
update_post(post_id, {
|
||||
"title": result["title"],
|
||||
"body": result["body"],
|
||||
"excerpt": result["excerpt"],
|
||||
"tags": result["tags"],
|
||||
"status": "draft",
|
||||
"review_score": None,
|
||||
"review_detail": {},
|
||||
})
|
||||
|
||||
update_task(task_id, "succeeded", 100, "재생성 완료", result_id=post_id)
|
||||
except Exception as e:
|
||||
logger.exception("Regenerate failed for post_id=%s", post_id)
|
||||
update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
|
||||
@app.post("/api/blog-marketing/regenerate/{post_id}")
|
||||
def start_regenerate(post_id: int, background_tasks: BackgroundTasks):
|
||||
"""피드백 기반 블로그 글 재생성. task_id 즉시 반환."""
|
||||
if not ANTHROPIC_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Claude API 키가 설정되지 않았습니다")
|
||||
post = get_post(post_id)
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
create_task(task_id, "regenerate", {"post_id": post_id})
|
||||
background_tasks.add_task(_run_regenerate, task_id, post_id)
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
# ── 포스트 CRUD API ──────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/blog-marketing/posts")
|
||||
def list_posts(status: str = None, limit: int = Query(50, ge=1, le=100)):
|
||||
return {"posts": get_posts(status=status, limit=limit)}
|
||||
|
||||
|
||||
@app.get("/api/blog-marketing/posts/{post_id}")
|
||||
def get_post_detail(post_id: int):
|
||||
post = get_post(post_id)
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
return post
|
||||
|
||||
|
||||
@app.put("/api/blog-marketing/posts/{post_id}")
|
||||
def edit_post(post_id: int, data: dict):
|
||||
result = update_post(post_id, data)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
return result
|
||||
|
||||
|
||||
@app.delete("/api/blog-marketing/posts/{post_id}")
|
||||
def remove_post(post_id: int):
|
||||
if not delete_post(post_id):
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/blog-marketing/posts/{post_id}/publish")
|
||||
def publish_post(post_id: int, data: dict = None):
|
||||
"""네이버 URL 등록 + 상태를 published로 변경."""
|
||||
naver_url = (data or {}).get("naver_url", "")
|
||||
result = update_post(post_id, {"status": "published", "naver_url": naver_url})
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
return result
|
||||
|
||||
|
||||
# ── 브랜드커넥트 링크 API ──────────────────────────────────────────────────
|
||||
|
||||
@app.post("/api/blog-marketing/links", status_code=201)
|
||||
def create_link(req: LinkRequest):
|
||||
return add_brand_link(req.model_dump())
|
||||
|
||||
|
||||
@app.get("/api/blog-marketing/links")
|
||||
def list_links(post_id: int = None, keyword_id: int = None):
|
||||
return {"links": get_brand_links(post_id=post_id, keyword_id=keyword_id)}
|
||||
|
||||
|
||||
@app.put("/api/blog-marketing/links/{link_id}")
|
||||
def edit_link(link_id: int, data: dict):
|
||||
result = update_brand_link(link_id, data)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Link not found")
|
||||
return result
|
||||
|
||||
|
||||
@app.delete("/api/blog-marketing/links/{link_id}")
|
||||
def remove_link(link_id: int):
|
||||
if not delete_brand_link(link_id):
|
||||
raise HTTPException(status_code=404, detail="Link not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── 마케터 API ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _run_market(task_id: str, post_id: int):
|
||||
"""BackgroundTask: 마케터 전환율 강화."""
|
||||
try:
|
||||
post = get_post(post_id)
|
||||
if not post:
|
||||
update_task(task_id, "failed", 0, "", error="포스트를 찾을 수 없습니다")
|
||||
return
|
||||
|
||||
brand_links = get_brand_links(post_id=post_id)
|
||||
if not brand_links and post.get("keyword_id"):
|
||||
brand_links = get_brand_links(keyword_id=post["keyword_id"])
|
||||
|
||||
if not brand_links:
|
||||
update_task(task_id, "failed", 0, "", error="브랜드커넥트 링크가 없습니다. 먼저 링크를 등록하세요.")
|
||||
return
|
||||
|
||||
analysis = get_keyword_analysis(post["keyword_id"]) if post.get("keyword_id") else {}
|
||||
keyword = (analysis or {}).get("keyword", "")
|
||||
|
||||
update_task(task_id, "processing", 50, "마케터가 전환율 강화 중...")
|
||||
result = enhance_for_conversion(
|
||||
post_body=post["body"],
|
||||
post_title=post["title"],
|
||||
brand_links=brand_links,
|
||||
keyword=keyword,
|
||||
)
|
||||
|
||||
update_post(post_id, {
|
||||
"title": result["title"],
|
||||
"body": result["body"],
|
||||
"excerpt": result["excerpt"],
|
||||
"status": "marketed",
|
||||
})
|
||||
|
||||
update_task(task_id, "succeeded", 100, "마케팅 강화 완료", result_id=post_id)
|
||||
except Exception as e:
|
||||
logger.exception("Market failed for post_id=%s", post_id)
|
||||
update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
|
||||
@app.post("/api/blog-marketing/market/{post_id}")
|
||||
def start_market(post_id: int, background_tasks: BackgroundTasks):
|
||||
"""마케터 단계 실행. task_id 즉시 반환."""
|
||||
if not ANTHROPIC_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Claude API 키가 설정되지 않았습니다")
|
||||
post = get_post(post_id)
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
create_task(task_id, "market", {"post_id": post_id})
|
||||
background_tasks.add_task(_run_market, task_id, post_id)
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
# ── 수익 추적 API ────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/blog-marketing/commissions")
|
||||
def list_commissions(post_id: int = None, limit: int = Query(100, ge=1, le=100)):
|
||||
return {"commissions": get_commissions(post_id=post_id, limit=limit)}
|
||||
|
||||
|
||||
@app.post("/api/blog-marketing/commissions", status_code=201)
|
||||
def create_commission(data: dict):
|
||||
return add_commission(data)
|
||||
|
||||
|
||||
@app.put("/api/blog-marketing/commissions/{comm_id}")
|
||||
def edit_commission(comm_id: int, data: dict):
|
||||
result = update_commission(comm_id, data)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Commission not found")
|
||||
return result
|
||||
|
||||
|
||||
@app.delete("/api/blog-marketing/commissions/{comm_id}")
|
||||
def remove_commission(comm_id: int):
|
||||
if not delete_commission(comm_id):
|
||||
raise HTTPException(status_code=404, detail="Commission not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── 대시보드 API ─────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/blog-marketing/dashboard")
|
||||
def dashboard():
|
||||
return get_dashboard_stats()
|
||||
@@ -1,105 +0,0 @@
|
||||
"""마케터 단계 — 전환율 강화 + 브랜드커넥트 링크 삽입."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import anthropic
|
||||
|
||||
from .config import ANTHROPIC_API_KEY, CLAUDE_MODEL
|
||||
from .db import get_template
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_client: Optional[anthropic.Anthropic] = None
|
||||
|
||||
|
||||
def _get_client() -> anthropic.Anthropic:
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
|
||||
return _client
|
||||
|
||||
|
||||
def _call_claude(prompt: str, max_tokens: int = 8192) -> str:
|
||||
client = _get_client()
|
||||
today = date.today().isoformat()
|
||||
resp = client.messages.create(
|
||||
model=CLAUDE_MODEL,
|
||||
max_tokens=max_tokens,
|
||||
system=f"현재 날짜는 {today}입니다. 모든 콘텐츠는 이 날짜 기준으로 작성하세요.",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
return resp.content[0].text
|
||||
|
||||
|
||||
def enhance_for_conversion(
|
||||
post_body: str,
|
||||
post_title: str,
|
||||
brand_links: List[Dict[str, Any]],
|
||||
keyword: str,
|
||||
) -> Dict[str, str]:
|
||||
"""초안에 제휴 링크를 자연스럽게 삽입하고 전환율을 강화.
|
||||
|
||||
Args:
|
||||
post_body: 작가 초안 HTML 본문
|
||||
post_title: 작가 초안 제목
|
||||
brand_links: 브랜드커넥트 링크 리스트
|
||||
keyword: 타겟 키워드
|
||||
|
||||
Returns:
|
||||
{"title": str, "body": str, "excerpt": str}
|
||||
|
||||
Raises:
|
||||
ValueError: 브랜드 링크가 없을 때
|
||||
"""
|
||||
if not brand_links:
|
||||
raise ValueError("브랜드커넥트 링크가 필요합니다")
|
||||
|
||||
template = get_template("marketer_enhance")
|
||||
if not template:
|
||||
raise RuntimeError("marketer_enhance 템플릿이 없습니다")
|
||||
|
||||
brand_links_text = ""
|
||||
for i, link in enumerate(brand_links, 1):
|
||||
brand_links_text += (
|
||||
f"{i}. 상품명: {link.get('product_name', '')}\n"
|
||||
f" 설명: {link.get('description', '')}\n"
|
||||
f" URL: {link.get('url', '')}\n"
|
||||
f" 배치 힌트: {link.get('placement_hint', '자연스럽게')}\n\n"
|
||||
)
|
||||
|
||||
prompt = template.format(
|
||||
draft_body=post_body[:6000],
|
||||
keyword=keyword,
|
||||
brand_links_info=brand_links_text,
|
||||
)
|
||||
|
||||
prompt += (
|
||||
"\n\n---\n"
|
||||
"응답은 반드시 아래 JSON 형식으로 해주세요 (JSON만 출력):\n"
|
||||
'{"title": "개선된 제목", "body": "개선된 HTML 본문", "excerpt": "2줄 요약"}'
|
||||
)
|
||||
|
||||
raw = _call_claude(prompt)
|
||||
|
||||
try:
|
||||
text = raw.strip()
|
||||
if text.startswith("```"):
|
||||
lines = text.split("\n")
|
||||
lines = [l for l in lines if not l.strip().startswith("```")]
|
||||
text = "\n".join(lines)
|
||||
result = json.loads(text)
|
||||
return {
|
||||
"title": result.get("title", post_title),
|
||||
"body": result.get("body", post_body),
|
||||
"excerpt": result.get("excerpt", ""),
|
||||
}
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
logger.warning("Marketer JSON parse failed, using raw text")
|
||||
return {
|
||||
"title": post_title,
|
||||
"body": raw,
|
||||
"excerpt": raw[:200],
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
"""네이버 검색 API 연동 — 블로그 + 쇼핑 검색."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import requests
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from .config import NAVER_CLIENT_ID, NAVER_CLIENT_SECRET
|
||||
|
||||
BLOG_URL = "https://openapi.naver.com/v1/search/blog.json"
|
||||
SHOP_URL = "https://openapi.naver.com/v1/search/shop.json"
|
||||
|
||||
_HEADERS = {
|
||||
"X-Naver-Client-Id": NAVER_CLIENT_ID,
|
||||
"X-Naver-Client-Secret": NAVER_CLIENT_SECRET,
|
||||
}
|
||||
|
||||
_TAG_RE = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
def _strip_html(text: str) -> str:
|
||||
return _TAG_RE.sub("", text).strip()
|
||||
|
||||
|
||||
def search_blog(keyword: str, display: int = 10, sort: str = "sim") -> Dict[str, Any]:
|
||||
"""네이버 블로그 검색.
|
||||
|
||||
Args:
|
||||
keyword: 검색 키워드
|
||||
display: 결과 수 (1-100)
|
||||
sort: sim(정확도) | date(날짜)
|
||||
|
||||
Returns:
|
||||
{"total": int, "items": [...]}
|
||||
"""
|
||||
resp = requests.get(
|
||||
BLOG_URL,
|
||||
headers=_HEADERS,
|
||||
params={"query": keyword, "display": display, "sort": sort},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
items = [
|
||||
{
|
||||
"title": _strip_html(item.get("title", "")),
|
||||
"description": _strip_html(item.get("description", "")),
|
||||
"link": item.get("link", ""),
|
||||
"bloggername": item.get("bloggername", ""),
|
||||
"postdate": item.get("postdate", ""),
|
||||
}
|
||||
for item in data.get("items", [])
|
||||
]
|
||||
return {"total": data.get("total", 0), "items": items}
|
||||
|
||||
|
||||
def search_shopping(keyword: str, display: int = 20, sort: str = "sim") -> Dict[str, Any]:
|
||||
"""네이버 쇼핑 검색.
|
||||
|
||||
Args:
|
||||
keyword: 검색 키워드
|
||||
display: 결과 수 (1-100)
|
||||
sort: sim(정확도) | date(날짜) | asc(가격↑) | dsc(가격↓)
|
||||
|
||||
Returns:
|
||||
{"total": int, "items": [...], "price_stats": {...}}
|
||||
"""
|
||||
resp = requests.get(
|
||||
SHOP_URL,
|
||||
headers=_HEADERS,
|
||||
params={"query": keyword, "display": display, "sort": sort},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
items = []
|
||||
prices = []
|
||||
for item in data.get("items", []):
|
||||
lprice = _safe_int(item.get("lprice"))
|
||||
hprice = _safe_int(item.get("hprice"))
|
||||
parsed = {
|
||||
"title": _strip_html(item.get("title", "")),
|
||||
"link": item.get("link", ""),
|
||||
"image": item.get("image", ""),
|
||||
"lprice": lprice,
|
||||
"hprice": hprice,
|
||||
"mallName": item.get("mallName", ""),
|
||||
"productId": item.get("productId", ""),
|
||||
"productType": item.get("productType", ""),
|
||||
"category1": item.get("category1", ""),
|
||||
"category2": item.get("category2", ""),
|
||||
"category3": item.get("category3", ""),
|
||||
"brand": item.get("brand", ""),
|
||||
"maker": item.get("maker", ""),
|
||||
}
|
||||
items.append(parsed)
|
||||
if lprice and lprice > 0:
|
||||
prices.append(lprice)
|
||||
|
||||
price_stats = None
|
||||
if prices:
|
||||
price_stats = {
|
||||
"min": min(prices),
|
||||
"max": max(prices),
|
||||
"avg": int(sum(prices) / len(prices)),
|
||||
"count": len(prices),
|
||||
}
|
||||
|
||||
return {
|
||||
"total": data.get("total", 0),
|
||||
"items": items,
|
||||
"price_stats": price_stats,
|
||||
}
|
||||
|
||||
|
||||
def _safe_int(val) -> Optional[int]:
|
||||
if val is None:
|
||||
return None
|
||||
try:
|
||||
return int(val)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def analyze_keyword(keyword: str) -> Dict[str, Any]:
|
||||
"""키워드 경쟁도/기회 분석.
|
||||
|
||||
블로그 총 결과수, 쇼핑 총 결과수, 가격 통계를 기반으로
|
||||
competition_score(경쟁도)와 opportunity_score(기회점수) 산출.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"keyword", "blog_total", "shop_total",
|
||||
"competition", "opportunity",
|
||||
"avg_price", "min_price", "max_price",
|
||||
"top_products": [...], "top_blogs": [...]
|
||||
}
|
||||
"""
|
||||
blog = search_blog(keyword, display=10, sort="sim")
|
||||
shop = search_shopping(keyword, display=20, sort="sim")
|
||||
|
||||
blog_total = blog["total"]
|
||||
shop_total = shop["total"]
|
||||
|
||||
# 경쟁도: 블로그 결과 수 기반 (로그 스케일 0-100)
|
||||
import math
|
||||
if blog_total > 0:
|
||||
competition = min(100, int(math.log10(blog_total + 1) * 15))
|
||||
else:
|
||||
competition = 0
|
||||
|
||||
# 기회 점수: 쇼핑 수요가 높고 블로그 경쟁이 낮을수록 높음
|
||||
if shop_total > 0 and blog_total > 0:
|
||||
ratio = shop_total / blog_total
|
||||
opportunity = min(100, int(ratio * 20))
|
||||
elif shop_total > 0:
|
||||
opportunity = 90 # 경쟁 없이 수요만 있으면 높은 기회
|
||||
else:
|
||||
opportunity = 10 # 쇼핑 수요 없음
|
||||
|
||||
price_stats = shop.get("price_stats") or {}
|
||||
|
||||
return {
|
||||
"keyword": keyword,
|
||||
"blog_total": blog_total,
|
||||
"shop_total": shop_total,
|
||||
"competition": competition,
|
||||
"opportunity": opportunity,
|
||||
"avg_price": price_stats.get("avg"),
|
||||
"min_price": price_stats.get("min"),
|
||||
"max_price": price_stats.get("max"),
|
||||
"top_products": shop["items"][:5],
|
||||
"top_blogs": blog["items"][:5],
|
||||
}
|
||||
|
||||
|
||||
def _run_enrich(top_blogs: list) -> list:
|
||||
"""동기 컨텍스트에서 비동기 enrich_top_blogs 실행."""
|
||||
from .web_crawler import enrich_top_blogs
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
return pool.submit(
|
||||
asyncio.run, enrich_top_blogs(top_blogs)
|
||||
).result(timeout=60)
|
||||
else:
|
||||
return asyncio.run(enrich_top_blogs(top_blogs))
|
||||
except Exception as e:
|
||||
logger.warning("블로그 크롤링 실패, 기존 데이터 사용: %s", e)
|
||||
return top_blogs
|
||||
|
||||
|
||||
def analyze_keyword_with_crawling(keyword: str) -> Dict[str, Any]:
|
||||
"""analyze_keyword + 상위 블로그 본문 크롤링."""
|
||||
result = analyze_keyword(keyword)
|
||||
result["top_blogs"] = _run_enrich(result["top_blogs"])
|
||||
return result
|
||||
@@ -1,85 +0,0 @@
|
||||
"""Claude API 기반 블로그 글 품질 리뷰 — 6기준 × 10점, 42/60 통과."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import anthropic
|
||||
|
||||
from .config import ANTHROPIC_API_KEY, CLAUDE_MODEL
|
||||
from .db import get_template
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PASS_THRESHOLD = 42 # 60점 만점 중 42점 이상이면 통과 (70%)
|
||||
|
||||
_client: Optional[anthropic.Anthropic] = None
|
||||
|
||||
|
||||
def _get_client() -> anthropic.Anthropic:
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
|
||||
return _client
|
||||
|
||||
|
||||
def review_post(title: str, body: str) -> Dict[str, Any]:
|
||||
"""블로그 글 품질 리뷰.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"scores": {
|
||||
"empathy": N, "click_appeal": N, "conversion": N,
|
||||
"seo": N, "format": N, "link_natural": N
|
||||
},
|
||||
"total": N,
|
||||
"pass": bool,
|
||||
"feedback": str
|
||||
}
|
||||
"""
|
||||
template = get_template("quality_review")
|
||||
if not template:
|
||||
raise RuntimeError("quality_review 템플릿이 없습니다")
|
||||
|
||||
prompt = template.format(title=title, body=body[:6000])
|
||||
|
||||
client = _get_client()
|
||||
today = date.today().isoformat()
|
||||
resp = client.messages.create(
|
||||
model=CLAUDE_MODEL,
|
||||
max_tokens=2048,
|
||||
system=f"현재 날짜는 {today}입니다.",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
raw = resp.content[0].text
|
||||
|
||||
try:
|
||||
text = raw.strip()
|
||||
if text.startswith("```"):
|
||||
lines = text.split("\n")
|
||||
lines = [l for l in lines if not l.strip().startswith("```")]
|
||||
text = "\n".join(lines)
|
||||
result = json.loads(text)
|
||||
|
||||
scores = result.get("scores", {})
|
||||
total = sum(scores.values())
|
||||
passed = total >= PASS_THRESHOLD
|
||||
|
||||
return {
|
||||
"scores": scores,
|
||||
"total": total,
|
||||
"pass": passed,
|
||||
"feedback": result.get("feedback", ""),
|
||||
}
|
||||
except (json.JSONDecodeError, KeyError, TypeError) as e:
|
||||
logger.warning("Quality review JSON parse failed: %s", e)
|
||||
return {
|
||||
"scores": {
|
||||
"empathy": 0, "click_appeal": 0, "conversion": 0,
|
||||
"seo": 0, "format": 0, "link_natural": 0,
|
||||
},
|
||||
"total": 0,
|
||||
"pass": False,
|
||||
"feedback": f"리뷰 파싱 실패. 원본 응답:\n{raw[:500]}",
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
"""네이버 블로그 본문 크롤링 모듈."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TIMEOUT = 10 # 글당 크롤링 타임아웃 (초)
|
||||
_MAX_CONTENT_LENGTH = 2000 # 본문 최대 길이
|
||||
|
||||
# 네이버 블로그 URL 패턴: blog.naver.com/{blogId}/{logNo}
|
||||
_BLOG_URL_RE = re.compile(r"blog\.naver\.com/([^/]+)/(\d+)")
|
||||
|
||||
|
||||
def _parse_naver_blog_url(url: str) -> Optional[Tuple[str, str]]:
|
||||
"""네이버 블로그 URL에서 blogId, logNo 추출. 실패 시 None."""
|
||||
match = _BLOG_URL_RE.search(url)
|
||||
if not match:
|
||||
return None
|
||||
return match.group(1), match.group(2)
|
||||
|
||||
|
||||
async def _fetch_html(url: str) -> str:
|
||||
"""URL에서 HTML을 가져온다."""
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT, follow_redirects=True) as client:
|
||||
resp = await client.get(url, headers={
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
})
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
|
||||
|
||||
def _extract_text(html: str) -> str:
|
||||
"""HTML에서 본문 텍스트를 추출한다."""
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
|
||||
# 스마트에디터 3 (SE3)
|
||||
container = soup.select_one("div.se-main-container")
|
||||
if not container:
|
||||
# 구 에디터
|
||||
container = soup.select_one("div#postViewArea")
|
||||
if not container:
|
||||
# 폴백: body 전체
|
||||
container = soup.body
|
||||
|
||||
if not container:
|
||||
return ""
|
||||
|
||||
# 스크립트/스타일 제거
|
||||
for tag in container.find_all(["script", "style"]):
|
||||
tag.decompose()
|
||||
|
||||
text = container.get_text(separator="\n", strip=True)
|
||||
return text[:_MAX_CONTENT_LENGTH]
|
||||
|
||||
|
||||
async def crawl_blog_content(url: str) -> str:
|
||||
"""네이버 블로그 URL에서 본문 텍스트 추출.
|
||||
|
||||
- 네이버 블로그가 아니면 빈 문자열
|
||||
- 크롤링 실패 시 빈 문자열 (에러 로그만)
|
||||
- 본문 최대 2,000자
|
||||
"""
|
||||
parsed = _parse_naver_blog_url(url)
|
||||
if not parsed:
|
||||
return ""
|
||||
|
||||
blog_id, log_no = parsed
|
||||
# iframe 내부 실제 본문 URL
|
||||
post_url = f"https://blog.naver.com/PostView.naver?blogId={blog_id}&logNo={log_no}"
|
||||
|
||||
try:
|
||||
html = await _fetch_html(post_url)
|
||||
return _extract_text(html)
|
||||
except Exception as e:
|
||||
logger.warning("블로그 크롤링 실패 (%s): %s", url, e)
|
||||
return ""
|
||||
|
||||
|
||||
async def enrich_top_blogs(top_blogs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""top_blogs 리스트 각 항목에 content 필드를 추가.
|
||||
|
||||
개별 크롤링 실패 시 해당 항목의 content를 빈 문자열로 설정하고 나머지 계속 진행.
|
||||
"""
|
||||
result = []
|
||||
for blog in top_blogs:
|
||||
enriched = dict(blog)
|
||||
try:
|
||||
enriched["content"] = await crawl_blog_content(blog.get("link", ""))
|
||||
except Exception:
|
||||
enriched["content"] = ""
|
||||
result.append(enriched)
|
||||
return result
|
||||
@@ -1,9 +0,0 @@
|
||||
"""공통 테스트 픽스처."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# app 패키지를 blog_lab_app으로도 import 가능하게
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
if "blog_lab_app" not in sys.modules:
|
||||
import app as blog_lab_app
|
||||
sys.modules["blog_lab_app"] = blog_lab_app
|
||||
@@ -1,85 +0,0 @@
|
||||
"""브랜드커넥트 링크 API 테스트."""
|
||||
import os
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db(tmp_path):
|
||||
test_db = str(tmp_path / "test.db")
|
||||
import app.config as config
|
||||
config.DB_PATH = test_db
|
||||
from app import db
|
||||
db.DB_PATH = test_db
|
||||
db.init_db()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
from app.main import app
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_create_link(client):
|
||||
resp = client.post("/api/blog-marketing/links", json={
|
||||
"keyword_id": 1,
|
||||
"url": "https://link.coupang.com/abc",
|
||||
"product_name": "테스트 상품",
|
||||
"description": "상품 설명",
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["url"] == "https://link.coupang.com/abc"
|
||||
assert data["product_name"] == "테스트 상품"
|
||||
|
||||
|
||||
def test_create_link_requires_url(client):
|
||||
resp = client.post("/api/blog-marketing/links", json={
|
||||
"product_name": "상품",
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_create_link_requires_product_name(client):
|
||||
resp = client.post("/api/blog-marketing/links", json={
|
||||
"url": "https://a.com",
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_list_links_by_keyword_id(client):
|
||||
client.post("/api/blog-marketing/links", json={
|
||||
"keyword_id": 1, "url": "https://a.com", "product_name": "A",
|
||||
})
|
||||
client.post("/api/blog-marketing/links", json={
|
||||
"keyword_id": 2, "url": "https://b.com", "product_name": "B",
|
||||
})
|
||||
resp = client.get("/api/blog-marketing/links?keyword_id=1")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["links"]) == 1
|
||||
|
||||
|
||||
def test_update_link(client):
|
||||
create_resp = client.post("/api/blog-marketing/links", json={
|
||||
"url": "https://a.com", "product_name": "원래",
|
||||
})
|
||||
link_id = create_resp.json()["id"]
|
||||
resp = client.put(f"/api/blog-marketing/links/{link_id}", json={
|
||||
"product_name": "새이름",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["product_name"] == "새이름"
|
||||
|
||||
|
||||
def test_delete_link(client):
|
||||
create_resp = client.post("/api/blog-marketing/links", json={
|
||||
"url": "https://a.com", "product_name": "삭제",
|
||||
})
|
||||
link_id = create_resp.json()["id"]
|
||||
resp = client.delete(f"/api/blog-marketing/links/{link_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["ok"] is True
|
||||
|
||||
resp = client.delete(f"/api/blog-marketing/links/{link_id}")
|
||||
assert resp.status_code == 404
|
||||
@@ -1,67 +0,0 @@
|
||||
"""brand_links DB CRUD 테스트."""
|
||||
import os
|
||||
import pytest
|
||||
from app import db
|
||||
from app.config import DB_PATH
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db(tmp_path):
|
||||
"""테스트용 임시 DB 사용."""
|
||||
test_db = str(tmp_path / "test.db")
|
||||
import app.config as config
|
||||
config.DB_PATH = test_db
|
||||
db.DB_PATH = test_db
|
||||
db.init_db()
|
||||
yield
|
||||
|
||||
|
||||
def test_add_brand_link():
|
||||
link = db.add_brand_link({
|
||||
"keyword_id": 1,
|
||||
"url": "https://link.coupang.com/abc",
|
||||
"product_name": "테스트 상품",
|
||||
"description": "상품 설명",
|
||||
"placement_hint": "본문 중간",
|
||||
})
|
||||
assert link["id"] is not None
|
||||
assert link["url"] == "https://link.coupang.com/abc"
|
||||
assert link["product_name"] == "테스트 상품"
|
||||
assert link["keyword_id"] == 1
|
||||
assert link["post_id"] is None
|
||||
|
||||
|
||||
def test_get_brand_links_by_keyword_id():
|
||||
db.add_brand_link({"keyword_id": 1, "url": "https://a.com", "product_name": "A"})
|
||||
db.add_brand_link({"keyword_id": 1, "url": "https://b.com", "product_name": "B"})
|
||||
db.add_brand_link({"keyword_id": 2, "url": "https://c.com", "product_name": "C"})
|
||||
links = db.get_brand_links(keyword_id=1)
|
||||
assert len(links) == 2
|
||||
|
||||
|
||||
def test_get_brand_links_by_post_id():
|
||||
db.add_brand_link({"post_id": 10, "url": "https://a.com", "product_name": "A"})
|
||||
links = db.get_brand_links(post_id=10)
|
||||
assert len(links) == 1
|
||||
assert links[0]["post_id"] == 10
|
||||
|
||||
|
||||
def test_update_brand_link():
|
||||
link = db.add_brand_link({"url": "https://a.com", "product_name": "원래 이름"})
|
||||
updated = db.update_brand_link(link["id"], {"product_name": "새 이름", "post_id": 5})
|
||||
assert updated["product_name"] == "새 이름"
|
||||
assert updated["post_id"] == 5
|
||||
|
||||
|
||||
def test_delete_brand_link():
|
||||
link = db.add_brand_link({"url": "https://a.com", "product_name": "삭제할 링크"})
|
||||
assert db.delete_brand_link(link["id"]) is True
|
||||
assert db.delete_brand_link(link["id"]) is False
|
||||
|
||||
|
||||
def test_link_keyword_to_post():
|
||||
db.add_brand_link({"keyword_id": 1, "url": "https://a.com", "product_name": "A"})
|
||||
db.add_brand_link({"keyword_id": 1, "url": "https://b.com", "product_name": "B"})
|
||||
db.link_brand_links_to_post(keyword_id=1, post_id=10)
|
||||
links = db.get_brand_links(post_id=10)
|
||||
assert len(links) == 2
|
||||
@@ -1,74 +0,0 @@
|
||||
"""평가자 단계 테스트 — 6기준 60점."""
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def test_review_post_has_6_criteria():
|
||||
"""6개 기준으로 채점하는지 확인."""
|
||||
from app.quality_reviewer import review_post
|
||||
|
||||
mock_response = json.dumps({
|
||||
"scores": {
|
||||
"empathy": 8, "click_appeal": 7, "conversion": 9,
|
||||
"seo": 8, "format": 7, "link_natural": 9,
|
||||
},
|
||||
"total": 48,
|
||||
"pass": True,
|
||||
"feedback": "전체적으로 우수합니다",
|
||||
})
|
||||
|
||||
with patch("app.quality_reviewer._get_client") as mock_client_fn, \
|
||||
patch("app.quality_reviewer.get_template", return_value="제목: {title}\n본문: {body}"):
|
||||
mock_client = mock_client_fn.return_value
|
||||
mock_client.messages.create.return_value.content = [type("C", (), {"text": mock_response})()]
|
||||
result = review_post("테스트 제목", "<p>본문</p>")
|
||||
|
||||
assert "link_natural" in result["scores"]
|
||||
assert len(result["scores"]) == 6
|
||||
assert result["total"] == 48
|
||||
assert result["pass"] is True
|
||||
|
||||
|
||||
def test_review_pass_threshold_is_42():
|
||||
"""통과 기준이 42점인지 확인."""
|
||||
from app.quality_reviewer import PASS_THRESHOLD
|
||||
assert PASS_THRESHOLD == 42
|
||||
|
||||
|
||||
def test_review_fails_below_42():
|
||||
"""42점 미만이면 불통과."""
|
||||
from app.quality_reviewer import review_post
|
||||
|
||||
mock_response = json.dumps({
|
||||
"scores": {
|
||||
"empathy": 5, "click_appeal": 5, "conversion": 5,
|
||||
"seo": 5, "format": 5, "link_natural": 5,
|
||||
},
|
||||
"total": 30,
|
||||
"pass": False,
|
||||
"feedback": "개선 필요",
|
||||
})
|
||||
|
||||
with patch("app.quality_reviewer._get_client") as mock_client_fn, \
|
||||
patch("app.quality_reviewer.get_template", return_value="제목: {title}\n본문: {body}"):
|
||||
mock_client = mock_client_fn.return_value
|
||||
mock_client.messages.create.return_value.content = [type("C", (), {"text": mock_response})()]
|
||||
result = review_post("제목", "<p>본문</p>")
|
||||
|
||||
assert result["pass"] is False
|
||||
|
||||
|
||||
def test_review_handles_parse_failure():
|
||||
"""JSON 파싱 실패 시 기본값 반환 (6개 기준)."""
|
||||
from app.quality_reviewer import review_post
|
||||
|
||||
with patch("app.quality_reviewer._get_client") as mock_client_fn, \
|
||||
patch("app.quality_reviewer.get_template", return_value="제목: {title}\n본문: {body}"):
|
||||
mock_client = mock_client_fn.return_value
|
||||
mock_client.messages.create.return_value.content = [type("C", (), {"text": "잘못된 응답"})()]
|
||||
result = review_post("제목", "<p>본문</p>")
|
||||
|
||||
assert result["pass"] is False
|
||||
assert "link_natural" in result["scores"]
|
||||
assert result["total"] == 0
|
||||
@@ -1,66 +0,0 @@
|
||||
"""마케터 단계 테스트."""
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def test_enhance_for_conversion_inserts_links():
|
||||
"""마케터가 브랜드 링크를 본문에 삽입."""
|
||||
from app.marketer import enhance_for_conversion
|
||||
|
||||
brand_links = [
|
||||
{"url": "https://link.coupang.com/abc", "product_name": "갤럭시 버즈3",
|
||||
"description": "노이즈캔슬링", "placement_hint": "본문 중간"},
|
||||
]
|
||||
|
||||
mock_response = json.dumps({
|
||||
"title": "마케팅된 제목",
|
||||
"body": '<p>본문 <a href="https://link.coupang.com/abc">갤럭시 버즈3</a></p>',
|
||||
"excerpt": "요약",
|
||||
})
|
||||
|
||||
with patch("app.marketer._call_claude", return_value=mock_response) as mock_call, \
|
||||
patch("app.marketer.get_template", return_value="초안: {draft_body}\n키워드: {keyword}\n링크:\n{brand_links_info}"):
|
||||
result = enhance_for_conversion(
|
||||
post_body="<p>초안 본문</p>",
|
||||
post_title="초안 제목",
|
||||
brand_links=brand_links,
|
||||
keyword="무선 이어폰",
|
||||
)
|
||||
|
||||
prompt_used = mock_call.call_args[0][0]
|
||||
assert "갤럭시 버즈3" in prompt_used
|
||||
assert "노이즈캔슬링" in prompt_used
|
||||
assert result["title"] == "마케팅된 제목"
|
||||
|
||||
|
||||
def test_enhance_requires_brand_links():
|
||||
"""브랜드 링크가 없으면 ValueError."""
|
||||
from app.marketer import enhance_for_conversion
|
||||
|
||||
with pytest.raises(ValueError, match="브랜드커넥트 링크가 필요합니다"):
|
||||
enhance_for_conversion(
|
||||
post_body="<p>본문</p>",
|
||||
post_title="제목",
|
||||
brand_links=[],
|
||||
keyword="테스트",
|
||||
)
|
||||
|
||||
|
||||
def test_enhance_json_parse_fallback():
|
||||
"""JSON 파싱 실패 시 원본 제목 유지."""
|
||||
from app.marketer import enhance_for_conversion
|
||||
|
||||
brand_links = [{"url": "https://a.com", "product_name": "상품"}]
|
||||
|
||||
with patch("app.marketer._call_claude", return_value="잘못된 JSON"), \
|
||||
patch("app.marketer.get_template", return_value="초안: {draft_body}\n키워드: {keyword}\n링크:\n{brand_links_info}"):
|
||||
result = enhance_for_conversion(
|
||||
post_body="<p>원본</p>",
|
||||
post_title="원본 제목",
|
||||
brand_links=brand_links,
|
||||
keyword="테스트",
|
||||
)
|
||||
|
||||
assert result["title"] == "원본 제목"
|
||||
assert result["body"] == "잘못된 JSON"
|
||||
@@ -1,146 +0,0 @@
|
||||
"""4단계 파이프라인 통합 테스트."""
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db(tmp_path):
|
||||
test_db = str(tmp_path / "test.db")
|
||||
import app.config as config
|
||||
config.DB_PATH = test_db
|
||||
from app import db
|
||||
db.DB_PATH = test_db
|
||||
db.init_db()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
from app.main import app
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_full_pipeline_status_flow(client):
|
||||
"""draft → marketed → reviewed → published 상태 흐름."""
|
||||
from app import db
|
||||
|
||||
# 1. 키워드 분석 결과 직접 삽입
|
||||
analysis = db.add_keyword_analysis({
|
||||
"keyword": "무선 이어폰",
|
||||
"blog_total": 1000,
|
||||
"shop_total": 500,
|
||||
"competition": 45,
|
||||
"opportunity": 60,
|
||||
"top_products": [{"title": "에어팟", "lprice": 200000, "mallName": "애플"}],
|
||||
"top_blogs": [{"title": "리뷰", "link": "https://blog.naver.com/user/123", "content": "본문"}],
|
||||
})
|
||||
|
||||
# 2. 브랜드 링크 등록
|
||||
resp = client.post("/api/blog-marketing/links", json={
|
||||
"keyword_id": analysis["id"],
|
||||
"url": "https://link.coupang.com/abc",
|
||||
"product_name": "삼성 버즈3",
|
||||
"description": "노이즈캔슬링",
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
|
||||
# 3. 포스트 직접 생성 (generate는 Claude API 필요)
|
||||
post = db.add_post({
|
||||
"keyword_id": analysis["id"],
|
||||
"title": "무선 이어폰 추천",
|
||||
"body": "<p>초안 본문</p>",
|
||||
"excerpt": "요약",
|
||||
"tags": ["이어폰"],
|
||||
"status": "draft",
|
||||
})
|
||||
db.link_brand_links_to_post(keyword_id=analysis["id"], post_id=post["id"])
|
||||
|
||||
# 4. 상태 확인: draft
|
||||
resp = client.get(f"/api/blog-marketing/posts/{post['id']}")
|
||||
assert resp.json()["status"] == "draft"
|
||||
|
||||
# 5. marketed 상태
|
||||
db.update_post(post["id"], {"status": "marketed", "body": "<p>마케팅된 본문</p>"})
|
||||
resp = client.get(f"/api/blog-marketing/posts/{post['id']}")
|
||||
assert resp.json()["status"] == "marketed"
|
||||
|
||||
# 6. reviewed 상태 (점수 48/60 = 통과)
|
||||
db.update_post(post["id"], {
|
||||
"status": "reviewed",
|
||||
"review_score": 48,
|
||||
"review_detail": {
|
||||
"scores": {"empathy": 8, "click_appeal": 8, "conversion": 8, "seo": 8, "format": 8, "link_natural": 8},
|
||||
"total": 48, "pass": True, "feedback": "우수"
|
||||
},
|
||||
})
|
||||
resp = client.get(f"/api/blog-marketing/posts/{post['id']}")
|
||||
assert resp.json()["status"] == "reviewed"
|
||||
assert resp.json()["review_score"] == 48
|
||||
|
||||
# 7. 발행
|
||||
resp = client.post(f"/api/blog-marketing/posts/{post['id']}/publish", json={
|
||||
"naver_url": "https://blog.naver.com/mypost/123",
|
||||
})
|
||||
assert resp.json()["status"] == "published"
|
||||
|
||||
|
||||
def test_links_associated_with_post(client):
|
||||
"""keyword_id로 등록한 링크가 post 생성 후 post_id로도 조회 가능."""
|
||||
from app import db
|
||||
|
||||
analysis = db.add_keyword_analysis({"keyword": "테스트", "blog_total": 10, "shop_total": 5})
|
||||
client.post("/api/blog-marketing/links", json={
|
||||
"keyword_id": analysis["id"],
|
||||
"url": "https://link.com/1",
|
||||
"product_name": "상품1",
|
||||
})
|
||||
|
||||
post = db.add_post({"keyword_id": analysis["id"], "title": "제목", "body": "본문", "status": "draft"})
|
||||
db.link_brand_links_to_post(keyword_id=analysis["id"], post_id=post["id"])
|
||||
|
||||
resp = client.get(f"/api/blog-marketing/links?post_id={post['id']}")
|
||||
links = resp.json()["links"]
|
||||
assert len(links) == 1
|
||||
assert links[0]["product_name"] == "상품1"
|
||||
|
||||
|
||||
@patch("app.main.ANTHROPIC_API_KEY", "fake-key-for-test")
|
||||
def test_market_endpoint_returns_404_for_missing_post(client):
|
||||
"""존재하지 않는 post_id로 마케터 호출 시 404."""
|
||||
resp = client.post("/api/blog-marketing/market/9999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@patch("app.main.ANTHROPIC_API_KEY", "fake-key-for-test")
|
||||
def test_review_endpoint_returns_404_for_missing_post(client):
|
||||
"""존재하지 않는 post_id로 리뷰 호출 시 404."""
|
||||
resp = client.post("/api/blog-marketing/review/9999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_multiple_links_per_keyword(client):
|
||||
"""하나의 키워드에 복수 링크 등록 가능."""
|
||||
from app import db
|
||||
analysis = db.add_keyword_analysis({"keyword": "테스트", "blog_total": 10, "shop_total": 5})
|
||||
|
||||
for i in range(3):
|
||||
resp = client.post("/api/blog-marketing/links", json={
|
||||
"keyword_id": analysis["id"],
|
||||
"url": f"https://link.com/{i}",
|
||||
"product_name": f"상품{i}",
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
|
||||
resp = client.get(f"/api/blog-marketing/links?keyword_id={analysis['id']}")
|
||||
assert len(resp.json()["links"]) == 3
|
||||
|
||||
|
||||
def test_dashboard_still_works(client):
|
||||
"""대시보드 API가 여전히 정상 작동."""
|
||||
resp = client.get("/api/blog-marketing/dashboard")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "total_posts" in data
|
||||
assert "published_posts" in data
|
||||
@@ -1,58 +0,0 @@
|
||||
"""리서치 단계 크롤링 통합 테스트."""
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def test_analyze_keyword_with_crawling_enriches_top_blogs():
|
||||
"""analyze_keyword_with_crawling가 top_blogs에 content 필드를 추가."""
|
||||
from app.naver_search import analyze_keyword_with_crawling
|
||||
|
||||
mock_blog_result = {
|
||||
"total": 100,
|
||||
"items": [
|
||||
{"title": "테스트 블로그", "link": "https://blog.naver.com/user1/111",
|
||||
"bloggername": "유저1", "description": "설명", "postdate": "20260401"},
|
||||
],
|
||||
}
|
||||
mock_shop_result = {
|
||||
"total": 50,
|
||||
"items": [{"title": "상품1", "lprice": 10000, "mallName": "쿠팡"}],
|
||||
"price_stats": {"min": 10000, "max": 10000, "avg": 10000, "count": 1},
|
||||
}
|
||||
|
||||
with patch("app.naver_search.search_blog", return_value=mock_blog_result), \
|
||||
patch("app.naver_search.search_shopping", return_value=mock_shop_result), \
|
||||
patch("app.naver_search._run_enrich", return_value=[
|
||||
{"title": "테스트 블로그", "link": "https://blog.naver.com/user1/111",
|
||||
"bloggername": "유저1", "description": "설명", "postdate": "20260401",
|
||||
"content": "크롤링된 본문 내용"}
|
||||
]):
|
||||
result = analyze_keyword_with_crawling("테스트 키워드")
|
||||
|
||||
assert "content" in result["top_blogs"][0]
|
||||
assert result["top_blogs"][0]["content"] == "크롤링된 본문 내용"
|
||||
|
||||
|
||||
def test_analyze_keyword_with_crawling_fallback_on_enrich_failure():
|
||||
"""크롤링 실패 시 기존 데이터 유지."""
|
||||
from app.naver_search import analyze_keyword_with_crawling
|
||||
|
||||
mock_blog_result = {
|
||||
"total": 50,
|
||||
"items": [{"title": "블로그", "link": "https://blog.naver.com/u/1", "bloggername": "유저", "description": "설명"}],
|
||||
}
|
||||
mock_shop_result = {"total": 10, "items": [], "price_stats": None}
|
||||
|
||||
with patch("app.naver_search.search_blog", return_value=mock_blog_result), \
|
||||
patch("app.naver_search.search_shopping", return_value=mock_shop_result), \
|
||||
patch("app.naver_search._run_enrich", side_effect=Exception("크롤링 실패")):
|
||||
# _run_enrich 내부에서 예외를 잡으므로 실제로는 이 테스트에서는
|
||||
# _run_enrich 자체가 예외를 던지는 상황을 시뮬레이션
|
||||
# 하지만 _run_enrich는 내부에서 잡으므로, 직접 fallback 테스트
|
||||
pass
|
||||
|
||||
# _run_enrich 자체 fallback 테스트
|
||||
from app.naver_search import _run_enrich
|
||||
original_blogs = [{"title": "원본", "link": "https://blog.naver.com/u/1"}]
|
||||
with patch("app.web_crawler.enrich_top_blogs", side_effect=Exception("fail")):
|
||||
result = _run_enrich(original_blogs)
|
||||
assert result == original_blogs # fallback으로 원본 반환
|
||||
@@ -1,94 +0,0 @@
|
||||
"""web_crawler 모듈 테스트."""
|
||||
import pytest
|
||||
from unittest.mock import patch, AsyncMock
|
||||
from app.web_crawler import crawl_blog_content, enrich_top_blogs, _parse_naver_blog_url, _extract_text
|
||||
|
||||
|
||||
def test_parse_naver_blog_url_valid():
|
||||
"""blog.naver.com URL에서 blogId와 logNo를 올바르게 파싱."""
|
||||
result = _parse_naver_blog_url("https://blog.naver.com/testuser/123456")
|
||||
assert result == ("testuser", "123456")
|
||||
|
||||
|
||||
def test_parse_returns_none_for_invalid_url():
|
||||
"""잘못된 URL은 None 반환."""
|
||||
result = _parse_naver_blog_url("https://example.com/post")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_extract_text_prefers_se_main_container():
|
||||
"""SE3 에디터 컨테이너를 우선 선택."""
|
||||
html = '<div class="se-main-container"><p>SE3 본문</p></div><div id="postViewArea"><p>구 에디터</p></div>'
|
||||
assert _extract_text(html) == "SE3 본문"
|
||||
|
||||
|
||||
def test_extract_text_falls_back_to_post_view_area():
|
||||
"""SE3 없으면 구 에디터 컨테이너 사용."""
|
||||
html = '<div id="postViewArea"><p>구 에디터 본문</p></div>'
|
||||
assert _extract_text(html) == "구 에디터 본문"
|
||||
|
||||
|
||||
def test_extract_text_removes_script_and_style():
|
||||
"""스크립트/스타일 태그 제거."""
|
||||
html = '<div class="se-main-container"><p>본문</p><script>alert(1)</script><style>.x{}</style></div>'
|
||||
result = _extract_text(html)
|
||||
assert "alert" not in result
|
||||
assert ".x" not in result
|
||||
assert "본문" in result
|
||||
|
||||
|
||||
def test_extract_text_returns_empty_on_no_container():
|
||||
"""컨테이너가 없고 body도 없으면 빈 문자열."""
|
||||
assert _extract_text("") == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crawl_returns_empty_on_non_naver_url():
|
||||
"""네이버 블로그가 아닌 URL은 빈 문자열 반환."""
|
||||
result = await crawl_blog_content("https://example.com/post")
|
||||
assert result == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crawl_truncates_to_2000_chars():
|
||||
"""본문이 2000자를 초과하면 잘라낸다."""
|
||||
long_html = f'<div class="se-main-container"><p>{"가" * 3000}</p></div>'
|
||||
with patch("app.web_crawler._fetch_html", new_callable=AsyncMock, return_value=long_html):
|
||||
result = await crawl_blog_content("https://blog.naver.com/testuser/123")
|
||||
assert len(result) <= 2000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crawl_returns_empty_on_fetch_failure():
|
||||
"""HTTP 요청 실패 시 빈 문자열 반환."""
|
||||
with patch("app.web_crawler._fetch_html", new_callable=AsyncMock, side_effect=Exception("timeout")):
|
||||
result = await crawl_blog_content("https://blog.naver.com/testuser/123")
|
||||
assert result == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enrich_top_blogs_adds_content_field():
|
||||
"""enrich_top_blogs가 각 블로그에 content 필드를 추가."""
|
||||
blogs = [
|
||||
{"title": "테스트", "link": "https://blog.naver.com/user1/111", "bloggername": "유저1", "description": "설명"},
|
||||
{"title": "테스트2", "link": "https://blog.naver.com/user2/222", "bloggername": "유저2", "description": "설명2"},
|
||||
]
|
||||
with patch("app.web_crawler.crawl_blog_content", new_callable=AsyncMock, return_value="크롤링된 본문"):
|
||||
result = await enrich_top_blogs(blogs)
|
||||
assert len(result) == 2
|
||||
assert result[0]["content"] == "크롤링된 본문"
|
||||
assert result[1]["content"] == "크롤링된 본문"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enrich_top_blogs_handles_partial_failure():
|
||||
"""일부 크롤링 실패 시에도 나머지는 정상 처리."""
|
||||
blogs = [
|
||||
{"title": "성공", "link": "https://blog.naver.com/user1/111"},
|
||||
{"title": "실패", "link": "https://blog.naver.com/user2/222"},
|
||||
]
|
||||
side_effects = ["성공 본문", Exception("fail")]
|
||||
with patch("app.web_crawler.crawl_blog_content", new_callable=AsyncMock, side_effect=side_effects):
|
||||
result = await enrich_top_blogs(blogs)
|
||||
assert result[0]["content"] == "성공 본문"
|
||||
assert result[1]["content"] == ""
|
||||
@@ -1,86 +0,0 @@
|
||||
"""작가 단계 테스트 -- 크롤링 본문 + 링크 참조 글 생성."""
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def test_generate_blog_post_includes_crawled_content():
|
||||
"""크롤링 본문이 프롬프트에 포함되는지 확인."""
|
||||
from app.content_generator import generate_blog_post
|
||||
|
||||
analysis = {
|
||||
"keyword": "무선 이어폰",
|
||||
"top_products": [{"title": "에어팟", "lprice": 200000, "mallName": "애플"}],
|
||||
"top_blogs": [
|
||||
{"title": "에어팟 리뷰", "content": "에어팟을 한 달간 써봤는데 음질이 정말 좋았습니다."},
|
||||
],
|
||||
}
|
||||
|
||||
mock_response = json.dumps({
|
||||
"title": "무선 이어폰 추천",
|
||||
"body": "<p>본문</p>",
|
||||
"excerpt": "요약",
|
||||
"tags": ["이어폰"],
|
||||
})
|
||||
|
||||
with patch("app.content_generator._call_claude", return_value=mock_response) as mock_call, \
|
||||
patch("app.content_generator.get_template", return_value=(
|
||||
"키워드: {keyword}\n참고 블로그:\n{reference_blogs}\n상품: {top_products}\n링크 상품: {brand_products}"
|
||||
)):
|
||||
result = generate_blog_post(analysis, "트렌드 브리프", brand_links=[])
|
||||
|
||||
prompt_used = mock_call.call_args[0][0]
|
||||
assert "에어팟을 한 달간 써봤는데" in prompt_used
|
||||
assert result["title"] == "무선 이어폰 추천"
|
||||
|
||||
|
||||
def test_generate_blog_post_includes_brand_links():
|
||||
"""브랜드커넥트 링크 정보가 프롬프트에 포함되는지 확인."""
|
||||
from app.content_generator import generate_blog_post
|
||||
|
||||
analysis = {"keyword": "무선 이어폰", "top_products": [], "top_blogs": []}
|
||||
brand_links = [
|
||||
{"url": "https://link.coupang.com/abc", "product_name": "삼성 버즈3",
|
||||
"description": "노이즈캔슬링 지원", "placement_hint": "본문 중간"},
|
||||
]
|
||||
|
||||
mock_response = json.dumps({
|
||||
"title": "제목", "body": "<p>본문</p>", "excerpt": "요약", "tags": ["태그"],
|
||||
})
|
||||
|
||||
with patch("app.content_generator._call_claude", return_value=mock_response) as mock_call, \
|
||||
patch("app.content_generator.get_template", return_value=(
|
||||
"키워드: {keyword}\n참고 블로그:\n{reference_blogs}\n상품: {top_products}\n링크 상품: {brand_products}"
|
||||
)):
|
||||
result = generate_blog_post(analysis, "트렌드 브리프", brand_links=brand_links)
|
||||
|
||||
prompt_used = mock_call.call_args[0][0]
|
||||
assert "삼성 버즈3" in prompt_used
|
||||
assert "노이즈캔슬링 지원" in prompt_used
|
||||
|
||||
|
||||
def test_generate_blog_post_works_without_links():
|
||||
"""링크 없이도 정상 동작."""
|
||||
from app.content_generator import generate_blog_post
|
||||
|
||||
analysis = {"keyword": "테스트", "top_products": [], "top_blogs": []}
|
||||
mock_response = json.dumps({
|
||||
"title": "제목", "body": "<p>본문</p>", "excerpt": "요약", "tags": ["태그"],
|
||||
})
|
||||
|
||||
with patch("app.content_generator._call_claude", return_value=mock_response), \
|
||||
patch("app.content_generator.get_template", return_value=(
|
||||
"키워드: {keyword}\n참고 블로그:\n{reference_blogs}\n상품: {top_products}\n링크 상품: {brand_products}"
|
||||
)):
|
||||
result = generate_blog_post(analysis, "브리프")
|
||||
|
||||
assert result["title"] == "제목"
|
||||
|
||||
|
||||
def test_parse_blog_json_fallback():
|
||||
"""JSON 파싱 실패 시 원본 텍스트를 body로 사용."""
|
||||
from app.content_generator import _parse_blog_json
|
||||
|
||||
result = _parse_blog_json("잘못된 JSON", "테스트 키워드")
|
||||
assert result["title"] == "테스트 키워드 추천 리뷰"
|
||||
assert result["body"] == "잘못된 JSON"
|
||||
@@ -1,12 +1,12 @@
|
||||
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"
|
||||
@@ -18,16 +18,16 @@ services:
|
||||
- ${RUNTIME_PATH}/data:/app/data
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
interval: 60s
|
||||
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"
|
||||
@@ -43,11 +43,12 @@ services:
|
||||
- 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:
|
||||
- ${RUNTIME_PATH}/data/stock:/app/data
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -61,35 +62,107 @@ services:
|
||||
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}
|
||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
|
||||
- MUSIC_RENDER_URL=${MUSIC_RENDER_URL:-http://192.168.45.59:18711}
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/data/music:/app/data
|
||||
- ${RUNTIME_PATH:-.}/data/videos:/app/data/videos
|
||||
depends_on:
|
||||
- redis
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
blog-lab:
|
||||
video-lab:
|
||||
build:
|
||||
context: ./blog-lab
|
||||
container_name: blog-lab
|
||||
context: ./video-lab
|
||||
container_name: video-lab
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18801:8000"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
|
||||
- VIDEO_DATA_DIR=/app/data
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/data/video:/app/data
|
||||
depends_on:
|
||||
- redis
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
image-lab:
|
||||
build: ./image-lab
|
||||
container_name: image-lab
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18802:8000"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
|
||||
- IMAGE_DATA_DIR=/app/data
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/data/image:/app/data
|
||||
depends_on:
|
||||
- redis
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 60s
|
||||
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}
|
||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/data/blog:/app/data
|
||||
- ${RUNTIME_PATH}/data/insta:/app/data
|
||||
depends_on:
|
||||
- redis
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -104,11 +177,12 @@ services:
|
||||
- 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
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -122,30 +196,80 @@ services:
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
- STOCK_LAB_URL=http://stock-lab:8000
|
||||
- STOCK_URL=http://stock:8000
|
||||
- MUSIC_LAB_URL=http://music-lab:8000
|
||||
- BLOG_LAB_URL=http://blog-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:-}
|
||||
- LOTTO_BACKEND_URL=${LOTTO_BACKEND_URL:-http://lotto-backend:8000}
|
||||
- CLAUDE_HAIKU_MODEL=${CLAUDE_HAIKU_MODEL:-claude-haiku-4-5-20251001}
|
||||
- CLAUDE_SONNET_MODEL=${CLAUDE_SONNET_MODEL:-claude-sonnet-4-6}
|
||||
- LOTTO_BACKEND_URL=${LOTTO_BACKEND_URL:-http://lotto:8000}
|
||||
- LOTTO_CURATOR_MODEL=${LOTTO_CURATOR_MODEL:-claude-sonnet-4-5}
|
||||
- CONVERSATION_MODEL=${CONVERSATION_MODEL:-claude-haiku-4-5-20251001}
|
||||
- CONVERSATION_HISTORY_LIMIT=${CONVERSATION_HISTORY_LIMIT:-20}
|
||||
- CONVERSATION_RATE_PER_MIN=${CONVERSATION_RATE_PER_MIN:-6}
|
||||
- YOUTUBE_DATA_API_KEY=${YOUTUBE_DATA_API_KEY:-}
|
||||
volumes:
|
||||
- ${RUNTIME_PATH:-.}/data/agent-office:/app/data
|
||||
depends_on:
|
||||
- stock-lab
|
||||
- stock
|
||||
- music-lab
|
||||
- blog-lab
|
||||
- insta-lab
|
||||
- realestate-lab
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
interval: 60s
|
||||
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: 60s
|
||||
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: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -168,18 +292,26 @@ services:
|
||||
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:rw
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
frontend:
|
||||
image: nginx:alpine
|
||||
container_name: lotto-frontend
|
||||
container_name: frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- lotto
|
||||
- stock
|
||||
- music-lab
|
||||
- blog-lab
|
||||
- insta-lab
|
||||
- realestate-lab
|
||||
- agent-office
|
||||
- personal
|
||||
- packs-lab
|
||||
- travel-proxy
|
||||
- video-lab
|
||||
- image-lab
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
@@ -188,11 +320,14 @@ services:
|
||||
- ${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
|
||||
- ${RUNTIME_PATH}/data/video:/data/video:ro
|
||||
- ${RUNTIME_PATH}/data/insta/insta_cards:/data/insta_cards:ro
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:80/"]
|
||||
interval: 30s
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -212,3 +347,18 @@ services:
|
||||
- ${RUNTIME_PATH}:/runtime:rw
|
||||
- ${RUNTIME_PATH}/scripts:/scripts:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/redis-data:/data
|
||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
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()`
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,681 +0,0 @@
|
||||
# Travel-Proxy 성능 개선 구현 계획
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** travel-proxy의 os.scandir 기반 아키텍처를 SQLite 인덱스 DB로 전환하고, 앨범 커버 지정 + 썸네일 사전 생성을 지원한다.
|
||||
|
||||
**Architecture:** 기존 main.py의 스캔/캐시/썸네일 로직을 db.py(스키마+쿼리)와 indexer.py(동기화+썸네일)로 분리. main.py는 라우트만 담당. DB 경로는 `/data/thumbs/travel.db`.
|
||||
|
||||
**Tech Stack:** Python 3.12, FastAPI, SQLite (표준 라이브러리 sqlite3), Pillow
|
||||
|
||||
---
|
||||
|
||||
### 파일 구조
|
||||
|
||||
| 파일 | 역할 | 상태 |
|
||||
|------|------|------|
|
||||
| `travel-proxy/app/db.py` | SQLite 스키마 정의, 커넥션 헬퍼, 쿼리 함수 | 신규 |
|
||||
| `travel-proxy/app/indexer.py` | 폴더 스캔 → DB 동기화 + 썸네일 일괄 생성 | 신규 |
|
||||
| `travel-proxy/app/main.py` | FastAPI 라우트 (기존 수정 + 신규 추가) | 수정 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: db.py — SQLite 스키마 및 쿼리 헬퍼
|
||||
|
||||
**Files:**
|
||||
- Create: `travel-proxy/app/db.py`
|
||||
|
||||
- [ ] **Step 1: db.py 파일 생성**
|
||||
|
||||
```python
|
||||
import os
|
||||
import sqlite3
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
DB_PATH = os.getenv("TRAVEL_DB_PATH", "/data/thumbs/travel.db")
|
||||
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS photos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
album TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
mtime REAL NOT NULL,
|
||||
has_thumb INTEGER DEFAULT 0,
|
||||
indexed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
UNIQUE(album, filename)
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_photos_album ON photos(album)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS album_covers (
|
||||
album TEXT PRIMARY KEY,
|
||||
filename TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def get_photos_by_region(albums: List[str], page: int, size: int) -> Dict[str, Any]:
|
||||
"""region에 속한 앨범들의 사진을 페이지네이션하여 반환."""
|
||||
if not albums:
|
||||
return {"items": [], "total": 0, "has_next": False, "matched_albums": []}
|
||||
|
||||
placeholders = ",".join("?" for _ in albums)
|
||||
|
||||
with _conn() as conn:
|
||||
# 앨범별 사진 수
|
||||
rows = conn.execute(
|
||||
f"SELECT album, COUNT(*) as cnt FROM photos WHERE album IN ({placeholders}) GROUP BY album",
|
||||
albums,
|
||||
).fetchall()
|
||||
matched_albums = [{"album": r["album"], "count": r["cnt"]} for r in rows]
|
||||
|
||||
# 전체 수
|
||||
total_row = conn.execute(
|
||||
f"SELECT COUNT(*) as cnt FROM photos WHERE album IN ({placeholders})",
|
||||
albums,
|
||||
).fetchone()
|
||||
total = total_row["cnt"]
|
||||
|
||||
# 페이지네이션
|
||||
offset = (page - 1) * size
|
||||
items = conn.execute(
|
||||
f"""SELECT album, filename, mtime FROM photos
|
||||
WHERE album IN ({placeholders})
|
||||
ORDER BY album, filename
|
||||
LIMIT ? OFFSET ?""",
|
||||
[*albums, size, offset],
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"items": [dict(r) for r in items],
|
||||
"total": total,
|
||||
"has_next": (offset + size) < total,
|
||||
"matched_albums": matched_albums,
|
||||
}
|
||||
|
||||
|
||||
def get_all_albums() -> List[Dict[str, Any]]:
|
||||
"""전체 앨범 목록 + 사진 수 + 커버 정보."""
|
||||
with _conn() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT p.album, COUNT(*) as count,
|
||||
COALESCE(c.filename, MIN(p.filename)) as cover_filename
|
||||
FROM photos p
|
||||
LEFT JOIN album_covers c ON p.album = c.album
|
||||
GROUP BY p.album
|
||||
ORDER BY p.album
|
||||
""").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def set_album_cover(album: str, filename: str) -> bool:
|
||||
"""앨범 커버 지정. 해당 photo가 존재하면 True, 없으면 False."""
|
||||
with _conn() as conn:
|
||||
exists = conn.execute(
|
||||
"SELECT 1 FROM photos WHERE album = ? AND filename = ?",
|
||||
(album, filename),
|
||||
).fetchone()
|
||||
if not exists:
|
||||
return False
|
||||
|
||||
conn.execute(
|
||||
"""INSERT INTO album_covers (album, filename, updated_at)
|
||||
VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
ON CONFLICT(album) DO UPDATE SET
|
||||
filename = excluded.filename,
|
||||
updated_at = excluded.updated_at""",
|
||||
(album, filename),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def get_album_cover(album: str) -> Optional[str]:
|
||||
"""앨범 커버 파일명 반환. 미지정 시 None."""
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT filename FROM album_covers WHERE album = ?",
|
||||
(album,),
|
||||
).fetchone()
|
||||
return row["filename"] if row else None
|
||||
|
||||
|
||||
def upsert_photo(album: str, filename: str, mtime: float) -> str:
|
||||
"""사진 upsert. 반환: 'added' | 'updated' | 'unchanged'."""
|
||||
with _conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT mtime, has_thumb FROM photos WHERE album = ? AND filename = ?",
|
||||
(album, filename),
|
||||
).fetchone()
|
||||
|
||||
if not existing:
|
||||
conn.execute(
|
||||
"INSERT INTO photos (album, filename, mtime, has_thumb) VALUES (?, ?, ?, 0)",
|
||||
(album, filename, mtime),
|
||||
)
|
||||
return "added"
|
||||
elif existing["mtime"] != mtime:
|
||||
conn.execute(
|
||||
"UPDATE photos SET mtime = ?, has_thumb = 0 WHERE album = ? AND filename = ?",
|
||||
(mtime, album, filename),
|
||||
)
|
||||
return "updated"
|
||||
return "unchanged"
|
||||
|
||||
|
||||
def remove_missing_photos(album: str, existing_filenames: set) -> int:
|
||||
"""폴더에 없는 사진을 DB에서 제거. 제거 수 반환."""
|
||||
with _conn() as conn:
|
||||
db_rows = conn.execute(
|
||||
"SELECT filename FROM photos WHERE album = ?", (album,)
|
||||
).fetchall()
|
||||
db_filenames = {r["filename"] for r in db_rows}
|
||||
to_remove = db_filenames - existing_filenames
|
||||
|
||||
if to_remove:
|
||||
placeholders = ",".join("?" for _ in to_remove)
|
||||
conn.execute(
|
||||
f"DELETE FROM photos WHERE album = ? AND filename IN ({placeholders})",
|
||||
[album, *to_remove],
|
||||
)
|
||||
# 삭제된 파일이 커버였으면 커버도 제거
|
||||
conn.execute(
|
||||
f"DELETE FROM album_covers WHERE album = ? AND filename IN ({placeholders})",
|
||||
[album, *to_remove],
|
||||
)
|
||||
return len(to_remove)
|
||||
|
||||
|
||||
def get_photos_without_thumb() -> List[Dict[str, str]]:
|
||||
"""썸네일 미생성 사진 목록."""
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT album, filename FROM photos WHERE has_thumb = 0"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def mark_thumb_done(album: str, filename: str) -> None:
|
||||
"""썸네일 생성 완료 표시."""
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE photos SET has_thumb = 1 WHERE album = ? AND filename = ?",
|
||||
(album, filename),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 커밋**
|
||||
|
||||
```bash
|
||||
git add travel-proxy/app/db.py
|
||||
git commit -m "feat(travel-proxy): db.py — SQLite 스키마 + 쿼리 헬퍼"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: indexer.py — 폴더 동기화 + 썸네일 일괄 생성
|
||||
|
||||
**Files:**
|
||||
- Create: `travel-proxy/app/indexer.py`
|
||||
|
||||
- [ ] **Step 1: indexer.py 파일 생성**
|
||||
|
||||
기존 main.py의 `ensure_thumb` 로직(라인 105-144)과 `scan_album` 로직(라인 146-166)을 기반으로 작성. `IMAGE_EXT`, `THUMB_SIZE`, 경로 상수는 main.py에서 import.
|
||||
|
||||
```python
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Set
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from . import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp"}
|
||||
THUMB_SIZE = (480, 480)
|
||||
|
||||
|
||||
def _scan_folder(folder: Path) -> List[Dict[str, Any]]:
|
||||
"""폴더 내 이미지 파일 목록 수집 (os.scandir)."""
|
||||
if not folder.exists():
|
||||
return []
|
||||
items = []
|
||||
with os.scandir(folder) as entries:
|
||||
for entry in entries:
|
||||
if entry.is_file() and Path(entry.name).suffix.lower() in IMAGE_EXT:
|
||||
items.append({
|
||||
"filename": entry.name,
|
||||
"mtime": entry.stat().st_mtime,
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
def _generate_thumb(src: Path, dest: Path) -> bool:
|
||||
"""원본에서 480x480 썸네일 생성. 성공 시 True."""
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = dest.with_name(dest.stem + ".tmp" + dest.suffix)
|
||||
try:
|
||||
with Image.open(src) as im:
|
||||
im.thumbnail(THUMB_SIZE)
|
||||
ext = dest.suffix.lower()
|
||||
if ext in (".jpg", ".jpeg"):
|
||||
fmt = "JPEG"
|
||||
elif ext == ".png":
|
||||
fmt = "PNG"
|
||||
elif ext == ".webp":
|
||||
fmt = "WEBP"
|
||||
else:
|
||||
fmt = (im.format or "").upper() or "JPEG"
|
||||
im.save(tmp, format=fmt, quality=85, optimize=True)
|
||||
tmp.replace(dest)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("Thumb generation failed: %s → %s", src, e)
|
||||
try:
|
||||
if tmp.exists():
|
||||
tmp.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def sync(
|
||||
travel_root: Path,
|
||||
thumb_root: Path,
|
||||
region_map_path: Path,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
폴더 스캔 → DB 동기화 + 썸네일 일괄 생성.
|
||||
|
||||
Returns:
|
||||
{"added": int, "removed": int, "thumbs_generated": int, "duration_sec": float}
|
||||
"""
|
||||
start = time.time()
|
||||
|
||||
# 1. region_map.json에서 전체 앨범 폴더 수집
|
||||
with open(region_map_path, "r", encoding="utf-8") as f:
|
||||
region_map = json.load(f)
|
||||
|
||||
all_albums: Set[str] = set()
|
||||
for v in region_map.values():
|
||||
if isinstance(v, list):
|
||||
all_albums.update(v)
|
||||
elif isinstance(v, dict) and isinstance(v.get("albums"), list):
|
||||
all_albums.update(v["albums"])
|
||||
|
||||
# 2. 각 앨범 폴더 스캔 → DB 동기화
|
||||
added = 0
|
||||
removed = 0
|
||||
|
||||
for album in sorted(all_albums):
|
||||
folder = travel_root / album
|
||||
items = _scan_folder(folder)
|
||||
existing_filenames = set()
|
||||
|
||||
for item in items:
|
||||
existing_filenames.add(item["filename"])
|
||||
result = db.upsert_photo(album, item["filename"], item["mtime"])
|
||||
if result == "added":
|
||||
added += 1
|
||||
|
||||
removed += db.remove_missing_photos(album, existing_filenames)
|
||||
|
||||
# 3. 썸네일 미생성 분 일괄 생성
|
||||
no_thumb = db.get_photos_without_thumb()
|
||||
thumbs_generated = 0
|
||||
|
||||
for photo in no_thumb:
|
||||
src = travel_root / photo["album"] / photo["filename"]
|
||||
dest = thumb_root / photo["album"] / photo["filename"]
|
||||
if _generate_thumb(src, dest):
|
||||
db.mark_thumb_done(photo["album"], photo["filename"])
|
||||
thumbs_generated += 1
|
||||
|
||||
duration = round(time.time() - start, 2)
|
||||
logger.info(
|
||||
"Sync complete: added=%d removed=%d thumbs=%d duration=%.2fs",
|
||||
added, removed, thumbs_generated, duration,
|
||||
)
|
||||
|
||||
return {
|
||||
"added": added,
|
||||
"removed": removed,
|
||||
"thumbs_generated": thumbs_generated,
|
||||
"duration_sec": duration,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 커밋**
|
||||
|
||||
```bash
|
||||
git add travel-proxy/app/indexer.py
|
||||
git commit -m "feat(travel-proxy): indexer.py — 폴더 동기화 + 썸네일 일괄 생성"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: main.py 리팩토링 — DB 기반 photos API + 캐시 제거
|
||||
|
||||
**Files:**
|
||||
- Modify: `travel-proxy/app/main.py`
|
||||
|
||||
이 Task에서 main.py의 메모리 캐시, `scan_album()`, 기존 `photos()` 라우트를 DB 기반으로 교체한다.
|
||||
|
||||
- [ ] **Step 1: main.py를 DB 기반으로 재작성**
|
||||
|
||||
main.py 전체를 아래로 교체:
|
||||
|
||||
```python
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, List
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from PIL import Image
|
||||
|
||||
from .db import init_db, get_photos_by_region, get_all_albums, set_album_cover, get_album_cover
|
||||
from .indexer import sync
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# -----------------------------
|
||||
# Env / Paths
|
||||
# -----------------------------
|
||||
ROOT = Path(os.getenv("TRAVEL_ROOT", "/data/travel")).resolve()
|
||||
MEDIA_BASE = os.getenv("TRAVEL_MEDIA_BASE", "/media/travel")
|
||||
|
||||
META_DIR = ROOT / "_meta"
|
||||
REGION_MAP_PATH = META_DIR / "region_map.json"
|
||||
REGIONS_GEOJSON_PATH = META_DIR / "regions.geojson"
|
||||
|
||||
THUMB_ROOT = Path(os.getenv("TRAVEL_THUMB_ROOT", "/data/thumbs")).resolve()
|
||||
THUMB_SIZE = (480, 480)
|
||||
|
||||
THUMB_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp"}
|
||||
|
||||
# -----------------------------
|
||||
# DB init
|
||||
# -----------------------------
|
||||
init_db()
|
||||
|
||||
# -----------------------------
|
||||
# Helpers
|
||||
# -----------------------------
|
||||
def _read_json(path: Path) -> Any:
|
||||
if not path.exists():
|
||||
raise HTTPException(500, f"Missing required file: {path}")
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def load_region_map() -> dict:
|
||||
return _read_json(REGION_MAP_PATH)
|
||||
|
||||
|
||||
def load_regions_geojson() -> dict:
|
||||
return _read_json(REGIONS_GEOJSON_PATH)
|
||||
|
||||
|
||||
def _get_albums_for_region(region: str, region_map: dict) -> List[str]:
|
||||
if region not in region_map:
|
||||
raise HTTPException(400, "Unknown region")
|
||||
v = region_map[region]
|
||||
if isinstance(v, list):
|
||||
return v
|
||||
if isinstance(v, dict) and isinstance(v.get("albums"), list):
|
||||
return v["albums"]
|
||||
raise HTTPException(500, "Invalid region_map format")
|
||||
|
||||
|
||||
def _ensure_thumb_fallback(src: Path, album: str) -> Path:
|
||||
"""온디맨드 썸네일 폴백 (sync 누락 분 대응)."""
|
||||
out = THUMB_ROOT / album / src.name
|
||||
if out.exists():
|
||||
return out
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = out.with_name(out.stem + ".tmp" + out.suffix)
|
||||
try:
|
||||
with Image.open(src) as im:
|
||||
im.thumbnail(THUMB_SIZE)
|
||||
ext = out.suffix.lower()
|
||||
if ext in (".jpg", ".jpeg"):
|
||||
fmt = "JPEG"
|
||||
elif ext == ".png":
|
||||
fmt = "PNG"
|
||||
elif ext == ".webp":
|
||||
fmt = "WEBP"
|
||||
else:
|
||||
fmt = (im.format or "").upper() or "JPEG"
|
||||
im.save(tmp, format=fmt, quality=85, optimize=True)
|
||||
tmp.replace(out)
|
||||
return out
|
||||
finally:
|
||||
try:
|
||||
if tmp.exists():
|
||||
tmp.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# Models
|
||||
# -----------------------------
|
||||
class CoverRequest(BaseModel):
|
||||
filename: str
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# Routes
|
||||
# -----------------------------
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "healthy", "service": "travel-proxy"}
|
||||
|
||||
|
||||
@app.get("/api/travel/regions")
|
||||
def regions():
|
||||
return load_regions_geojson()
|
||||
|
||||
|
||||
@app.get("/api/travel/photos")
|
||||
def photos(
|
||||
region: str = Query(...),
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
):
|
||||
region_map = load_region_map()
|
||||
albums = _get_albums_for_region(region, region_map)
|
||||
result = get_photos_by_region(albums, page, size)
|
||||
|
||||
# URL 조합 (DB에는 경로를 저장하지 않음)
|
||||
items = []
|
||||
for row in result["items"]:
|
||||
items.append({
|
||||
"album": row["album"],
|
||||
"file": row["filename"],
|
||||
"url": f"{MEDIA_BASE}/{row['album']}/{row['filename']}",
|
||||
"thumb": f"{MEDIA_BASE}/.thumb/{row['album']}/{row['filename']}",
|
||||
"mtime": row["mtime"],
|
||||
})
|
||||
|
||||
return {
|
||||
"region": region,
|
||||
"page": page,
|
||||
"size": size,
|
||||
"total": result["total"],
|
||||
"has_next": result["has_next"],
|
||||
"items": items,
|
||||
"matched_albums": result["matched_albums"],
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/travel/sync")
|
||||
def sync_endpoint():
|
||||
result = sync(
|
||||
travel_root=ROOT,
|
||||
thumb_root=THUMB_ROOT,
|
||||
region_map_path=REGION_MAP_PATH,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/travel/albums")
|
||||
def albums_list():
|
||||
rows = get_all_albums()
|
||||
result = []
|
||||
for r in rows:
|
||||
cover = r["cover_filename"]
|
||||
result.append({
|
||||
"album": r["album"],
|
||||
"count": r["count"],
|
||||
"cover_url": f"{MEDIA_BASE}/{r['album']}/{cover}",
|
||||
"cover_thumb": f"{MEDIA_BASE}/.thumb/{r['album']}/{cover}",
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
@app.put("/api/travel/albums/{album}/cover")
|
||||
def set_cover(album: str, body: CoverRequest):
|
||||
ok = set_album_cover(album, body.filename)
|
||||
if not ok:
|
||||
raise HTTPException(404, f"Photo not found: {album}/{body.filename}")
|
||||
return {
|
||||
"album": album,
|
||||
"filename": body.filename,
|
||||
"cover_url": f"{MEDIA_BASE}/{album}/{body.filename}",
|
||||
"cover_thumb": f"{MEDIA_BASE}/.thumb/{album}/{body.filename}",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/media/travel/.thumb/{album}/{filename}")
|
||||
def get_thumb(album: str, filename: str):
|
||||
if ".." in album or ".." in filename:
|
||||
raise HTTPException(400, "Invalid path")
|
||||
src = (ROOT / album / filename).resolve()
|
||||
if not str(src).startswith(str(ROOT)):
|
||||
raise HTTPException(403, "Access denied")
|
||||
if not src.exists() or not src.is_file():
|
||||
raise HTTPException(404, "Source not found")
|
||||
p = _ensure_thumb_fallback(src, album)
|
||||
if not p.exists() or not p.is_file():
|
||||
raise HTTPException(404, "Thumbnail not found")
|
||||
return FileResponse(str(p))
|
||||
|
||||
|
||||
@app.get("/api/version")
|
||||
def version():
|
||||
return {"version": os.getenv("APP_VERSION", "dev")}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 커밋**
|
||||
|
||||
```bash
|
||||
git add travel-proxy/app/main.py
|
||||
git commit -m "refactor(travel-proxy): main.py DB 기반 전환 — 메모리 캐시 제거 + 신규 API"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 통합 검증
|
||||
|
||||
**Files:**
|
||||
- 없음 (기존 파일 검증만)
|
||||
|
||||
- [ ] **Step 1: import 구조 확인**
|
||||
|
||||
travel-proxy/app/ 디렉토리에 `__init__.py`가 필요한지 확인. FastAPI uvicorn 실행 명령이 `app.main:app`이므로 패키지 import가 동작하려면 `__init__.py`가 필요.
|
||||
|
||||
```bash
|
||||
ls travel-proxy/app/
|
||||
```
|
||||
|
||||
`__init__.py`가 없으면 생성:
|
||||
|
||||
```python
|
||||
# travel-proxy/app/__init__.py
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Dockerfile 확인**
|
||||
|
||||
현재 Dockerfile의 `COPY app /app/app` 라인이 db.py, indexer.py를 포함하는지 확인. 디렉토리 단위 복사이므로 추가 파일은 자동 포함됨. 변경 불필요.
|
||||
|
||||
- [ ] **Step 3: docker-compose.yml 환경변수 확인**
|
||||
|
||||
`TRAVEL_DB_PATH` 환경변수를 docker-compose.yml에 추가:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml의 travel-proxy 서비스 environment에 추가
|
||||
- TRAVEL_DB_PATH=${TRAVEL_DB_PATH:-/data/thumbs/travel.db}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: photos 응답 호환성 검증**
|
||||
|
||||
기존 응답 필드와 비교:
|
||||
- `region` ✓
|
||||
- `page`, `size` ✓
|
||||
- `total`, `has_next` ✓
|
||||
- `items[].album`, `items[].file`, `items[].url`, `items[].thumb`, `items[].mtime` ✓
|
||||
- `matched_albums` — 기존에는 `photos()` 응답에 없었으나 캐시 데이터에 포함. DB 버전은 항상 포함.
|
||||
|
||||
- [ ] **Step 5: 커밋 (변경 있을 시)**
|
||||
|
||||
```bash
|
||||
git add travel-proxy/app/__init__.py docker-compose.yml
|
||||
git commit -m "chore(travel-proxy): __init__.py + TRAVEL_DB_PATH 환경변수 추가"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: CLAUDE.md 업데이트
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md`
|
||||
|
||||
- [ ] **Step 1: travel-proxy 섹션에 DB 정보 추가**
|
||||
|
||||
CLAUDE.md의 travel-proxy 섹션에 아래 내용 추가:
|
||||
|
||||
- DB: `/data/thumbs/travel.db` (photos, album_covers 테이블)
|
||||
- 파일 구조에 `db.py`, `indexer.py` 추가
|
||||
|
||||
API 목록 테이블에 신규 API 3개 추가:
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| POST | `/api/travel/sync` | 폴더 스캔 → DB 동기화 + 썸네일 생성 |
|
||||
| GET | `/api/travel/albums` | 앨범 목록 + 사진 수 + 커버 정보 |
|
||||
| PUT | `/api/travel/albums/{album}/cover` | 앨범 커버 지정 |
|
||||
|
||||
`POST /api/travel/reload` 제거 표기.
|
||||
|
||||
- [ ] **Step 2: 커밋**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "docs: CLAUDE.md travel-proxy DB·API 업데이트"
|
||||
```
|
||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user