Compare commits
382 Commits
f074cbec2d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b6b251225 | |||
| 1efe3d3a48 | |||
| 3a9d6e986e | |||
| bb0280274e | |||
| cd9a73254b | |||
| 332525a6f0 | |||
| 11f591e3d4 | |||
| 8788763b3d | |||
| b89e92440a | |||
| 5ad0adf719 | |||
| d98cd9afbe | |||
| 4e846a2d5f | |||
| 5d9be51dba | |||
| cd4fb27d5a | |||
| b94b5973d6 | |||
| f54ade2c0d | |||
| 2cbc830004 | |||
| d0c057358a | |||
| 7d7064ae93 | |||
| 789785fe3a | |||
| c3a3055060 | |||
| 3056e8d35f | |||
| 4ed3794f71 | |||
| 241c24943f | |||
| c756b20c77 | |||
| fba6dbf1fd | |||
| b13c088739 | |||
| 116b2540c2 | |||
| 62169ad33f | |||
| 0ef7d414b7 | |||
| 885d52d8f5 | |||
| e3088f7cc6 | |||
| 2996cf16d1 | |||
| 03ee5ce147 | |||
| 11212c4afd | |||
| 1b8548a73f | |||
| c4ba7e81e6 | |||
| e8270c5a63 | |||
| 4063f29cd3 | |||
| 03056a4747 | |||
| 8e7b4adabd | |||
| add433233a | |||
| 74f385c7bd | |||
| 3bc4f423db | |||
| a425bb8809 | |||
| 850638ae58 | |||
| 94a94e260c | |||
| c196da4902 | |||
| aaba4fbc46 | |||
| 9f897ea4a0 | |||
| 77efa9b653 | |||
| 8dbb1abaeb | |||
| 41ad56e3ef | |||
| bb0e771a4a | |||
| 160fc27279 | |||
| f3f6cccd33 | |||
| 2bfbd1dd93 | |||
| c5c260aefc | |||
| 378f5210d4 | |||
| cfbb3c24b8 | |||
| c7214b8896 | |||
| 4224333219 | |||
| 5613497367 | |||
| b25abea80a | |||
| ed30790f22 | |||
| 1d723764b4 | |||
| c0c4422c7c | |||
| fe4d3912a5 | |||
| f461f05ac0 | |||
| dfd3b1bb17 | |||
| 809eec9b15 | |||
| 512ed59dcd | |||
| 4ee4a1ae7d | |||
| fd40777177 | |||
| be9165efd2 | |||
| 99dca8df64 | |||
| 03e1dc1dbb | |||
| f57c790437 | |||
| 030367da6c | |||
| 429e3448e5 | |||
| 579e7387be | |||
| 8ef0ba81f2 | |||
| afb4175bd5 | |||
| af836df1ac | |||
| 8123f758a8 | |||
| 8ec3abb800 | |||
| 6d752acbe1 | |||
| f995f8739f | |||
| cad65dc869 | |||
| f4f518fc80 | |||
| db1f69c7a5 | |||
| ebfade655a | |||
| 234ccfe857 | |||
| 3f0b7bcd74 | |||
| f91a74237b | |||
| 95243a7f1f | |||
| 07b5c32f2f | |||
| 4ddcd75453 | |||
| 018459db88 | |||
| 42182014f0 | |||
| 03edfb04aa | |||
| 8b0c12b595 | |||
| e52e47fe3b | |||
| 8d25a1467a | |||
| 901d3535ee | |||
| 91caddb4b2 | |||
| abdfcbb144 | |||
| a94c73b134 | |||
| 387d2465b0 | |||
| 4073370e1b | |||
| 1775f7dd2d | |||
| 677d05fc31 | |||
| d87ad2421d | |||
| 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 |
14
.env.example
14
.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
|
||||
@@ -99,6 +104,8 @@ YOUTUBE_DATA_API_KEY=
|
||||
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=
|
||||
@@ -115,3 +122,8 @@ 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
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -66,3 +66,11 @@ 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건. 진단 커맨드.
|
||||
185
CLAUDE.md
185
CLAUDE.md
@@ -7,7 +7,7 @@
|
||||
## 1. 프로젝트 개요
|
||||
|
||||
Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||
- **서비스**: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, packs-lab, deployer (10개)
|
||||
- **서비스**: lotto-lab, stock, travel-proxy, music-lab, insta-lab, realestate-lab, agent-office, personal, packs-lab, deployer (10개)
|
||||
- **프론트엔드**: 별도 레포 (React + Vite SPA), 빌드 산출물만 NAS에 배포
|
||||
- **인프라**: Docker Compose (10컨테이너) + Nginx(리버스 프록시) + Gitea Webhook 자동 배포
|
||||
|
||||
@@ -32,7 +32,7 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||
/volume1
|
||||
├── docker/webpage/ # 운영 런타임 (Docker Compose 실행 위치)
|
||||
│ ├── lotto/ # lotto 소스 (rsync 동기화)
|
||||
│ ├── stock-lab/ # stock-lab 소스 (rsync 동기화)
|
||||
│ ├── stock/ # stock 소스 (rsync 동기화)
|
||||
│ ├── travel-proxy/ # travel-proxy 소스 (rsync 동기화)
|
||||
│ ├── deployer/ # deployer 소스 (rsync 동기화)
|
||||
│ ├── nginx/default.conf # Nginx 설정
|
||||
@@ -54,9 +54,9 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||
| 컨테이너 | 포트 | 역할 |
|
||||
|---------|------|------|
|
||||
| `lotto` | 18000 | 로또 데이터 수집·분석·추천 API |
|
||||
| `stock-lab` | 18500 | 주식 뉴스·AI 분석·KIS 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 통신) |
|
||||
@@ -73,11 +73,11 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||
|------|------------|------|
|
||||
| `/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 |
|
||||
@@ -135,7 +135,7 @@ 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 |
|
||||
|
||||
@@ -164,10 +164,16 @@ docker compose up -d
|
||||
| `lotto_briefings` | AI 큐레이터 주간 브리핑 (5세트 + 내러티브 + 토큰·비용 집계) |
|
||||
| `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 목록**
|
||||
|
||||
@@ -204,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 목록**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
@@ -454,65 +465,81 @@ docker compose up -d
|
||||
| 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/realestate-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음)
|
||||
- stock/music-lab/realestate-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음)
|
||||
- 실시간 상태 동기화: WebSocket (`/api/agent-office/ws`)
|
||||
- 텔레그램 봇: 양방향 알림 + 승인 (인라인 키보드)
|
||||
- 청약 매칭 알림: realestate-lab이 신규 매칭 발견 시 push → `RealestateAgent.on_new_matches()` → 텔레그램 1통(인라인 [🔖 북마크]/[📄 공고] 또는 [전체 보기] 버튼)
|
||||
@@ -522,7 +549,7 @@ docker compose up -d
|
||||
**에이전트 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`)
|
||||
@@ -537,6 +564,11 @@ docker compose up -d
|
||||
- `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에 등록
|
||||
@@ -561,6 +593,11 @@ docker compose up -d
|
||||
- ~~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}`
|
||||
@@ -592,6 +629,9 @@ docker compose up -d
|
||||
| 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/)
|
||||
- 개인 서비스 (포트폴리오 + 블로그 + 투두 통합)
|
||||
@@ -642,15 +682,18 @@ docker compose up -d
|
||||
- Vercel SaaS와 HMAC 인증으로 통신, 사용자 인증은 Vercel이 Supabase로 처리 (본 서비스는 외부 인증 없음)
|
||||
- DB: 외부 Supabase `pack_files` 테이블 (DDL: `packs-lab/supabase/pack_files.sql`)
|
||||
- 파일 구조: `app/main.py`, `app/auth.py`, `app/dsm_client.py`, `app/routes.py`, `app/models.py`
|
||||
- 컨테이너 저장 경로: `PACK_BASE_DIR` env (default `/app/data/packs`). docker-compose volume 마운트와 일치 필수.
|
||||
- 경로 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_DATA_PATH`: 호스트 마운트 경로 (로컬 `./data/packs`, NAS `/volume1/docker/webpage/media/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))
|
||||
@@ -663,10 +706,21 @@ docker compose up -d
|
||||
|--------|------|------|
|
||||
| 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 |
|
||||
| POST | `/api/packs/upload` | Bearer token (single-shot) → multipart 5GB 저장 + Supabase INSERT |
|
||||
| POST | `/api/packs/upload/init` | Bearer token → chunked upload 세션 초기화 (`session_id = jti`, `chunk_max_size` 반환). init만 jti consume |
|
||||
| PUT | `/api/packs/upload/{session_id}/chunk?offset=N` | 동일 Bearer token → 부분파일 append (offset 불일치 시 409 + `X-Current-Offset` 헤더) |
|
||||
| GET | `/api/packs/upload/{session_id}/status` | 동일 Bearer token → `{written, expected_size}` 조회 (재개용) |
|
||||
| POST | `/api/packs/upload/{session_id}/complete` | 동일 Bearer token → 부분파일 rename + Supabase INSERT |
|
||||
| DELETE | `/api/packs/upload/{session_id}` | 동일 Bearer token → 세션 중단 + 부분파일 정리 |
|
||||
| GET | `/api/packs/list` | Vercel HMAC → 활성 pack_files 목록 (deleted_at IS NULL) |
|
||||
| DELETE | `/api/packs/{file_id}` | Vercel HMAC → soft delete (DSM 공유는 자동 만료) |
|
||||
|
||||
**Chunked upload 흐름 (5GB+ 안정성)**
|
||||
- 같은 mint-token을 init·chunk·status·complete·abort 전체에서 Bearer로 재사용 (jti consume은 init에서만)
|
||||
- 세션 state: 컨테이너 내부 `PACK_BASE_DIR/.uploads/{jti}/meta.json + data.part`
|
||||
- chunk 재시도: 클라이언트는 PUT 응답 헤더 `X-Current-Offset` 또는 `GET /status`로 재개 지점 확인
|
||||
- 환경변수 `PACK_CHUNK_MAX_SIZE` (기본 64MB) — 너무 크면 nginx buffering 부담, 너무 작으면 RTT 비용
|
||||
|
||||
### deployer (deployer/)
|
||||
- Webhook 검증: `X-Gitea-Signature` (HMAC SHA256, `compare_digest` 사용)
|
||||
- `WEBHOOK_SECRET` 환경변수로 시크릿 관리
|
||||
@@ -683,7 +737,8 @@ docker compose up -d
|
||||
- **캐시 전략**: `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슬레이트만 렌더하도록 직렬화됨
|
||||
|
||||
142
README.md
142
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,22 +176,28 @@ 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: [...]}`
|
||||
|
||||
#### 스케줄러 잡
|
||||
|
||||
- 07:00 월요일 — Lotto: AI 큐레이터 브리핑 (5세트 + 내러티브)
|
||||
- 07:30 — Stock: 뉴스 요약
|
||||
- 09:15 — Realestate: 매칭 리포트
|
||||
- 10:00 — Blog: 자동 파이프라인 (리서치→생성→리뷰→승인 대기)
|
||||
- 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/`)
|
||||
@@ -224,7 +241,7 @@ Gitea Webhook 수신 → NAS 자동 배포.
|
||||
| 공동 출현 | 15% | 번호 쌍 동시 출현 빈도 |
|
||||
| 다양성 | 10% | 연속번호·범위·구간 커버리지 |
|
||||
|
||||
### LLM 요약 provider 추상화 (stock-lab)
|
||||
### LLM 요약 provider 추상화 (stock)
|
||||
|
||||
`ai_summarizer.py`는 provider 분리 구조. `summarize_news(articles)` 시그니처는 provider와 무관하게 고정.
|
||||
|
||||
@@ -232,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).
|
||||
|
||||
@@ -265,13 +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, lotto_briefings, todos, blog_posts |
|
||||
| `stock.db` | stock-lab | articles, portfolio, broker_cash, asset_snapshots, sell_history |
|
||||
| `music.db` | music-lab | music_tasks, music_library (provider, lyrics, image_url, suno_id, file_hash, cover_images, wav_url, video_url, stem_urls) |
|
||||
| `blog_marketing.db` | blog-lab | keyword_analyses, blog_posts, brand_links, commissions, generation_tasks, prompt_templates |
|
||||
| `lotto.db` | lotto | draws, recommendations, simulation_runs/candidates, best_picks, purchase_history, strategy_performance/weights, weekly_reports, lotto_briefings |
|
||||
| `stock.db` | stock | articles, portfolio, broker_cash, asset_snapshots, sell_history |
|
||||
| `music.db` | music-lab | music_tasks, music_library (provider, lyrics, image_url, suno_id, file_hash, cover_images, wav_url, video_url, stem_urls), video_projects, revenue_records, market_trends, trend_reports |
|
||||
| `insta.db` | insta-lab | news_articles, trending_keywords (source 컬럼), card_slates, card_assets, generation_tasks, prompt_templates, account_preferences |
|
||||
| `realestate.db` | realestate-lab | announcements, announcement_models, user_profile, match_results, collect_log |
|
||||
| `agent_office.db` | agent-office | agent_config, agent_tasks, agent_logs, telegram_state, conversation_messages |
|
||||
| `personal.db` | personal | profile, careers, projects, skills, introductions, todos, blog_posts |
|
||||
| `travel.db` | travel-proxy | photos (album, filename, mtime, has_thumb), album_covers |
|
||||
| `pack_files` (외부 Supabase) | packs-lab | filename, host_path, mime, byte_size, sha256, deleted_at |
|
||||
|
||||
---
|
||||
|
||||
@@ -292,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=
|
||||
```
|
||||
|
||||
---
|
||||
@@ -343,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`
|
||||
|
||||
34
STATUS.md
34
STATUS.md
@@ -1,40 +1,42 @@
|
||||
# web-backend — 구현 현황 & 로드맵
|
||||
|
||||
> 최종 갱신: 2026-05-07
|
||||
> 최종 갱신: 2026-05-17
|
||||
> 자세한 서비스·환경변수·DB 표는 [CLAUDE.md](./CLAUDE.md), 설계는 `docs/superpowers/specs/`, 실행 계획은 `docs/superpowers/plans/` 참조.
|
||||
|
||||
---
|
||||
|
||||
## 1. 서비스 구현 현황
|
||||
|
||||
### 1-1. 운영 중인 컨테이너 (10개)
|
||||
### 1-1. 운영 중인 컨테이너 (11개)
|
||||
|
||||
| 서비스 | 포트 | 상태 | 핵심 기능 |
|
||||
|--------|------|------|-----------|
|
||||
| `lotto-backend` | 18000 | ✅ | 로또 추천·통계·리포트·구매내역 + 블로그·투두 |
|
||||
| `stock-lab` | 18500 | ✅ | 주식 뉴스·지수·트레이딩·포트폴리오·자산 스냅샷 |
|
||||
| `lotto` | 18000 | ✅ | 로또 추천·통계·리포트·구매내역·AI 큐레이터 |
|
||||
| `stock` | 18500 | ✅ | 주식 뉴스·지수·트레이딩·포트폴리오·자산 스냅샷·스크리너 |
|
||||
| `music-lab` | 18600 | ✅ | Suno + MusicGen + YouTube 수익화 + 컴파일 |
|
||||
| `blog-lab` | 18700 | ✅ | 블로그 마케팅 수익화 파이프라인 |
|
||||
| `realestate-lab` | 18800 | ✅ | 청약 수집·5티어 매칭·매칭 알림 |
|
||||
| `agent-office` | 18900 | ✅ | AI 에이전트 (WebSocket + 텔레그램 + YouTubeResearcher) |
|
||||
| `packs-lab` | 18950 | ✅ | NAS 자료 다운로드 자동화 (HMAC + Supabase) — 2026-05-05 |
|
||||
| `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 + 썸네일 + 지역 관리 |
|
||||
| `nginx` | 8080 | ✅ | SPA + 리버스 프록시 (5GB body limit) |
|
||||
| `webpage-deployer` | 19010 | ✅ | Gitea Webhook 자동 배포 |
|
||||
| `frontend` (nginx) | 8080 | ✅ | SPA + 리버스 프록시 (5GB body limit, 인스타 라우팅 포함) |
|
||||
| `webpage-deployer` | 19010 | ✅ | Gitea Webhook 자동 배포 (BUILDKIT timeout 600s, healthcheck via docker inspect) |
|
||||
|
||||
### 1-2. 최근 큰 작업 (2026-04 ~ 05)
|
||||
### 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축 점수·알림 대상 카운트) |
|
||||
| 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-24 | travel-proxy | 갤러리 리디자인 + 성능 개선 (썸네일/페이지네이션) |
|
||||
| 2026-04-15 | lotto-backend | AI 큐레이터 (Claude 기반 주간 브리핑 자동 생성) |
|
||||
| 2026-04-08 | music-lab | Suno enhancement + MusicGen 통합 |
|
||||
| 2026-04-06 | blog-lab | 마케팅 파이프라인 (research → generate → market → review) |
|
||||
| 2026-04-15 | lotto | AI 큐레이터 (Claude 기반 주간 브리핑 자동 생성) |
|
||||
|
||||
### 1-3. 인프라 / DX
|
||||
|
||||
|
||||
1
_shared/__init__.py
Normal file
1
_shared/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# empty
|
||||
112
_shared/access_log.py
Normal file
112
_shared/access_log.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""각 lab 컨테이너에서 import 하는 공용 액세스/이벤트 로그 모듈.
|
||||
|
||||
사용법:
|
||||
from _shared.access_log import install as install_access_log
|
||||
install_access_log(app)
|
||||
"""
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.applications import FastAPI
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
# 컨테이너당 최근 500개를 in-memory 로 유지. 재시작 시 휘발.
|
||||
_BUFFER: deque = deque(maxlen=500)
|
||||
|
||||
EXCLUDED_PATHS = {
|
||||
"/health", "/healthz", "/ping", "/favicon.ico",
|
||||
"/docs", "/redoc", "/openapi.json", "/logs/recent",
|
||||
}
|
||||
EXCLUDED_PREFIXES = ("/static/",)
|
||||
EXCLUDED_METHODS = {"OPTIONS", "HEAD"}
|
||||
|
||||
|
||||
def _should_log(request: Request) -> bool:
|
||||
if request.method in EXCLUDED_METHODS:
|
||||
return False
|
||||
path = request.url.path
|
||||
if path in EXCLUDED_PATHS:
|
||||
return False
|
||||
if any(path.startswith(p) for p in EXCLUDED_PREFIXES):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class AccessLogMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request, call_next):
|
||||
start = time.time()
|
||||
response = await call_next(request)
|
||||
if not _should_log(request):
|
||||
return response
|
||||
elapsed_ms = int((time.time() - start) * 1000)
|
||||
status = response.status_code
|
||||
if status < 400:
|
||||
level = "info"
|
||||
elif status < 500:
|
||||
level = "warning"
|
||||
else:
|
||||
level = "error"
|
||||
_BUFFER.append({
|
||||
"ts": datetime.utcnow().isoformat() + "Z",
|
||||
"level": level,
|
||||
"source": "access",
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"status": status,
|
||||
"ms": elapsed_ms,
|
||||
"message": f"{request.method} {request.url.path} → {status} ({elapsed_ms}ms)",
|
||||
})
|
||||
return response
|
||||
|
||||
|
||||
class BufferLogHandler(logging.Handler):
|
||||
"""root logger 에 부착하면 모든 logger.info/warning/error 가 buffer 에 흐름."""
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
_BUFFER.append({
|
||||
"ts": datetime.utcfromtimestamp(record.created).isoformat() + "Z",
|
||||
"level": record.levelname.lower(),
|
||||
"source": "log",
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
})
|
||||
except Exception:
|
||||
# buffer 에 못 넣는다고 서비스가 죽으면 안 됨
|
||||
pass
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/logs/recent")
|
||||
def logs_recent(limit: int = 200, since: Optional[str] = None,
|
||||
path_prefix: Optional[str] = None):
|
||||
items = list(_BUFFER)
|
||||
if since:
|
||||
items = [x for x in items if x["ts"] > since]
|
||||
if path_prefix:
|
||||
items = [
|
||||
x for x in items
|
||||
if x["source"] == "log"
|
||||
or x.get("path", "").startswith(path_prefix)
|
||||
]
|
||||
return {"logs": items[-limit:]}
|
||||
|
||||
|
||||
def install(app: FastAPI, logger_root: str = "") -> None:
|
||||
"""서비스 main.py 에서 호출하는 단일 설치 함수.
|
||||
|
||||
- AccessLogMiddleware 등록
|
||||
- /logs/recent 라우터 등록
|
||||
- root logger 에 BufferLogHandler 부착 (모든 child logger 자동 전파)
|
||||
"""
|
||||
app.add_middleware(AccessLogMiddleware)
|
||||
app.include_router(router)
|
||||
root = logging.getLogger(logger_root)
|
||||
if not any(isinstance(h, BufferLogHandler) for h in root.handlers):
|
||||
root.addHandler(BufferLogHandler())
|
||||
129
_shared/tests/test_access_log.py
Normal file
129
_shared/tests/test_access_log.py
Normal file
@@ -0,0 +1,129 @@
|
||||
import logging
|
||||
import time
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from _shared.access_log import (
|
||||
AccessLogMiddleware,
|
||||
BufferLogHandler,
|
||||
router as logs_router,
|
||||
install,
|
||||
_BUFFER,
|
||||
)
|
||||
|
||||
|
||||
def _reset_buffer():
|
||||
_BUFFER.clear()
|
||||
|
||||
|
||||
def test_access_middleware_records_request():
|
||||
_reset_buffer()
|
||||
app = FastAPI()
|
||||
app.add_middleware(AccessLogMiddleware)
|
||||
|
||||
@app.get("/api/lotto/recommend")
|
||||
def recommend():
|
||||
return {"ok": True}
|
||||
|
||||
client = TestClient(app)
|
||||
client.get("/api/lotto/recommend")
|
||||
|
||||
items = [x for x in _BUFFER if x["source"] == "access"]
|
||||
assert len(items) == 1
|
||||
assert items[0]["method"] == "GET"
|
||||
assert items[0]["path"] == "/api/lotto/recommend"
|
||||
assert items[0]["status"] == 200
|
||||
assert items[0]["ms"] >= 0
|
||||
|
||||
|
||||
def test_access_middleware_skips_health():
|
||||
_reset_buffer()
|
||||
app = FastAPI()
|
||||
app.add_middleware(AccessLogMiddleware)
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True}
|
||||
|
||||
client = TestClient(app)
|
||||
client.get("/health")
|
||||
|
||||
items = [x for x in _BUFFER if x["source"] == "access"]
|
||||
assert items == []
|
||||
|
||||
|
||||
def test_access_middleware_skips_options():
|
||||
_reset_buffer()
|
||||
app = FastAPI()
|
||||
app.add_middleware(AccessLogMiddleware)
|
||||
|
||||
@app.get("/api/lotto/recommend")
|
||||
def recommend():
|
||||
return {"ok": True}
|
||||
|
||||
client = TestClient(app)
|
||||
client.options("/api/lotto/recommend")
|
||||
|
||||
items = [x for x in _BUFFER if x["source"] == "access"]
|
||||
assert items == []
|
||||
|
||||
|
||||
def test_buffer_log_handler_captures_logger_info():
|
||||
_reset_buffer()
|
||||
root = logging.getLogger("")
|
||||
handler = BufferLogHandler()
|
||||
root.addHandler(handler)
|
||||
try:
|
||||
lg = logging.getLogger("lotto.test")
|
||||
lg.setLevel(logging.INFO)
|
||||
lg.info("뉴스 스크래핑 완료: 국내 12건")
|
||||
finally:
|
||||
root.removeHandler(handler)
|
||||
|
||||
items = [x for x in _BUFFER if x["source"] == "log"]
|
||||
assert len(items) == 1
|
||||
assert items[0]["message"] == "뉴스 스크래핑 완료: 국내 12건"
|
||||
assert items[0]["level"] == "info"
|
||||
assert items[0]["logger"] == "lotto.test"
|
||||
|
||||
|
||||
def test_logs_recent_endpoint_returns_recent_items():
|
||||
_reset_buffer()
|
||||
app = FastAPI()
|
||||
install(app)
|
||||
|
||||
@app.get("/api/lotto/recommend")
|
||||
def recommend():
|
||||
return {"ok": True}
|
||||
|
||||
client = TestClient(app)
|
||||
client.get("/api/lotto/recommend")
|
||||
client.get("/api/lotto/recommend")
|
||||
client.get("/health") # 제외되어야 함
|
||||
|
||||
resp = client.get("/logs/recent")
|
||||
assert resp.status_code == 200
|
||||
logs = resp.json()["logs"]
|
||||
access_items = [x for x in logs if x["source"] == "access"]
|
||||
assert len(access_items) == 2
|
||||
|
||||
|
||||
def test_logs_recent_with_since_filter():
|
||||
_reset_buffer()
|
||||
app = FastAPI()
|
||||
install(app)
|
||||
|
||||
@app.get("/api/lotto/recommend")
|
||||
def recommend():
|
||||
return {"ok": True}
|
||||
|
||||
client = TestClient(app)
|
||||
client.get("/api/lotto/recommend")
|
||||
time.sleep(0.01)
|
||||
cursor_resp = client.get("/logs/recent")
|
||||
cursor_ts = cursor_resp.json()["logs"][-1]["ts"]
|
||||
client.get("/api/lotto/recommend")
|
||||
|
||||
resp = client.get(f"/logs/recent?since={cursor_ts}")
|
||||
items = [x for x in resp.json()["logs"] if x["source"] == "access"]
|
||||
assert len(items) == 1
|
||||
@@ -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,6 +1,6 @@
|
||||
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
|
||||
@@ -11,7 +11,7 @@ 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()
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
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 +9,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,11 +26,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})")
|
||||
|
||||
if self._ws_manager:
|
||||
await self._ws_manager.send_agent_state(self.agent_id, new_state, detail, task_id)
|
||||
@@ -48,19 +37,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}")
|
||||
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,269 @@ 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()
|
||||
if action == "sunday_review":
|
||||
return await self.run_sunday_review()
|
||||
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_sunday_review(self) -> dict:
|
||||
"""일 09:00 — 최신 회차 forward+calibration 보장 후 회고 텔레그램."""
|
||||
from ..service_proxy import lotto_latest_draw, lotto_backtest_review
|
||||
from ..notifiers.telegram_lotto import send_sunday_review
|
||||
from ..db import create_task, update_task_status, add_log
|
||||
|
||||
task_id = create_task("lotto", "sunday_review", {})
|
||||
try:
|
||||
draw_no = await lotto_latest_draw()
|
||||
if not draw_no:
|
||||
update_task_status(task_id, "failed", result_data={"reason": "no_draw"})
|
||||
return {"ok": False, "message": "no latest draw"}
|
||||
# forward는 lotto cron이 이미 돌렸을 수 있으나 멱등이라 안전 — review만 호출
|
||||
payload = await lotto_backtest_review(draw_no)
|
||||
await send_sunday_review(payload)
|
||||
update_task_status(task_id, "succeeded", result_data={"draw_no": draw_no})
|
||||
add_log("lotto", f"sunday_review 발송: #{draw_no}", task_id=task_id)
|
||||
return {"ok": True, "draw_no": draw_no}
|
||||
except Exception as e:
|
||||
update_task_status(task_id, "failed", result_data={"error": str(e)})
|
||||
add_log("lotto", f"sunday_review 예외: {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)
|
||||
|
||||
@@ -44,14 +44,14 @@ 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", "최신 뉴스 수집 중...", task_id)
|
||||
|
||||
try:
|
||||
# stock-lab cron(매일 8:00)이 7:30 브리핑보다 늦게 돌아 어제 뉴스가
|
||||
# stock cron(매일 8:00)이 7:30 브리핑보다 늦게 돌아 어제 뉴스가
|
||||
# 요약되던 문제 방지 — 요약 직전에 동기 스크랩으로 DB를 갱신한다.
|
||||
try:
|
||||
await service_proxy.scrape_stock_news()
|
||||
@@ -60,7 +60,7 @@ class StockAgent(BaseAgent):
|
||||
|
||||
await self.transition("working", "AI 뉴스 요약 생성 중...")
|
||||
|
||||
# AI 요약 호출 (LLM 처리는 stock-lab이 담당)
|
||||
# AI 요약 호출 (LLM 처리는 stock이 담당)
|
||||
result = await service_proxy.summarize_stock_news(limit=15)
|
||||
|
||||
await self.transition("reporting", "뉴스 요약 전송 중...")
|
||||
@@ -119,7 +119,273 @@ 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 run_holdings_eod(self) -> dict:
|
||||
"""평일 16:50 — 보유종목 시그널 계산·저장."""
|
||||
# idle 가드 없음(의도적): 스크리너 진행 중에도 EOD/브리핑은 독립적으로 실행되어야 함
|
||||
from ..service_proxy import stock_holdings_run
|
||||
from ..db import create_task, update_task_status, add_log
|
||||
task_id = create_task(self.agent_id, "holdings_eod", {})
|
||||
try:
|
||||
res = await stock_holdings_run()
|
||||
update_task_status(task_id, "succeeded", res)
|
||||
add_log(self.agent_id, f"holdings_eod: {res}", "info", task_id)
|
||||
return {"ok": True, **res}
|
||||
except Exception as e:
|
||||
update_task_status(task_id, "failed", {"error": str(e)})
|
||||
add_log(self.agent_id, f"holdings_eod 실패: {e}", "error", task_id)
|
||||
return {"ok": False, "message": str(e)}
|
||||
|
||||
async def run_holdings_brief(self) -> dict:
|
||||
"""평일 08:30 — 저장된 시그널 브리핑 텔레그램."""
|
||||
# idle 가드 없음(의도적): 스크리너 진행 중에도 EOD/브리핑은 독립적으로 실행되어야 함
|
||||
from ..service_proxy import stock_holdings_brief
|
||||
from ..notifiers.telegram_stock import send_holdings_brief
|
||||
from ..db import create_task, update_task_status, add_log
|
||||
task_id = create_task(self.agent_id, "holdings_brief", {})
|
||||
try:
|
||||
payload = await stock_holdings_brief()
|
||||
await send_holdings_brief(payload)
|
||||
update_task_status(task_id, "succeeded", {"date": payload.get("date"),
|
||||
"count": len(payload.get("holdings", []))})
|
||||
add_log(self.agent_id, f"holdings_brief 발송: {payload.get('date')}", "info", task_id)
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
update_task_status(task_id, "failed", {"error": str(e)})
|
||||
add_log(self.agent_id, f"holdings_brief 실패: {e}", "error", task_id)
|
||||
return {"ok": False, "message": str(e)}
|
||||
|
||||
async def on_command(self, command: str, params: dict) -> dict:
|
||||
if command == "holdings_eod":
|
||||
return await self.run_holdings_eod()
|
||||
|
||||
if command == "holdings_brief":
|
||||
return await self.run_holdings_brief()
|
||||
|
||||
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(
|
||||
|
||||
@@ -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,28 @@ CORS_ALLOW_ORIGINS = os.getenv(
|
||||
"CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080"
|
||||
)
|
||||
|
||||
# Idle break threshold (seconds)
|
||||
IDLE_BREAK_THRESHOLD = int(os.getenv("IDLE_BREAK_THRESHOLD", "300")) # 5 min
|
||||
BREAK_DURATION_MIN = int(os.getenv("BREAK_DURATION_MIN", "60")) # 1 min
|
||||
BREAK_DURATION_MAX = int(os.getenv("BREAK_DURATION_MAX", "180")) # 3 min
|
||||
|
||||
# Lotto Curator
|
||||
LOTTO_BACKEND_URL = os.getenv("LOTTO_BACKEND_URL", "http://lotto:8000")
|
||||
LOTTO_CURATOR_MODEL = os.getenv("LOTTO_CURATOR_MODEL", "claude-sonnet-4-5")
|
||||
|
||||
# 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"))
|
||||
|
||||
import re as _re
|
||||
|
||||
# 에이전트 → (container_host, port, path_prefix_regex)
|
||||
# path_prefix_regex: lotto 컨테이너에 personal/blog/todo 도 같이 있어
|
||||
# /api/lotto 만 골라내기 위한 정규식. business log (source='log') 는 모두 통과.
|
||||
AGENT_CONTAINER_MAP: dict[str, tuple[str, int, _re.Pattern]] = {
|
||||
"lotto": ("lotto", 8000, _re.compile(r"^/api/lotto")),
|
||||
"stock": ("stock", 8000, _re.compile(r"^/api/(stock|trade|portfolio)")),
|
||||
"music": ("music-lab", 8000, _re.compile(r"^/api/music")),
|
||||
"insta": ("insta-lab", 8000, _re.compile(r"^/api/insta")),
|
||||
"realestate": ("realestate-lab", 8000, _re.compile(r"^/api/realestate")),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -97,6 +98,66 @@ def init_db() -> None:
|
||||
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", "주식 트레이더"),
|
||||
@@ -202,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]
|
||||
|
||||
|
||||
@@ -248,7 +321,13 @@ def add_log(agent_id: str, message: str, level: str = "info", task_id: str = Non
|
||||
def get_logs(agent_id: str, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM agent_logs WHERE agent_id=? ORDER BY created_at DESC LIMIT ?",
|
||||
"""
|
||||
SELECT * FROM agent_logs
|
||||
WHERE agent_id = ?
|
||||
AND message NOT LIKE 'State: %'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(agent_id, limit),
|
||||
).fetchall()
|
||||
return [
|
||||
@@ -259,6 +338,7 @@ def get_logs(agent_id: str, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
"level": r["level"],
|
||||
"message": r["message"],
|
||||
"created_at": r["created_at"],
|
||||
"source": "agent",
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -515,6 +595,20 @@ def get_activity_feed(limit: int = 50, offset: int = 0) -> dict:
|
||||
return {"items": items, "total": total}
|
||||
|
||||
|
||||
import datetime as _dt
|
||||
|
||||
|
||||
def delete_old_logs(days: int = 90) -> int:
|
||||
"""retention 정책: N일 이전 agent_logs 삭제. 매일 03:00 스케줄러가 호출."""
|
||||
cutoff = (_dt.datetime.utcnow() - _dt.timedelta(days=days)).isoformat()
|
||||
with _conn() as conn:
|
||||
c = conn.execute(
|
||||
"DELETE FROM agent_logs WHERE created_at < ?",
|
||||
(cutoff,),
|
||||
)
|
||||
return c.rowcount
|
||||
|
||||
|
||||
# ── youtube_research_jobs CRUD ────────────────────────────────────────────────
|
||||
|
||||
def add_youtube_research_job(countries: list) -> int:
|
||||
@@ -555,3 +649,170 @@ def get_latest_youtube_research_job() -> Optional[Dict[str, Any]]:
|
||||
"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]
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import json
|
||||
from typing import Optional
|
||||
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
@@ -10,8 +11,10 @@ from .websocket_manager import ws_manager
|
||||
from .agents import init_agents, get_agent, get_all_agent_states, AGENT_REGISTRY
|
||||
from .scheduler import init_scheduler
|
||||
from . import telegram_bot
|
||||
from .routers import notify as notify_router
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(notify_router.router)
|
||||
|
||||
_cors_origins = CORS_ALLOW_ORIGINS.split(",")
|
||||
app.add_middleware(
|
||||
@@ -102,12 +105,29 @@ 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):
|
||||
return {"logs": get_logs(agent_id, limit)}
|
||||
async def agent_logs(agent_id: str, limit: int = 50):
|
||||
from .service_proxy import fetch_service_logs
|
||||
|
||||
agent_items = get_logs(agent_id, limit=limit)
|
||||
service_items = await fetch_service_logs(agent_id, limit=limit)
|
||||
|
||||
def _sort_key(x):
|
||||
# agent_logs: created_at, service: ts
|
||||
return x.get("ts") or x.get("created_at") or ""
|
||||
|
||||
merged = sorted(agent_items + service_items, key=_sort_key, reverse=True)
|
||||
return {"logs": merged[:limit]}
|
||||
|
||||
@app.get("/api/agent-office/tasks/pending")
|
||||
def pending_tasks():
|
||||
@@ -225,3 +245,30 @@ def youtube_research_status():
|
||||
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):
|
||||
|
||||
266
agent-office/app/notifiers/telegram_lotto.py
Normal file
266
agent-office/app/notifiers/telegram_lotto.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""로또 큐레이션·당첨 알림 — 텔레그램 푸시."""
|
||||
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}")
|
||||
|
||||
|
||||
# ---------- 일요 회고 브리핑 ----------
|
||||
|
||||
def format_sunday_review(payload: Dict[str, Any]) -> str:
|
||||
"""일요 회고 브리핑 텍스트 (HTML parse_mode)."""
|
||||
wa = payload.get("winner_analysis") or {}
|
||||
draw_no = payload.get("draw_no") or "?"
|
||||
pct = wa.get("percentile")
|
||||
pct_txt = f"{pct*100:.0f}%" if pct is not None else "—"
|
||||
lines = [f"🔍 <b>로또 #{draw_no} 일요 회고</b>", ""]
|
||||
if wa:
|
||||
lines.append(f"이번 당첨조합 분석치: <b>{wa.get('score_total',0):.2f}</b> "
|
||||
f"(무작위 분포 상위 {pct_txt})")
|
||||
lines.append(f" 빈도 {wa.get('score_frequency',0):.2f} · 지문 {wa.get('score_fingerprint',0):.2f} "
|
||||
f"· 갭 {wa.get('score_gap',0):.2f} · 공동출현 {wa.get('score_cooccur',0):.2f} "
|
||||
f"· 다양성 {wa.get('score_diversity',0):.2f}")
|
||||
lines.append("")
|
||||
if payload.get("forward"):
|
||||
lines.append("📊 <b>이번 회차 가상구매 성적</b>")
|
||||
for f in payload.get("forward", []):
|
||||
p = f.get("prizes") or {}
|
||||
name = {"engine_w": f"엔진({f.get('label','')})", "random_null": "무작위", "coverage": "커버리지"}.get(
|
||||
f.get("strategy", ""), f.get("strategy", "?"))
|
||||
lines.append(f" {name}: 최고 {f.get('best_match','?')}일치 / "
|
||||
f"4등 {p.get('4th', 0)} · 5등 {p.get('5th', 0)}")
|
||||
else:
|
||||
lines.append("📊 <b>이번 회차 가상구매 성적</b>: 데이터 없음 (아직 집계 전)")
|
||||
lines.append("")
|
||||
lines.append("ℹ️ 무작위 대비 우위가 통계적으로 의미있을 때만 가중치가 진화합니다.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def send_sunday_review(payload: Dict[str, Any]) -> None:
|
||||
text = format_sunday_review(payload)
|
||||
try:
|
||||
await send_raw(text)
|
||||
except Exception as e:
|
||||
logger.warning(f"[telegram_lotto] sunday review send failed: {e}")
|
||||
42
agent-office/app/notifiers/telegram_stock.py
Normal file
42
agent-office/app/notifiers/telegram_stock.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""보유종목 인텔리전스 텔레그램 포매터 (advisory)."""
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from ..telegram.messaging import send_raw
|
||||
|
||||
logger = logging.getLogger("agent-office")
|
||||
|
||||
_ACTION_KR = {"add": "🟢 추가매수", "hold": "⚪ 보유", "trim": "🟡 축소", "sell": "🔴 매도"}
|
||||
_SEV = {"high": "🔴", "med": "🟠", "low": "🟡"}
|
||||
|
||||
|
||||
def format_holdings_brief(payload: Dict[str, Any]) -> str:
|
||||
date = payload.get("date") or "?"
|
||||
lines = [f"📊 <b>보유종목 인텔리전스</b> ({date})", ""]
|
||||
ph = payload.get("portfolio_health") or {}
|
||||
if ph:
|
||||
lines.append(f"포트 손익 {ph.get('total_pnl_rate',0):+.1f}% · "
|
||||
f"종목 {ph.get('positions',0)} · 최대비중 {ph.get('max_weight',0)*100:.0f}% · "
|
||||
f"현금 {ph.get('cash_ratio',0)*100:.0f}%")
|
||||
lines.append("")
|
||||
for h in payload.get("holdings", []):
|
||||
act = _ACTION_KR.get(h.get("action"), h.get("action", "?"))
|
||||
pnl = h.get("pnl_rate")
|
||||
pnl_txt = f"{pnl:+.1f}%" if pnl is not None else "—"
|
||||
line = f"{act} <b>{h.get('name') or h.get('ticker')}</b> ({pnl_txt})"
|
||||
if h.get("reasons"):
|
||||
line += f" — {h['reasons']}"
|
||||
lines.append(line)
|
||||
for iss in (h.get("issues") or [])[:3]:
|
||||
lines.append(f" {_SEV.get(iss.get('severity'),'•')} {iss.get('summary','')}")
|
||||
lines.append("")
|
||||
lines.append("ℹ️ 투자 판단 보조용 제안입니다(자동매매 아님).")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def send_holdings_brief(payload: Dict[str, Any]) -> None:
|
||||
text = format_holdings_brief(payload)
|
||||
try:
|
||||
await send_raw(text)
|
||||
except Exception as e:
|
||||
logger.warning(f"[telegram_stock] holdings brief send failed: {e}")
|
||||
0
agent-office/app/routers/__init__.py
Normal file
0
agent-office/app/routers/__init__.py
Normal file
20
agent-office/app/routers/notify.py
Normal file
20
agent-office/app/routers/notify.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""다른 서비스가 트리거하는 웹훅 — 현재 lotto-backend → 텔레그램 푸시."""
|
||||
from typing import List
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
from ..notifiers.telegram_lotto import send_prize_alert
|
||||
|
||||
router = APIRouter(prefix="/api/agent-office/notify")
|
||||
|
||||
|
||||
class LottoPrizeEvent(BaseModel):
|
||||
draw_no: int
|
||||
match_count: int
|
||||
numbers: List[int]
|
||||
purchase_id: int
|
||||
|
||||
|
||||
@router.post("/lotto-prize")
|
||||
async def lotto_prize(body: LottoPrizeEvent):
|
||||
await send_prize_alert(body.model_dump())
|
||||
return {"ok": True}
|
||||
@@ -1,29 +1,88 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
|
||||
from .agents import AGENT_REGISTRY
|
||||
from .db import delete_old_logs
|
||||
|
||||
scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
|
||||
|
||||
async def _check_idle_breaks():
|
||||
for agent in AGENT_REGISTRY.values():
|
||||
await agent.check_idle_break()
|
||||
|
||||
async def _run_stock_schedule():
|
||||
agent = AGENT_REGISTRY.get("stock")
|
||||
if agent:
|
||||
await agent.on_schedule()
|
||||
|
||||
async def _run_blog_schedule():
|
||||
agent = AGENT_REGISTRY.get("blog")
|
||||
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_stock_holdings_eod():
|
||||
agent = AGENT_REGISTRY.get("stock")
|
||||
if agent:
|
||||
await agent.run_holdings_eod()
|
||||
|
||||
async def _run_stock_holdings_brief():
|
||||
agent = AGENT_REGISTRY.get("stock")
|
||||
if agent:
|
||||
await agent.run_holdings_brief()
|
||||
|
||||
async def _run_insta_schedule():
|
||||
agent = AGENT_REGISTRY.get("insta")
|
||||
if agent:
|
||||
await agent.on_schedule()
|
||||
|
||||
|
||||
async def _run_insta_trends_collect():
|
||||
agent = AGENT_REGISTRY.get("insta")
|
||||
if agent:
|
||||
await agent.on_command("collect_trends", {})
|
||||
|
||||
async def _run_lotto_schedule():
|
||||
agent = AGENT_REGISTRY.get("lotto")
|
||||
if agent:
|
||||
await agent.on_schedule()
|
||||
|
||||
async def _run_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_lotto_sunday_review():
|
||||
agent = AGENT_REGISTRY.get("lotto")
|
||||
if agent:
|
||||
await agent.run_sunday_review()
|
||||
|
||||
async def _run_youtube_research():
|
||||
agent = AGENT_REGISTRY.get("youtube")
|
||||
if agent:
|
||||
@@ -39,12 +98,49 @@ async def _poll_pipelines():
|
||||
if agent:
|
||||
await agent.poll_state_changes()
|
||||
|
||||
def _cleanup_old_logs():
|
||||
n = delete_old_logs(days=90)
|
||||
if n:
|
||||
logging.getLogger(__name__).info("delete_old_logs: %d rows removed", n)
|
||||
|
||||
def init_scheduler():
|
||||
scheduler.add_job(_run_stock_schedule, "cron", hour=7, minute=30, id="stock_news")
|
||||
scheduler.add_job(_run_blog_schedule, "cron", hour=10, minute=0, id="blog_pipeline")
|
||||
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=7, minute=0, id="lotto_curate")
|
||||
scheduler.add_job(_run_youtube_research, "cron", hour=9, minute=0, id="youtube_research")
|
||||
scheduler.add_job(
|
||||
_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_stock_holdings_eod, "cron", day_of_week="mon-fri", hour=16, minute=50, id="stock_holdings_eod") # 16:50: 스크리너 snapshot(16:30) 완료 후 — 부분 일봉 읽기 방지
|
||||
scheduler.add_job(_run_stock_holdings_brief, "cron", day_of_week="mon-fri", hour=8, minute=30, id="stock_holdings_brief")
|
||||
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_sunday_review, "cron", day_of_week="sun", hour=9, minute=0, id="lotto_sunday_review")
|
||||
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(_check_idle_breaks, "interval", seconds=60, id="idle_check")
|
||||
scheduler.add_job(_poll_pipelines, "interval", seconds=30, id="pipeline_poll")
|
||||
scheduler.add_job(_cleanup_old_logs, "cron", hour=3, minute=0, id="cleanup_old_logs", replace_existing=True)
|
||||
scheduler.start()
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import httpx
|
||||
import logging
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_client = httpx.AsyncClient(timeout=30.0)
|
||||
|
||||
@@ -9,40 +12,105 @@ 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-lab의 수동 뉴스 스크랩 트리거 — DB에 최신 뉴스 저장.
|
||||
"""stock의 수동 뉴스 스크랩 트리거 — DB에 최신 뉴스 저장.
|
||||
|
||||
아침 브리핑 직전 호출하여 어제 데이터가 아닌 오늘 새벽 뉴스를 보장한다.
|
||||
네이버 금융 단일 요청이라 보통 수 초 내 완료, 여유있게 60s.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
resp = await client.post(f"{STOCK_LAB_URL}/api/stock/scrap")
|
||||
resp = await client.post(f"{STOCK_URL}/api/stock/scrap")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def stock_holdings_run() -> Dict[str, Any]:
|
||||
"""보유종목 시그널 계산 트리거 (EOD, use_llm=True).
|
||||
|
||||
stock BackgroundTask 등록 후 즉시 {ok, queued} 반환.
|
||||
실제 계산은 stock 컨테이너 백그라운드에서 진행 — 여유있게 120s.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
resp = await client.post(
|
||||
f"{STOCK_URL}/api/stock/holdings/intel/run",
|
||||
params={"use_llm": True},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def stock_holdings_brief() -> Dict[str, Any]:
|
||||
"""보유종목 최신 브리핑 payload 조회 (GET, 모듈 레벨 _client 사용)."""
|
||||
resp = await _client.get(f"{STOCK_URL}/api/stock/holdings/intel")
|
||||
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()
|
||||
@@ -59,60 +127,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]:
|
||||
@@ -180,6 +295,34 @@ async def lotto_save_briefing(payload: dict) -> Dict[str, Any]:
|
||||
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]:
|
||||
@@ -221,3 +364,102 @@ async def lookup_pipeline_by_msg(msg_id: int) -> Optional[dict]:
|
||||
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()
|
||||
|
||||
|
||||
async def lotto_backtest_review(draw_no: int) -> Dict[str, Any]:
|
||||
from .config import LOTTO_BACKEND_URL
|
||||
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/backtest/review/{draw_no}")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
|
||||
from .config import AGENT_CONTAINER_MAP
|
||||
|
||||
|
||||
async def fetch_service_logs(
|
||||
agent_id: str,
|
||||
since: Optional[str] = None,
|
||||
limit: int = 200,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""해당 에이전트가 가리키는 컨테이너의 /logs/recent 를 호출해서
|
||||
path_prefix 정규식으로 필터한 결과를 반환.
|
||||
|
||||
네트워크 실패 시 빈 리스트를 반환하고 warning 만 남김 (LogTab 이 죽지 않게).
|
||||
"""
|
||||
mapping = AGENT_CONTAINER_MAP.get(agent_id)
|
||||
if not mapping:
|
||||
return []
|
||||
host, port, path_re = mapping
|
||||
url = f"http://{host}:{port}/logs/recent"
|
||||
params: Dict[str, Any] = {"limit": limit}
|
||||
if since:
|
||||
params["since"] = since
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
resp = await client.get(url, params=params)
|
||||
data = resp.json().get("logs", [])
|
||||
except Exception as e:
|
||||
logger.warning("fetch_service_logs(%s) 실패: %s", agent_id, e)
|
||||
return []
|
||||
return [
|
||||
x for x in data
|
||||
if x.get("source") == "log"
|
||||
or path_re.match(x.get("path", "") or "")
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -37,6 +37,9 @@ async def _handle_callback(callback_query: dict) -> Optional[dict]:
|
||||
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
|
||||
@@ -97,6 +100,38 @@ async def _handle_realestate_bookmark(callback_query: dict, callback_id: str) ->
|
||||
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
|
||||
|
||||
@@ -93,6 +93,41 @@ def test_telegram_state():
|
||||
print(" [PASS] test_telegram_state")
|
||||
|
||||
|
||||
def test_get_logs_excludes_state_messages():
|
||||
init_db()
|
||||
add_log("stock", "State: idle -> working (큐레이션 시작)")
|
||||
add_log("stock", "뉴스 12건 스크랩 완료")
|
||||
add_log("stock", "State: working -> idle ()")
|
||||
|
||||
logs = get_logs("stock", limit=10)
|
||||
messages = [x["message"] for x in logs]
|
||||
assert "뉴스 12건 스크랩 완료" in messages
|
||||
assert not any(m.startswith("State: ") for m in messages)
|
||||
|
||||
|
||||
def test_delete_old_logs_removes_beyond_retention():
|
||||
import datetime as _dt
|
||||
from app.db import delete_old_logs, _conn
|
||||
|
||||
init_db()
|
||||
add_log("stock", "오래된 로그")
|
||||
# 강제로 200일 전으로 옮김
|
||||
cutoff = (_dt.datetime.utcnow() - _dt.timedelta(days=200)).isoformat()
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE agent_logs SET created_at = ? WHERE message = '오래된 로그'",
|
||||
(cutoff,),
|
||||
)
|
||||
|
||||
add_log("stock", "최근 로그")
|
||||
deleted = delete_old_logs(days=90)
|
||||
assert deleted >= 1
|
||||
|
||||
msgs = [x["message"] for x in get_logs("stock", limit=20)]
|
||||
assert "최근 로그" in msgs
|
||||
assert "오래된 로그" not in msgs
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_init_and_seed()
|
||||
test_agent_config_update()
|
||||
|
||||
@@ -4,5 +4,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
|
||||
|
||||
81
agent-office/scripts/migrate_tarot_to_lab.py
Normal file
81
agent-office/scripts/migrate_tarot_to_lab.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""1회성 마이그레이션 — agent_office.db.tarot_readings → tarot.db.tarot_readings.
|
||||
|
||||
멱등성: 이미 존재하는 id는 SKIP.
|
||||
|
||||
실행:
|
||||
docker exec agent-office python /app/scripts/migrate_tarot_to_lab.py
|
||||
|
||||
또는 호스트에서 직접:
|
||||
AGENT_OFFICE_DB=/path/to/agent_office.db TAROT_DB=/path/to/tarot.db \\
|
||||
python scripts/migrate_tarot_to_lab.py
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
|
||||
SRC = os.getenv("AGENT_OFFICE_DB", "/app/data/agent_office.db")
|
||||
DST = os.getenv("TAROT_DB", "/app/data/tarot.db")
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
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
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def migrate() -> int:
|
||||
"""이관된 row 수 반환."""
|
||||
src = sqlite3.connect(SRC)
|
||||
src.row_factory = sqlite3.Row
|
||||
dst = sqlite3.connect(DST)
|
||||
dst.execute("PRAGMA journal_mode=WAL")
|
||||
dst.executescript(SCHEMA)
|
||||
|
||||
rows = src.execute("SELECT * FROM tarot_readings").fetchall()
|
||||
if not rows:
|
||||
src.close(); dst.close()
|
||||
return 0
|
||||
|
||||
all_cols = list(rows[0].keys())
|
||||
|
||||
moved = 0
|
||||
for r in rows:
|
||||
exists = dst.execute("SELECT 1 FROM tarot_readings WHERE id=?", (r["id"],)).fetchone()
|
||||
if exists:
|
||||
continue
|
||||
# NULL 값은 INSERT에서 제외 → 목적지 스키마의 DEFAULT가 적용되도록 함
|
||||
# (예: created_at이 NULL이면 strftime() 기본값 사용)
|
||||
cols = [c for c in all_cols if r[c] is not None]
|
||||
placeholders = ",".join("?" * len(cols))
|
||||
cols_str = ",".join(cols)
|
||||
dst.execute(
|
||||
f"INSERT INTO tarot_readings ({cols_str}) VALUES ({placeholders})",
|
||||
tuple(r[c] for c in cols),
|
||||
)
|
||||
moved += 1
|
||||
dst.commit()
|
||||
src.close(); dst.close()
|
||||
return moved
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
moved = migrate()
|
||||
total = sqlite3.connect(SRC).execute("SELECT COUNT(*) FROM tarot_readings").fetchone()[0]
|
||||
print(f"migrated {moved} / {total} rows from {SRC} to {DST}")
|
||||
sys.exit(0)
|
||||
@@ -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)
|
||||
|
||||
82
agent-office/tests/test_holdings_brief_format.py
Normal file
82
agent-office/tests/test_holdings_brief_format.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from app.notifiers import telegram_stock as ts
|
||||
|
||||
|
||||
def test_format_holdings_brief():
|
||||
payload = {
|
||||
"date": "2026-05-29",
|
||||
"holdings": [
|
||||
{"ticker": "005930", "name": "삼성전자", "action": "trim", "tech_score": 60.0,
|
||||
"exit_flags": {"ma50_break": True}, "issues": [{"type":"news","severity":"high","summary":"악재"}],
|
||||
"pnl_rate": 5.2, "reasons": "MA50 이탈"},
|
||||
{"ticker": "000660", "name": "SK하이닉스", "action": "hold", "tech_score": 75.0,
|
||||
"exit_flags": {}, "issues": [], "pnl_rate": -2.0, "reasons": "특이 신호 없음"},
|
||||
],
|
||||
"portfolio_health": {"positions": 2, "total_pnl_rate": 3.1, "max_weight": 0.6, "cash_ratio": 0.2},
|
||||
}
|
||||
txt = ts.format_holdings_brief(payload)
|
||||
assert "삼성전자" in txt
|
||||
assert "축소" in txt or "trim" in txt
|
||||
assert "%" in txt
|
||||
|
||||
|
||||
def test_format_holdings_brief_empty_holdings():
|
||||
"""빈 holdings + None portfolio_health에도 크래시 없음."""
|
||||
payload = {"date": "2026-05-29", "holdings": [], "portfolio_health": None}
|
||||
txt = ts.format_holdings_brief(payload)
|
||||
assert "보유종목 인텔리전스" in txt
|
||||
assert "자동매매" in txt
|
||||
|
||||
|
||||
def test_format_holdings_brief_missing_fields():
|
||||
"""pnl_rate None·name None·issues None 방어적 처리."""
|
||||
payload = {
|
||||
"date": None,
|
||||
"holdings": [
|
||||
{"ticker": "005930", "name": None, "action": "sell",
|
||||
"pnl_rate": None, "reasons": None, "issues": None},
|
||||
],
|
||||
"portfolio_health": {},
|
||||
}
|
||||
txt = ts.format_holdings_brief(payload)
|
||||
assert "005930" in txt # ticker fallback
|
||||
assert "🔴 매도" in txt
|
||||
|
||||
|
||||
def test_format_holdings_brief_sell_action():
|
||||
"""sell 액션은 🔴 매도로 표시."""
|
||||
payload = {
|
||||
"date": "2026-05-29",
|
||||
"holdings": [
|
||||
{"ticker": "000660", "name": "SK하이닉스", "action": "sell",
|
||||
"pnl_rate": -12.5, "reasons": "손절선 이탈", "issues": []},
|
||||
],
|
||||
"portfolio_health": {"positions": 1, "total_pnl_rate": -12.5,
|
||||
"max_weight": 1.0, "cash_ratio": 0.0},
|
||||
}
|
||||
txt = ts.format_holdings_brief(payload)
|
||||
assert "🔴 매도" in txt
|
||||
assert "-12.5%" in txt
|
||||
|
||||
|
||||
def test_format_holdings_brief_issue_severity_icons():
|
||||
"""이슈 심각도별 이모지 매핑 확인."""
|
||||
payload = {
|
||||
"date": "2026-05-29",
|
||||
"holdings": [
|
||||
{"ticker": "005930", "name": "삼성전자", "action": "hold", "pnl_rate": 2.0,
|
||||
"reasons": "특이 신호 없음",
|
||||
"issues": [
|
||||
{"type": "news", "severity": "high", "summary": "심각 악재"},
|
||||
{"type": "volume_surge", "severity": "med", "summary": "거래량 급증"},
|
||||
{"type": "price_move", "severity": "low", "summary": "소폭 변동"},
|
||||
]},
|
||||
],
|
||||
"portfolio_health": {},
|
||||
}
|
||||
txt = ts.format_holdings_brief(payload)
|
||||
assert "🔴" in txt # high severity
|
||||
assert "🟠" in txt # med severity
|
||||
assert "🟡" in txt # low severity
|
||||
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
|
||||
47
agent-office/tests/test_log_merge.py
Normal file
47
agent-office/tests/test_log_merge.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import pytest
|
||||
import respx
|
||||
import httpx
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.db import add_log, _conn
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_logs():
|
||||
with _conn() as conn:
|
||||
conn.execute("DELETE FROM agent_logs WHERE agent_id = 'lotto'")
|
||||
yield
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_agent_logs_endpoint_merges_db_and_service_logs():
|
||||
add_log("lotto", "큐레이션 완료: #1234 conf=0.78")
|
||||
respx.get("http://lotto:8000/logs/recent").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"logs": [
|
||||
{"ts": "2026-05-28T10:00:00Z", "source": "access",
|
||||
"method": "GET", "path": "/api/lotto/latest",
|
||||
"status": 200, "ms": 8,
|
||||
"message": "GET /api/lotto/latest → 200 (8ms)"},
|
||||
{"ts": "2026-05-28T10:00:02Z", "source": "log",
|
||||
"logger": "lotto", "level": "info",
|
||||
"message": "성과 통계 캐시 갱신"},
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/agent-office/agents/lotto/logs?limit=20")
|
||||
assert resp.status_code == 200
|
||||
logs = resp.json()["logs"]
|
||||
|
||||
sources = {x["source"] for x in logs}
|
||||
assert "agent" in sources
|
||||
assert "access" in sources
|
||||
assert "log" in sources
|
||||
|
||||
messages = [x["message"] for x in logs]
|
||||
assert any("큐레이션 완료" in m for m in messages)
|
||||
assert any("성과 통계 캐시 갱신" in m for m in messages)
|
||||
assert any("/api/lotto/latest" in m for m in messages)
|
||||
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 == ""
|
||||
72
agent-office/tests/test_migrate_tarot.py
Normal file
72
agent-office/tests/test_migrate_tarot.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""migrate_tarot_to_lab.py 단위 테스트 — 멱등성 + 데이터 보존."""
|
||||
import sqlite3
|
||||
import sys
|
||||
import os
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def src_db(tmp_path):
|
||||
p = tmp_path / "agent_office.db"
|
||||
conn = sqlite3.connect(str(p))
|
||||
conn.execute("""
|
||||
CREATE TABLE tarot_readings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT, spread_type TEXT, category TEXT, question TEXT,
|
||||
cards TEXT, interpretation_json TEXT, summary TEXT, model TEXT,
|
||||
tokens_in INTEGER, tokens_out INTEGER, cost_usd REAL,
|
||||
confidence TEXT, favorite INTEGER, note TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
INSERT INTO tarot_readings (id, spread_type, category, cards, model, favorite)
|
||||
VALUES (1, 'three_card', '연애', '[]', 'm', 0),
|
||||
(2, 'one_card', '재물', '[]', 'm', 1)
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return str(p)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dst_db(tmp_path):
|
||||
return str(tmp_path / "tarot.db")
|
||||
|
||||
|
||||
def _import_migrate(src, dst, monkeypatch):
|
||||
monkeypatch.setenv("AGENT_OFFICE_DB", src)
|
||||
monkeypatch.setenv("TAROT_DB", dst)
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
|
||||
import migrate_tarot_to_lab as m
|
||||
import importlib
|
||||
importlib.reload(m)
|
||||
return m
|
||||
|
||||
|
||||
def test_first_run_copies_all_rows(src_db, dst_db, monkeypatch):
|
||||
m = _import_migrate(src_db, dst_db, monkeypatch)
|
||||
moved = m.migrate()
|
||||
assert moved == 2
|
||||
conn = sqlite3.connect(dst_db)
|
||||
rows = conn.execute("SELECT id, spread_type, category FROM tarot_readings ORDER BY id").fetchall()
|
||||
conn.close()
|
||||
assert rows == [(1, "three_card", "연애"), (2, "one_card", "재물")]
|
||||
|
||||
|
||||
def test_idempotent_second_run(src_db, dst_db, monkeypatch):
|
||||
m = _import_migrate(src_db, dst_db, monkeypatch)
|
||||
m.migrate()
|
||||
moved2 = m.migrate()
|
||||
assert moved2 == 0
|
||||
|
||||
|
||||
def test_partial_migration(src_db, dst_db, monkeypatch):
|
||||
"""dst에 id=1만 있는 상태에서 다시 돌리면 id=2만 옮김."""
|
||||
m = _import_migrate(src_db, dst_db, monkeypatch)
|
||||
m.migrate()
|
||||
conn = sqlite3.connect(dst_db)
|
||||
conn.execute("DELETE FROM tarot_readings WHERE id=2")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
moved = m.migrate()
|
||||
assert moved == 1
|
||||
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
|
||||
53
agent-office/tests/test_service_proxy_logs.py
Normal file
53
agent-office/tests/test_service_proxy_logs.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import pytest
|
||||
import respx
|
||||
import httpx
|
||||
|
||||
from app.service_proxy import fetch_service_logs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_fetch_service_logs_filters_by_path_prefix():
|
||||
# lotto 컨테이너 응답: lotto + personal 섞임
|
||||
respx.get("http://lotto:8000/logs/recent").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"logs": [
|
||||
{"ts": "2026-05-28T10:00:00Z", "source": "access",
|
||||
"method": "GET", "path": "/api/lotto/recommend",
|
||||
"status": 200, "ms": 12,
|
||||
"message": "GET /api/lotto/recommend → 200 (12ms)"},
|
||||
{"ts": "2026-05-28T10:00:01Z", "source": "access",
|
||||
"method": "GET", "path": "/api/blog/posts",
|
||||
"status": 200, "ms": 5,
|
||||
"message": "GET /api/blog/posts → 200 (5ms)"},
|
||||
{"ts": "2026-05-28T10:00:02Z", "source": "log",
|
||||
"logger": "lotto", "level": "info",
|
||||
"message": "성과 통계 캐시 갱신"},
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
result = await fetch_service_logs("lotto", limit=50)
|
||||
# lotto path 와 모든 log 이벤트만 통과
|
||||
paths = [x.get("path") for x in result]
|
||||
assert "/api/lotto/recommend" in paths
|
||||
assert "/api/blog/posts" not in paths
|
||||
# 비즈니스 로그도 포함
|
||||
assert any(x["source"] == "log" and x["message"] == "성과 통계 캐시 갱신"
|
||||
for x in result)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_service_logs_unknown_agent_returns_empty():
|
||||
result = await fetch_service_logs("nonexistent", limit=50)
|
||||
assert result == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_fetch_service_logs_handles_connection_error():
|
||||
respx.get("http://lotto:8000/logs/recent").mock(
|
||||
side_effect=httpx.ConnectError("connection refused")
|
||||
)
|
||||
result = await fetch_service_logs("lotto", limit=50)
|
||||
assert result == []
|
||||
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
|
||||
38
agent-office/tests/test_sunday_review.py
Normal file
38
agent-office/tests/test_sunday_review.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from app.notifiers import telegram_lotto as tl
|
||||
|
||||
|
||||
def test_format_sunday_review_text():
|
||||
payload = {
|
||||
"draw_no": 1170,
|
||||
"winner_analysis": {"score_total": 0.41, "percentile": 0.33,
|
||||
"score_frequency": 0.4, "score_fingerprint": 0.5, "score_gap": 0.3,
|
||||
"score_cooccur": 0.45, "score_diversity": 0.6},
|
||||
"forward": [
|
||||
{"strategy": "engine_w", "label": "w1", "prizes": {"1st":0,"2nd":0,"3rd":0,"4th":1,"5th":12}, "best_match": 4, "avg_meta_score": 0.55},
|
||||
{"strategy": "random_null", "label": "-", "prizes": {"1st":0,"2nd":0,"3rd":0,"4th":0,"5th":10}, "best_match": 3, "avg_meta_score": 0.33},
|
||||
],
|
||||
"track_record": {},
|
||||
"calibration_trend": [{"draw_no":1170,"score_total":0.41,"percentile":0.33}],
|
||||
}
|
||||
txt = tl.format_sunday_review(payload)
|
||||
assert "1170" in txt
|
||||
assert "%" in txt # percentile 표기
|
||||
assert "engine" in txt.lower() or "엔진" in txt
|
||||
|
||||
|
||||
def test_format_sunday_review_no_calibration():
|
||||
payload = {"draw_no": 1171, "winner_analysis": None, "forward": []}
|
||||
txt = tl.format_sunday_review(payload)
|
||||
assert "1171" in txt
|
||||
assert "%" not in txt # no percentile section when calibration absent
|
||||
assert "데이터 없음" in txt
|
||||
|
||||
|
||||
def test_format_sunday_review_missing_prizes_no_crash():
|
||||
payload = {"draw_no": 1171, "winner_analysis": None,
|
||||
"forward": [{"strategy": "engine_w", "label": "w1", "best_match": 3}]} # no 'prizes'
|
||||
txt = tl.format_sunday_review(payload) # must NOT raise
|
||||
assert "1171" in txt
|
||||
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
|
||||
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,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"
|
||||
@@ -14,20 +14,27 @@ services:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- LOTTO_ALL_URL=${LOTTO_ALL_URL:-https://smok95.github.io/lotto/results/all.json}
|
||||
- LOTTO_LATEST_URL=${LOTTO_LATEST_URL:-https://smok95.github.io/lotto/results/latest.json}
|
||||
- PYTHONPATH=/app:/shared
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/data:/app/data
|
||||
- ${RUNTIME_PATH}/_shared:/shared/_shared:ro
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
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 +50,19 @@ 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:-}
|
||||
- PYTHONPATH=/app:/shared
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/data/stock:/app/data
|
||||
- ${RUNTIME_PATH}/_shared:/shared/_shared:ro
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -61,7 +76,6 @@ 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:-}
|
||||
@@ -76,33 +90,107 @@ services:
|
||||
- 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}
|
||||
- PYTHONPATH=/app:/shared
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/data/music:/app/data
|
||||
- ${RUNTIME_PATH:-.}/data/videos:/app/data/videos
|
||||
- ${RUNTIME_PATH}/_shared:/shared/_shared:ro
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
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:-}
|
||||
- PYTHONPATH=/app:/shared
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/data/blog:/app/data
|
||||
- ${RUNTIME_PATH}/data/insta:/app/data
|
||||
- ${RUNTIME_PATH}/_shared:/shared/_shared:ro
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
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
|
||||
|
||||
@@ -118,11 +206,18 @@ services:
|
||||
- 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}
|
||||
- PYTHONPATH=/app:/shared
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/data/realestate:/app/data
|
||||
- ${RUNTIME_PATH}/_shared:/shared/_shared:ro
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -136,9 +231,9 @@ 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:-}
|
||||
@@ -157,13 +252,61 @@ services:
|
||||
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
|
||||
|
||||
tarot-lab:
|
||||
build:
|
||||
context: ./tarot-lab
|
||||
container_name: tarot-lab
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18250:8000"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
- TAROT_MODEL=${TAROT_MODEL:-claude-sonnet-4-6}
|
||||
- TAROT_COST_INPUT_PER_M=${TAROT_COST_INPUT_PER_M:-3.0}
|
||||
- TAROT_COST_OUTPUT_PER_M=${TAROT_COST_OUTPUT_PER_M:-15.0}
|
||||
- TAROT_TIMEOUT_SEC=${TAROT_TIMEOUT_SEC:-180}
|
||||
- TAROT_DATA_PATH=/app/data
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
volumes:
|
||||
- ${RUNTIME_PATH:-.}/data/tarot:/app/data
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
saju-lab:
|
||||
build:
|
||||
context: ./saju-lab
|
||||
container_name: saju-lab
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18300:8000"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
- SAJU_MODEL=${SAJU_MODEL:-claude-sonnet-4-6}
|
||||
- SAJU_COST_INPUT_PER_M=${SAJU_COST_INPUT_PER_M:-3.0}
|
||||
- SAJU_COST_OUTPUT_PER_M=${SAJU_COST_OUTPUT_PER_M:-15.0}
|
||||
- SAJU_TIMEOUT_SEC=${SAJU_TIMEOUT_SEC:-240}
|
||||
- SAJU_DATA_PATH=/app/data
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
volumes:
|
||||
- ${RUNTIME_PATH:-.}/data/saju:/app/data
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -182,7 +325,7 @@ services:
|
||||
- ${RUNTIME_PATH:-.}/data/personal:/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
|
||||
|
||||
@@ -198,16 +341,18 @@ services:
|
||||
- DSM_HOST=${DSM_HOST:-}
|
||||
- DSM_USER=${DSM_USER:-}
|
||||
- DSM_PASS=${DSM_PASS:-}
|
||||
- DSM_VERIFY_SSL=${DSM_VERIFY_SSL:-true}
|
||||
- BACKEND_HMAC_SECRET=${BACKEND_HMAC_SECRET:-}
|
||||
- SUPABASE_URL=${SUPABASE_URL:-}
|
||||
- SUPABASE_SERVICE_KEY=${SUPABASE_SERVICE_KEY:-}
|
||||
- UPLOAD_TOKEN_TTL_SEC=${UPLOAD_TOKEN_TTL_SEC:-1800}
|
||||
- PACK_BASE_DIR=${PACK_BASE_DIR:-/app/data/packs}
|
||||
- PACK_HOST_DIR=${PACK_HOST_DIR:-${PACK_DATA_PATH:-./data/packs}}
|
||||
volumes:
|
||||
- ${PACK_DATA_PATH:-./data/packs}:${PACK_BASE_DIR:-/app/data/packs}
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -230,18 +375,30 @@ 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
|
||||
# ngx_http_rewrite_module 힙 오버플로우 2건 대응 (미고정 nginx:alpine → 패치 stable 고정)
|
||||
# - CVE-2026-42945 (NGINX Rift, CVSS 9.2): fixed in 1.30.1+ / 1.31.0+
|
||||
# - CVE-2026-9256 (nginx-poolslip, 영향 ~1.31.0): fixed in 1.30.2+ / 1.31.1+
|
||||
# → 둘 다 커버하는 최소 stable = 1.30.2
|
||||
image: nginx:1.30.2-alpine
|
||||
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:
|
||||
@@ -251,11 +408,13 @@ services:
|
||||
- ${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
|
||||
|
||||
@@ -275,3 +434,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
|
||||
|
||||
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)
|
||||
2753
docs/superpowers/plans/2026-05-15-insta-agent-implementation.md
Normal file
2753
docs/superpowers/plans/2026-05-15-insta-agent-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
1785
docs/superpowers/plans/2026-05-16-insta-trends-implementation.md
Normal file
1785
docs/superpowers/plans/2026-05-16-insta-trends-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
635
docs/superpowers/plans/2026-05-18-plan-b-base-redis-wsl2.md
Normal file
635
docs/superpowers/plans/2026-05-18-plan-b-base-redis-wsl2.md
Normal file
@@ -0,0 +1,635 @@
|
||||
# Plan-B-Base — NAS Redis 컨테이너 + Windows WSL2/Docker/Tailscale/SMB 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:** 분산 아키텍처 base 인프라 셋업 — NAS에 24/7 Redis 컨테이너 신설 + Windows AI 머신에 WSL2 + Docker Engine + Tailscale + NAS SMB 마운트 구성. 후속 Plan-B-Insta/Music/Video/Infra 트랙의 prerequisite.
|
||||
|
||||
**Architecture:** SP-1 (NAS Redis) = docker-compose service 추가 + deployer auto-rebuild. SP-2 (Windows) = 박재오 머신 192.168.45.59에서 직접 셋업 (WSL2 Ubuntu 22.04 + Docker Engine + Tailscale + cifs-utils로 NAS SMB 마운트). 두 SP가 모두 끝나야 후속 트랙의 worker가 NAS ↔ Windows 양방향 통신 가능.
|
||||
|
||||
**Tech Stack:** Redis 7-alpine, WSL2, Ubuntu 22.04, Docker Engine 24+, Tailscale, cifs-utils (SMB 3.0). PowerShell (관리자) + bash (WSL2 내부).
|
||||
|
||||
**Spec:** `web-backend/docs/superpowers/specs/2026-05-18-nas-windows-distributed-architecture-design.md` §4 SP-1·SP-2, §10 SP-1·SP-2 상세
|
||||
|
||||
---
|
||||
|
||||
## 사전 확인 사항
|
||||
|
||||
- **박재오 자격증명 필요**: NAS SMB 마운트용 user/password (Synology DSM 사용자, SMB 권한 보유)
|
||||
- **Windows AI 머신 직접 접근 필요**: WSL2 설치는 관리자 PowerShell + 재부팅 1회. Claude는 별도 머신이라 명령 직접 실행 불가 — **Task 4~7은 박재오가 콘솔에서 직접 수행**. 명령어와 검증 방법 명시.
|
||||
- **NAS deployer 사용자**: Gitea webhook으로 docker compose up -d 자동 실행. 새 redis 서비스도 추가 시 자동 startup.
|
||||
|
||||
## File Structure
|
||||
|
||||
### SP-1 — NAS 측 (Modify)
|
||||
|
||||
| 파일 | 변경 | 책임 |
|
||||
|------|------|------|
|
||||
| `web-backend/docker-compose.yml` | `redis:` 서비스 블록 추가 | 컨테이너 정의 (image, volume, healthcheck) |
|
||||
|
||||
### SP-2 — Windows 측 (Create, 박재오 머신 로컬)
|
||||
|
||||
| 파일/위치 | 변경 | 책임 |
|
||||
|----------|------|------|
|
||||
| (Windows AI) WSL2 Ubuntu-22.04 | install | Linux 런타임 |
|
||||
| WSL2 `/etc/apt/keyrings/docker.gpg` | install | Docker Engine apt key |
|
||||
| WSL2 `/etc/apt/sources.list.d/docker.list` | install | Docker Engine apt source |
|
||||
| (Windows AI) Tailscale | install + auth | 사설망 100.x.x.x |
|
||||
| WSL2 `/etc/nas-smb-credentials` (신규) | NAS user/password | SMB 자격증명 (chmod 600) |
|
||||
| WSL2 `/etc/fstab` (수정) | SMB 마운트 항목 추가 | 부팅 시 자동 마운트 |
|
||||
| WSL2 `/mnt/nas` | mkdir | 마운트 포인트 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: NAS docker-compose.yml에 redis 서비스 추가
|
||||
|
||||
**Files:**
|
||||
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml`
|
||||
|
||||
- [ ] **Step 1: 현재 docker-compose.yml 끝부분 확인 (deployer 위치)**
|
||||
|
||||
Run: `tail -20 C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml`
|
||||
Expected: `deployer` 서비스가 마지막. line ~277-293 영역.
|
||||
|
||||
- [ ] **Step 2: redis 서비스 블록 추가**
|
||||
|
||||
`C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml` 파일 **끝**에 (deployer 서비스 다음, volumes 블록 있다면 그 전에) 다음 블록 추가. 들여쓰기는 다른 서비스(`lotto:`, `stock:` 등)와 동일하게 services 아래 2칸 들여쓰기:
|
||||
|
||||
```yaml
|
||||
|
||||
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
|
||||
networks:
|
||||
- default
|
||||
```
|
||||
|
||||
**주의:**
|
||||
- 파일 끝에 추가하되, 만약 `networks:` / `volumes:` top-level 블록이 services 다음에 있다면 그 블록들 **앞에** 삽입
|
||||
- 첫 줄에 빈 줄 1개 두기 (deployer와 분리)
|
||||
- `${RUNTIME_PATH}` 환경변수는 다른 서비스에서도 사용 중. 자동 적용됨
|
||||
|
||||
- [ ] **Step 3: yaml 문법 검증**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
python -c "import yaml; yaml.safe_load(open('C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml'))" && echo "yaml OK"
|
||||
```
|
||||
Expected: `yaml OK`
|
||||
|
||||
만약 실패하면 indent 또는 trailing space 확인.
|
||||
|
||||
- [ ] **Step 4: redis 서비스가 services dict에 들어갔는지 확인**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
python -c "import yaml; d=yaml.safe_load(open('C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml')); print(sorted(d['services'].keys()))"
|
||||
```
|
||||
Expected: 리스트에 `'redis'` 포함. 다른 서비스(`lotto`, `stock`, `music-lab`, `insta-lab`, `realestate-lab`, `agent-office`, `personal`, `packs-lab`, `travel-proxy`, `frontend`, `deployer`)도 모두 그대로.
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||||
git add docker-compose.yml
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(infra): add redis container as 24/7 queue + cache base (SP-1)
|
||||
|
||||
redis:7-alpine, 256MB maxmemory, AOF appendonly ON, allkeys-lru.
|
||||
docker volume ${RUNTIME_PATH}/redis-data로 영속화.
|
||||
Plan-B 후속 트랙(insta-render/music-render/video-render Windows
|
||||
워커)의 BLPOP 큐 + NAS↔Windows pub/sub의 base.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 6: push (Gitea webhook → NAS deployer 자동 적용)**
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||||
git push origin main
|
||||
```
|
||||
|
||||
자격증명 prompt 시 입력. 1회 실패 시 1회 재시도 패턴.
|
||||
|
||||
Expected: push 성공. NAS deployer가 webhook 수신 → `git pull` → `docker compose up -d redis` 자동 실행.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: NAS Redis 컨테이너 헬스 확인
|
||||
|
||||
**Files:** 없음 (NAS 검증)
|
||||
|
||||
- [ ] **Step 1: deployer 완료까지 대기 (통상 30초~2분)**
|
||||
|
||||
Run (Windows 로컬에서):
|
||||
```bash
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" https://gahusb.synology.me/api/stock/news -m 5)
|
||||
echo "[try $i] HTTP $code"
|
||||
if [ "$code" = "200" ]; then break; fi
|
||||
sleep 15
|
||||
done
|
||||
```
|
||||
|
||||
Expected: HTTP 200 응답 — NAS 컨테이너 안정 상태. redis 컨테이너는 별도 endpoint 없으나 deployer가 build 완료했음을 시사.
|
||||
|
||||
- [ ] **Step 2: NAS에서 redis 컨테이너 확인 (박재오 SSH)**
|
||||
|
||||
NAS bash:
|
||||
```bash
|
||||
ssh -p 22 박재오@gahusb.synology.me
|
||||
cd /volume1/docker/webpage
|
||||
docker compose ps redis
|
||||
```
|
||||
|
||||
또는 한 번에:
|
||||
```bash
|
||||
ssh -p 22 박재오@gahusb.synology.me "cd /volume1/docker/webpage && docker compose ps redis && docker exec redis redis-cli PING"
|
||||
```
|
||||
|
||||
Expected:
|
||||
- `docker compose ps redis` → `redis ... healthy` 또는 `Up X seconds (health: starting)` 후 곧 healthy
|
||||
- `redis-cli PING` → `PONG`
|
||||
|
||||
만약 `docker compose ps`에 redis가 안 보이면:
|
||||
```bash
|
||||
cd /volume1/docker/webpage && docker compose up -d redis
|
||||
```
|
||||
|
||||
수동 실행해서 startup 확인.
|
||||
|
||||
- [ ] **Step 3: redis-data 볼륨 생성 확인 (Z: drive로)**
|
||||
|
||||
Run (Windows):
|
||||
```powershell
|
||||
Test-Path "Z:\webpage\redis-data"
|
||||
```
|
||||
|
||||
또는 NAS bash:
|
||||
```bash
|
||||
ls -la /volume1/docker/webpage/redis-data/
|
||||
```
|
||||
|
||||
Expected: 디렉토리 존재. 그 안에 `appendonlydir/` 또는 `dump.rdb` 등의 redis 데이터 파일.
|
||||
|
||||
- [ ] **Step 4: AOF append-only 작동 확인 (선택, 데이터 영속성 검증)**
|
||||
|
||||
```bash
|
||||
ssh -p 22 박재오@gahusb.synology.me 'docker exec redis redis-cli SET test_key "hello"'
|
||||
ssh -p 22 박재오@gahusb.synology.me 'docker exec redis redis-cli RESTART' # 또는 docker restart
|
||||
# 잠시 대기
|
||||
ssh -p 22 박재오@gahusb.synology.me 'docker exec redis redis-cli GET test_key'
|
||||
```
|
||||
|
||||
Expected: `"hello"` — 재시작 후에도 값 유지 (AOF 영속화 작동).
|
||||
|
||||
테스트 후 정리: `docker exec redis redis-cli DEL test_key`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Windows AI에 WSL2 + Ubuntu 22.04 설치
|
||||
|
||||
**Files:** 없음 (Windows AI 머신 192.168.45.59에서 박재오 직접 실행)
|
||||
|
||||
**전제:** Windows 10 build 19041+ 또는 Windows 11. 박재오 9800X3D 머신 충족.
|
||||
|
||||
- [ ] **Step 1: 관리자 PowerShell 실행**
|
||||
|
||||
박재오 Windows AI 머신에서 시작 메뉴 → "PowerShell" 우클릭 → "관리자 권한으로 실행".
|
||||
|
||||
- [ ] **Step 2: WSL2 + Ubuntu 22.04 설치**
|
||||
|
||||
```powershell
|
||||
wsl --install -d Ubuntu-22.04
|
||||
```
|
||||
|
||||
Expected: 다운로드 progress + "Ubuntu-22.04 has been installed". **재부팅 필요할 수 있음.**
|
||||
|
||||
- [ ] **Step 3: 재부팅 (필요 시)**
|
||||
|
||||
설치 완료 메시지에 "재시작이 필요합니다"가 보이면 재부팅. 자동 재부팅 안 됨.
|
||||
|
||||
- [ ] **Step 4: Ubuntu 초기 설정 (재부팅 후 자동 실행 또는 시작 메뉴에서 "Ubuntu" 클릭)**
|
||||
|
||||
새 콘솔이 열리고 다음 입력 요청됨:
|
||||
- 새 UNIX username: `jaeoh` 또는 박재오 선호 username (이후 모든 sudo에 사용)
|
||||
- 비밀번호: 박재오가 정하는 값. 잘 기억할 것.
|
||||
|
||||
Expected: `jaeoh@<hostname>:~$` 프롬프트 표시 → WSL2 진입 성공.
|
||||
|
||||
- [ ] **Step 5: WSL 버전 확인**
|
||||
|
||||
WSL2 내부에서 PowerShell로 잠시 돌아와서:
|
||||
```powershell
|
||||
wsl -l -v
|
||||
```
|
||||
|
||||
Expected:
|
||||
```
|
||||
NAME STATE VERSION
|
||||
* Ubuntu-22.04 Running 2
|
||||
```
|
||||
|
||||
VERSION=2 확인. 만약 1이면:
|
||||
```powershell
|
||||
wsl --set-version Ubuntu-22.04 2
|
||||
```
|
||||
|
||||
- [ ] **Step 6: WSL2 안 진입 (이후 작업)**
|
||||
|
||||
```powershell
|
||||
wsl -d Ubuntu-22.04
|
||||
```
|
||||
|
||||
이후 Task 4~7은 모두 WSL2 안 bash에서 실행.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: WSL2 안 Docker Engine 설치 (Docker Desktop 사용 X)
|
||||
|
||||
**Files:** (WSL2 내부) `/etc/apt/keyrings/docker.gpg`, `/etc/apt/sources.list.d/docker.list`
|
||||
|
||||
**위치:** WSL2 Ubuntu-22.04 bash 프롬프트.
|
||||
|
||||
- [ ] **Step 1: 패키지 인덱스 + 기본 의존성 설치**
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y ca-certificates curl gnupg lsb-release
|
||||
```
|
||||
|
||||
Expected: 에러 없이 완료.
|
||||
|
||||
- [ ] **Step 2: Docker apt key 등록**
|
||||
|
||||
```bash
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
sudo chmod a+r /etc/apt/keyrings/docker.gpg
|
||||
```
|
||||
|
||||
Expected: 에러 없이 완료. `/etc/apt/keyrings/docker.gpg` 파일 생성.
|
||||
|
||||
- [ ] **Step 3: Docker repository 추가**
|
||||
|
||||
```bash
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | \
|
||||
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
sudo apt update
|
||||
```
|
||||
|
||||
Expected: `Hit:N https://download.docker.com/linux/ubuntu jammy InRelease` 라인 보임.
|
||||
|
||||
- [ ] **Step 4: Docker Engine + Compose 설치**
|
||||
|
||||
```bash
|
||||
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
```
|
||||
|
||||
Expected: 설치 완료. 용량 ~400MB.
|
||||
|
||||
- [ ] **Step 5: 현재 사용자를 docker 그룹에 추가**
|
||||
|
||||
```bash
|
||||
sudo usermod -aG docker $USER
|
||||
```
|
||||
|
||||
Expected: 출력 없음 (정상). **새 셸 열어야 적용됨.**
|
||||
|
||||
- [ ] **Step 6: Docker 서비스 시작 + 자동 시작 설정**
|
||||
|
||||
```bash
|
||||
sudo systemctl enable docker
|
||||
sudo systemctl start docker
|
||||
sudo systemctl status docker | head -5
|
||||
```
|
||||
|
||||
Expected: `Active: active (running)`.
|
||||
|
||||
만약 `systemctl: command not found` 또는 systemd 미지원 시:
|
||||
```bash
|
||||
sudo service docker start
|
||||
```
|
||||
|
||||
WSL2 systemd 활성화는 `/etc/wsl.conf`에 `[boot]\nsystemd=true` 추가 후 PowerShell에서 `wsl --shutdown` 후 재진입. (Ubuntu-22.04는 보통 기본 활성)
|
||||
|
||||
- [ ] **Step 7: docker 명령 동작 확인**
|
||||
|
||||
새 셸로 (PowerShell에서 다시 `wsl -d Ubuntu-22.04` 또는 현재 셸 종료 후 재진입):
|
||||
|
||||
```bash
|
||||
docker version
|
||||
docker run --rm hello-world
|
||||
```
|
||||
|
||||
Expected:
|
||||
- `docker version`: Client + Server 둘 다 표시 (Server에 Engine version)
|
||||
- `hello-world`: "Hello from Docker!" 출력
|
||||
|
||||
---
|
||||
|
||||
## Task 5: WSL2 안 Tailscale 설치 + 가입
|
||||
|
||||
**Files:** Tailscale은 systemd service 등록 (별도 path 신경 안 써도 됨)
|
||||
|
||||
- [ ] **Step 1: Tailscale 설치**
|
||||
|
||||
WSL2 bash:
|
||||
```bash
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
```
|
||||
|
||||
Expected: 패키지 install 후 "Installation complete!" 출력.
|
||||
|
||||
- [ ] **Step 2: Tailscale 가입 (브라우저 OAuth)**
|
||||
|
||||
```bash
|
||||
sudo tailscale up
|
||||
```
|
||||
|
||||
Expected: `To authenticate, visit: https://login.tailscale.com/a/...` URL 표시.
|
||||
|
||||
브라우저에서 그 URL 열기 → Google/Microsoft/GitHub 등으로 로그인 → 박재오 Tailscale 네트워크에 가입 (기존 계정 없으면 생성).
|
||||
|
||||
- [ ] **Step 3: 가입 완료 확인**
|
||||
|
||||
```bash
|
||||
tailscale status
|
||||
```
|
||||
|
||||
Expected:
|
||||
- 첫 줄에 Windows AI 머신의 100.x.x.x IP 표시
|
||||
- (이미 가입된) NAS도 같은 네트워크에 있다면 NAS의 100.x.x.x IP도 표시
|
||||
|
||||
- [ ] **Step 4: NAS와 Tailscale ping (양방향 사설망 확인)**
|
||||
|
||||
NAS의 Tailscale IP를 `tailscale status` 출력에서 찾아 (예: `100.64.0.10`):
|
||||
```bash
|
||||
tailscale ping 100.64.0.10
|
||||
```
|
||||
|
||||
Expected: `pong from <NAS hostname>` (직접 LAN 또는 DERP 중계). 만약 NAS가 Tailscale 미가입이면 별도로 NAS DSM Tailscale 패키지 셋업 필요 — 이는 박재오 결정 사항이라 plan 외.
|
||||
|
||||
> **참고:** Tailscale은 spec §3 sense의 사설망 layer 보조. LAN(192.168.45.0/24) 안에서만 작업한다면 Tailscale 없이도 작동. 외부 출장 등에서 NAS↔Windows 통신을 위해 권장.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: WSL2 안 NAS SMB 자격증명 파일 + 마운트 포인트 준비
|
||||
|
||||
**Files:** `/etc/nas-smb-credentials`, `/mnt/nas`
|
||||
|
||||
- [ ] **Step 1: cifs-utils 설치 (SMB 마운트 패키지)**
|
||||
|
||||
```bash
|
||||
sudo apt install -y cifs-utils
|
||||
```
|
||||
|
||||
Expected: 설치 완료.
|
||||
|
||||
- [ ] **Step 2: SMB 자격증명 파일 생성**
|
||||
|
||||
박재오 NAS 계정의 username과 password를 사용. 파일 위치는 system-wide `/etc/`.
|
||||
|
||||
```bash
|
||||
sudo bash -c 'cat > /etc/nas-smb-credentials <<EOF
|
||||
username=박재오NAS사용자명
|
||||
password=박재오NAS비밀번호
|
||||
domain=
|
||||
EOF'
|
||||
```
|
||||
|
||||
**위 명령 실행 전 `박재오NAS사용자명` / `박재오NAS비밀번호`를 실제 값으로 교체.** Synology DSM Control Panel → User & Group 에서 SMB 접근 권한 있는 계정 사용. 비밀번호에 특수문자 있을 시 escape 필요 (특히 `!`, `$`, `\`).
|
||||
|
||||
- [ ] **Step 3: 자격증명 파일 권한 보호**
|
||||
|
||||
```bash
|
||||
sudo chmod 600 /etc/nas-smb-credentials
|
||||
sudo chown root:root /etc/nas-smb-credentials
|
||||
```
|
||||
|
||||
Expected: 출력 없음.
|
||||
|
||||
```bash
|
||||
ls -la /etc/nas-smb-credentials
|
||||
```
|
||||
|
||||
Expected: `-rw------- 1 root root ... /etc/nas-smb-credentials`
|
||||
|
||||
- [ ] **Step 4: 마운트 포인트 생성**
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /mnt/nas
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: NAS SMB 마운트 (수동 마운트 + fstab 자동화)
|
||||
|
||||
**Files:** `/etc/fstab` (수정)
|
||||
|
||||
- [ ] **Step 1: 수동 마운트 시도 (자격증명·경로 검증)**
|
||||
|
||||
```bash
|
||||
sudo mount -t cifs //gahusb.synology.me/docker /mnt/nas \
|
||||
-o credentials=/etc/nas-smb-credentials,vers=3.0,uid=1000,gid=1000,_netdev
|
||||
```
|
||||
|
||||
Expected: 출력 없음 (성공). 만약 `mount error(13)` (permission) → 자격증명 오류. `mount error(2)` (no such file) → share name `docker` 확인.
|
||||
|
||||
> **share name 변형:** 박재오 NAS는 메모리(`feedback_nas_deploy_paths.md`)에 따르면 SMB 매핑이 `/volume1/docker/`를 share `docker`로 노출. 만약 다른 share name(예: `webpage`)이라면 그것으로 교체.
|
||||
|
||||
- [ ] **Step 2: 마운트 결과 확인**
|
||||
|
||||
```bash
|
||||
ls /mnt/nas/
|
||||
```
|
||||
|
||||
Expected: `webpage/` 디렉토리 + 다른 share 내 디렉토리 보임.
|
||||
|
||||
```bash
|
||||
ls /mnt/nas/webpage/data/
|
||||
```
|
||||
|
||||
Expected: `insta/`, `music/` 등 후속 트랙에서 사용할 디렉토리. 없으면 후속 트랙에서 생성됨.
|
||||
|
||||
- [ ] **Step 3: 마운트 해제 후 fstab으로 자동화**
|
||||
|
||||
```bash
|
||||
sudo umount /mnt/nas
|
||||
```
|
||||
|
||||
Expected: 출력 없음.
|
||||
|
||||
`/etc/fstab` 끝에 다음 라인 추가:
|
||||
```bash
|
||||
sudo bash -c 'cat >> /etc/fstab <<EOF
|
||||
|
||||
# NAS Synology SMB mount for web-ai-services workers (2026-05-18)
|
||||
//gahusb.synology.me/docker /mnt/nas cifs credentials=/etc/nas-smb-credentials,vers=3.0,uid=1000,gid=1000,_netdev,nofail 0 0
|
||||
EOF'
|
||||
```
|
||||
|
||||
`nofail` 옵션은 부팅 시 NAS 미접속이어도 boot 진행 (production 안전).
|
||||
|
||||
- [ ] **Step 4: fstab 적용 + 검증**
|
||||
|
||||
```bash
|
||||
sudo mount -a
|
||||
ls /mnt/nas/webpage/data/ 2>&1 | head -5
|
||||
mount | grep cifs
|
||||
```
|
||||
|
||||
Expected:
|
||||
- `mount -a` 출력 없음 (성공)
|
||||
- `ls /mnt/nas/webpage/data/` 디렉토리 내용 표시
|
||||
- `mount | grep cifs` 라인에 마운트 정보 보임
|
||||
|
||||
- [ ] **Step 5: WSL2 재시작 시 자동 마운트 확인**
|
||||
|
||||
PowerShell에서 (관리자 권한 불필요):
|
||||
```powershell
|
||||
wsl --shutdown
|
||||
wsl -d Ubuntu-22.04
|
||||
```
|
||||
|
||||
WSL2 다시 진입 후:
|
||||
```bash
|
||||
ls /mnt/nas/webpage/data/
|
||||
```
|
||||
|
||||
Expected: 정상 디렉토리 목록. 자동 마운트 성공.
|
||||
|
||||
만약 마운트 안 됨:
|
||||
- `dmesg | grep cifs` 확인
|
||||
- `nofail` 때문에 boot은 통과했으나 마운트 실패 가능. 수동 `sudo mount -a` 후 동작 확인 → fstab syntax 재검토
|
||||
|
||||
---
|
||||
|
||||
## Task 8: 통합 검증 — base 인프라 동작 확인
|
||||
|
||||
**Files:** 없음 (검증)
|
||||
|
||||
- [ ] **Step 1: NAS Redis 외부 ping (Windows 로컬에서)**
|
||||
|
||||
```powershell
|
||||
# Windows AI 또는 박재오 PC에서
|
||||
Test-NetConnection -ComputerName 192.168.45.54 -Port 6379
|
||||
```
|
||||
|
||||
Expected: `TcpTestSucceeded : True`
|
||||
|
||||
> 외부 6379 노출은 LAN 한정. 가능하면 NAS firewall (DSM Control Panel)에서 6379 LAN-only allowed로 한정 권장. (이번 plan에 포함 안 됨, 별도 사용자 작업)
|
||||
|
||||
- [ ] **Step 2: WSL2에서 NAS Redis 접속**
|
||||
|
||||
WSL2 bash:
|
||||
```bash
|
||||
docker run --rm redis:7-alpine redis-cli -h 192.168.45.54 PING
|
||||
```
|
||||
|
||||
또는 Tailscale 사용 시:
|
||||
```bash
|
||||
docker run --rm redis:7-alpine redis-cli -h <NAS_TAILSCALE_IP> PING
|
||||
```
|
||||
|
||||
Expected: `PONG`
|
||||
|
||||
- [ ] **Step 3: NAS volume 쓰기 테스트 (Windows→NAS 양방향)**
|
||||
|
||||
WSL2 bash:
|
||||
```bash
|
||||
echo "Plan-B-Base test $(date)" | sudo tee /mnt/nas/webpage/data/.plan-b-test.txt
|
||||
cat /mnt/nas/webpage/data/.plan-b-test.txt
|
||||
sudo rm /mnt/nas/webpage/data/.plan-b-test.txt
|
||||
```
|
||||
|
||||
Expected:
|
||||
- `tee` 출력에 같은 내용 + 파일 생성됨
|
||||
- `cat` 으로 확인 성공
|
||||
- 파일 삭제 성공
|
||||
|
||||
`sudo` 필요 시 chmod로 uid 1000 쓰기 권한 확인. 또는 mount option `uid=1000,gid=1000` 적용 후 일반 사용자도 쓰기 가능. 만약 안 되면 NAS DSM에서 SMB user의 write 권한 확인.
|
||||
|
||||
- [ ] **Step 4: WSL2 Docker로 hello-world 한 번 더 (재진입 후 상태 확인)**
|
||||
|
||||
```bash
|
||||
docker run --rm hello-world
|
||||
```
|
||||
|
||||
Expected: "Hello from Docker!"
|
||||
|
||||
- [ ] **Step 5: 모든 검증 완료 후 보고 — 후속 트랙으로 진입 가능 상태**
|
||||
|
||||
다음 plan(Plan-B-Insta 등)이 가정하는 상태:
|
||||
- ✅ NAS `redis:6379` PING/PONG 성공
|
||||
- ✅ Windows WSL2 Ubuntu-22.04 작동 + Docker Engine 실행
|
||||
- ✅ `/mnt/nas/webpage/data/` 양방향 read·write 성공
|
||||
- ✅ Tailscale 가입 (선택, 외부 출장 시 필요)
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
### Spec 커버리지
|
||||
|
||||
| Spec 요구사항 | 구현 Task |
|
||||
|---------------|-----------|
|
||||
| §4 SP-1: NAS Redis 컨테이너 | Task 1 (compose 추가) + Task 2 (헬스 검증) |
|
||||
| §10 SP-1: redis:7-alpine + 256MB + AOF + healthcheck | Task 1 Step 2 |
|
||||
| §4 SP-2: Windows WSL2 + Docker Engine | Task 3 (WSL2) + Task 4 (Docker) |
|
||||
| §10 SP-2: Tailscale | Task 5 |
|
||||
| §10 SP-2: NAS SMB mount `/mnt/nas` | Task 6 (자격증명·포인트) + Task 7 (마운트+fstab) |
|
||||
| §10 SP-2: 검증 (docker ps, tailscale status, ls /mnt/nas) | Task 8 |
|
||||
| §6 Redis 키 컨벤션 사용 가능 | Task 2 Step 2 (PING) — 컨벤션 자체는 후속 트랙에서 RPUSH로 시작 |
|
||||
|
||||
### Placeholder 스캔
|
||||
|
||||
- TBD/TODO 없음 ✓
|
||||
- 모든 명령어가 그대로 실행 가능한 형태 ✓
|
||||
- 한 가지 예외: Task 6 Step 2 — `박재오NAS사용자명/박재오NAS비밀번호`는 사용자 자격증명이라 placeholder가 의도된 것. 실행 전 교체 명시 ✓
|
||||
- Task 5 Step 4 — `<NAS 의 Tailscale IP>`는 `tailscale status` 출력에서 박재오가 보고 입력. 사용자 환경에서만 결정 가능, plan에 명시 ✓
|
||||
|
||||
### Type/이름 consistency
|
||||
|
||||
- `redis` 서비스명 (Task 1, 2, 8 모두 동일) ✓
|
||||
- `/mnt/nas` 마운트 포인트 (Task 6, 7, 8 모두 동일) ✓
|
||||
- `/etc/nas-smb-credentials` 자격증명 파일 (Task 6, 7 동일) ✓
|
||||
- share name `docker` (Task 7 Step 1, fstab 동일) ✓
|
||||
- Ubuntu-22.04 (Task 3, 4 동일) ✓
|
||||
|
||||
### 위험·주의
|
||||
|
||||
| 위험 | 완화 |
|
||||
|------|------|
|
||||
| Windows 재부팅 시 WSL2 자동 시작 안 함 | 향후 Plan-B-Infra(SP-9)에서 NSSM으로 자동 시작 |
|
||||
| WSL2 systemd 미지원 시 docker service 자동 시작 안 함 | Task 4 Step 6의 fallback `sudo service docker start` 또는 `/etc/wsl.conf` 수정 |
|
||||
| SMB 마운트 자격증명 노출 | `/etc/nas-smb-credentials` chmod 600 + root:root |
|
||||
| NAS firewall에서 6379 외부 노출 | 권장: LAN(192.168.45.0/24) only allow. 본 plan 외 (DSM 수동) |
|
||||
| Tailscale 미가입 시 NAS↔Windows 외부 통신 불가 | LAN 내에선 작동. 외부 출장 시 필요할 때만 가입 |
|
||||
| /mnt/nas 쓰기 권한 부족 | uid=1000 mount option + NAS DSM에서 SMB user의 share write 권한 확인 |
|
||||
|
||||
---
|
||||
|
||||
## 완료 후 다음 단계
|
||||
|
||||
Plan-B-Base 완료 후 spec §14 권장 순서대로:
|
||||
|
||||
1. **Plan-B-Insta** — SP-3 (insta-render Windows worker) + SP-4 (NAS insta-lab 분할)
|
||||
2. **Plan-B-Music** — SP-5 + SP-6
|
||||
3. **Plan-B-Video** — SP-7 + SP-8
|
||||
4. **Plan-B-Infra** — SP-9 (NSSM 자동 시작) + SP-10 (task-watcher)
|
||||
|
||||
각 후속 plan은 본 plan이 제공한 base 인프라(Redis + WSL2/Docker + /mnt/nas)에 의존.
|
||||
656
docs/superpowers/plans/2026-05-18-track-a-cache-hardening.md
Normal file
656
docs/superpowers/plans/2026-05-18-track-a-cache-hardening.md
Normal file
@@ -0,0 +1,656 @@
|
||||
# Track A — NAS↔Windows API 부하 캐시 강화 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:** web-ai → NAS stock 호출량을 분당 12회 → 분당 3~4회로 축소하여, V2 재시작 시점부터 즉시 NAS CPU 부담 70% 감소.
|
||||
|
||||
**Architecture:** 2-layer cache. (1) web-ai client side: 3개 endpoint TTL 60/300/60 → 180/600/300으로 증가. (2) NAS stock server side: 동일 endpoint에 in-memory TTLCache 추가하여 web-ai 캐시 miss 시에도 KIS·LLM 재호출 차단. 두 layer가 cumulative하게 작동.
|
||||
|
||||
**Tech Stack:** Python 3.12 / FastAPI / pytest / `cachetools.TTLCache`. **two repos**: `web-ai` (signal_v2/) + `web-backend` (stock/).
|
||||
|
||||
**Spec:** `web-backend/docs/superpowers/specs/2026-05-18-nas-windows-distributed-architecture-design.md` §4 SP-A1·A2, §10 상세
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### SP-A1 — web-ai 캐시 TTL (Modify)
|
||||
|
||||
| 파일 | 변경 | 책임 |
|
||||
|------|------|------|
|
||||
| `web-ai/signal_v2/stock_client.py:13-17` | `_TTL` dict 3개 값 변경 | endpoint별 client-side cache TTL |
|
||||
| `web-ai/signal_v2/tests/test_stock_client_ttl.py` (Create) | TTL 값 회귀 테스트 | 미래 변경 시 의도하지 않은 회귀 방지 |
|
||||
|
||||
### SP-A2 — NAS stock TTLCache (Modify + Create)
|
||||
|
||||
| 파일 | 변경 | 책임 |
|
||||
|------|------|------|
|
||||
| `web-backend/stock/requirements.txt` | `cachetools>=5.3` 추가 | 의존성 |
|
||||
| `web-backend/stock/app/webai_cache.py` (Create) | 3개 TTLCache + helper 함수 | server-side cache 중앙화 |
|
||||
| `web-backend/stock/app/main.py:419-422` | `get_webai_portfolio()` cache 적용 | NAS portfolio 캐시 |
|
||||
| `web-backend/stock/app/main.py:467-470` | `get_webai_news_sentiment(date)` cache 적용 | date별 캐시 |
|
||||
| `web-backend/stock/app/screener/router.py:173` | `post_run()` cache 적용 (mode=preview만) | screener preview 캐시 |
|
||||
| `web-backend/stock/app/test_webai_cache.py` (Create) | cache 동작 + TTL + key 분기 | 캐시 hit/miss 검증 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: web-ai SP-A1 — `_TTL` dict 회귀 테스트 작성
|
||||
|
||||
**Files:**
|
||||
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/signal_v2/tests/test_stock_client_ttl.py`
|
||||
|
||||
- [ ] **Step 1: 실패하는 테스트 작성**
|
||||
|
||||
```python
|
||||
# tests/test_stock_client_ttl.py
|
||||
"""SP-A1 회귀 — _TTL이 NAS 부담 완화를 위한 값으로 설정되어 있어야 함."""
|
||||
from signal_v2.stock_client import _TTL
|
||||
|
||||
|
||||
def test_portfolio_ttl_is_180s():
|
||||
"""portfolio TTL은 180초 이상 (3분 폴링에서 1회 fetch가 3 폴링 커버)."""
|
||||
assert _TTL["portfolio"] >= 180.0
|
||||
|
||||
|
||||
def test_news_sentiment_ttl_is_600s():
|
||||
"""news-sentiment TTL은 600초 이상 (10분, 뉴스 sentiment는 자주 안 바뀜)."""
|
||||
assert _TTL["news-sentiment"] >= 600.0
|
||||
|
||||
|
||||
def test_screener_preview_ttl_is_300s():
|
||||
"""screener-preview TTL은 300초 이상 (5분, Top-20은 분 단위로 거의 안 바뀜)."""
|
||||
assert _TTL["screener-preview"] >= 300.0
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실패 확인**
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai && python -m pytest signal_v2/tests/test_stock_client_ttl.py -v`
|
||||
Expected: FAIL — 현재 _TTL 값은 60/300/60. portfolio·screener-preview 모두 < 180/300.
|
||||
|
||||
- [ ] **Step 3: `_TTL` 값 변경**
|
||||
|
||||
`C:/Users/jaeoh/Desktop/workspace/web-ai/signal_v2/stock_client.py` line 13-17:
|
||||
|
||||
변경 전:
|
||||
```python
|
||||
_TTL = {
|
||||
"portfolio": 60.0,
|
||||
"news-sentiment": 300.0,
|
||||
"screener-preview": 60.0,
|
||||
}
|
||||
```
|
||||
|
||||
변경 후:
|
||||
```python
|
||||
# Cache TTL by endpoint (seconds).
|
||||
# 2026-05-18 — NAS 인바운드 호출 부담 완화 (Plan-A SP-A1).
|
||||
_TTL = {
|
||||
"portfolio": 180.0, # 3분 (1분 폴링 시 3 폴링당 1회 실제 fetch)
|
||||
"news-sentiment": 600.0, # 10분 (뉴스 sentiment는 자주 안 바뀜)
|
||||
"screener-preview": 300.0, # 5분 (Top-20은 분 단위로 거의 안 바뀜)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 테스트 통과 확인**
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai && python -m pytest signal_v2/tests/test_stock_client_ttl.py -v`
|
||||
Expected: PASS — 3개 모두 통과.
|
||||
|
||||
- [ ] **Step 5: 전체 회귀 확인 (기존 56 tests + 신규 3 tests)**
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai && python -m pytest signal_v2/tests/ -v 2>&1 | tail -5`
|
||||
Expected: 59 tests 모두 PASS (기존 56 + 신규 3).
|
||||
|
||||
- [ ] **Step 6: 커밋**
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
||||
git add signal_v2/stock_client.py signal_v2/tests/test_stock_client_ttl.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
perf(signal_v2): raise stock_client TTL for NAS load relief (SP-A1)
|
||||
|
||||
portfolio 60s → 180s (3분 폴링 → 3회당 1회 fetch)
|
||||
news-sent 300s → 600s (sentiment는 자주 안 바뀜)
|
||||
screener 60s → 300s (Top-20 분 단위 변화 미미)
|
||||
|
||||
V2 재시작 시점부터 NAS stock에 대한 인바운드 호출이
|
||||
분당 12 → 분당 3~4 로 감소 예상. 캐시 hit ratio 0~50% → 66~80%.
|
||||
회귀 테스트 3건 추가로 미래 의도치 않은 TTL 변경 차단.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: NAS SP-A2 — `cachetools` 의존성 추가
|
||||
|
||||
**Files:**
|
||||
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/requirements.txt`
|
||||
|
||||
- [ ] **Step 1: 현재 requirements.txt 확인**
|
||||
|
||||
Run: `cat C:/Users/jaeoh/Desktop/workspace/web-backend/stock/requirements.txt`
|
||||
파일 끝 확인 — 마지막 줄 newline 여부 확인 (sed/append 안전).
|
||||
|
||||
- [ ] **Step 2: cachetools 추가**
|
||||
|
||||
`C:/Users/jaeoh/Desktop/workspace/web-backend/stock/requirements.txt` 끝에 한 줄 추가:
|
||||
|
||||
```
|
||||
cachetools>=5.3
|
||||
```
|
||||
|
||||
(파일 마지막에 newline 없으면 newline 먼저, 그 다음 cachetools 줄.)
|
||||
|
||||
- [ ] **Step 3: 로컬 import 가능 여부 확인 (선택, NAS rebuild가 정본)**
|
||||
|
||||
Run (Windows 로컬에서 docker 외부 검증용, 선택):
|
||||
```bash
|
||||
python -c "import cachetools; print(cachetools.__version__)" 2>&1
|
||||
```
|
||||
|
||||
로컬 미설치라면 skip — NAS deployer가 rebuild 시 install. 이 plan은 코드 정합성만 보장.
|
||||
|
||||
- [ ] **Step 4: 커밋 (단독 커밋, deps만)**
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||||
git add stock/requirements.txt
|
||||
git commit -m "$(cat <<'EOF'
|
||||
chore(stock): add cachetools for server-side TTLCache (SP-A2 prep)
|
||||
|
||||
다음 커밋에서 /api/webai/portfolio·news-sentiment·screener/run에
|
||||
in-memory TTLCache 적용 예정.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: NAS SP-A2 — `webai_cache.py` 모듈 + 단위 테스트
|
||||
|
||||
**Files:**
|
||||
- Create: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/webai_cache.py`
|
||||
- Create: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/test_webai_cache.py`
|
||||
|
||||
- [ ] **Step 1: 실패하는 테스트 작성**
|
||||
|
||||
`C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/test_webai_cache.py`:
|
||||
|
||||
```python
|
||||
"""SP-A2 — webai_cache module의 cache hit/miss + key 분기 검증."""
|
||||
import time
|
||||
import pytest
|
||||
from app.webai_cache import (
|
||||
PORTFOLIO_CACHE, NEWS_CACHE, SCREENER_CACHE,
|
||||
cache_get_portfolio, cache_set_portfolio,
|
||||
cache_get_news, cache_set_news,
|
||||
cache_get_screener, cache_set_screener,
|
||||
_screener_key,
|
||||
)
|
||||
|
||||
|
||||
def _clear_all():
|
||||
PORTFOLIO_CACHE.clear()
|
||||
NEWS_CACHE.clear()
|
||||
SCREENER_CACHE.clear()
|
||||
|
||||
|
||||
def test_portfolio_cache_miss_then_hit():
|
||||
_clear_all()
|
||||
assert cache_get_portfolio() is None
|
||||
cache_set_portfolio({"holdings": [], "cash": 0})
|
||||
assert cache_get_portfolio() == {"holdings": [], "cash": 0}
|
||||
|
||||
|
||||
def test_news_cache_key_by_date():
|
||||
"""date가 다르면 별도 캐시 슬롯."""
|
||||
_clear_all()
|
||||
cache_set_news("2026-05-18", {"count": 5})
|
||||
cache_set_news("2026-05-17", {"count": 3})
|
||||
assert cache_get_news("2026-05-18") == {"count": 5}
|
||||
assert cache_get_news("2026-05-17") == {"count": 3}
|
||||
assert cache_get_news("2026-05-16") is None # not cached
|
||||
|
||||
|
||||
def test_news_cache_latest_key_normalized():
|
||||
"""date=None은 'latest' 키로 정규화되어 동일 슬롯."""
|
||||
_clear_all()
|
||||
cache_set_news(None, {"count": 9})
|
||||
assert cache_get_news(None) == {"count": 9}
|
||||
|
||||
|
||||
def test_screener_key_includes_mode_and_top_n():
|
||||
"""screener key는 mode + top_n + weights hash로 분기."""
|
||||
k_preview = _screener_key("preview", 20, None)
|
||||
k_preview_w = _screener_key("preview", 20, {"news": 0.3})
|
||||
k_auto = _screener_key("auto", 20, None)
|
||||
assert k_preview != k_preview_w
|
||||
assert k_preview != k_auto
|
||||
|
||||
|
||||
def test_screener_cache_roundtrip():
|
||||
_clear_all()
|
||||
payload = {"asof": "2026-05-18", "survivors_count": 17}
|
||||
cache_set_screener("preview", 20, None, payload)
|
||||
assert cache_get_screener("preview", 20, None) == payload
|
||||
assert cache_get_screener("preview", 20, {"news": 0.3}) is None
|
||||
|
||||
|
||||
def test_ttl_expiry_portfolio():
|
||||
"""짧은 ttl로 만료 확인 — 직접 시간 조작 대신 TTLCache 내부 동작 신뢰."""
|
||||
from cachetools import TTLCache
|
||||
short = TTLCache(maxsize=1, ttl=0.1) # 0.1초
|
||||
short["result"] = "x"
|
||||
assert short.get("result") == "x"
|
||||
time.sleep(0.2)
|
||||
assert short.get("result") is None
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실패 확인**
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -m pytest app/test_webai_cache.py -v`
|
||||
Expected: FAIL — `app.webai_cache` 모듈 존재 안 함.
|
||||
|
||||
- [ ] **Step 3: `webai_cache.py` 작성**
|
||||
|
||||
`C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/webai_cache.py`:
|
||||
|
||||
```python
|
||||
"""SP-A2 — NAS stock의 /api/webai/* 엔드포인트 in-memory TTLCache.
|
||||
|
||||
web-ai 측 캐시(stock_client._TTL)가 miss됐을 때도 NAS에서 같은 데이터를
|
||||
KIS·LLM 재호출 없이 즉시 반환하기 위한 2-layer 캐시의 server 측.
|
||||
V1+V2가 동시 호출해도 NAS는 1회만 계산.
|
||||
|
||||
TTL 정책 (spec §10 SP-A2):
|
||||
- portfolio: 120s (web-ai TTL 180s 보다 짧게 — 변경 감지 가능)
|
||||
- news: 600s (sentiment는 일 단위)
|
||||
- screener: 180s
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from cachetools import TTLCache
|
||||
|
||||
|
||||
PORTFOLIO_CACHE: TTLCache = TTLCache(maxsize=1, ttl=120.0)
|
||||
NEWS_CACHE: TTLCache = TTLCache(maxsize=10, ttl=600.0)
|
||||
SCREENER_CACHE: TTLCache = TTLCache(maxsize=10, ttl=180.0)
|
||||
|
||||
|
||||
# ----- portfolio -----
|
||||
|
||||
def cache_get_portfolio() -> Optional[Any]:
|
||||
return PORTFOLIO_CACHE.get("result")
|
||||
|
||||
|
||||
def cache_set_portfolio(value: Any) -> None:
|
||||
PORTFOLIO_CACHE["result"] = value
|
||||
|
||||
|
||||
# ----- news-sentiment -----
|
||||
|
||||
def _news_key(date: Optional[str]) -> str:
|
||||
return date if date else "latest"
|
||||
|
||||
|
||||
def cache_get_news(date: Optional[str]) -> Optional[Any]:
|
||||
return NEWS_CACHE.get(_news_key(date))
|
||||
|
||||
|
||||
def cache_set_news(date: Optional[str], value: Any) -> None:
|
||||
NEWS_CACHE[_news_key(date)] = value
|
||||
|
||||
|
||||
# ----- screener -----
|
||||
|
||||
def _screener_key(mode: str, top_n: int, weights: Optional[dict]) -> str:
|
||||
"""mode + top_n + weights canonical hash. weights 객체 동등성을 키로."""
|
||||
if weights is None:
|
||||
w_repr = "none"
|
||||
else:
|
||||
# canonical: sorted keys → md5 hex (긴 weights도 짧은 키로)
|
||||
canon = json.dumps(weights, sort_keys=True, ensure_ascii=False)
|
||||
w_repr = hashlib.md5(canon.encode("utf-8")).hexdigest()[:12]
|
||||
return f"{mode}:{top_n}:{w_repr}"
|
||||
|
||||
|
||||
def cache_get_screener(mode: str, top_n: int, weights: Optional[dict]) -> Optional[Any]:
|
||||
return SCREENER_CACHE.get(_screener_key(mode, top_n, weights))
|
||||
|
||||
|
||||
def cache_set_screener(mode: str, top_n: int, weights: Optional[dict], value: Any) -> None:
|
||||
SCREENER_CACHE[_screener_key(mode, top_n, weights)] = value
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 테스트 통과 확인**
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -m pytest app/test_webai_cache.py -v`
|
||||
Expected: PASS — 6개 모두 통과.
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||||
git add stock/app/webai_cache.py stock/app/test_webai_cache.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(stock): webai_cache module (TTLCache for SP-A2)
|
||||
|
||||
3개의 TTLCache (portfolio 120s · news 600s · screener 180s) +
|
||||
헬퍼 함수. screener key는 mode + top_n + weights canonical hash로
|
||||
분기. 다음 커밋에서 /api/webai/portfolio·news-sentiment·screener/run
|
||||
3 endpoint에 적용.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: NAS SP-A2 — `/api/webai/portfolio` 캐시 적용
|
||||
|
||||
**Files:**
|
||||
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/main.py:419-422`
|
||||
|
||||
- [ ] **Step 1: 현재 endpoint 코드 확인**
|
||||
|
||||
`web-backend/stock/app/main.py` 419-422 line은 spec §10 SP-A2와 일치:
|
||||
```python
|
||||
@app.get("/api/webai/portfolio", dependencies=[Depends(verify_webai_key)])
|
||||
def get_webai_portfolio():
|
||||
"""web-ai 전용 portfolio (인증 필수, pnl_pct 비율 필드 추가)."""
|
||||
return _augment_portfolio_with_pnl_pct(get_portfolio())
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 캐시 적용으로 교체**
|
||||
|
||||
`web-backend/stock/app/main.py` 419-422 line을 다음으로 교체:
|
||||
|
||||
```python
|
||||
@app.get("/api/webai/portfolio", dependencies=[Depends(verify_webai_key)])
|
||||
def get_webai_portfolio():
|
||||
"""web-ai 전용 portfolio (인증 필수, pnl_pct 비율 필드 추가).
|
||||
|
||||
SP-A2 server-side TTLCache 적용. V1+V2 동시 호출도 NAS에서 1회 계산.
|
||||
"""
|
||||
cached = webai_cache.cache_get_portfolio()
|
||||
if cached is not None:
|
||||
return cached
|
||||
result = _augment_portfolio_with_pnl_pct(get_portfolio())
|
||||
webai_cache.cache_set_portfolio(result)
|
||||
return result
|
||||
```
|
||||
|
||||
- [ ] **Step 3: import 추가 (파일 상단)**
|
||||
|
||||
`web-backend/stock/app/main.py` 파일 상단 import 블록 (다른 `from .xxx import` 들과 같은 위치)에 추가:
|
||||
|
||||
```python
|
||||
from . import webai_cache
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 빠른 import sanity 체크**
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -c "from app import main; print('OK')"` 2>&1 | tail -3
|
||||
|
||||
(`cachetools` 미설치 환경에선 ImportError 가능 → 그 경우 `pip install cachetools` 후 재시도. 실제 검증은 NAS rebuild 후.)
|
||||
Expected: `OK` 또는 cachetools 누락 메시지 (의도된 상태).
|
||||
|
||||
---
|
||||
|
||||
## Task 5: NAS SP-A2 — `/api/webai/news-sentiment` 캐시 적용
|
||||
|
||||
**Files:**
|
||||
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/main.py:467-470`
|
||||
|
||||
- [ ] **Step 1: 캐시 적용**
|
||||
|
||||
`web-backend/stock/app/main.py` 467-470 line을 다음으로 교체:
|
||||
|
||||
```python
|
||||
@app.get("/api/webai/news-sentiment", dependencies=[Depends(verify_webai_key)])
|
||||
def get_webai_news_sentiment(date: str | None = None):
|
||||
"""web-ai 전용 news sentiment 일별 dump.
|
||||
|
||||
SP-A2 server-side TTLCache 적용. date 파라미터별로 별도 슬롯.
|
||||
"""
|
||||
cached = webai_cache.cache_get_news(date)
|
||||
if cached is not None:
|
||||
return cached
|
||||
result = _fetch_news_sentiment_dump(date)
|
||||
webai_cache.cache_set_news(date, result)
|
||||
return result
|
||||
```
|
||||
|
||||
- [ ] **Step 2: import sanity 체크**
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -c "from app import main; print('OK')" 2>&1 | tail -3`
|
||||
Expected: `OK`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: NAS SP-A2 — `/api/stock/screener/run` 캐시 적용 (preview 모드만)
|
||||
|
||||
**Files:**
|
||||
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/screener/router.py:173-...`
|
||||
|
||||
- [ ] **Step 1: 현재 함수 확인 (참고)**
|
||||
|
||||
`web-backend/stock/app/screener/router.py:173` 시작 `def post_run(body: schemas.RunRequest):` — 함수 본체는 mode 분기 후 _conn() + KIS 호출 등. 단, `mode == "auto"` 는 휴장일/실 운영 트리거이므로 캐시하지 않음 (매 호출이 다른 의미). `mode == "preview"` 는 frontend·web-ai 폴링용 → 캐시 적용.
|
||||
|
||||
- [ ] **Step 2: 함수 진입부에 cache 분기 추가**
|
||||
|
||||
`web-backend/stock/app/screener/router.py:173` `@router.post("/run", ...)` 의 `def post_run(...)` 본체 **첫 줄들에** 다음 캐시 분기 추가:
|
||||
|
||||
변경 전 (line 173-179 근처):
|
||||
```python
|
||||
@router.post("/run", response_model=schemas.RunResponse)
|
||||
def post_run(body: schemas.RunRequest):
|
||||
from .registry import NODE_REGISTRY as _NR, GATE_REGISTRY as _GR
|
||||
started_at = dt.datetime.utcnow().isoformat()
|
||||
with _conn() as c:
|
||||
asof = _resolve_asof(body.asof, c)
|
||||
```
|
||||
|
||||
변경 후:
|
||||
```python
|
||||
@router.post("/run", response_model=schemas.RunResponse)
|
||||
def post_run(body: schemas.RunRequest):
|
||||
from .registry import NODE_REGISTRY as _NR, GATE_REGISTRY as _GR
|
||||
# SP-A2 — preview 모드는 web-ai/frontend 폴링이라 캐시 적용.
|
||||
# auto 모드는 실제 운영 트리거(휴장일 게이트 등)라 캐시 미적용.
|
||||
if body.mode == "preview":
|
||||
cached = webai_cache.cache_get_screener(body.mode, body.top_n, body.weights)
|
||||
if cached is not None:
|
||||
return cached
|
||||
started_at = dt.datetime.utcnow().isoformat()
|
||||
with _conn() as c:
|
||||
asof = _resolve_asof(body.asof, c)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 함수 끝 부분 — preview 결과를 캐시에 저장**
|
||||
|
||||
`post_run`의 반환부 직전에 (preview 모드일 때만) 캐시 저장. `post_run` 함수는 결과를 `schemas.RunResponse(...)` 로 만들어 return하는 구조일 것. 정확한 return 위치 확인 후, return 직전에:
|
||||
|
||||
`web-backend/stock/app/screener/router.py` `post_run` 함수의 마지막 return 직전에:
|
||||
|
||||
```python
|
||||
# SP-A2 — preview 모드 결과 캐시 저장.
|
||||
if body.mode == "preview":
|
||||
webai_cache.cache_set_screener(body.mode, body.top_n, body.weights, response)
|
||||
return response
|
||||
```
|
||||
|
||||
(`response` 라는 변수가 없으면, 기존 return 표현식을 `response = ...` 로 binding 후 위 코드 추가.)
|
||||
|
||||
> **주의:** post_run의 정확한 return 라인을 먼저 확인. `grep -n "return " app/screener/router.py | head` 로 위치 파악 후 적용.
|
||||
|
||||
- [ ] **Step 4: import 추가 (router.py 상단)**
|
||||
|
||||
`web-backend/stock/app/screener/router.py` 상단 import 블록에 추가:
|
||||
|
||||
```python
|
||||
from .. import webai_cache
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 빠른 import sanity 체크**
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -c "from app.screener import router; print('OK')" 2>&1 | tail -3`
|
||||
Expected: `OK`
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 통합 검증 — 기존 테스트 회귀 + SP-A2 신규 테스트
|
||||
|
||||
**Files:** (조회만)
|
||||
|
||||
- [ ] **Step 1: stock 전체 pytest 실행**
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -m pytest -v 2>&1 | tail -30`
|
||||
Expected: 기존 모든 테스트 + SP-A2 신규 6 tests 모두 PASS. **0 failed**.
|
||||
|
||||
- [ ] **Step 2: 회귀 발견 시 처리**
|
||||
|
||||
회귀가 발견되면:
|
||||
- import 누락 → `from . import webai_cache` 또는 `from .. import webai_cache` 위치 재확인
|
||||
- screener test가 cache hit으로 fail → test가 `_clear_all()` 또는 cache fixture 통해 격리되어 있는지 확인. 필요 시 conftest에 `autouse=True` cache reset fixture 추가:
|
||||
|
||||
```python
|
||||
# conftest.py에 추가 (선택)
|
||||
import pytest
|
||||
from app import webai_cache
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_webai_cache():
|
||||
webai_cache.PORTFOLIO_CACHE.clear()
|
||||
webai_cache.NEWS_CACHE.clear()
|
||||
webai_cache.SCREENER_CACHE.clear()
|
||||
yield
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 커밋 (SP-A2 endpoint 통합 + 회귀 확인)**
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||||
git add stock/app/main.py stock/app/screener/router.py
|
||||
# (필요 시) git add stock/app/conftest.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(stock): apply webai_cache to portfolio/news/screener-preview (SP-A2)
|
||||
|
||||
3 endpoint cache 적용 — /api/webai/portfolio, /api/webai/news-sentiment,
|
||||
/api/stock/screener/run (preview 모드만, auto는 캐시 미적용).
|
||||
V1+V2 동시 호출도 NAS에서 1회 계산. web-ai 측 SP-A1 캐시와 2-layer로
|
||||
작동하여 NAS 인바운드 부담 70% 감소 예상.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: 양쪽 push + NAS deploy 트리거
|
||||
|
||||
**Files:** 없음 (git 작업)
|
||||
|
||||
- [ ] **Step 1: web-ai push**
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
||||
git push origin main
|
||||
```
|
||||
|
||||
Expected: success. 인증 prompt 뜨면 자격증명 입력. 1회 실패 시 1회 재시도 (캐시 패턴).
|
||||
|
||||
> **참고:** web-ai는 NAS deployer가 별도 webhook 없음 (Windows 머신 코드). push는 백업/이력 동기화 목적. 실제 적용은 V2 재시작 시점.
|
||||
|
||||
- [ ] **Step 2: web-backend push (NAS deployer 트리거)**
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||||
git push origin main
|
||||
```
|
||||
|
||||
Expected: success. NAS deployer가 webhook 수신 → `git pull` → `docker compose build stock --no-cache` (cachetools 신규 설치) → `docker compose up -d stock`. 통상 2~3분 소요.
|
||||
|
||||
- [ ] **Step 3: NAS stock 컨테이너 헬스 확인**
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "HTTP %{http_code}\n" https://gahusb.synology.me/api/stock/news -m 10
|
||||
```
|
||||
|
||||
Expected: `HTTP 200`. (NAS deploy 완료 후 통상 30초 ~ 2분 대기 필요.)
|
||||
|
||||
- [ ] **Step 4: webai 캐시 효과 확인 (선택)**
|
||||
|
||||
연속 2회 호출 시 두 번째가 즉시 응답하는지 (cached):
|
||||
|
||||
```bash
|
||||
# 인증키 필요. .env의 WEBAI_API_KEY 사용 또는 NAS에서 직접 호출.
|
||||
# Windows 로컬에서:
|
||||
# 첫 호출
|
||||
time curl -s -H "X-WebAI-Key: $WEBAI_API_KEY" https://gahusb.synology.me/api/webai/portfolio -o /dev/null
|
||||
# 즉시 두번째 (캐시 hit 기대, 첫 호출 < 1s + DB / 두번째 < 100ms)
|
||||
time curl -s -H "X-WebAI-Key: $WEBAI_API_KEY" https://gahusb.synology.me/api/webai/portfolio -o /dev/null
|
||||
```
|
||||
|
||||
Expected: 두 번째 호출이 첫 번째보다 명확히 빠름 (DB·계산 skip).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
### Spec 커버리지
|
||||
|
||||
| Spec 요구사항 | 구현 Task |
|
||||
|---------------|-----------|
|
||||
| §4 SP-A1: web-ai 캐시 TTL 증가 (180/600/300) | Task 1 |
|
||||
| §4 SP-A2: NAS stock TTLCache | Task 2~7 |
|
||||
| §10 SP-A2: 3 endpoint (portfolio/news/screener) 적용 | Task 4 (portfolio), Task 5 (news), Task 6 (screener preview) |
|
||||
| §10 SP-A2: cachetools 의존성 | Task 2 |
|
||||
| §8: X-WebAI-Key 인증 (기존 verify_webai_key 유지) | 기존 dependency 그대로, 변경 없음 |
|
||||
| §6: server cache 별개 (Redis 캐시 옵션) | in-memory TTLCache 선택 (Redis는 SP-1 이후 도입 검토) |
|
||||
|
||||
§4의 SP-A2는 `/api/webai/portfolio`, `/api/webai/news-sentiment`, `/api/stock/screener/run` 3건만 명시. 추가 endpoint 캐시는 out of scope (별도 plan에서).
|
||||
|
||||
### Placeholder 스캔
|
||||
|
||||
- TBD/TODO/"implement later" 패턴 없음 ✓
|
||||
- 모든 code step에 완전 코드 포함 ✓
|
||||
- Task 6에 한 가지 conditional ("`post_run`의 정확한 return 라인을 먼저 확인") — 이건 plan 실행 시 grep 명령으로 즉시 해결 가능한 단순 lookup이라 placeholder가 아님. 그러나 안전성 위해 helper note 그대로 유지.
|
||||
|
||||
### Type consistency
|
||||
|
||||
- `webai_cache.cache_get_portfolio()` / `cache_set_portfolio(value)` — Task 3에서 정의, Task 4에서 사용. 시그니처 일치 ✓
|
||||
- `cache_get_news(date)` — Task 3·5 일치 ✓
|
||||
- `cache_get_screener(mode, top_n, weights)` / `cache_set_screener(mode, top_n, weights, value)` — Task 3·6 일치 ✓
|
||||
- 변수명 `cached`, `result`, `payload` — 각 함수 안에서만 사용, 충돌 없음 ✓
|
||||
|
||||
### 위험·주의
|
||||
|
||||
- **NAS deployer rebuild**: `requirements.txt` 변경은 docker image rebuild 필요. deployer가 변경 감지 시 rebuild 트리거. 만약 deployer가 변경 미감지(예: requirements.txt만 변경 시 rebuild 안 함)라면 NAS에서 `docker compose build stock --no-cache && docker compose up -d stock` 수동 실행 필요.
|
||||
- **Cache stale**: TTL이 충분히 짧아 stale 문제 미미. portfolio 120s = web-ai 폴링 주기(1분) 2배. 변경 감지에 최대 2분 지연.
|
||||
- **Cache miss thunder herd**: V1+V2가 정확히 동시에 캐시 miss 시 KIS 동시 호출 가능. 현재 V1/V2 둘 다 정지 상태라 risk 0. 향후 재시작 시 KIS rate limit 모니터링 필요 (별도 plan 항목).
|
||||
|
||||
---
|
||||
|
||||
## 완료 후 다음 단계
|
||||
|
||||
Plan-A 완료 후 spec §14 "차후 plan 작성 순서 권장"대로:
|
||||
|
||||
1. **Plan-B-Base** — SP-1 (Redis) + SP-2 (WSL2)
|
||||
2. **Plan-B-Insta** — SP-3 + SP-4
|
||||
3. **Plan-B-Music** — SP-5 + SP-6
|
||||
4. **Plan-B-Video** — SP-7 + SP-8
|
||||
5. **Plan-B-Infra** — SP-9 + SP-10
|
||||
|
||||
각각은 별도 brainstorm 없이 spec §10에서 직접 plan 작성 가능 (이미 명세 충분).
|
||||
1887
docs/superpowers/plans/2026-05-19-plan-b-insta-render.md
Normal file
1887
docs/superpowers/plans/2026-05-19-plan-b-insta-render.md
Normal file
File diff suppressed because it is too large
Load Diff
3241
docs/superpowers/plans/2026-05-19-plan-b-music-render.md
Normal file
3241
docs/superpowers/plans/2026-05-19-plan-b-music-render.md
Normal file
File diff suppressed because it is too large
Load Diff
2573
docs/superpowers/plans/2026-05-19-plan-b-video-render.md
Normal file
2573
docs/superpowers/plans/2026-05-19-plan-b-video-render.md
Normal file
File diff suppressed because it is too large
Load Diff
1651
docs/superpowers/plans/2026-05-20-lotto-active-agent.md
Normal file
1651
docs/superpowers/plans/2026-05-20-lotto-active-agent.md
Normal file
File diff suppressed because it is too large
Load Diff
1587
docs/superpowers/plans/2026-05-22-lotto-weight-evolver.md
Normal file
1587
docs/superpowers/plans/2026-05-22-lotto-weight-evolver.md
Normal file
File diff suppressed because it is too large
Load Diff
929
docs/superpowers/plans/2026-05-22-plan-b-infra.md
Normal file
929
docs/superpowers/plans/2026-05-22-plan-b-infra.md
Normal file
@@ -0,0 +1,929 @@
|
||||
# Plan-B-Infra — NSSM 자동 시작 + task-watcher (시간대 큐 토글) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Windows AI 머신의 서비스(ai_trade + WSL2 Docker)를 NSSM으로 부팅 시 자동 시작 + 우선순위 설정(SP-9), 그리고 시간대 기반으로 `queue:paused`를 토글하는 task-watcher 컨테이너 신설(SP-10). 트레이딩 시간대(비휴장 평일 07:00–16:30)에 무거운 render 작업을 일시정지하여 KIS 트레이딩 우선순위 보장.
|
||||
|
||||
**Architecture:** task-watcher는 WSL2 Docker 컨테이너로 30초마다 `current_mode()` 판정(KST 시각 + NAS `/api/stock/holidays` 조회) → 트레이딩 시간대면 `SET queue:paused 1 EX 600`, 그 외엔 `DEL queue:paused`. 모든 render worker(insta/music/video)가 BLPOP 전 `queue:paused`를 확인하므로 단일 키로 전체 일시정지. NSSM(SP-9)은 박재오 Windows 머신에서 수동 설치 — plan은 정확한 명령 + 안내 문서 제공.
|
||||
|
||||
**Tech Stack:** Python 3.12 / `redis>=5.0` / `httpx` (holidays fetch) / `zoneinfo` (KST) / Docker Engine in WSL2 / NSSM (Windows service manager) / FastAPI (NAS stock holidays endpoint)
|
||||
|
||||
**Spec:** `web-backend/docs/superpowers/specs/2026-05-18-nas-windows-distributed-architecture-design.md` §3 시간대별 우선순위 모드, §10 SP-9·SP-10. **박재오 결정 (2026-05-22): idle/게임 감지 생략 — 시간대만으로 토글** (spec §3의 "박재오 활동 감지 시 SET" → "트레이딩 시간대면 무조건 SET"). idle 감지가 없으므로 WSL2 컨테이너로 구현 가능 (Win32 input API 불필요).
|
||||
|
||||
**Spec 갱신 사항 (현 상태 반영):**
|
||||
- `signal_v2` → **`ai_trade`** (rename 완료, web-ai/ai_trade/)
|
||||
- `Ubuntu-22.04` → **`Ubuntu-24.04`** (Plan-B-Base에서 변경)
|
||||
- `web-ai-services` → **`web-ai/services`** (실제 경로)
|
||||
- `/api/stock/holidays` endpoint **미존재 → 신설** (Task 1)
|
||||
|
||||
**Prerequisites (✅ 모두 완료):**
|
||||
- Plan-A / Plan-B-Base / Plan-B-Insta / Plan-B-Music / Plan-B-Video 모두 완료
|
||||
- WSL2 mirror mode + Redis chown 999:999 영구 적용
|
||||
- services/.env 분기 패턴 정착 (NAS_BASE_URL service-local default)
|
||||
|
||||
---
|
||||
|
||||
## Phase 구조
|
||||
|
||||
| Phase | 내용 | Task |
|
||||
|-------|------|------|
|
||||
| **1. NAS stock holidays endpoint** | `/api/stock/holidays` GET 신설 (task-watcher가 조회) | 1 |
|
||||
| **2. Windows task-watcher** | mode 판정 + Redis 토글 loop + Dockerfile + compose | 2~6 |
|
||||
| **3. NSSM 안내 + 검증** | SP-9 NSSM 안내 문서 + 박재오 빌드 + end-to-end | 7~8 |
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Phase 1 — NAS web-backend
|
||||
|
||||
| 파일 | 변경 | 책임 |
|
||||
|------|------|------|
|
||||
| `web-backend/stock/app/main.py` | `GET /api/stock/holidays` endpoint 추가 | holidays.json + 주말 노출 |
|
||||
| `web-backend/stock/app/test_holidays_endpoint.py` (Create) | 2 tests | TDD |
|
||||
|
||||
### Phase 2 — Windows web-ai/services/task-watcher
|
||||
|
||||
| 파일 | 변경 | 책임 |
|
||||
|------|------|------|
|
||||
| `web-ai/services/task-watcher/mode.py` (Create) | `current_mode(now, holidays)` 순수 함수 + `fetch_holidays()` | 시간대 판정 |
|
||||
| `web-ai/services/task-watcher/watcher.py` (Create) | 30초 loop + Redis 토글 | dispatcher |
|
||||
| `web-ai/services/task-watcher/main.py` (Create) | FastAPI + lifespan(watcher spawn) + /health | entry |
|
||||
| `web-ai/services/task-watcher/Dockerfile` (Create) | python:3.12-slim | image |
|
||||
| `web-ai/services/task-watcher/requirements.txt` (Create) | fastapi, redis, httpx, pytest | deps |
|
||||
| `web-ai/services/task-watcher/.env.example` (Create) | REDIS_URL, STOCK_BASE_URL, TRADING_START, TRADING_END | secrets |
|
||||
| `web-ai/services/task-watcher/tests/test_mode.py` (Create) | current_mode 6 cases | TDD |
|
||||
| `web-ai/services/task-watcher/tests/__init__.py` (Create) | 빈 marker | pkg |
|
||||
| `web-ai/services/docker-compose.yml` | task-watcher service 추가 (port 18713) | compose |
|
||||
|
||||
### Phase 3 — 안내 문서
|
||||
|
||||
| 파일 | 변경 | 책임 |
|
||||
|------|------|------|
|
||||
| `web-ai/services/task-watcher/NSSM_SETUP.md` (Create) | SP-9 NSSM 설치 안내 (ai_trade + wsl_docker + task-watcher) | 박재오 수동 가이드 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: NAS stock — `/api/stock/holidays` endpoint + tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/main.py`
|
||||
- Create: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/test_holidays_endpoint.py`
|
||||
|
||||
### Step 1: 실패 테스트 작성
|
||||
|
||||
`C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/test_holidays_endpoint.py`:
|
||||
|
||||
```python
|
||||
"""GET /api/stock/holidays — task-watcher 휴장일 조회용."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_holidays_returns_list():
|
||||
r = client.get("/api/stock/holidays")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert "holidays" in data
|
||||
assert isinstance(data["holidays"], list)
|
||||
|
||||
|
||||
def test_holidays_entries_are_iso_dates():
|
||||
r = client.get("/api/stock/holidays")
|
||||
holidays = r.json()["holidays"]
|
||||
# 비어 있지 않다면 ISO date 형식 (YYYY-MM-DD)
|
||||
if holidays:
|
||||
import datetime as dt
|
||||
for h in holidays[:5]:
|
||||
dt.date.fromisoformat(h) # raise 안 하면 통과
|
||||
```
|
||||
|
||||
### Step 2: 테스트 실패 확인
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -m pytest app/test_holidays_endpoint.py -v`
|
||||
Expected: FAIL — endpoint 404.
|
||||
|
||||
### Step 3: `main.py`에 endpoint 추가
|
||||
|
||||
`C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/main.py`에서 `_HOLIDAYS_PATH` (현재 line 82 부근) 정의를 활용. 적절한 위치(다른 `@app.get` 근처)에 추가:
|
||||
|
||||
```python
|
||||
@app.get("/api/stock/holidays")
|
||||
def get_holidays():
|
||||
"""task-watcher가 조회하는 휴장일 목록. holidays.json 그대로 노출 (인증 불필요)."""
|
||||
import json
|
||||
try:
|
||||
with open(_HOLIDAYS_PATH, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
# holidays.json 구조가 list이거나 {"holidays": [...]} 또는 {year: [...]} 형태일 수 있음
|
||||
if isinstance(data, list):
|
||||
holidays = data
|
||||
elif isinstance(data, dict) and "holidays" in data:
|
||||
holidays = data["holidays"]
|
||||
elif isinstance(data, dict):
|
||||
# {year: [dates]} → flatten
|
||||
holidays = [d for v in data.values() if isinstance(v, list) for d in v]
|
||||
else:
|
||||
holidays = []
|
||||
except (OSError, ValueError):
|
||||
holidays = []
|
||||
return {"holidays": holidays}
|
||||
```
|
||||
|
||||
**주의:** 작성 전 `holidays.json` 실제 구조를 확인할 것 (`Read web-backend/stock/app/holidays.json`). 위 코드는 list / `{"holidays":[]}` / `{year:[]}` 3가지 형태를 모두 처리하지만, 실제 구조에 맞게 단순화 가능.
|
||||
|
||||
### Step 4: 테스트 통과
|
||||
|
||||
Run: `python -m pytest app/test_holidays_endpoint.py -v`
|
||||
Expected: 2 PASS.
|
||||
|
||||
### Step 5: 회귀 확인
|
||||
|
||||
Run: `python -m pytest app/ -v 2>&1 | tail -5`
|
||||
Expected: 기존 stock 테스트 모두 통과 + 새 2개.
|
||||
|
||||
### Step 6: 커밋
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||||
git add stock/app/main.py stock/app/test_holidays_endpoint.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(stock): GET /api/stock/holidays endpoint (SP-10 task-watcher용)
|
||||
|
||||
holidays.json 노출. task-watcher가 휴장일 판정에 조회.
|
||||
인증 불필요 (민감 정보 아님). 주말은 task-watcher가 weekday로 별도 판정.
|
||||
Plan-B-Infra Phase 1.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- spec §3: "휴장일 단일 소스 — web-backend/stock/app/holidays.json 정본. NAS stock이 GET /api/stock/holidays로 노출."
|
||||
- 현재 holidays.json은 `_is_holiday()` 내부 함수에서만 사용, HTTP endpoint 없음 → 신설.
|
||||
- stock 컨테이너는 이미 deploy.sh BUILD_TARGETS에 등재됨 (신규 lab 아님 — deploy scripts 추가 불필요).
|
||||
- 작업 디렉토리: `C:/Users/jaeoh/Desktop/workspace/web-backend`
|
||||
|
||||
## Report
|
||||
|
||||
- Status: DONE | DONE_WITH_CONCERNS | BLOCKED
|
||||
- holidays.json 실제 구조 (확인 결과)
|
||||
- 2 PASS + 회귀
|
||||
- 커밋 SHA
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Windows task-watcher — mode.py (current_mode + fetch_holidays) + tests
|
||||
|
||||
**Files:**
|
||||
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/mode.py`
|
||||
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/tests/__init__.py`
|
||||
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/tests/test_mode.py`
|
||||
|
||||
### Step 1: 실패 테스트 작성
|
||||
|
||||
`tests/__init__.py`: (빈 파일)
|
||||
|
||||
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/tests/test_mode.py`:
|
||||
|
||||
```python
|
||||
"""current_mode — 시간대 + 휴장일 판정 (순수 함수)."""
|
||||
import datetime as dt
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from mode import current_mode
|
||||
|
||||
KST = ZoneInfo("Asia/Seoul")
|
||||
HOLIDAYS = {"2026-05-25"} # 가상 휴장일 (월요일)
|
||||
|
||||
|
||||
def _kst(y, m, d, hh, mm):
|
||||
return dt.datetime(y, m, d, hh, mm, tzinfo=KST)
|
||||
|
||||
|
||||
def test_weekday_trading_hours_is_trading():
|
||||
# 2026-05-22 금요일 10:00 — 트레이딩 시간대
|
||||
assert current_mode(_kst(2026, 5, 22, 10, 0), HOLIDAYS) == "trading"
|
||||
|
||||
|
||||
def test_weekday_before_open_is_free():
|
||||
# 평일 06:00 — 장 전
|
||||
assert current_mode(_kst(2026, 5, 22, 6, 0), HOLIDAYS) == "free"
|
||||
|
||||
|
||||
def test_weekday_after_close_is_free():
|
||||
# 평일 17:00 — 장 마감 후
|
||||
assert current_mode(_kst(2026, 5, 22, 17, 0), HOLIDAYS) == "free"
|
||||
|
||||
|
||||
def test_weekend_is_free():
|
||||
# 2026-05-23 토요일 10:00
|
||||
assert current_mode(_kst(2026, 5, 23, 10, 0), HOLIDAYS) == "free"
|
||||
|
||||
|
||||
def test_holiday_weekday_is_free():
|
||||
# 2026-05-25 월요일이지만 휴장일 → 트레이딩 시간대라도 free
|
||||
assert current_mode(_kst(2026, 5, 25, 10, 0), HOLIDAYS) == "free"
|
||||
|
||||
|
||||
def test_trading_boundary_inclusive_start_exclusive_end():
|
||||
# 07:00 정각 = 트레이딩 시작, 16:30 정각 = 마감 (16:30은 free)
|
||||
assert current_mode(_kst(2026, 5, 22, 7, 0), HOLIDAYS) == "trading"
|
||||
assert current_mode(_kst(2026, 5, 22, 16, 29), HOLIDAYS) == "trading"
|
||||
assert current_mode(_kst(2026, 5, 22, 16, 30), HOLIDAYS) == "free"
|
||||
```
|
||||
|
||||
### Step 2: 테스트 실패 확인
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher && python -m pytest tests/test_mode.py -v`
|
||||
Expected: FAIL — `mode` 모듈 미존재.
|
||||
|
||||
### Step 3: `mode.py` 작성
|
||||
|
||||
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/mode.py`:
|
||||
|
||||
```python
|
||||
"""시간대 + 휴장일 기반 모드 판정 (idle 감지 생략 — 박재오 결정 2026-05-22).
|
||||
|
||||
trading: 비휴장 평일 07:00–16:30 (장중) → queue:paused SET
|
||||
free: 그 외 (장 전/후, 주말, 휴장) → queue:paused DEL
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import logging
|
||||
import os
|
||||
from typing import Set
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KST = ZoneInfo("Asia/Seoul")
|
||||
STOCK_BASE_URL = os.getenv("STOCK_BASE_URL", "http://192.168.45.54:18500")
|
||||
|
||||
# 트레이딩 윈도우 (HH:MM, KST). .env로 조정 가능.
|
||||
TRADING_START = os.getenv("TRADING_START", "07:00")
|
||||
TRADING_END = os.getenv("TRADING_END", "16:30")
|
||||
|
||||
|
||||
def _parse_hhmm(s: str) -> dt.time:
|
||||
hh, mm = s.split(":")
|
||||
return dt.time(int(hh), int(mm))
|
||||
|
||||
|
||||
def current_mode(now: dt.datetime, holidays: Set[str]) -> str:
|
||||
"""now(KST aware) + holidays(ISO date set) → 'trading' | 'free'."""
|
||||
# 주말 (토=5, 일=6)
|
||||
if now.weekday() >= 5:
|
||||
return "free"
|
||||
# 휴장일
|
||||
if now.date().isoformat() in holidays:
|
||||
return "free"
|
||||
# 트레이딩 윈도우 [start, end)
|
||||
start = _parse_hhmm(TRADING_START)
|
||||
end = _parse_hhmm(TRADING_END)
|
||||
t = now.timetz().replace(tzinfo=None)
|
||||
if start <= t < end:
|
||||
return "trading"
|
||||
return "free"
|
||||
|
||||
|
||||
def fetch_holidays() -> Set[str]:
|
||||
"""NAS stock /api/stock/holidays 조회. 실패 시 빈 set (안전 — free로 판정)."""
|
||||
try:
|
||||
r = httpx.get(f"{STOCK_BASE_URL}/api/stock/holidays", timeout=10.0)
|
||||
if r.status_code == 200:
|
||||
return set(r.json().get("holidays", []))
|
||||
logger.warning("holidays fetch returned %d", r.status_code)
|
||||
except Exception:
|
||||
logger.exception("holidays fetch 실패")
|
||||
return set()
|
||||
```
|
||||
|
||||
### Step 4: 테스트 통과
|
||||
|
||||
Run: `python -m pytest tests/test_mode.py -v`
|
||||
Expected: 6 PASS.
|
||||
|
||||
### Step 5: 커밋
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
||||
git add services/task-watcher/mode.py services/task-watcher/tests/__init__.py services/task-watcher/tests/test_mode.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(task-watcher): mode.py — 시간대+휴장일 판정 (SP-10)
|
||||
|
||||
current_mode(now, holidays): 비휴장 평일 07:00–16:30 → trading, 그 외 free.
|
||||
fetch_holidays(): NAS /api/stock/holidays 조회 (실패 시 빈 set = free 안전).
|
||||
TRADING_START/END env로 윈도우 조정. idle 감지 생략 (박재오 결정).
|
||||
6 tests (평일 장중/장전/장후, 주말, 휴장, 경계).
|
||||
Plan-B-Infra Phase 2.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- KST 시각 + holidays set → trading/free 순수 함수. 테스트 용이 (now를 인자로).
|
||||
- holidays는 fetch_holidays()로 NAS 조회. 매 loop마다 호출하면 부하 — watcher.py에서 캐싱 (Task 3).
|
||||
- 작업 디렉토리: `C:/Users/jaeoh/Desktop/workspace/web-ai`
|
||||
|
||||
## Report
|
||||
- Status / 6 PASS / 커밋 SHA
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Windows task-watcher — watcher.py (Redis 토글 loop)
|
||||
|
||||
**Files:**
|
||||
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/watcher.py`
|
||||
|
||||
### Step 1: `watcher.py` 작성
|
||||
|
||||
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/watcher.py`:
|
||||
|
||||
```python
|
||||
"""30초마다 current_mode 판정 → queue:paused 토글.
|
||||
|
||||
trading → SET queue:paused 1 EX 600 (10분 TTL — watcher 죽어도 자동 해제)
|
||||
free → DEL queue:paused
|
||||
holidays는 1시간마다 refresh (매 loop fetch 부하 회피).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime as dt
|
||||
import logging
|
||||
import os
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from mode import current_mode, fetch_holidays, KST
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REDIS_URL = os.getenv("REDIS_URL", "redis://192.168.45.54:6379")
|
||||
PAUSED_KEY = "queue:paused"
|
||||
LOOP_INTERVAL = 30 # 초
|
||||
HOLIDAYS_REFRESH = 3600 # 1시간
|
||||
PAUSED_TTL = 600 # 10분 (watcher 죽어도 자동 해제)
|
||||
|
||||
|
||||
async def watcher_loop():
|
||||
redis = aioredis.from_url(REDIS_URL, decode_responses=False)
|
||||
holidays = fetch_holidays()
|
||||
last_holiday_refresh = dt.datetime.now(KST)
|
||||
last_mode = None
|
||||
logger.info("task-watcher started (trading window 토글)")
|
||||
|
||||
while True:
|
||||
try:
|
||||
now = dt.datetime.now(KST)
|
||||
# holidays 주기적 refresh
|
||||
if (now - last_holiday_refresh).total_seconds() >= HOLIDAYS_REFRESH:
|
||||
holidays = fetch_holidays()
|
||||
last_holiday_refresh = now
|
||||
|
||||
mode = current_mode(now, holidays)
|
||||
if mode == "trading":
|
||||
await redis.set(PAUSED_KEY, b"1", ex=PAUSED_TTL)
|
||||
else:
|
||||
await redis.delete(PAUSED_KEY)
|
||||
|
||||
if mode != last_mode:
|
||||
logger.info("mode 전환: %s → %s (paused=%s)", last_mode, mode, mode == "trading")
|
||||
last_mode = mode
|
||||
|
||||
await asyncio.sleep(LOOP_INTERVAL)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("watcher_loop cancelled")
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("watcher_loop iteration 실패, 30초 후 재시도")
|
||||
await asyncio.sleep(LOOP_INTERVAL)
|
||||
```
|
||||
|
||||
### Step 2: 임포트 smoke
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher && python -c "from watcher import watcher_loop; print('OK')"`
|
||||
Expected: `OK`.
|
||||
|
||||
### Step 3: 커밋
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
||||
git add services/task-watcher/watcher.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(task-watcher): watcher.py — 30초 loop + queue:paused 토글 (SP-10)
|
||||
|
||||
trading → SET queue:paused 1 EX 600 / free → DEL.
|
||||
holidays 1시간마다 refresh. PAUSED_TTL 600s (watcher 죽어도 자동 해제 — 안전).
|
||||
mode 전환 시에만 로그.
|
||||
Plan-B-Infra Phase 2.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- `PAUSED_TTL=600`이 핵심 안전장치: task-watcher가 죽어도 10분 후 자동으로 paused 해제 → 큐 영구 정지 방지.
|
||||
- holidays는 1시간 캐싱 (매 30초 fetch 안 함).
|
||||
- render worker들(insta/music/video)이 이미 `queue:paused` 체크 로직 보유 (Plan-B-Insta/Music/Video).
|
||||
|
||||
## Report
|
||||
- Status / smoke 결과 / 커밋 SHA
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Windows task-watcher — main.py + Dockerfile + requirements + .env.example
|
||||
|
||||
**Files:**
|
||||
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/main.py`
|
||||
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/Dockerfile`
|
||||
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/requirements.txt`
|
||||
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/.env.example`
|
||||
|
||||
### Step 1: `requirements.txt`
|
||||
|
||||
```
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
redis>=5.0
|
||||
httpx>=0.27
|
||||
pytest>=8.0
|
||||
```
|
||||
|
||||
### Step 2: `Dockerfile`
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.12-slim-bookworm
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
```
|
||||
|
||||
(tzdata 추가 — zoneinfo Asia/Seoul 사용.)
|
||||
|
||||
### Step 3: `.env.example`
|
||||
|
||||
```
|
||||
# Plan-B-Infra — task-watcher
|
||||
|
||||
# NAS Redis
|
||||
REDIS_URL=redis://192.168.45.54:6379
|
||||
|
||||
# NAS stock holidays endpoint
|
||||
STOCK_BASE_URL=http://192.168.45.54:18500
|
||||
|
||||
# 트레이딩 윈도우 (KST, HH:MM) — 이 시간대에만 queue:paused
|
||||
TRADING_START=07:00
|
||||
TRADING_END=16:30
|
||||
```
|
||||
|
||||
### Step 4: `main.py`
|
||||
|
||||
```python
|
||||
"""task-watcher FastAPI entry — health + lifespan (watcher loop spawn)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
import watcher
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
watcher_task = asyncio.create_task(watcher.watcher_loop())
|
||||
logger.info("task-watcher lifespan 시작")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
watcher_task.cancel()
|
||||
try:
|
||||
await watcher_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.info("task-watcher lifespan 종료")
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True, "service": "task-watcher"}
|
||||
```
|
||||
|
||||
### Step 5: smoke + 회귀
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher
|
||||
python -c "from main import app; print(len(app.routes))"
|
||||
python -m pytest tests/ -v 2>&1 | tail -5
|
||||
```
|
||||
Expected: 숫자 출력 + 6 PASS (test_mode).
|
||||
|
||||
### Step 6: 커밋
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
||||
git add services/task-watcher/main.py services/task-watcher/Dockerfile services/task-watcher/requirements.txt services/task-watcher/.env.example
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(task-watcher): main.py + Dockerfile + requirements + env (SP-10)
|
||||
|
||||
FastAPI lifespan에서 watcher_loop 스폰. /health. tzdata(zoneinfo Asia/Seoul).
|
||||
.env: REDIS_URL, STOCK_BASE_URL, TRADING_START/END.
|
||||
Plan-B-Infra Phase 2.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
## Report
|
||||
- Status / routes 개수 / 6 PASS / 커밋 SHA
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Windows services/docker-compose — task-watcher entry
|
||||
|
||||
**Files:**
|
||||
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/docker-compose.yml`
|
||||
|
||||
### Step 1: video-render service 다음에 task-watcher 추가
|
||||
|
||||
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/docker-compose.yml`에 추가:
|
||||
|
||||
```yaml
|
||||
|
||||
task-watcher:
|
||||
build:
|
||||
context: ./task-watcher
|
||||
container_name: task-watcher
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18713:8000"
|
||||
environment:
|
||||
- TZ=Asia/Seoul
|
||||
- REDIS_URL=${REDIS_URL:-redis://192.168.45.54:6379}
|
||||
- STOCK_BASE_URL=${STOCK_BASE_URL:-http://192.168.45.54:18500}
|
||||
- TRADING_START=${TRADING_START:-07:00}
|
||||
- TRADING_END=${TRADING_END:-16:30}
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
```
|
||||
|
||||
### Step 2: YAML 검증
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai/services && python -c "import yaml; yaml.safe_load(open('docker-compose.yml')); print('valid YAML')"`
|
||||
Expected: `valid YAML`.
|
||||
|
||||
### Step 3: 커밋 + push
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
||||
git add services/docker-compose.yml
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(task-watcher): services/docker-compose entry (SP-10)
|
||||
|
||||
port 18713, REDIS_URL/STOCK_BASE_URL/TRADING_START/END env.
|
||||
insta/music/video-render와 같은 services 묶음. outbound only.
|
||||
Plan-B-Infra Phase 2 완료 — 박재오 빌드 대기.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
git push 2>&1 # 자격증명 실패 시 박재오 수동 push
|
||||
```
|
||||
|
||||
## Report
|
||||
- Status / YAML 검증 / 커밋 SHA / push 결과
|
||||
|
||||
---
|
||||
|
||||
## Task 6: NSSM 안내 문서 (SP-9)
|
||||
|
||||
**Files:**
|
||||
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/NSSM_SETUP.md`
|
||||
|
||||
SP-9는 박재오 Windows 머신에서 NSSM 수동 설치. controller는 정확한 명령 + 안내 문서 작성. (코드 아님 — 안내 문서.)
|
||||
|
||||
### Step 1: `NSSM_SETUP.md` 작성
|
||||
|
||||
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/NSSM_SETUP.md`:
|
||||
|
||||
```markdown
|
||||
# NSSM 자동 시작 설정 (SP-9)
|
||||
|
||||
Windows AI 머신 부팅 시 ai_trade(트레이딩) + WSL2 Docker(render workers + task-watcher) 자동 시작.
|
||||
|
||||
## 1. NSSM 다운로드
|
||||
|
||||
https://nssm.cc/download → nssm-2.24.zip → `C:\nssm\nssm.exe` 배치 (또는 PATH 등록).
|
||||
|
||||
## 2. ai_trade (Native Python, HIGH priority)
|
||||
|
||||
⚠️ spec의 signal_v2는 ai_trade로 rename됨. 경로/포트 확인.
|
||||
|
||||
```powershell
|
||||
# 관리자 PowerShell
|
||||
C:\nssm\nssm.exe install ai_trade "C:\Python312\python.exe" "-m uvicorn main:app --host 0.0.0.0 --port 8001"
|
||||
C:\nssm\nssm.exe set ai_trade AppDirectory "C:\Users\jaeoh\Desktop\workspace\web-ai\ai_trade"
|
||||
C:\nssm\nssm.exe set ai_trade Priority HIGH_PRIORITY_CLASS
|
||||
C:\nssm\nssm.exe set ai_trade Start SERVICE_AUTO_START
|
||||
C:\nssm\nssm.exe set ai_trade AppStdout "C:\Users\jaeoh\nssm-logs\ai_trade.log"
|
||||
C:\nssm\nssm.exe set ai_trade AppStderr "C:\Users\jaeoh\nssm-logs\ai_trade.log"
|
||||
```
|
||||
|
||||
(ai_trade의 실제 진입점이 main:app + port 8001인지 확인. 다르면 조정.)
|
||||
|
||||
## 3. WSL2 Docker (NORMAL priority — render workers + task-watcher)
|
||||
|
||||
```powershell
|
||||
C:\nssm\nssm.exe install wsl_docker "C:\Windows\System32\wsl.exe" "-d Ubuntu-24.04 -- sh -c 'sudo service docker start && cd /workspace/web-ai/services && docker compose up -d'"
|
||||
C:\nssm\nssm.exe set wsl_docker Priority NORMAL_PRIORITY_CLASS
|
||||
C:\nssm\nssm.exe set wsl_docker Start SERVICE_AUTO_START
|
||||
C:\nssm\nssm.exe set wsl_docker AppStdout "C:\Users\jaeoh\nssm-logs\wsl_docker.log"
|
||||
```
|
||||
|
||||
⚠️ 변경점: Ubuntu-22.04 → **Ubuntu-24.04**, web-ai-services → **web-ai/services**. WSL 경로는 `/mnt/c/...` 또는 박재오 WSL 마운트 기준 (`/workspace`가 web-ai에 매핑되어 있으면 그대로).
|
||||
|
||||
`sudo service docker start`가 비밀번호 요구하면 sudoers에 NOPASSWD 추가:
|
||||
```bash
|
||||
# WSL2 안
|
||||
echo "$USER ALL=(ALL) NOPASSWD: /usr/sbin/service docker start" | sudo tee /etc/sudoers.d/docker-start
|
||||
```
|
||||
|
||||
## 4. 서비스 시작 + 확인
|
||||
|
||||
```powershell
|
||||
C:\nssm\nssm.exe start ai_trade
|
||||
C:\nssm\nssm.exe start wsl_docker
|
||||
|
||||
# 상태 확인
|
||||
C:\nssm\nssm.exe status ai_trade
|
||||
C:\nssm\nssm.exe status wsl_docker
|
||||
sc query ai_trade
|
||||
```
|
||||
|
||||
## 5. 검증
|
||||
|
||||
```powershell
|
||||
# ai_trade
|
||||
curl http://localhost:8001/health # 또는 ai_trade의 실제 health endpoint
|
||||
|
||||
# WSL2 docker 컨테이너 (재부팅 후 자동 시작 확인)
|
||||
wsl -d Ubuntu-24.04 -- docker ps
|
||||
# insta-render, music-render, video-render, task-watcher 4개 Up 확인
|
||||
```
|
||||
|
||||
## 6. 재부팅 테스트
|
||||
|
||||
Windows 재부팅 → 로그인 → 수동 조작 없이:
|
||||
- ai_trade 서비스 자동 시작 (HIGH priority)
|
||||
- WSL2 + Docker + 4 컨테이너 자동 시작 (NORMAL priority)
|
||||
- task-watcher가 trading window에 queue:paused 토글 시작
|
||||
|
||||
## task-watcher 동작 확인
|
||||
|
||||
```bash
|
||||
# WSL2
|
||||
docker logs task-watcher --tail 20
|
||||
# 기대: "task-watcher started" + mode 전환 로그 (trading/free)
|
||||
|
||||
# Redis 큐 상태 (NAS 또는 LAN)
|
||||
docker exec redis redis-cli GET queue:paused
|
||||
# 트레이딩 시간대(평일 07:00-16:30): "1"
|
||||
# 그 외: (nil)
|
||||
```
|
||||
```
|
||||
|
||||
### Step 2: 커밋 + push
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
||||
git add services/task-watcher/NSSM_SETUP.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
docs(task-watcher): NSSM_SETUP.md — SP-9 자동 시작 안내
|
||||
|
||||
ai_trade(HIGH, native python :8001) + wsl_docker(NORMAL, WSL2 Ubuntu-24.04
|
||||
docker compose up). spec의 signal_v2→ai_trade, 22.04→24.04, web-ai-services
|
||||
→web-ai/services 정정. sudoers NOPASSWD + 재부팅 검증 절차.
|
||||
Plan-B-Infra Phase 3.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
git push 2>&1
|
||||
```
|
||||
|
||||
## Report
|
||||
- Status / 커밋 SHA / push 결과
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 박재오 빌드 + task-watcher 검증
|
||||
|
||||
**Files:** (변경 없음 — 박재오 측 작업 + 검증)
|
||||
|
||||
### Step 1: web-backend push (Task 1 holidays endpoint)
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-backend && git push
|
||||
```
|
||||
→ NAS deployer가 stock 컨테이너 rebuild. `/api/stock/holidays` 활성화.
|
||||
|
||||
### Step 2: 박재오 NAS 측 holidays endpoint 확인
|
||||
|
||||
```bash
|
||||
curl https://gahusb.synology.me/api/stock/holidays
|
||||
# → {"holidays": ["2026-01-01", ...]}
|
||||
```
|
||||
|
||||
### Step 3: 박재오 Windows 측 task-watcher 빌드
|
||||
|
||||
```bash
|
||||
cd /workspace/web-ai && git pull
|
||||
cd /workspace/web-ai/services
|
||||
docker compose build task-watcher
|
||||
docker compose up -d task-watcher
|
||||
docker logs task-watcher --tail 20
|
||||
# 기대: "task-watcher lifespan 시작" + "task-watcher started" + mode 로그
|
||||
curl -m 3 http://localhost:18713/health
|
||||
```
|
||||
|
||||
### Step 4: 시간대 토글 검증
|
||||
|
||||
현재 KST 시각 기준:
|
||||
```bash
|
||||
# 트레이딩 시간대(평일 07:00-16:30)면 paused=1, 아니면 nil
|
||||
docker exec task-watcher python -c "import datetime as dt; from zoneinfo import ZoneInfo; from mode import current_mode, fetch_holidays; print('now mode:', current_mode(dt.datetime.now(ZoneInfo('Asia/Seoul')), fetch_holidays()))"
|
||||
|
||||
# Redis 확인 (NAS 또는 LAN)
|
||||
ssh nas
|
||||
docker exec redis redis-cli GET queue:paused
|
||||
```
|
||||
|
||||
기대:
|
||||
- 평일 07:00-16:30 (비휴장): `current_mode` = "trading", `queue:paused` = "1"
|
||||
- 그 외: "free", (nil)
|
||||
|
||||
### Step 5: render worker가 paused 존중하는지 (선택)
|
||||
|
||||
트레이딩 시간대에 video 생성 요청 → worker가 BLPOP 전 paused 확인 → 10초 대기 반복 (처리 보류). free 시간대 되면 자동 처리. (이미 Plan-B-Insta/Music/Video worker에 `queue:paused` 체크 로직 있음.)
|
||||
|
||||
### Step 6: 메모리 기록
|
||||
|
||||
`reference_plan_b_infra_complete.md` 작성 + MEMORY.md 인덱스 추가 (Task 8에서).
|
||||
|
||||
## Report
|
||||
- holidays endpoint 응답
|
||||
- task-watcher health + mode
|
||||
- queue:paused 토글 확인
|
||||
|
||||
---
|
||||
|
||||
## Task 8: 메모리 기록 + 최종 정리
|
||||
|
||||
**Files:**
|
||||
- Create: `C:/Users/jaeoh/.claude/projects/C--Users-jaeoh-Desktop-workspace-web-ui/memory/reference_plan_b_infra_complete.md`
|
||||
- Modify: `C:/Users/jaeoh/.claude/projects/C--Users-jaeoh-Desktop-workspace-web-ui/memory/MEMORY.md`
|
||||
|
||||
### Step 1: `reference_plan_b_infra_complete.md`
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: plan-b-infra-complete
|
||||
description: 2026-05-22 Plan-B-Infra — NSSM 자동 시작(SP-9) + task-watcher 시간대 큐 토글(SP-10). spec 12 SP 전부 완료
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
Plan-B-Infra 2026-05-22 완료. spec §10 SP-9 + SP-10. 이로써 NAS↔Windows 분산 아키텍처 spec의 12 SP 전부 완료.
|
||||
|
||||
## SP-10 task-watcher (구현)
|
||||
- web-ai/services/task-watcher/ WSL2 컨테이너 (port 18713)
|
||||
- 30초 loop: current_mode(KST + holidays) → queue:paused 토글
|
||||
- trading(비휴장 평일 07:00-16:30) → SET queue:paused 1 EX 600 / free → DEL
|
||||
- **idle/게임 감지 생략** (박재오 결정 2026-05-22) — WSL2 컨테이너는 Win32 input API 접근 불가. 시간대만으로 판정.
|
||||
- PAUSED_TTL 600s = watcher 죽어도 10분 후 자동 해제 (큐 영구정지 방지 안전장치)
|
||||
- holidays는 NAS GET /api/stock/holidays (신설) 1시간 캐싱
|
||||
- TRADING_START/END env로 윈도우 조정
|
||||
|
||||
## SP-9 NSSM (박재오 수동)
|
||||
- NSSM_SETUP.md 안내 문서. ai_trade(HIGH, native :8001) + wsl_docker(NORMAL, WSL2 docker compose up)
|
||||
- spec 정정: signal_v2→ai_trade, Ubuntu-22.04→24.04, web-ai-services→web-ai/services
|
||||
|
||||
## NAS holidays endpoint (신설)
|
||||
- GET /api/stock/holidays — holidays.json 노출. 기존엔 _is_holiday() 내부 함수만 있었음.
|
||||
|
||||
## 다음
|
||||
- frontend video/music/insta UI (backend gateway만 완료, UI 별도)
|
||||
- FOLLOW-UP B: -lab suffix 제거
|
||||
```
|
||||
|
||||
### Step 2: MEMORY.md 인덱스 추가
|
||||
|
||||
`reference_plan_b_video_complete.md` 항목 뒤:
|
||||
```markdown
|
||||
- [Plan-B-Infra 완료](reference_plan_b_infra_complete.md) — 2026-05-22 NSSM 자동 시작(SP-9) + task-watcher 시간대 큐 토글(SP-10). idle 감지 생략. spec 12 SP 전부 완료
|
||||
```
|
||||
|
||||
### Step 3: 양쪽 push 확인
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-backend && git status && git log --oneline -3
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-ai && git status && git log --oneline -5
|
||||
```
|
||||
|
||||
### Step 4: 박재오 보고
|
||||
- spec 12 SP 전부 완료
|
||||
- task-watcher 시간대 토글 동작
|
||||
- NSSM은 박재오 수동 (NSSM_SETUP.md 참고)
|
||||
|
||||
## Report
|
||||
- 메모리 파일 생성
|
||||
- push 상태
|
||||
- 최종 보고
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**1. Spec coverage**
|
||||
|
||||
| Spec 요구사항 | 구현 위치 | 상태 |
|
||||
|--------------|-----------|------|
|
||||
| SP-9 §10: NSSM ai_trade(HIGH) + wsl_docker(NORMAL) 자동 시작 | Task 6 NSSM_SETUP.md | ✓ (박재오 수동 + 안내) |
|
||||
| SP-10 §10: task-watcher 컨테이너 30초 loop | Task 3 watcher.py | ✓ |
|
||||
| SP-10 §10: current_mode (시간대 + holidays + KST) | Task 2 mode.py | ✓ |
|
||||
| SP-10 §10: queue:paused 토글 (free→DEL, trading→SET) | Task 3 | ✓ |
|
||||
| §3 휴장일 단일 소스 GET /api/stock/holidays | Task 1 | ✓ (신설) |
|
||||
| 박재오 결정: idle 감지 생략 — 시간대만 | Task 2 (is_user_active 제거) | ✓ |
|
||||
| §3 트레이딩 모드 = 평일 비휴장 07:00-16:30 | Task 2 TRADING_START/END | ✓ |
|
||||
|
||||
**spec 대비 의도적 변경 (박재오 승인):**
|
||||
- idle/게임 감지 생략 — spec §10 SP-10의 `is_user_active()` 제거. trading 시간대면 무조건 paused.
|
||||
- spec §3의 🟡 일반(16:30-23:30) 모드 → free로 통합 (트레이딩 시간대만 paused).
|
||||
|
||||
**2. Placeholder scan:** 통과. NSSM_SETUP.md의 "(확인)" 표기는 박재오 환경 검증 안내 (placeholder 아님).
|
||||
|
||||
**3. Type consistency:**
|
||||
- `current_mode(now: dt.datetime, holidays: Set[str]) -> str` — Task 2 정의, Task 3 watcher_loop + Task 7 검증 호출 일관
|
||||
- `fetch_holidays() -> Set[str]` — Task 2 정의, Task 3 호출
|
||||
- mode 값 `"trading"` | `"free"` 2개 — Task 2/3/7 일관
|
||||
- `PAUSED_KEY = "queue:paused"` — Task 3, render workers의 PAUSED_KEY와 동일 문자열 (Plan-B-Insta/Music/Video)
|
||||
|
||||
**4. 함정 사전 인지:**
|
||||
- task-watcher는 services/ 컨테이너 (NAS lab 아님) → deploy.sh 6위치 등재 불필요
|
||||
- holidays endpoint(stock)는 기존 컨테이너 수정 → deploy.sh 등재 이미 됨
|
||||
- services/.env: TRADING_START/END는 task-watcher 전용 → 다른 서비스와 충돌 없음 (compose default로 분기)
|
||||
- PAUSED_TTL로 watcher 장애 시 큐 영구정지 방지
|
||||
|
||||
플랜 완성. 모든 검토 통과.
|
||||
|
||||
---
|
||||
|
||||
## 부록 — 알려진 결정 + follow-up
|
||||
|
||||
**박재오 결정 (2026-05-22):** idle/게임 감지 생략. 시간대만으로 큐 토글. 박재오 7결정 #1의 "Windows 작업 감지 큐 정지"는 부분 포기 (시간대 기반만). 향후 idle 감지 필요 시 Windows native idle-reporter(GetLastInputInfo) → Redis user:last_input_ts 기록 → task-watcher가 읽는 hybrid로 확장 가능.
|
||||
|
||||
**spec 12 SP 완료 후 follow-up:**
|
||||
- frontend `/video` `/music` UI (backend gateway만 완료)
|
||||
- FOLLOW-UP B: `-lab` suffix 일괄 제거
|
||||
- GCS lifecycle (Veo Vertex 미사용으로 무관 — Gemini API는 GCS 안 씀)
|
||||
- Sora 2 alternative (2026-09-24 deprecated 대비)
|
||||
1618
docs/superpowers/plans/2026-05-23-lotto-evolver-ui.md
Normal file
1618
docs/superpowers/plans/2026-05-23-lotto-evolver-ui.md
Normal file
File diff suppressed because it is too large
Load Diff
3547
docs/superpowers/plans/2026-05-23-tarot-lab.md
Normal file
3547
docs/superpowers/plans/2026-05-23-tarot-lab.md
Normal file
File diff suppressed because it is too large
Load Diff
1373
docs/superpowers/plans/2026-05-23-video-studio-backend.md
Normal file
1373
docs/superpowers/plans/2026-05-23-video-studio-backend.md
Normal file
File diff suppressed because it is too large
Load Diff
3274
docs/superpowers/plans/2026-05-25-saju-tarot-lab-migration.md
Normal file
3274
docs/superpowers/plans/2026-05-25-saju-tarot-lab-migration.md
Normal file
File diff suppressed because it is too large
Load Diff
2913
docs/superpowers/plans/2026-05-26-saju-ui-v1.md
Normal file
2913
docs/superpowers/plans/2026-05-26-saju-ui-v1.md
Normal file
File diff suppressed because it is too large
Load Diff
2970
docs/superpowers/plans/2026-05-26-saju-ui-v2-redesign.md
Normal file
2970
docs/superpowers/plans/2026-05-26-saju-ui-v2-redesign.md
Normal file
File diff suppressed because it is too large
Load Diff
1616
docs/superpowers/plans/2026-05-28-agent-office-docker-logs.md
Normal file
1616
docs/superpowers/plans/2026-05-28-agent-office-docker-logs.md
Normal file
File diff suppressed because it is too large
Load Diff
1328
docs/superpowers/plans/2026-05-31-lotto-self-learning-backtest.md
Normal file
1328
docs/superpowers/plans/2026-05-31-lotto-self-learning-backtest.md
Normal file
File diff suppressed because it is too large
Load Diff
1102
docs/superpowers/plans/2026-05-31-stock-holdings-intelligence.md
Normal file
1102
docs/superpowers/plans/2026-05-31-stock-holdings-intelligence.md
Normal file
File diff suppressed because it is too large
Load Diff
408
docs/superpowers/plans/2026-06-02-insta-cardnews-upgrade.md
Normal file
408
docs/superpowers/plans/2026-06-02-insta-cardnews-upgrade.md
Normal file
@@ -0,0 +1,408 @@
|
||||
# 인스타 카드뉴스 품질 고도화 + 업로드 친화 패키지 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:** 인스타 카드를 모던 미니멀 디자인 시스템으로 격상하고(렌더 견고화로 known-issue 해결), 완성 패키지를 zip으로 받아 인스타에 쉽게 업로드(반자동)할 수 있게 한다.
|
||||
|
||||
**Architecture:** 디자인 시스템 Jinja 템플릿(페이지 타입별 레이아웃)을 web-ai insta-render 워커(authoritative)와 insta-lab(참조 복사본)에 작성. 워커 `card_renderer.py`에 `document.fonts.ready` 대기 + PNG 검증 추가. card_writer 프롬프트에 글자수 가이드. insta-lab에 zip 패키지 API + web-ui 다운로드 버튼. Graph API 미사용(반자동).
|
||||
|
||||
**Tech Stack:** Jinja2 + HTML/CSS, Playwright(Chromium), FastAPI, pytest / React+Vite(web-ui).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-02-insta-cardnews-upgrade-design.md`
|
||||
|
||||
**⚠️ 3 repo 작업** (커밋·배포 경로 다름):
|
||||
- `web-backend/insta-lab` — git push → Gitea webhook 자동배포 (NAS)
|
||||
- `web-ai/services/insta-render` — **별도 repo(ai-trade.git), Windows 머신 구동** — 워커가 실제 렌더하는 authoritative 템플릿 위치
|
||||
- `web-ui` — **별도 repo**, `npm run release:nas` 수동 배포
|
||||
|
||||
---
|
||||
|
||||
## 검증된 컨텍스트
|
||||
- 워커 렌더: `web-ai/services/insta-render/card_renderer.py` — `_build_pages(slate)`가 10 spec 생성(cover page_no=1 / body page_no=2~9 / cta page_no=10, 각 `page_type`/`headline`/`body`/`accent_color`/`cta`/`page_no`/`total_pages`). `CARD_TEMPLATE_DIR`(기본 `/app/templates`)에서 `{theme}/card.html.j2` 로드 → `page.goto(file://, networkidle)` → `screenshot(full_page=False)` @viewport 1080×1350.
|
||||
- 워커 템플릿 실제 위치: `web-ai/services/insta-render/templates/default/card.html.j2` (현재 insta-lab과 동일한 55줄 기본형). **이게 렌더에 쓰이는 authoritative 파일.**
|
||||
- 카피: `insta-lab/app/card_writer.py` `DEFAULT_PROMPT`(DB `slate_writer` 오버라이드 가능). 산출: cover_copy{headline,body,accent_color}/body_copies[8]{headline,body}/cta_copy{headline,body,cta}/suggested_caption/hashtags[].
|
||||
- 슬레이트 PNG: 워커가 `INSTA_MEDIA_ROOT/{slate_id}/{page_no:02d}.png` 저장. NAS에서 `card_assets` 테이블 + `db.list_card_assets(slate_id)`(page_index + 파일경로)로 추적. `GET /api/insta/slates/{id}/assets/{page}`가 단일 PNG 서빙(파일경로 읽어 반환).
|
||||
- 슬레이트 데이터: `db.get_card_slate(slate_id)` + `db.list_card_assets(slate_id)`. `GET /api/insta/slates/{id}`가 slate + assets 반환.
|
||||
|
||||
---
|
||||
|
||||
# Phase 1 — 모던 미니멀 디자인 시스템 템플릿 (web-ai authoritative + insta-lab 복사본)
|
||||
|
||||
## Task 1.1: 디자인 시스템 card.html.j2 작성
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-ai/services/insta-render/templates/default/card.html.j2` (**렌더 authoritative**)
|
||||
- Modify: `web-backend/insta-lab/app/templates/default/card.html.j2` (참조 복사본 — 동일 내용 유지)
|
||||
|
||||
> 두 파일을 **동일 내용**으로 작성한다. 워커가 web-ai 쪽을 렌더하지만 insta-lab 복사본도 일관성 위해 갱신.
|
||||
|
||||
- [ ] **Step 1: 디자인 시스템 템플릿 작성** — 아래 전체 내용으로 두 파일을 교체:
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css');
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 1080px; height: 1350px; }
|
||||
body {
|
||||
font-family: 'Pretendard', 'Noto Sans KR', sans-serif;
|
||||
background: #F7F7FA; color: #14171A;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.card {
|
||||
position: relative; width: 1080px; height: 1350px; overflow: hidden;
|
||||
padding: 96px 84px 72px;
|
||||
display: flex; flex-direction: column;
|
||||
background: #FFFFFF;
|
||||
}
|
||||
.accent-bar { position: absolute; top: 0; left: 0; width: 100%; height: 14px; background: {{ accent_color }}; }
|
||||
.badge {
|
||||
align-self: flex-start; padding: 10px 24px; border-radius: 999px;
|
||||
background: {{ accent_color }}; color: #fff;
|
||||
font-size: 30px; font-weight: 700; letter-spacing: -0.02em;
|
||||
}
|
||||
.idx { font-size: 120px; font-weight: 800; line-height: 1; color: {{ accent_color }}; letter-spacing: -0.04em; }
|
||||
.content { flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 36px; }
|
||||
.headline {
|
||||
font-weight: 800; line-height: 1.18; letter-spacing: -0.04em; color: #14171A;
|
||||
display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.cover .headline { font-size: 104px; -webkit-line-clamp: 4; }
|
||||
.body-page .headline { font-size: 76px; -webkit-line-clamp: 3; }
|
||||
.cta .headline { font-size: 88px; -webkit-line-clamp: 3; }
|
||||
.sub {
|
||||
font-size: 42px; font-weight: 400; line-height: 1.5; color: #3A4047;
|
||||
display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; -webkit-line-clamp: 8;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.footer {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 28px; color: #8A9099; font-weight: 600; margin-top: 40px;
|
||||
}
|
||||
.cta-pill {
|
||||
align-self: flex-start; margin-top: 8px; padding: 18px 40px; border-radius: 16px;
|
||||
background: {{ accent_color }}; color: #fff; font-size: 40px; font-weight: 700;
|
||||
}
|
||||
.progress { display: flex; gap: 10px; }
|
||||
.progress i { width: 14px; height: 14px; border-radius: 50%; background: #D8DCE0; display: inline-block; }
|
||||
.progress i.on { background: {{ accent_color }}; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card {{ 'cover' if page_type=='cover' else ('cta' if page_type=='cta' else 'body-page') }}">
|
||||
<div class="accent-bar"></div>
|
||||
|
||||
{% if page_type == 'cover' %}
|
||||
<span class="badge">{{ category_label|default(headline[:0]) }}{{ '오늘의 이슈' if not category_label }}</span>
|
||||
<div class="content">
|
||||
<h1 class="headline">{{ headline }}</h1>
|
||||
<p class="sub">{{ body }}</p>
|
||||
</div>
|
||||
{% elif page_type == 'cta' %}
|
||||
<div class="content">
|
||||
<h1 class="headline">{{ headline }}</h1>
|
||||
<p class="sub">{{ body }}</p>
|
||||
{% if cta %}<div class="cta-pill">{{ cta }}</div>{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="idx">{{ '%02d'|format(page_no - 1) }}</span>
|
||||
<div class="content">
|
||||
<h1 class="headline">{{ headline }}</h1>
|
||||
<p class="sub">{{ body }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="footer">
|
||||
{% if page_type == 'cover' or page_type == 'cta' %}
|
||||
<span>{{ brand_handle|default('') }}</span><span>{{ page_no }} / {{ total_pages }}</span>
|
||||
{% else %}
|
||||
<div class="progress">{% for n in range(2, total_pages) %}<i class="{{ 'on' if n <= page_no }}"></i>{% endfor %}</div>
|
||||
<span>{{ page_no }} / {{ total_pages }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
> 디자인 노트: 페이지 타입별 분기(cover 대형 헤드라인+서브+배지 / body 좌상단 인덱스 `01~08`(page_no-1)+헤드라인+본문+진행 점 / cta 요약+CTA pill). `-webkit-line-clamp`로 오버플로우 2차 방어(글자수 가이드가 1차). `accent_color`는 기존 데이터. `brand_handle`은 미설정 시 빈칸(추후 핸들 주입 가능). Pretendard CDN(@import) — Phase 2의 fonts.ready 대기와 짝.
|
||||
|
||||
- [ ] **Step 2: 렌더 스모크 확인 (web-ai)** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render && python -c "from jinja2 import Environment, FileSystemLoader; e=Environment(loader=FileSystemLoader('templates')); t=e.get_template('default/card.html.j2'); [print(pt, len(t.render(page_type=pt, page_no=n, total_pages=10, headline='테스트 헤드라인', body='본문 테스트입니다.', accent_color='#0F62FE', cta='팔로우')) > 0) for pt,n in [('cover',1),('body',3),('cta',10)]]"`
|
||||
Expected: `True` 3줄 (3 페이지 타입 모두 렌더 예외 없음).
|
||||
|
||||
- [ ] **Step 3: Commit (2 repo 각각)**
|
||||
```bash
|
||||
# web-ai repo
|
||||
cd /c/Users/jaeoh/Desktop/workspace/web-ai && git add services/insta-render/templates/default/card.html.j2 && git commit -m "feat(insta-render): 모던 미니멀 디자인 시스템 템플릿"
|
||||
# insta-lab repo (참조 복사본)
|
||||
cd /c/Users/jaeoh/Desktop/workspace/web-backend && git add insta-lab/app/templates/default/card.html.j2 && git commit -m "feat(insta-lab): default 템플릿 디자인 시스템 동기화(참조용)"
|
||||
```
|
||||
> 커밋 메시지 trailer 각각에 `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` 추가.
|
||||
|
||||
---
|
||||
|
||||
# Phase 2 — 렌더 견고화 (web-ai 워커, known-issue 해결)
|
||||
|
||||
## Task 2.1: fonts.ready 대기 + PNG 비어있음 검증
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-ai/services/insta-render/card_renderer.py` (`_render_slate_locked`)
|
||||
- Test: `web-ai/services/insta-render/tests/test_worker.py` (또는 기존 테스트 파일에 추가)
|
||||
|
||||
- [ ] **Step 1: 실패 테스트** — `tests/test_worker.py`에 추가 (실제 Chromium 렌더 + 검증). 워커 테스트 관례 확인 후 맞출 것; pytest-asyncio 사용 가정:
|
||||
```python
|
||||
import os
|
||||
import pytest
|
||||
from card_renderer import render_slate, init_browser, shutdown_browser
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_produces_nonempty_1080x1350(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("card_renderer.INSTA_MEDIA_ROOT", str(tmp_path))
|
||||
await init_browser()
|
||||
try:
|
||||
slate = {
|
||||
"cover_copy": {"headline": "헤드라인", "body": "서브", "accent_color": "#0F62FE"},
|
||||
"body_copies": [{"headline": f"포인트{i}", "body": "본문"} for i in range(8)],
|
||||
"cta_copy": {"headline": "요약", "body": "마무리", "cta": "팔로우"},
|
||||
}
|
||||
paths = await render_slate(slate, slate_id=99999)
|
||||
assert len(paths) == 10
|
||||
for p in paths:
|
||||
assert os.path.getsize(p) > 1000 # 비어있지 않음
|
||||
finally:
|
||||
await shutdown_browser()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실패/현황 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render && python -m pytest tests/test_worker.py::test_render_produces_nonempty_1080x1350 -v`
|
||||
Expected: 현재 코드로도 통과할 수 있으나(렌더 자체는 동작), 폰트/검증 보강 전이므로 FAIL이 아니면 다음 Step에서 검증 로직 추가가 의미를 갖도록 진행. (Playwright/Chromium 미설치 환경이면 `playwright install chromium` 필요 — 안 되면 DONE_WITH_CONCERNS로 보고)
|
||||
|
||||
- [ ] **Step 3: card_renderer 보강** — `_render_slate_locked`의 페이지 루프에서 `page.goto` 직후·`screenshot` 직전에 폰트 대기 추가, screenshot 후 비어있음 검증:
|
||||
```python
|
||||
try:
|
||||
await page.goto(f"file://{html_path}", wait_until="networkidle")
|
||||
await page.evaluate("document.fonts.ready") # 웹폰트 로딩 완료까지 대기
|
||||
out_path = os.path.join(out_dir, f"{spec['page_no']:02d}.png")
|
||||
await page.screenshot(path=out_path, full_page=False, omit_background=False)
|
||||
if os.path.getsize(out_path) < 1000: # 빈/깨진 PNG 방어
|
||||
raise RuntimeError(f"rendered PNG too small: {out_path}")
|
||||
paths.append(out_path)
|
||||
finally:
|
||||
...
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 통과 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render && python -m pytest tests/test_worker.py -v` Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit (web-ai repo)**
|
||||
```bash
|
||||
cd /c/Users/jaeoh/Desktop/workspace/web-ai && git add services/insta-render/card_renderer.py services/insta-render/tests/test_worker.py && git commit -m "fix(insta-render): fonts.ready 대기 + PNG 비어있음 검증 (렌더 known-issue 해결)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Phase 3 — 카피 글자수 가이드 (insta-lab)
|
||||
|
||||
## Task 3.1: card_writer 프롬프트에 글자수 상한 추가
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-backend/insta-lab/app/card_writer.py` (`DEFAULT_PROMPT`)
|
||||
- Test: `web-backend/insta-lab/app/test_card_writer_prompt.py` (NEW)
|
||||
|
||||
- [ ] **Step 1: 실패 테스트**
|
||||
|
||||
`insta-lab/app/test_card_writer_prompt.py`:
|
||||
```python
|
||||
from app import card_writer
|
||||
|
||||
def test_default_prompt_has_length_guidance():
|
||||
p = card_writer.DEFAULT_PROMPT
|
||||
# 글자수 가이드가 프롬프트에 포함됐는지
|
||||
assert "22자" in p and "120자" in p
|
||||
# 포맷 placeholder는 유지
|
||||
assert "{category}" in p and "{keyword}" in p and "{articles}" in p
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실패 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -m pytest app/test_card_writer_prompt.py -v` Expected: FAIL
|
||||
|
||||
- [ ] **Step 3: DEFAULT_PROMPT에 가이드 추가** — `DEFAULT_PROMPT` 문자열의 JSON 스키마 안내 뒤(닫는 `}}` 다음)에 글자수 가이드 문단 추가:
|
||||
```python
|
||||
DEFAULT_PROMPT = """너는 인스타그램 카드 뉴스 카피라이터다.
|
||||
카테고리: {category}
|
||||
키워드: {keyword}
|
||||
참고 기사:
|
||||
{articles}
|
||||
|
||||
10페이지 인스타 카드용 카피를 다음 JSON 한 객체로만 출력해라 (코드펜스 금지):
|
||||
{{
|
||||
"cover_copy": {{"headline": "<훅 한 줄>", "body": "<서브카피 1~2줄>", "accent_color": "#hex"}},
|
||||
"body_copies": [
|
||||
{{"headline": "<포인트 헤드라인>", "body": "<2~4문장 본문>"}},
|
||||
... (총 8개)
|
||||
],
|
||||
"cta_copy": {{"headline": "<요약 한 줄>", "body": "<마무리 1~2줄>", "cta": "팔로우/저장 등"}},
|
||||
"suggested_caption": "<인스타 캡션 본문>",
|
||||
"hashtags": ["#태그1", "#태그2", ...]
|
||||
}}
|
||||
|
||||
[글자수 제약 — 카드 디자인 박스에 맞게 반드시 준수]
|
||||
- cover_copy.headline: 22자 이내
|
||||
- body_copies[].headline: 26자 이내
|
||||
- body_copies[].body: 120자 이내 (2~4문장)
|
||||
- cta_copy.headline: 22자 이내
|
||||
초과하면 잘리므로 간결하고 임팩트 있게 작성한다.
|
||||
"""
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 통과 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -m pytest app/test_card_writer_prompt.py -v` Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit (insta-lab)**
|
||||
```bash
|
||||
cd /c/Users/jaeoh/Desktop/workspace/web-backend && git add insta-lab/app/card_writer.py insta-lab/app/test_card_writer_prompt.py && git commit -m "feat(insta-lab): card_writer 프롬프트에 글자수 가이드(오버플로우 예방)"
|
||||
```
|
||||
> 주의: 운영 DB에 `slate_writer` prompt_template 오버라이드가 있으면 DEFAULT_PROMPT 대신 그게 쓰임 → 배포 후 필요 시 `PUT /api/insta/templates/prompts/slate_writer`로 동일 가이드 반영(plan §검증에서 안내).
|
||||
|
||||
---
|
||||
|
||||
# Phase 4 — zip 패키지 다운로드 API (insta-lab)
|
||||
|
||||
## Task 4.1: GET /api/insta/slates/{id}/package
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-backend/insta-lab/app/main.py` (엔드포인트 추가)
|
||||
- Test: `web-backend/insta-lab/app/test_package_api.py` (NEW)
|
||||
|
||||
- [ ] **Step 1: (확인됨) asset 스키마** — `card_assets(slate_id, page_index, file_path, file_hash)`. `db.list_card_assets(slate_id)` → 각 row에 `file_path`·`page_index`. `db.add_card_asset(slate_id, page_index, file_path, file_hash="")`. `db.add_card_slate(row: dict)`. 기존 `/assets/{page}`는 `FileResponse(match["file_path"], media_type="image/png")`. zip 엔드포인트는 동일하게 `a["file_path"]`를 읽는다.
|
||||
|
||||
- [ ] **Step 2: 실패 테스트**
|
||||
|
||||
`insta-lab/app/test_package_api.py`:
|
||||
```python
|
||||
import io, os, tempfile, zipfile, sys
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
def _client(monkeypatch):
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
from app import config, db
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setattr(config, "INSTA_DATA_PATH", tmp, raising=False)
|
||||
monkeypatch.setattr(db, "DB_PATH", os.path.join(tmp, "insta.db"), raising=False)
|
||||
db.init_db()
|
||||
from app.main import app
|
||||
return TestClient(app), db, tmp
|
||||
|
||||
def test_package_zip_contains_pngs_and_caption(monkeypatch):
|
||||
client, db, tmp = _client(monkeypatch)
|
||||
# 슬레이트 + 2개 asset(실제 PNG 파일) 시드
|
||||
sid = db.add_card_slate({"keyword":"k","category":"economy","status":"rendered",
|
||||
"cover_copy":{"headline":"h"}, "body_copies":[{"headline":"b","body":"x"}]*8,
|
||||
"cta_copy":{}, "suggested_caption":"캡션입니다", "hashtags":["#a","#b"]})
|
||||
cards_dir = os.path.join(tmp, "insta_cards", str(sid)); os.makedirs(cards_dir, exist_ok=True)
|
||||
for pg in (1,2):
|
||||
fp = os.path.join(cards_dir, f"{pg:02d}.png")
|
||||
with open(fp, "wb") as f: f.write(b"\x89PNG\r\n" + b"0"*2000)
|
||||
db.add_card_asset(slate_id=sid, page_index=pg, file_path=fp)
|
||||
r = client.get(f"/api/insta/slates/{sid}/package")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"] == "application/zip"
|
||||
z = zipfile.ZipFile(io.BytesIO(r.content))
|
||||
names = z.namelist()
|
||||
assert any(n.endswith(".png") for n in names)
|
||||
assert "caption.txt" in names
|
||||
cap = z.read("caption.txt").decode("utf-8")
|
||||
assert "캡션입니다" in cap and "#a" in cap
|
||||
```
|
||||
> `db.add_card_slate`/`add_card_asset`/`list_card_assets`의 실제 시그니처·컬럼명은 db.py 확인 후 맞출 것. asset 경로 컬럼이 `path`가 아니면 테스트·구현 모두 조정.
|
||||
|
||||
- [ ] **Step 3: 실패 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -m pytest app/test_package_api.py -v` Expected: FAIL (404)
|
||||
|
||||
- [ ] **Step 4: 엔드포인트 구현** — `insta-lab/app/main.py`에 추가 (`/assets/{page}` 엔드포인트 근처, 동일한 asset 파일경로 접근 방식 사용. `import io, zipfile`은 상단에 추가):
|
||||
```python
|
||||
@app.get("/api/insta/slates/{slate_id}/package")
|
||||
def download_package(slate_id: int):
|
||||
slate = db.get_card_slate(slate_id)
|
||||
if not slate:
|
||||
raise HTTPException(404, "slate not found")
|
||||
assets = sorted(db.list_card_assets(slate_id), key=lambda a: a["page_index"])
|
||||
if not assets:
|
||||
raise HTTPException(409, "아직 렌더된 카드가 없습니다")
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for a in assets:
|
||||
fp = a["file_path"]
|
||||
if os.path.exists(fp):
|
||||
z.write(fp, arcname=f"{a['page_index']:02d}.png")
|
||||
caption = (slate.get("suggested_caption") or "").strip()
|
||||
tags = slate.get("hashtags") or []
|
||||
if isinstance(tags, str):
|
||||
import json as _json
|
||||
try: tags = _json.loads(tags)
|
||||
except Exception: tags = []
|
||||
caption_full = caption + ("\n\n" + " ".join(tags) if tags else "")
|
||||
z.writestr("caption.txt", caption_full)
|
||||
buf.seek(0)
|
||||
from fastapi.responses import StreamingResponse
|
||||
return StreamingResponse(buf, media_type="application/zip", headers={
|
||||
"Content-Disposition": f'attachment; filename="insta_slate_{slate_id}.zip"'})
|
||||
```
|
||||
> `HTTPException`/`os`는 main.py에 이미 import됨. `slate.get("hashtags")`가 JSON 문자열일 수 있어 방어 파싱.
|
||||
|
||||
- [ ] **Step 5: 통과 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -m pytest app/test_package_api.py -v` Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit (insta-lab)**
|
||||
```bash
|
||||
cd /c/Users/jaeoh/Desktop/workspace/web-backend && git add insta-lab/app/main.py insta-lab/app/test_package_api.py && git commit -m "feat(insta-lab): 슬레이트 zip 패키지 다운로드 API (10 PNG + caption.txt)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Phase 5 — web-ui 패키지 다운로드 버튼 (별도 repo: web-ui)
|
||||
|
||||
## Task 5.1: 슬레이트 상세에 다운로드 버튼
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-ui/src/api.js` (헬퍼)
|
||||
- Modify: insta 카드 페이지 (`web-ui/src/pages/insta/InstaCards.jsx` 또는 슬레이트 상세 컴포넌트)
|
||||
|
||||
- [ ] **Step 1: 구조 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && git checkout -b feat/insta-package-download && grep -rln "insta\|슬레이트\|slate" src/pages/insta/ src/api.js 2>/dev/null | head` 로 슬레이트 상세 UI + apiGet 패턴 확인.
|
||||
|
||||
- [ ] **Step 2: api.js 헬퍼 + 다운로드** — `src/api.js`에 패키지 URL 헬퍼 추가(파일 다운로드는 새 탭/anchor로):
|
||||
```javascript
|
||||
export const instaPackageUrl = (slateId) => `/api/insta/slates/${slateId}/package`;
|
||||
```
|
||||
슬레이트 상세 컴포넌트에 버튼 추가 (기존 버튼 스타일 맞춤):
|
||||
```jsx
|
||||
<a className="insta-pkg-btn" href={instaPackageUrl(slate.id)} download>
|
||||
📦 패키지 다운로드 (10장 + 캡션)
|
||||
</a>
|
||||
```
|
||||
> import에 `instaPackageUrl` 추가. 실제 슬레이트 객체의 id 필드명·버튼 클래스는 Step 1 확인 결과에 맞출 것.
|
||||
|
||||
- [ ] **Step 3: 빌드 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && npm run build` Expected: exit 0
|
||||
|
||||
- [ ] **Step 4: Commit (web-ui repo)**
|
||||
```bash
|
||||
cd /c/Users/jaeoh/Desktop/workspace/web-ui && git add src/ && git commit -m "feat: 인스타 슬레이트 패키지 다운로드 버튼"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Phase 6 — 통합 검증
|
||||
|
||||
## Task 6.1: 회귀 + 배포 안내
|
||||
|
||||
- [ ] **Step 1: insta-lab 테스트** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -m pytest app/ -q` (Playwright 의존 테스트는 web-ai에만 있음). 신규 통과 + 회귀 없음. (`_shared` import로 main 로드 시 PYTHONPATH 필요하면 test에 sys.path.insert 적용 — Phase 4 test가 이미 처리)
|
||||
- [ ] **Step 2: web-ai 테스트** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render && python -m pytest -q` (Chromium 필요; 미설치 시 `playwright install chromium`).
|
||||
- [ ] **Step 3: 배포 안내** — 3 repo 각각 push/배포:
|
||||
- insta-lab: `git push origin main` → webhook 자동배포(NAS).
|
||||
- web-ai: Windows 머신에서 워커 repo pull + 재시작 (insta-render 서비스). **신규 템플릿이 워커 CARD_TEMPLATE_DIR에 반영돼야 효과 발생.**
|
||||
- web-ui: `npm run release:nas`.
|
||||
- 배포 후 슬레이트 1건 생성 → 카드 PNG 육안 확인(디자인 시스템 적용·폰트 정상) → `/package` zip 다운로드 확인. DB `slate_writer` 오버라이드 존재 시 글자수 가이드 반영.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review 체크리스트 결과
|
||||
- **Spec 커버리지**: 디자인 시스템 템플릿(Task 1.1) / 렌더 견고화 fonts.ready+검증(2.1) / 카피 글자수 가이드(3.1) / zip 패키지(4.1) / web-ui 버튼(5.1) / 검증(6.1). known-issue(폰트·오버플로우)=2.1+템플릿 clamp. 모두 매핑.
|
||||
- **Placeholder**: 모든 코드 step에 실제 코드. db asset 컬럼명·web-ui 슬레이트 필드·워커 테스트 관례는 "Step에서 확인 후 맞춤" 명시(코드베이스 의존, 합리적). brand_handle 기본 빈칸(미설정 허용).
|
||||
- **타입 일관성**: 템플릿이 쓰는 spec 키(page_type/page_no/total_pages/headline/body/accent_color/cta)가 워커 `_build_pages` 산출과 일치. zip 엔드포인트가 쓰는 `list_card_assets`/`get_card_slate`/`suggested_caption`/`hashtags`는 기존 db/슬레이트 스키마와 일치(Step 1에서 asset 경로 컬럼명만 확인).
|
||||
- **3 repo 경로**: 각 Task에 repo별 cd + 커밋 분리 명시.
|
||||
@@ -0,0 +1,471 @@
|
||||
# packs-lab 인프라 통합 + admin mint-token 설계
|
||||
|
||||
> 대상: `web-backend/packs-lab/`
|
||||
> 외부 의존: Supabase(`pack_files` 테이블) + Vercel SaaS(HMAC 호출자)
|
||||
> 후속 별도 스펙: Vercel-side admin UI / 사용자 다운로드 / cleanup cron / multi-admin
|
||||
|
||||
---
|
||||
|
||||
## 1. 목표
|
||||
|
||||
`packs-lab`은 NAS 자료 다운로드 자동화 백엔드. Synology DSM 공유 링크 발급 + 5GB 멀티파트 업로드 수신을 담당하고, Vercel SaaS와 HMAC으로 통신한다. 사용자 인증은 Vercel이 Supabase로 처리하고 본 서비스는 외부 인증을 다루지 않는다.
|
||||
|
||||
이미 코드(HMAC 미들웨어 / DSM client / 4 라우트)는 작성되어 있으나 인프라 통합 + Supabase 스키마 + admin upload 토큰 발급 흐름이 빠져 있어 운영 가능 상태가 아니다. 본 스펙은 그 갭을 메운다.
|
||||
|
||||
### 핵심 변경
|
||||
|
||||
- **신규 라우트**: `POST /api/packs/admin/mint-token` (Vercel HMAC → 일회성 업로드 토큰)
|
||||
- **Supabase DDL**: `pack_files` 테이블 + 활성·삭제 인덱스
|
||||
- **인프라**: docker-compose `packs-lab` 서비스 등록(18950) + nginx `/api/packs/` 5GB 통과 + `.env.example` 6+1 환경변수
|
||||
- **테스트**: routes 통합 + DSM client mock
|
||||
- **문서**: web-backend / workspace CLAUDE.md 5곳 갱신
|
||||
- **DELETE 라우트 docstring**: "DSM 공유 정리" 표현을 "DSM 공유 자동 만료"로 수정 (실제 동작과 일치)
|
||||
|
||||
### 변경하지 않는 것
|
||||
|
||||
- 기존 `auth.py` (`mint_upload_token` 그대로 활용)
|
||||
- 기존 `dsm_client.py`
|
||||
- 기존 `routes.py`의 sign-link / upload / list / delete 본문
|
||||
- DSM 공유 추적 테이블 — 4시간 자동 만료로 충분(브레인스토밍 결정)
|
||||
|
||||
---
|
||||
|
||||
## 2. 컴포넌트 + 통신 흐름
|
||||
|
||||
### 2.1 변경 받는 파일
|
||||
|
||||
| 영역 | 파일 | 변경 |
|
||||
|------|------|------|
|
||||
| 백엔드 | `packs-lab/app/routes.py` | DELETE docstring 수정 + admin mint-token 라우트 추가 |
|
||||
| 백엔드 | `packs-lab/app/models.py` | `MintTokenRequest`, `MintTokenResponse` 스키마 추가 |
|
||||
| 백엔드 | `packs-lab/app/auth.py` | 변경 없음 (기존 `mint_upload_token` 활용) |
|
||||
| 테스트 | `packs-lab/tests/conftest.py` (신규) | autouse `BACKEND_HMAC_SECRET` 셋팅 |
|
||||
| 테스트 | `packs-lab/tests/test_routes.py` (신규) | 5 라우트 통합 테스트 |
|
||||
| 테스트 | `packs-lab/tests/test_dsm_client.py` (신규) | DSM 7.x API mock 테스트 |
|
||||
| DB | `packs-lab/supabase/pack_files.sql` (신규) | DDL + 인덱스 |
|
||||
| 인프라 | `docker-compose.yml` | `packs-lab` 서비스 추가 |
|
||||
| 인프라 | `nginx/default.conf` | `/api/packs/` 라우팅 (`client_max_body_size 5G` + streaming) |
|
||||
| 인프라 | `.env.example` | 6+1 신규 환경변수 |
|
||||
| 문서 | `web-backend/CLAUDE.md` | 1·4·5·8·9 섹션 갱신 |
|
||||
| 문서 | `workspace/CLAUDE.md` | 컨테이너 표 한 줄 추가 |
|
||||
|
||||
### 2.2 통신 흐름
|
||||
|
||||
**ADMIN 업로드**
|
||||
|
||||
```
|
||||
Vercel admin UI ─────→ Vercel API (HMAC 헤더 추가)
|
||||
│
|
||||
▼
|
||||
POST /api/packs/admin/mint-token
|
||||
│
|
||||
backend: verify_request_hmac
|
||||
│
|
||||
mint_upload_token({tier, label, filename, size_bytes, jti, expires_at})
|
||||
│
|
||||
Vercel ←─────────────── token ──────┘
|
||||
│
|
||||
▼
|
||||
admin browser → POST /api/packs/upload
|
||||
Authorization: Bearer <token>
|
||||
multipart body (≤5GB)
|
||||
│
|
||||
backend: verify_upload_token + JTI mark
|
||||
│
|
||||
파일 저장 (PACK_BASE_DIR/{filename}, 평면 구조 — tier는 filename 규칙으로 구분)
|
||||
│
|
||||
Supabase INSERT pack_files
|
||||
```
|
||||
|
||||
**사용자 다운로드**
|
||||
|
||||
```
|
||||
사용자 → Vercel SaaS (Supabase auth + tier·결제 검증)
|
||||
│
|
||||
▼
|
||||
POST /api/packs/sign-link (HMAC + file_path)
|
||||
│
|
||||
backend: verify_request_hmac
|
||||
│
|
||||
DSM Sharing.create (4시간 만료)
|
||||
│
|
||||
사용자 ← Vercel ← 다운로드 URL (4시간 유효)
|
||||
```
|
||||
|
||||
### 2.3 기각된 대안
|
||||
|
||||
| 대안 | 기각 사유 |
|
||||
|------|-----------|
|
||||
| Vercel-side 토큰 발급 | 토큰 포맷 양쪽 분산, 변경 시 동기화 부담 |
|
||||
| admin browser → backend 직접 HMAC | admin browser에 secret 노출, 보안 약화 |
|
||||
| DSM 공유 추적 테이블 | 4시간 자동 만료로 충분, YAGNI |
|
||||
| Resumable multipart upload | 5GB는 단일 stream으로 충분, 복잡도 증가 |
|
||||
| `pack_files.min_tier`를 PostgreSQL ENUM | tier 추가 시 ALTER TYPE 번거로움. text+CHECK 채택 |
|
||||
|
||||
---
|
||||
|
||||
## 3. `POST /api/packs/admin/mint-token`
|
||||
|
||||
### 3.1 Pydantic 스키마 (`models.py` 추가)
|
||||
|
||||
```python
|
||||
class MintTokenRequest(BaseModel):
|
||||
"""Vercel → backend: admin upload 토큰 발급 요청."""
|
||||
tier: PackTier
|
||||
label: str = Field(..., max_length=200)
|
||||
filename: str = Field(..., max_length=255)
|
||||
size_bytes: int = Field(..., gt=0, le=5 * 1024 * 1024 * 1024)
|
||||
|
||||
|
||||
class MintTokenResponse(BaseModel):
|
||||
token: str
|
||||
expires_at: datetime
|
||||
jti: str
|
||||
```
|
||||
|
||||
### 3.2 라우트 본문 (`routes.py` 추가)
|
||||
|
||||
```python
|
||||
import time, uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .auth import mint_upload_token, verify_request_hmac
|
||||
from .models import MintTokenRequest, MintTokenResponse
|
||||
|
||||
UPLOAD_TOKEN_TTL_SEC = int(os.getenv("UPLOAD_TOKEN_TTL_SEC", "1800")) # 30분 default
|
||||
|
||||
@router.post("/admin/mint-token", response_model=MintTokenResponse)
|
||||
async def mint_token(
|
||||
request: Request,
|
||||
x_timestamp: str = Header(""),
|
||||
x_signature: str = Header(""),
|
||||
):
|
||||
body = await request.body()
|
||||
verify_request_hmac(body, x_timestamp, x_signature)
|
||||
payload = MintTokenRequest.model_validate_json(body)
|
||||
_check_filename(payload.filename) # upload 라우트와 동일 검증
|
||||
|
||||
jti = str(uuid.uuid4())
|
||||
expires_ts = int(time.time()) + UPLOAD_TOKEN_TTL_SEC
|
||||
token = mint_upload_token({
|
||||
"tier": payload.tier,
|
||||
"label": payload.label,
|
||||
"filename": payload.filename,
|
||||
"size_bytes": payload.size_bytes,
|
||||
"jti": jti,
|
||||
"expires_at": expires_ts,
|
||||
})
|
||||
return MintTokenResponse(
|
||||
token=token,
|
||||
expires_at=datetime.fromtimestamp(expires_ts, tz=timezone.utc),
|
||||
jti=jti,
|
||||
)
|
||||
```
|
||||
|
||||
### 3.3 결정 근거
|
||||
|
||||
| 항목 | 값 | 근거 |
|
||||
|------|-----|------|
|
||||
| TTL default | 1800s (30분) | 5GB 업로드 시작 + 진행 시간 여유. 1Gbps에서 약 40s, 50Mbps에서 약 14분 |
|
||||
| TTL env override | `UPLOAD_TOKEN_TTL_SEC` | 운영 중 조정 가능 |
|
||||
| filename 검증 | upload와 동일 (`_check_filename`) | 토큰 발급 시점에 미리 거부 → admin UI 즉시 피드백 |
|
||||
| jti 응답 포함 | yes | admin이 업로드 결과 추적용 |
|
||||
| Vercel ↔ backend | HMAC (`X-Timestamp` + `X-Signature`) | 다른 admin 라우트와 동일 패턴 |
|
||||
| admin browser ↔ backend | Bearer token (단발성 jti) | 기존 upload 라우트 그대로 |
|
||||
|
||||
### 3.4 DELETE 라우트 docstring 수정
|
||||
|
||||
`routes.py` 모듈 docstring에서:
|
||||
|
||||
```diff
|
||||
- DELETE /api/packs/{file_id} — Vercel HMAC 인증 → soft delete + DSM 공유 정리
|
||||
+ DELETE /api/packs/{file_id} — Vercel HMAC 인증 → soft delete (DSM 공유는 자동 만료)
|
||||
```
|
||||
|
||||
`delete_file` 함수에는 변경 없음.
|
||||
|
||||
---
|
||||
|
||||
## 4. Supabase `pack_files` DDL
|
||||
|
||||
**파일**: `packs-lab/supabase/pack_files.sql` (신규, 운영 배포 시 Supabase SQL editor에서 실행)
|
||||
|
||||
```sql
|
||||
-- pack_files: NAS에 저장된 다운로드 가능한 패키지 파일 메타
|
||||
create table if not exists public.pack_files (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
min_tier text not null check (min_tier in ('starter','pro','master')),
|
||||
label text not null,
|
||||
file_path text not null unique, -- NAS 절대경로, 동일 경로 중복 방지
|
||||
filename text not null,
|
||||
size_bytes bigint not null check (size_bytes > 0),
|
||||
sort_order integer not null default 0,
|
||||
uploaded_at timestamptz not null default now(),
|
||||
deleted_at timestamptz
|
||||
);
|
||||
|
||||
-- list 라우트의 hot path: deleted_at IS NULL + tier/order 정렬
|
||||
create index if not exists pack_files_active_idx
|
||||
on public.pack_files (min_tier, sort_order)
|
||||
where deleted_at is null;
|
||||
|
||||
-- soft-deleted 통계 / cleanup 잡 대비
|
||||
create index if not exists pack_files_deleted_at_idx
|
||||
on public.pack_files (deleted_at)
|
||||
where deleted_at is not null;
|
||||
```
|
||||
|
||||
### 4.1 필드 결정 근거
|
||||
|
||||
| 필드 | 타입 / 제약 | 근거 |
|
||||
|------|------------|------|
|
||||
| `id` | uuid PK + `gen_random_uuid()` default | routes.py가 client-side `uuid.uuid4()` 생성하지만 default도 둬 fallback |
|
||||
| `min_tier` | text + CHECK | enum 대신 text+CHECK가 PostgreSQL에서 더 유연 |
|
||||
| `file_path` | text NOT NULL UNIQUE | 같은 tier/filename 충돌은 파일시스템에서 잡지만 DB 레벨도 보강 |
|
||||
| `size_bytes` | bigint + CHECK > 0 | 5GB는 int 범위 안이지만 미래 대비 bigint |
|
||||
| `sort_order` | int NOT NULL default 0 | routes INSERT가 sort_order 미지정 → 0 기본 |
|
||||
| `uploaded_at` | timestamptz default now() | routes 코드가 `res.data[0]["uploaded_at"]` 그대로 응답에 사용 — DB가 채워줌 |
|
||||
| `deleted_at` | nullable | soft delete |
|
||||
|
||||
### 4.2 RLS
|
||||
|
||||
비활성. backend가 `service_role` key 사용하므로 RLS 우회. Vercel/사용자 직접 접근 없음 → unsafe 아님.
|
||||
|
||||
---
|
||||
|
||||
## 5. 인프라 통합
|
||||
|
||||
### 5.1 `docker-compose.yml` — `packs-lab` 서비스
|
||||
|
||||
```yaml
|
||||
packs-lab:
|
||||
build:
|
||||
context: ./packs-lab
|
||||
dockerfile: Dockerfile
|
||||
container_name: packs-lab
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18950:8000"
|
||||
environment:
|
||||
TZ: Asia/Seoul
|
||||
DSM_HOST: ${DSM_HOST}
|
||||
DSM_USER: ${DSM_USER}
|
||||
DSM_PASS: ${DSM_PASS}
|
||||
BACKEND_HMAC_SECRET: ${BACKEND_HMAC_SECRET}
|
||||
SUPABASE_URL: ${SUPABASE_URL}
|
||||
SUPABASE_SERVICE_KEY: ${SUPABASE_SERVICE_KEY}
|
||||
UPLOAD_TOKEN_TTL_SEC: ${UPLOAD_TOKEN_TTL_SEC:-1800}
|
||||
PACK_BASE_DIR: ${PACK_BASE_DIR:-/app/data/packs}
|
||||
PACK_HOST_DIR: ${PACK_HOST_DIR:-${PACK_DATA_PATH:-./data/packs}}
|
||||
volumes:
|
||||
- ${PACK_DATA_PATH:-./data/packs}:${PACK_BASE_DIR:-/app/data/packs}
|
||||
```
|
||||
|
||||
| 결정 | 값 | 근거 |
|
||||
|------|-----|------|
|
||||
| 포트 | 18950 | 18800(realestate) → 18900(agent-office) → 18950(packs) 순차 |
|
||||
| `PACK_BASE_DIR` (컨테이너 내부) | `/app/data/packs` | routes.py upload target. docker-compose volume 우측. |
|
||||
| `PACK_HOST_DIR` (NAS 호스트) | 운영 `/volume1/docker/webpage/media/packs` / 로컬 fallback `./data/packs` | DSM·Supabase에 노출되는 절대경로. routes.py가 file_path로 저장. 미설정 시 `PACK_BASE_DIR`로 fallback. |
|
||||
| `PACK_DATA_PATH` (호스트 마운트) | default `./data/packs` (로컬), NAS `/volume1/docker/webpage/media/packs` | docker-compose volume 좌측만 사용 |
|
||||
|
||||
### 5.2 `nginx/default.conf` — `/api/packs/` 라우팅
|
||||
|
||||
```nginx
|
||||
location /api/packs/ {
|
||||
proxy_pass http://packs-lab:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 5GB 멀티파트 업로드 대응
|
||||
client_max_body_size 5G;
|
||||
proxy_request_buffering off; # 스트리밍 통과 (메모리/디스크 buffer 회피)
|
||||
proxy_read_timeout 1800s;
|
||||
proxy_send_timeout 1800s;
|
||||
}
|
||||
```
|
||||
|
||||
| 결정 | 근거 |
|
||||
|------|------|
|
||||
| `client_max_body_size 5G` | 라우트 단위 — 다른 location은 default 유지 |
|
||||
| `proxy_request_buffering off` | 5GB 파일을 nginx가 모두 받고 backend에 forward하면 ~5GB 디스크 buffer 발생 |
|
||||
| `proxy_read/send_timeout 1800s` | 30분 — 업로드 토큰 TTL과 일치, 느린 업링크에서 5GB 전송 여유 |
|
||||
|
||||
### 5.3 `.env.example` — 신규 환경변수 (7 + 3 path)
|
||||
|
||||
```bash
|
||||
# ─── packs-lab — NAS 자료 다운로드 자동화 ────────────────────────────
|
||||
# Synology DSM 7.x 인증 (공유 링크 발급용)
|
||||
DSM_HOST=https://gahusb.synology.me:5001
|
||||
DSM_USER=
|
||||
DSM_PASS=
|
||||
# LAN IP + self-signed cert 환경에서 IP mismatch 시 false (LAN 내부 통신이라 허용)
|
||||
DSM_VERIFY_SSL=false
|
||||
|
||||
# Vercel SaaS ↔ backend HMAC 시크릿 (양쪽 동일 값)
|
||||
BACKEND_HMAC_SECRET=
|
||||
|
||||
# Supabase pack_files 테이블 접근 (service_role 키, RLS 우회)
|
||||
SUPABASE_URL=https://<project>.supabase.co
|
||||
SUPABASE_SERVICE_KEY=
|
||||
|
||||
# admin upload 토큰 TTL (초). default 1800 = 30분
|
||||
UPLOAD_TOKEN_TTL_SEC=1800
|
||||
|
||||
# 호스트 마운트 경로 (로컬 ./data/packs, NAS /volume1/docker/webpage/media/packs)
|
||||
PACK_DATA_PATH=./data/packs
|
||||
|
||||
# 컨테이너 내부 저장 경로 (routes.py upload target. docker-compose volume 우측)
|
||||
PACK_BASE_DIR=/app/data/packs
|
||||
|
||||
# DSM API용 path. Synology DSM API는 일반 사용자 권한일 때 /<shared_folder>/... 형식만 인식하고 /volume1/... 절대경로는 거부(error 408).
|
||||
# 운영 NAS는 반드시 shared folder 시점 — /docker/webpage/media/packs.
|
||||
# admin 사용자는 /volume1/... 도 가능하지만 보안상 별도 packs-bot user 권장.
|
||||
PACK_HOST_DIR=/docker/webpage/media/packs
|
||||
```
|
||||
|
||||
### 5.4 NAS 디렉토리 준비
|
||||
|
||||
운영 첫 배포 시 SSH로 1회. 파일은 `PACK_HOST_DIR` 평면에 직접 저장 — tier 디렉토리 분기는 만들지 않음(tier 구분은 filename 규칙으로 admin이 관리):
|
||||
|
||||
```bash
|
||||
mkdir -p /volume1/docker/webpage/media/packs # 호스트 OS path (volume 마운트용)
|
||||
chown -R PUID:PGID /volume1/docker/webpage/media/packs
|
||||
```
|
||||
|
||||
PUID/PGID는 `.env`의 기존 값 사용.
|
||||
|
||||
> ⚠️ **DSM 사용자 권한 — File Station + Sharing 둘 다 필요**: Control Panel → User → packs-bot(또는 admin) → Permissions → File Station에서 `docker` shared folder Read 권한 + Applications → Sharing 권한 ON.
|
||||
|
||||
### 5.5 `scripts/deploy-nas.sh` SERVICES 화이트리스트
|
||||
|
||||
webhook 자동 배포(deployer)가 호출하는 sync 스크립트는 화이트리스트로 동기화 대상 디렉토리를 명시한다. 신규 서비스 추가 시 반드시 함께 수정해야 NAS 운영 디렉토리에 소스 sync + docker compose 빌드가 동작한다.
|
||||
|
||||
```bash
|
||||
SERVICES="lotto travel-proxy deployer stock-lab music-lab blog-lab realestate-lab agent-office personal packs-lab nginx scripts"
|
||||
```
|
||||
|
||||
(packs-lab 누락 시 `docker compose ps`에 packs-lab 미등장 — 첫 배포 시 가장 흔한 누락 항목)
|
||||
|
||||
---
|
||||
|
||||
## 6. 테스트 전략
|
||||
|
||||
기존 `tests/test_auth.py` 유지. 신규 3 파일.
|
||||
|
||||
### 6.1 `tests/conftest.py` (신규)
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _hmac_secret(monkeypatch):
|
||||
"""모든 테스트에서 동일한 HMAC secret 사용."""
|
||||
monkeypatch.setenv("BACKEND_HMAC_SECRET", "test-secret-do-not-use-in-prod")
|
||||
```
|
||||
|
||||
### 6.2 `tests/test_routes.py` (신규) — 통합 테스트
|
||||
|
||||
DSM·Supabase 모두 mock. `pytest`, `monkeypatch`, `unittest.mock`, `fastapi.testclient.TestClient` 사용.
|
||||
|
||||
| 테스트 | 검증 |
|
||||
|--------|------|
|
||||
| `test_sign_link_hmac_required` | timestamp/signature 헤더 누락 → 401 |
|
||||
| `test_sign_link_outside_base_dir` | file_path가 `PACK_BASE_DIR` 외부 → 400 |
|
||||
| `test_sign_link_calls_dsm` | mock된 `create_share_link` 호출 검증, URL 응답 |
|
||||
| `test_mint_token_hmac_required` | HMAC 누락 → 401 |
|
||||
| `test_mint_token_returns_valid_token` | 발급된 token이 `verify_upload_token`으로 통과 |
|
||||
| `test_mint_token_invalid_filename` | 확장자 미허용 → 400 |
|
||||
| `test_upload_token_required` | Authorization Bearer 누락 → 401 |
|
||||
| `test_upload_size_mismatch` | 토큰 size_bytes ≠ 실제 → 400 |
|
||||
| `test_upload_jti_replay` | 같은 토큰 두 번 → 두 번째 409 |
|
||||
| `test_list_returns_active_only` | mock supabase 응답에서 deleted_at NULL만 반환 |
|
||||
| `test_delete_soft_deletes` | mock supabase update에 deleted_at ISO timestamp 들어감 |
|
||||
|
||||
### 6.3 `tests/test_dsm_client.py` (신규)
|
||||
|
||||
httpx mock(`respx` 또는 `MockTransport`) 또는 `monkeypatch.setattr` 패치.
|
||||
|
||||
| 테스트 | 검증 |
|
||||
|--------|------|
|
||||
| `test_create_share_link_login_logout` | login → Sharing.create → logout 순서 |
|
||||
| `test_create_share_link_returns_url_and_expiry` | 응답 파싱 |
|
||||
| `test_dsm_login_failure_raises` | login API success=false → DSMError |
|
||||
| `test_dsm_share_failure_logs_out` | Sharing.create 실패해도 logout 호출 (try/finally) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 문서 갱신
|
||||
|
||||
### 7.1 `web-backend/CLAUDE.md` — 5곳
|
||||
|
||||
**1. 1.프로젝트 개요**
|
||||
|
||||
```diff
|
||||
- 서비스: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, deployer (9개)
|
||||
+ 서비스: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, packs-lab, deployer (10개)
|
||||
```
|
||||
|
||||
**2. 4.Docker 서비스 표** — 신규 행
|
||||
|
||||
```
|
||||
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 업로드, Vercel SaaS와 HMAC 통신) |
|
||||
```
|
||||
|
||||
**3. 5.Nginx 라우팅 표** — 신규 행
|
||||
|
||||
```
|
||||
| `/api/packs/` | `packs-lab:8000` | 5GB 업로드 (`client_max_body_size 5G` + `proxy_request_buffering off`) |
|
||||
```
|
||||
|
||||
**4. 8.로컬 개발 표** — 신규 행
|
||||
|
||||
```
|
||||
| Packs Lab | http://localhost:18950 |
|
||||
```
|
||||
|
||||
**5. 9.서비스별** — `### packs-lab (packs-lab/)` 신규 섹션
|
||||
|
||||
내용:
|
||||
- 용도 (NAS DSM 공유링크 + 5GB 업로드 + Vercel HMAC, 사용자 인증은 Vercel이 Supabase로 처리)
|
||||
- 환경변수 6+1개
|
||||
- DB는 외부 Supabase `pack_files` (DDL은 `packs-lab/supabase/pack_files.sql`)
|
||||
- 파일 구조: `main.py`, `auth.py`, `dsm_client.py`, `routes.py`, `models.py`
|
||||
- API 표 5개:
|
||||
- `POST /api/packs/sign-link` (Vercel HMAC → DSM Sharing.create)
|
||||
- `POST /api/packs/admin/mint-token` (Vercel HMAC → upload 토큰)
|
||||
- `POST /api/packs/upload` (Bearer token → multipart 5GB)
|
||||
- `GET /api/packs/list` (Vercel HMAC → 활성 파일 목록)
|
||||
- `DELETE /api/packs/{file_id}` (Vercel HMAC → soft delete)
|
||||
|
||||
### 7.2 `workspace/CLAUDE.md`
|
||||
|
||||
컨테이너 표에 한 줄 추가:
|
||||
|
||||
```
|
||||
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (Vercel SaaS와 HMAC 통신) |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 스코프
|
||||
|
||||
### 본 spec 범위
|
||||
|
||||
- ✅ admin mint-token 라우트 신설
|
||||
- ✅ Supabase `pack_files` DDL
|
||||
- ✅ docker-compose / nginx / .env.example / NAS 디렉토리 마운트
|
||||
- ✅ tests (auth 유지 + routes 통합 + dsm_client mock)
|
||||
- ✅ CLAUDE.md 2곳 갱신
|
||||
- ✅ DELETE 라우트 docstring 수정
|
||||
|
||||
### 후속 별도 spec
|
||||
|
||||
- ❌ Vercel SaaS-side admin UI / 사용자 다운로드 UI / Supabase pricing & user 테이블
|
||||
- ❌ DSM 공유 추적 (즉시 차단 필요시)
|
||||
- ❌ deleted_at + N일 후 실제 파일 삭제 cron
|
||||
- ❌ multi-admin 토큰 발급 권한 분리
|
||||
- ❌ resumable multipart 업로드 (5GB tus 등)
|
||||
- ❌ pack_files sort_order 편집 endpoint (admin UI 단계)
|
||||
- ❌ monitoring (업로드 실패율, DSM API latency)
|
||||
358
docs/superpowers/specs/2026-05-15-insta-agent-design.md
Normal file
358
docs/superpowers/specs/2026-05-15-insta-agent-design.md
Normal file
@@ -0,0 +1,358 @@
|
||||
# insta-agent 설계 — blog-lab 폐기, 인스타 카드 피드 파이프라인 신설
|
||||
|
||||
작성일: 2026-05-15
|
||||
상태: 사용자 승인 대기 → writing-plans 진입 예정
|
||||
|
||||
---
|
||||
|
||||
## 1. 목적·배경
|
||||
|
||||
기존 `blog-lab` 서비스(네이버 블로그 마케팅 수익화)를 폐기하고, 인스타그램 프로페셔널 계정에 올릴 카드 형식 피드(1080×1350, 10페이지)를 자동 생산하는 `insta-lab` 서비스로 대체한다.
|
||||
|
||||
핵심 가치 제안:
|
||||
- 매일 경제·심리학·연예 등 카테고리에서 화제 키워드를 자동 발견
|
||||
- 사용자가 키워드 1개를 선택하면 10페이지 카드 카피 + PNG 자동 생성
|
||||
- 텔레그램으로 카드 묶음 미디어 그룹 + 추천 캡션·해시태그 푸시
|
||||
- 사용자는 카드 다운로드 → 인스타 수동 업로드 (Graph API 미사용)
|
||||
|
||||
블로그 발행 자동화의 운영 부담(네이버 SEO, 브랜드커넥트 링크 관리, 커미션 추적)을 제거하고 카드 콘텐츠 생산에 집중한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 스코프
|
||||
|
||||
### 포함
|
||||
|
||||
- 신규 컨테이너 `insta-lab` (포트 18700 재활용)
|
||||
- 신규 에이전트 `insta-agent` (`agent-office/app/agents/insta.py`)
|
||||
- 뉴스 수집 → 키워드 추출 → 카드 카피 생성 → 카드 PNG 렌더 → 텔레그램 푸시 파이프라인
|
||||
- HTML/CSS 카드 템플릿 골격 (사용자가 디자인 직접 수정)
|
||||
- 카드 슬레이트·기사·키워드·자산 5테이블 (`insta.db`)
|
||||
- nginx 라우팅 변경 (`/api/blog-marketing/` 제거 → `/api/insta/`)
|
||||
- CLAUDE.md (workspace + web-backend) 갱신
|
||||
|
||||
### 제외
|
||||
|
||||
- 인스타그램 Graph API 자동 발행 (수동 업로드 사용)
|
||||
- 카드 디자인 비주얼 완성 (사용자가 직접 작업)
|
||||
- blog_marketing.db 데이터 마이그레이션 (clean slate)
|
||||
- 다국어 번역, A/B 테스트, 성과 추적
|
||||
|
||||
---
|
||||
|
||||
## 3. 서비스 구성·폐기 범위
|
||||
|
||||
### 폐기
|
||||
|
||||
| 대상 | 처리 |
|
||||
|------|------|
|
||||
| `blog-lab/` 디렉토리 | git rm 통째로 삭제 |
|
||||
| `blog_marketing.db` | 운영·로컬 모두 삭제 (clean slate) |
|
||||
| `agent-office/app/agents/blog.py` | 삭제 |
|
||||
| `service_proxy.py`의 blog_* 함수 | 삭제 |
|
||||
| `agent-office`의 blog 라우팅·텔레그램 명령 | 삭제 |
|
||||
| docker-compose의 `blog-lab` 서비스 정의 | 교체 |
|
||||
| nginx의 `/api/blog-marketing/` location | 교체 |
|
||||
| 환경변수 `BLOG_DATA_PATH` | 제거 |
|
||||
|
||||
### 신규
|
||||
|
||||
| 대상 | 비고 |
|
||||
|------|------|
|
||||
| `insta-lab/` 디렉토리 | 신규 생성 |
|
||||
| `insta-lab` 컨테이너 (포트 18700) | blog-lab 자리 재활용 |
|
||||
| `agents/insta.py` | 신규 에이전트 |
|
||||
| nginx `/api/insta/` → `insta-lab:8000` | 신규 |
|
||||
| 환경변수 `INSTA_DATA_PATH`, `CARD_TEMPLATE_DIR` | 신규 |
|
||||
|
||||
### 재사용 자산 (코드 패턴 차용)
|
||||
|
||||
- `naver_search.py` — 엔드포인트만 `news.json`으로 교체
|
||||
- `generation_tasks` 테이블 + BackgroundTask 폴링 패턴
|
||||
- `prompt_templates` 테이블 + DB 저장 프롬프트 패턴
|
||||
- agent-office의 텔레그램 인라인 키보드·승인 패턴 (`realestate_message.py` 참고)
|
||||
|
||||
---
|
||||
|
||||
## 4. 데이터 흐름
|
||||
|
||||
### 일일 사이클
|
||||
|
||||
```
|
||||
[09:30 매일 cron — agent-office 스케줄러]
|
||||
1. 뉴스 수집 ─ 카테고리별 시드 키워드로 NAVER news.json 검색
|
||||
─ 카테고리당 상위 30건 메타 + 본문 일부 → news_articles
|
||||
2. 키워드 추출 ─ 카테고리당 빈도 상위 + Claude Haiku 정제
|
||||
─ trending_keywords (score 내림차순)
|
||||
3. 텔레그램 푸시 ─ 카테고리별 후보 5개씩 인라인 키보드
|
||||
─ 사용자 선택 대기
|
||||
|
||||
[사용자가 텔레그램 인라인 버튼 선택]
|
||||
4. 카피 생성 ─ Claude로 10페이지 카피 (1=훅/커버, 2~9=본문 8장, 10=요약/CTA)
|
||||
─ card_slates 저장 (status='draft')
|
||||
5. 카드 렌더 ─ Jinja → HTML 1080×1350 → Playwright headless 스크린샷 10장
|
||||
─ /app/data/insta_cards/{slate_id}/01.png ~ 10.png
|
||||
6. 텔레그램 ─ 미디어 그룹 10장 + 추천 캡션·해시태그
|
||||
─ 사용자 다운로드 후 인스타 수동 업로드
|
||||
```
|
||||
|
||||
### 자동 모드 (옵션)
|
||||
|
||||
- agent-office의 `agent_config.custom_config.auto_select`(bool) 플래그로 제어
|
||||
- `auto_select=true` 설정 시 키워드 추출 직후 카테고리당 score 1위 키워드를 자동 선택해 4~6 단계까지 즉시 진행
|
||||
- 사용자가 텔레그램에서 결과만 확인 (인라인 후보 푸시 단계 skip)
|
||||
|
||||
---
|
||||
|
||||
## 5. 컴포넌트
|
||||
|
||||
### insta-lab (FastAPI 서비스)
|
||||
|
||||
```
|
||||
insta-lab/
|
||||
├── Dockerfile # python:3.12-slim + playwright install chromium --with-deps
|
||||
├── requirements.txt
|
||||
├── pytest.ini
|
||||
├── tests/
|
||||
└── app/
|
||||
├── main.py # FastAPI 라우터
|
||||
├── config.py # NAVER_*, ANTHROPIC_API_KEY, INSTA_DATA_PATH, CARD_TEMPLATE_DIR
|
||||
├── db.py # 6테이블 init + CRUD
|
||||
├── news_collector.py # 네이버 뉴스 API + 본문 정리
|
||||
├── keyword_extractor.py # 빈도 + LLM 정제
|
||||
├── card_writer.py # Claude 10페이지 카피 생성
|
||||
├── card_renderer.py # Jinja → Playwright 스크린샷
|
||||
└── templates/ # 사용자가 직접 수정 (rsync로 NAS 배포)
|
||||
└── default/
|
||||
└── card.html.j2
|
||||
```
|
||||
|
||||
### agent-office 변경
|
||||
|
||||
```
|
||||
agent-office/app/agents/insta.py (신규)
|
||||
- on_schedule: 09:30 → news collect → keyword extract → 텔레그램 후보 푸시
|
||||
- on_command: extract / render <keyword> / list_categories
|
||||
- on_callback: 텔레그램 inline button "render_<keyword_id>" → 카피·렌더·푸시
|
||||
|
||||
agent-office/app/service_proxy.py
|
||||
- blog_* 함수 모두 제거
|
||||
- insta_* 함수 신규 (collect, extract, list_keywords, create_slate, render_slate, get_slate, get_asset)
|
||||
|
||||
agent-office/app/telegram/agent_registry.py
|
||||
- blog 명령 등록 제거 → insta 명령 등록
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. DB 스키마 (insta.db)
|
||||
|
||||
| 테이블 | 핵심 컬럼 | 설명 |
|
||||
|--------|----------|------|
|
||||
| `news_articles` | id PK, category, title, link UNIQUE, summary, pub_date, fetched_at | 일일 수집 기사 메타 |
|
||||
| `trending_keywords` | id PK, keyword, category, score REAL, articles_count, suggested_at, used INTEGER | 카테고리별 화제 키워드 (used=1이면 이미 슬레이트 생성됨) |
|
||||
| `card_slates` | id PK, keyword, category, status (draft/rendered/sent/failed), cover_copy TEXT, body_copies TEXT(JSON 8개), cta_copy TEXT, suggested_caption TEXT, hashtags TEXT(JSON), created_at | 10페이지 카피 묶음 |
|
||||
| `card_assets` | id PK, slate_id FK→card_slates(id), page_index INTEGER 1~10, file_path, file_hash, created_at | 렌더된 PNG 자산 |
|
||||
| `generation_tasks` | id TEXT PK, type, status, progress, message, result_id INTEGER, error TEXT, params TEXT, created_at, updated_at | blog-lab 패턴 그대로 (collect/extract/write/render 통합) |
|
||||
| `prompt_templates` | id PK, name UNIQUE, description, template TEXT, updated_at | `slate_writer`, `keyword_extractor` 두 개 시드 |
|
||||
|
||||
**인덱스**:
|
||||
- `idx_na_category_fetched` ON news_articles(category, fetched_at DESC)
|
||||
- `idx_tk_score` ON trending_keywords(category, score DESC)
|
||||
- `idx_cs_created` ON card_slates(created_at DESC)
|
||||
- `idx_ca_slate` ON card_assets(slate_id, page_index)
|
||||
|
||||
---
|
||||
|
||||
## 7. 카드 렌더 (Playwright)
|
||||
|
||||
### 템플릿
|
||||
|
||||
`templates/default/card.html.j2` — Jinja 변수:
|
||||
|
||||
| 변수 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| `page_type` | str | "cover" / "body" / "cta" |
|
||||
| `headline` | str | 페이지 헤드라인 |
|
||||
| `body` | str | 본문 (markdown-lite 허용 — 줄바꿈 보존) |
|
||||
| `accent_color` | str | hex (예: "#FF5733") |
|
||||
| `page_no` | int | 1~10 |
|
||||
| `total_pages` | int | 10 |
|
||||
|
||||
컨테이너 CSS: `width: 1080px; height: 1350px; overflow: hidden;`
|
||||
|
||||
### 렌더 로직 (card_renderer.py)
|
||||
|
||||
1. Playwright async chromium browser 1회 launch
|
||||
2. browser.new_context(viewport={"width": 1080, "height": 1350}) → page
|
||||
3. 10번 반복:
|
||||
- Jinja 렌더 → temp HTML 파일 저장
|
||||
- page.goto(`file://...`)
|
||||
- page.screenshot(path=f"{page_no:02}.png", omit_background=False)
|
||||
4. browser.close
|
||||
|
||||
### Dockerfile
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.12-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
fonts-noto-cjk fonts-noto-cjk-extra \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN playwright install chromium --with-deps
|
||||
COPY app ./app
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
이미지 사이즈 +500MB 예상. NAS Celeron J4025에서 카드 10장 렌더 ≤ 30초 목표.
|
||||
|
||||
---
|
||||
|
||||
## 8. API (insta-lab)
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/insta/status` | 서비스 상태 (NAVER/ANTHROPIC 키 여부) |
|
||||
| POST | `/api/insta/news/collect` | 뉴스 수집 수동 트리거 → BackgroundTask |
|
||||
| GET | `/api/insta/news/articles` | 수집 기사 목록 (category, days 필터) |
|
||||
| POST | `/api/insta/keywords/extract` | 키워드 추출 수동 트리거 → BackgroundTask |
|
||||
| GET | `/api/insta/keywords` | 트렌딩 키워드 (category, used 필터) |
|
||||
| POST | `/api/insta/slates` | 슬레이트 생성 (keyword, category) → BackgroundTask |
|
||||
| GET | `/api/insta/slates` | 슬레이트 목록 |
|
||||
| GET | `/api/insta/slates/{id}` | 슬레이트 상세 (카피 + 자산 경로) |
|
||||
| POST | `/api/insta/slates/{id}/render` | 카드 렌더 재시도 |
|
||||
| GET | `/api/insta/slates/{id}/assets/{page}` | 카드 PNG 다운로드 (1~10) |
|
||||
| DELETE | `/api/insta/slates/{id}` | 삭제 (slate + assets) |
|
||||
| GET | `/api/insta/tasks/{task_id}` | BackgroundTask 상태 폴링 |
|
||||
| GET/PUT | `/api/insta/templates/prompts/{name}` | 프롬프트 템플릿 조회·수정 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 키워드 추출 알고리즘
|
||||
|
||||
```python
|
||||
def extract_keywords(category: str, articles: list[Article]) -> list[Keyword]:
|
||||
# 1. 빈도 기반 후보 추출
|
||||
# - 명사 추출 (간단: 한글 2~6자 정규식 + 불용어 제거)
|
||||
# - 카테고리 시드 키워드와 코사인 유사도 ≥ 0.3 이상만
|
||||
raw_freq = count_nouns(articles)
|
||||
candidates = top_n(raw_freq, n=20)
|
||||
|
||||
# 2. Claude Haiku로 정제
|
||||
# - 시스템 프롬프트: "{category} 인스타 카드용 키워드"
|
||||
# - 입력: 후보 20개 + 각 후보가 등장한 기사 제목 3개
|
||||
# - 출력 JSON: [{"keyword": str, "score": 0~1, "reason": str}]
|
||||
refined = claude_haiku_refine(category, candidates, articles)
|
||||
|
||||
# 3. score 내림차순 → 상위 5개 trending_keywords로 저장
|
||||
return refined[:5]
|
||||
```
|
||||
|
||||
- `score`는 LLM이 평가한 "카드 콘텐츠 적합도" (호기심 유발성 + 시의성 + 구체성)
|
||||
- 시드 키워드는 `prompt_templates.name='category_seeds'`에서 카테고리별 JSON으로 관리
|
||||
|
||||
---
|
||||
|
||||
## 10. 카드 카피 생성 (slate_writer)
|
||||
|
||||
Claude 호출 1회로 10페이지 카피 생성:
|
||||
|
||||
```
|
||||
시스템 프롬프트 (DB 저장, 사용자가 수정 가능):
|
||||
- 너는 인스타그램 카드 뉴스 카피라이터다.
|
||||
- {category} 카테고리, 키워드: {keyword}
|
||||
- 출력은 JSON 객체:
|
||||
{
|
||||
"cover_copy": {"headline": str, "body": str, "accent_color": "#hex"},
|
||||
"body_copies": [
|
||||
{"headline": str, "body": str},
|
||||
... (8개)
|
||||
],
|
||||
"cta_copy": {"headline": str, "body": str, "cta": str},
|
||||
"suggested_caption": str,
|
||||
"hashtags": ["#tag1", ...]
|
||||
}
|
||||
|
||||
입력:
|
||||
- 키워드 + 관련 기사 제목·요약 5건
|
||||
```
|
||||
|
||||
`accent_color`는 카테고리별 기본값(경제=#0F62FE, 심리학=#A66CFF, 연예=#FF5C8A) 사용, LLM이 더 어울리면 override.
|
||||
|
||||
---
|
||||
|
||||
## 11. 에러 처리
|
||||
|
||||
| 단계 | 실패 시 |
|
||||
|------|---------|
|
||||
| 뉴스 수집 | 카테고리별 try/except, 한 카테고리 빈 결과여도 다른 카테고리 진행. 모두 실패 시 텔레그램 알림 |
|
||||
| 키워드 추출 | LLM 실패 시 빈도 기반 결과만 사용 (degrade). LLM 타임아웃 60s |
|
||||
| 카피 생성 | LLM 실패 시 BackgroundTask `failed`, 텔레그램 알림. JSON 파싱 실패 시 1회 retry |
|
||||
| 카드 렌더 | Playwright 크래시 시 retry 1회. 실패 시 slate.status='failed' + 텔레그램 알림. 일부 페이지만 실패 시 해당 페이지만 재렌더 가능 |
|
||||
| 텔레그램 미디어 그룹 | 텔레그램 API 10MB/장 제한 → PNG quality 90, 평균 < 500KB 예상. 초과 시 압축 후 재시도 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 테스트
|
||||
|
||||
- pytest 단위 테스트:
|
||||
- `news_collector` mocked HTTP, JSON 파싱 검증
|
||||
- `keyword_extractor` 빈도 추출 단위 + Claude mock
|
||||
- `card_writer` Claude mock, JSON 스키마 검증
|
||||
- `card_renderer` 작은 fixture HTML로 PNG 1장 생성 (실제 Playwright 통합 테스트 1건)
|
||||
- agent-office 통합: `agents/insta.py` mocked service_proxy로 on_schedule·on_command·on_callback 분기 검증
|
||||
|
||||
---
|
||||
|
||||
## 13. 운영·환경
|
||||
|
||||
### 환경변수 (insta-lab)
|
||||
|
||||
| 변수 | 기본값 | 설명 |
|
||||
|------|--------|------|
|
||||
| `NAVER_CLIENT_ID` | (필수) | 네이버 검색 API 키 |
|
||||
| `NAVER_CLIENT_SECRET` | (필수) | 네이버 검색 API 시크릿 |
|
||||
| `ANTHROPIC_API_KEY` | (필수) | Claude API 키 |
|
||||
| `INSTA_DATA_PATH` | `./data/insta` | DB + 카드 PNG 저장 경로 |
|
||||
| `CARD_TEMPLATE_DIR` | `/app/app/templates` | HTML/CSS 템플릿 디렉토리 |
|
||||
| `CORS_ALLOW_ORIGINS` | `*` | CORS 설정 |
|
||||
|
||||
### docker-compose.yml 변경
|
||||
|
||||
- `blog-lab` 서비스 블록 → `insta-lab` 서비스 블록 (포트 18700:8000 그대로)
|
||||
- 볼륨: `./data/insta:/app/data/insta`
|
||||
|
||||
### nginx default.conf 변경
|
||||
|
||||
```
|
||||
location /api/blog-marketing/ { # 제거
|
||||
...
|
||||
}
|
||||
|
||||
location /api/insta/ { # 신규
|
||||
proxy_pass http://insta-lab:8000;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### CLAUDE.md 갱신
|
||||
|
||||
- workspace/CLAUDE.md: blog-lab 표 행 제거 → insta-lab 추가, `/api/blog-marketing/` 행 제거 → `/api/insta/` 추가, 컨테이너 이름·역할 업데이트
|
||||
- web-backend/CLAUDE.md: 9.x 섹션 blog-lab 통째로 → insta-lab 섹션, 4·5 표 갱신
|
||||
|
||||
---
|
||||
|
||||
## 14. 완료 정의
|
||||
|
||||
- [ ] blog-lab 디렉토리·DB 삭제, 컨테이너에서 더 이상 빌드 안 됨
|
||||
- [ ] insta-lab 컨테이너 빌드 및 헬스체크 통과
|
||||
- [ ] `POST /api/insta/news/collect` → news_articles에 카테고리당 30건 저장 확인
|
||||
- [ ] `POST /api/insta/keywords/extract` → trending_keywords 카테고리당 5개 저장
|
||||
- [ ] `POST /api/insta/slates` → 카피 생성 + 카드 PNG 10장 렌더 (수동 호출)
|
||||
- [ ] agent-office의 insta-agent 09:30 cron 등록, 텔레그램 인라인 키보드 후보 푸시 작동
|
||||
- [ ] 텔레그램 인라인 버튼 클릭 → 미디어 그룹 10장 발송 성공
|
||||
- [ ] CLAUDE.md 양쪽 갱신 후 커밋
|
||||
- [ ] pytest 전체 통과
|
||||
251
docs/superpowers/specs/2026-05-16-insta-trends-design.md
Normal file
251
docs/superpowers/specs/2026-05-16-insta-trends-design.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# insta-lab Trends 탭 설계 — 외부 트렌드 수집 + 카테고리 가중치
|
||||
|
||||
작성일: 2026-05-16
|
||||
상태: 사용자 승인 대기 → writing-plans 진입 예정
|
||||
연관 문서: `2026-05-15-insta-agent-design.md` (insta-lab 기본 설계)
|
||||
|
||||
## ⚠️ 변경 이력
|
||||
|
||||
- **2026-05-17**: 본문에 `google_trends` source로 기재된 모든 항목은 **실제 구현에서 `youtube_trending`으로 교체됨**. Google Trends 비공식 endpoint 두 가지(`trendingsearches/daily/rss?geo=KR`, `/trends/api/dailytrends?...`)가 모두 404로 폐기되어 운영 호출이 빈 결과로 끝나는 문제 확인 → YouTube Data API v3 `videos.list?chart=mostPopular®ionCode=KR`로 source 대체. 이후 spec 본문을 읽을 때는 `google_trends` → `youtube_trending`, "Google Trends" → "YouTube 인기"로 치환 해석. 사유와 source 교체 시 동시 갱신 체크리스트: `feedback_external_data_sources.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. 목적·배경
|
||||
|
||||
insta-lab 운영 첫 사이클(2026-05-16 머지·배포 완료)에서 다음 두 가지 한계가 드러남:
|
||||
|
||||
1. **키워드 발견 소스가 사용자 시드 키워드에만 의존** — 진짜 "지금 뜨고 있는" 화제를 잡지 못함. 카테고리당 5개 시드를 고정해두고 거기에 매칭되는 기사만 모음.
|
||||
2. **계정 정체성을 시스템이 모름** — 사용자가 "내 인스타 계정은 경제 위주"라고 정해도 시스템은 모든 카테고리를 균등하게 처리.
|
||||
|
||||
이 spec은 두 한계를 해소하기 위해:
|
||||
- 외부 트렌드 소스(NAVER 인기 + Google Trends)를 추가해 "발견" 단계를 보강
|
||||
- 계정 카테고리 가중치 모델을 도입해 자동 추출 알고리즘이 계정 정체성을 반영
|
||||
|
||||
---
|
||||
|
||||
## 2. 스코프
|
||||
|
||||
### 포함
|
||||
|
||||
- 신규 백엔드 모듈 `trend_collector.py` (NAVER 인기 + Google Trends 두 source)
|
||||
- 신규 백엔드 모듈 변경: `keyword_extractor.py`에 가중치 기반 `extract_with_weights()` 추가
|
||||
- DB 마이그레이션: `trending_keywords` 테이블에 `source` 컬럼 추가, `account_preferences` 신규 테이블
|
||||
- 신규 API 4개 (`POST /trends/collect`, `GET /trends`, `GET/PUT /preferences`)
|
||||
- 09:00 매일 cron 추가 (트렌드 수집), 09:30 cron 가중치 적용
|
||||
- 프론트엔드: InstaCards 페이지에 탭 네비게이션 추가, Trends 탭 신규 3개 패널
|
||||
|
||||
### 제외
|
||||
|
||||
- pytrends 외 외부 SaaS 트렌드 API (BuzzSumo 등)
|
||||
- 트렌드 시계열 차트
|
||||
- 카테고리 자동 학습 (사용자 카드 생성 이력에서 선호도 추론)
|
||||
- 트렌드 알림 (특정 키워드 등장 시 push)
|
||||
|
||||
---
|
||||
|
||||
## 3. 데이터 소스
|
||||
|
||||
### 3-1. NAVER 인기 (source = 'naver_popular')
|
||||
- NAVER news.json API 재사용. 카테고리당 시드 키워드로 `sort=sim` (정확도 정렬 = 인기 시그널) 30건 수집
|
||||
- 응답 기사 묶음에서 빈도어 추출 → 카테고리 매핑 (기존 keyword_extractor의 `_count_nouns` + `_top_candidates` 재사용)
|
||||
- 상위 N개를 `trending_keywords` 테이블에 source='naver_popular'로 저장
|
||||
|
||||
### 3-2. Google Trends (source = 'google_trends')
|
||||
- 라이브러리: `pytrends` (PyPI, MIT)
|
||||
- `TrendReq(hl='ko-KR', tz=540).trending_searches(pn='south_korea')` 호출 → 일일 트렌딩 키워드 리스트
|
||||
- 각 키워드에 대해 Claude Haiku 1회 호출로 카테고리 분류 (`economy` / `psychology` / `celebrity` / 사용자 추가 카테고리 / `uncategorized`)
|
||||
- LLM 분류 비용 절감을 위해 분류 결과를 1일 캐시 — `trend_collector` 모듈 레벨 `_category_cache: dict[str, tuple[str, float]]` (keyword → (category, expires_ts)), 컨테이너 lifetime 동안 유효. 같은 키워드 재요청 시 cache hit. 캐시는 영속화하지 않음 (재시작 시 첫 호출은 LLM 재분류)
|
||||
- `trending_keywords` 테이블에 source='google_trends', score=traffic 정규화값
|
||||
|
||||
### 3-3. 통합 저장
|
||||
|
||||
기존 `trending_keywords` 스키마에 한 컬럼 추가:
|
||||
|
||||
```sql
|
||||
ALTER TABLE trending_keywords ADD COLUMN source TEXT NOT NULL DEFAULT 'manual';
|
||||
-- 기존 row 모두 'manual'로 마킹됨 (시드 키워드에서 추출된 것)
|
||||
-- 신규 source: 'naver_popular' | 'google_trends'
|
||||
```
|
||||
|
||||
`source`별 추가 인덱스:
|
||||
```sql
|
||||
CREATE INDEX idx_tk_source ON trending_keywords(source, suggested_at DESC);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 카테고리 가중치 모델
|
||||
|
||||
### 4-1. 신규 테이블 `account_preferences`
|
||||
|
||||
```sql
|
||||
CREATE TABLE account_preferences (
|
||||
category TEXT PRIMARY KEY,
|
||||
weight REAL NOT NULL DEFAULT 1.0,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
```
|
||||
|
||||
- 초기 시드: `economy=1.0`, `psychology=1.0`, `celebrity=1.0` (균등)
|
||||
- 사용자는 0~10 자유 범위 (UI는 0~100 정수%로 노출, 백엔드에서 0~1 정규화)
|
||||
- 합계 강제 없음. 알고리즘 내부에서 비율 정규화
|
||||
- 카테고리 추가 자유. 단 추가 시 `prompt_templates.category_seeds`에도 시드 키워드 함께 정의해야 자동 추출에 반영됨 (UI에서 안내)
|
||||
|
||||
### 4-2. 가중치 기반 추출 알고리즘
|
||||
|
||||
기존 `keyword_extractor.extract_for_category(category, limit)` 유지. 신규:
|
||||
|
||||
```python
|
||||
def extract_with_weights(weights: dict[str, float], total_limit: int) -> list[Keyword]:
|
||||
"""카테고리 가중치 비율대로 키워드를 분배 추출."""
|
||||
if not weights or sum(weights.values()) == 0:
|
||||
# fallback: 균등 가중치
|
||||
cats = list(DEFAULT_CATEGORY_SEEDS.keys())
|
||||
weights = {c: 1.0 for c in cats}
|
||||
|
||||
total_weight = sum(weights.values())
|
||||
saved = []
|
||||
for category, w in weights.items():
|
||||
if w <= 0:
|
||||
continue
|
||||
per_cat = round(total_limit * w / total_weight)
|
||||
if per_cat <= 0:
|
||||
continue
|
||||
saved.extend(extract_for_category(category, limit=per_cat))
|
||||
return saved
|
||||
```
|
||||
|
||||
- `total_limit` 기본 15 (3 카테고리 × 5 시드 시절 합계와 동일)
|
||||
- weight=0 카테고리는 skip (분류는 유지하되 자동 추출에서 제외하고 싶을 때)
|
||||
|
||||
---
|
||||
|
||||
## 5. API (insta-lab)
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| POST | `/api/insta/trends/collect` | 두 source 모두 수집 (BackgroundTask) → `{task_id}` |
|
||||
| GET | `/api/insta/trends` | 트렌드 조회. query: `source` (`naver_popular`/`google_trends`/`all`), `category`, `days` (default 1, 의미: `suggested_at >= now() - days*24h`). 정렬 `suggested_at DESC, score DESC` |
|
||||
| GET | `/api/insta/preferences` | 가중치 조회 → `{categories: [{category, weight, updated_at}]}` |
|
||||
| PUT | `/api/insta/preferences` | body `{categories: {economy: 0.6, ...}}` → upsert |
|
||||
|
||||
기존 `/api/insta/keywords`는 source 필터 추가 (`?source=manual` 등). 미지정 시 모든 source 반환 (default behavior 유지).
|
||||
|
||||
---
|
||||
|
||||
## 6. 스케줄러 변경 (agent-office InstaAgent)
|
||||
|
||||
기존:
|
||||
- 09:30 — 키워드 추출 → 텔레그램 푸시
|
||||
|
||||
신규:
|
||||
- **09:00 — 외부 트렌드 수집** (NAVER 인기 + Google Trends) — `_run_insta_trends_collect()` 신규 cron
|
||||
- **09:30 — 키워드 추출** (기존 + 가중치 적용) — InstaAgent가 `get_preferences()` 호출 후 `extract_with_weights()` 사용
|
||||
|
||||
수동 트리거: InstaAgent에 `on_command("collect_trends", {})` 신규 액션. 텔레그램에서 `/insta collect_trends` 슬래시 명령 또는 Insta 페이지 버튼에서 호출.
|
||||
|
||||
---
|
||||
|
||||
## 7. 프론트엔드 변경 (web-ui InstaCards.jsx)
|
||||
|
||||
### 7-1. 탭 네비게이션
|
||||
|
||||
기존 5개 패널을 두 탭으로 재구성:
|
||||
|
||||
| 탭 | 패널 |
|
||||
|----|------|
|
||||
| **Cards** (기본) | Trigger, Trending Keywords, Slates, SlateDetail, PromptEditor (기존 그대로) |
|
||||
| **Trends** (신규) | AccountFocusPanel, ExternalTrendsPanel, PreferenceImpactPanel |
|
||||
|
||||
탭 컴포넌트: `<TabBar>` 단순 buttons (`activeTab` state), URL에 `?tab=trends` 쿼리로 deep-link 지원.
|
||||
|
||||
### 7-2. AccountFocusPanel
|
||||
- 카테고리별 가중치 슬라이더 (0~100 정수%) + 우측 막대 차트 (분포 시각화)
|
||||
- **+ 카테고리 추가** 버튼 → 모달로 카테고리명 + 시드 키워드 N개 입력 (시드는 category_seeds 프롬프트 템플릿에 머지)
|
||||
- **저장** 버튼 → `PUT /preferences` (debounce 1초)
|
||||
|
||||
### 7-3. ExternalTrendsPanel
|
||||
- 상단: **🔄 수동 수집** 버튼 + "마지막 수집: HH:MM" 라벨 + 진행 task box
|
||||
- 두 컬럼 (반응형 → 모바일은 세로):
|
||||
- **🔥 NAVER 인기** — 카테고리별 그룹핑, 각 카드: keyword + score + 카테고리 배지
|
||||
- **🌐 Google Trends** — 단순 리스트, 각 카드: keyword + 카테고리 배지 + traffic
|
||||
- 각 카드 우측에 **🎴** 버튼 → 즉시 `POST /slates` (기존 흐름)
|
||||
- 색상 매핑: economy=#0F62FE, psychology=#A66CFF, celebrity=#FF5C8A, custom=#6B7280
|
||||
|
||||
### 7-4. PreferenceImpactPanel (작은 박스)
|
||||
- "현재 가중치 기준 다음 자동 추출 결과 미리보기: economy 3 / psychology 2 / celebrity 0"
|
||||
- 가중치 슬라이더 변경 시 즉시 클라이언트에서 계산해 갱신
|
||||
- 컴팩트 1줄 표시
|
||||
|
||||
### 7-5. 신규 API 헬퍼 (src/api.js)
|
||||
|
||||
```js
|
||||
export function getInstaTrends({ source, category, days = 1 } = {}) { ... }
|
||||
export function instaCollectTrends() { ... }
|
||||
export function getInstaPreferences() { ... }
|
||||
export function putInstaPreferences(categories) { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 에러 처리
|
||||
|
||||
| 상황 | 처리 |
|
||||
|------|------|
|
||||
| pytrends rate limit / 차단 | try/except → 빈 결과로 graceful degrade. NAVER 인기는 정상 수집 |
|
||||
| LLM 분류 실패 | `uncategorized` 카테고리로 폴백, 사용자가 UI에서 수동 재분류 가능 |
|
||||
| 가중치 합계 0 | 균등 가중치 (1/N)로 폴백, 로그 warning |
|
||||
| 카테고리 추가했는데 시드 없음 | 자동 추출에서 자연스럽게 skip (NAVER 검색에 시드 필요), UI에서 "시드 키워드 추가 필요" 경고 |
|
||||
| Google Trends 한국 region 부재 | hl='ko-KR' + pn='south_korea' 명시. 실패 시 빈 결과 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 테스트
|
||||
|
||||
### insta-lab pytest
|
||||
- `test_trend_collector.py` (4): `fetch_naver_popular` mocked, `fetch_google_trends` pytrends mocked, 카테고리 매핑, 캐시 hit
|
||||
- `test_extract_with_weights.py` (3): 균등 가중치, 한쪽 0 가중치, fallback 빈 가중치
|
||||
- `test_preferences_crud.py` (2): GET 기본값, PUT upsert
|
||||
- `test_main_trends.py` (3): 신규 4개 엔드포인트 통합
|
||||
|
||||
### agent-office pytest
|
||||
- `test_insta_agent_trends.py` (2): `on_schedule_trends` mocked, weight-applied extract
|
||||
|
||||
---
|
||||
|
||||
## 10. 마이그레이션 절차
|
||||
|
||||
1. `db.init_db()`에 `ALTER TABLE trending_keywords ADD COLUMN source ...` 추가 — `PRAGMA table_info`로 컬럼 존재 여부 확인 후 idempotent하게 실행
|
||||
2. `account_preferences` 테이블 신규 생성
|
||||
3. 초기 시드: 기존 카테고리 economy/psychology/celebrity 모두 weight=1.0
|
||||
4. 기존 `trending_keywords` row는 자동으로 source='manual' (컬럼 DEFAULT)
|
||||
5. `requirements.txt`에 `pytrends>=4.9` 추가
|
||||
6. 배포 후 사용자가 Trends 탭에서 가중치 조정 (필수 아님, 균등이 디폴트 동작)
|
||||
|
||||
---
|
||||
|
||||
## 11. 운영 영향
|
||||
|
||||
| 항목 | 영향 |
|
||||
|------|------|
|
||||
| Anthropic 토큰 비용 | +미미 (Google Trends 1회당 ~20 키워드 × Haiku 분류 1콜 ≈ 600 토큰/일) |
|
||||
| DB 크기 | +미미 (트렌드 row 일일 ~50개, 카테고리당 30 + Google 20) |
|
||||
| NAS CPU | +낮음 (pytrends + NAVER API 호출만, LLM은 외부) |
|
||||
| 카드 생성 흐름 | 변경 없음. 트렌드는 "발견" 단계만 보강 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 완료 정의
|
||||
|
||||
- [ ] `trending_keywords.source` 컬럼 마이그레이션 적용, 기존 row 모두 'manual'로 표시됨
|
||||
- [ ] `account_preferences` 테이블 생성, 초기 3개 카테고리 weight=1.0
|
||||
- [ ] `POST /api/insta/trends/collect` 호출 시 NAVER 인기 + Google Trends 모두 수집되어 DB 저장
|
||||
- [ ] `GET /api/insta/trends?source=google_trends` 결과 카테고리 분류됨
|
||||
- [ ] `PUT /api/insta/preferences` 후 09:30 cron이 가중치 비율대로 추출
|
||||
- [ ] 09:00 cron 등록, 매일 자동 트렌드 수집
|
||||
- [ ] Insta 페이지에 Cards/Trends 탭 전환 작동
|
||||
- [ ] Trends 탭의 AccountFocusPanel에서 가중치 변경·저장 가능
|
||||
- [ ] ExternalTrendsPanel에서 NAVER 인기 + Google Trends 한 눈에 표시, 각 카드 생성 트리거 작동
|
||||
- [ ] PreferenceImpactPanel 미리보기 갱신
|
||||
- [ ] insta-lab pytest 전체 통과 (기존 21 + 신규 12 = 33)
|
||||
- [ ] agent-office pytest 전체 통과
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user