Compare commits
405 Commits
134e628e5e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c7036212e2 | |||
| 756d9fccf3 | |||
| ea5cf49cea | |||
| d07a8dad76 | |||
| d74bc189b5 | |||
| d4405204f9 | |||
| 2c157334dc | |||
| d840859fc9 | |||
| e115eee159 | |||
| fc1ebf134d | |||
| d71937b6ee | |||
| 0cc4505af7 | |||
| 9c18f0a467 | |||
| 8212a51f90 | |||
| 0d466b235c | |||
| 1129600341 | |||
| 2a0a2f3490 | |||
| 56d0f5b8a8 | |||
| 796ac6d39f | |||
| 18cea427be | |||
| 6c178006d3 | |||
| 084e4f1b4d | |||
| d048251a97 | |||
| ef1a7a92fd | |||
| 44dbe7c426 | |||
| e90e25d78f | |||
| d638666659 | |||
| 51eff1538e | |||
| ffb96de61d | |||
| c8ce6cb617 | |||
| 3c11b75a5f | |||
| 2c2828c8f0 | |||
| c62e3e70b9 | |||
| e1b1944f43 | |||
| 149e7c40fe | |||
| 28d489770a | |||
| 9d50aa4256 | |||
| bc0f583a0f | |||
| 7c5ca15b64 | |||
| 9fc764a78c | |||
| 83398c8413 | |||
| 7d1857c8a4 | |||
| c3a6e78954 | |||
| 5d0e80fb49 | |||
| af2fb57760 | |||
| 4d02d9c321 | |||
| c99017e68c | |||
| ce6c8d8f7d | |||
| 0d1b04d322 | |||
| 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 |
12
.env.example
12
.env.example
@@ -51,9 +51,14 @@ PGID=1000
|
|||||||
# Windows AI Server (NAS 입장에서 바라본 Windows PC IP)
|
# Windows AI Server (NAS 입장에서 바라본 Windows PC IP)
|
||||||
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
|
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=
|
||||||
|
|
||||||
|
# 개발 모드: 위 ADMIN_API_KEY 비워둔 채로 trade/admin 엔드포인트 호출 허용.
|
||||||
|
# 운영 환경에서는 절대 true로 두지 말 것. 기본 false (보호 활성).
|
||||||
|
ALLOW_UNAUTHENTICATED_ADMIN=false
|
||||||
|
|
||||||
# Anthropic API Key (AI Coach 프록시 + 뉴스 요약 Claude provider)
|
# Anthropic API Key (AI Coach 프록시 + 뉴스 요약 Claude provider)
|
||||||
ANTHROPIC_API_KEY=
|
ANTHROPIC_API_KEY=
|
||||||
ANTHROPIC_MODEL=claude-haiku-4-5-20251001
|
ANTHROPIC_MODEL=claude-haiku-4-5-20251001
|
||||||
@@ -119,5 +124,6 @@ PACK_DATA_PATH=./data/packs
|
|||||||
PACK_BASE_DIR=/app/data/packs
|
PACK_BASE_DIR=/app/data/packs
|
||||||
|
|
||||||
# DSM·Supabase에 노출되는 NAS 호스트 절대경로 (PACK_DATA_PATH와 같은 디렉토리를 호스트 시점에서 가리킴).
|
# DSM·Supabase에 노출되는 NAS 호스트 절대경로 (PACK_DATA_PATH와 같은 디렉토리를 호스트 시점에서 가리킴).
|
||||||
# 운영 NAS는 반드시 /volume1/docker/webpage/media/packs 같은 절대경로 설정. 미설정 시 PACK_DATA_PATH로 fallback (로컬 개발용).
|
# 운영 NAS는 반드시 /volume1/docker/webpage/media/packs 같은 절대경로 설정.
|
||||||
PACK_HOST_DIR=/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
|
# Git worktrees
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
|
||||||
|
################################
|
||||||
|
# Local working files
|
||||||
|
################################
|
||||||
|
# Superpowers 스킬 캐시·세션 메타
|
||||||
|
.superpowers/
|
||||||
|
# 임시 코드 리뷰 노트 (작업 끝나면 폐기 또는 docs/로 이동)
|
||||||
|
CODE_REVIEW.md
|
||||||
|
|||||||
9
.mcp.json
Normal file
9
.mcp.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"co-gahusb": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://gahusb.synology.me/api/co/mcp",
|
||||||
|
"headers": { "Authorization": "Bearer ${CO_BUS_KEY}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
121
CHECK_POINT.md
Normal file
121
CHECK_POINT.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# web-backend CHECK_POINT
|
||||||
|
|
||||||
|
> NAS Docker (Synology Celeron J4025 2C 2.0GHz, 18GB). 16+ 컨테이너(14 서비스 + Redis + frontend + deployer).
|
||||||
|
> 2026-06-12 갱신 — 5/18 CPU 진단·NAS↔Windows 분산부터 6/12 음악 파이프라인 신뢰성까지 반영.
|
||||||
|
> 운영 세부(DB·스케줄러·env·함정)는 `memory/service_<name>.md`가 authoritative. 이 파일은 **무엇이 끝났고 다음에 뭘 하나**의 보드.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 완료 타임라인 (5/18 → 6/12)
|
||||||
|
|
||||||
|
### 5/18~22 — CPU 진단 + NAS↔Windows 분산 + 로또 자율화
|
||||||
|
- **CPU 폭주 즉시 5건**: 09:00 cron 5분 스태거링(insta/lotto/youtube/realestate) · lotto Monte Carlo 08:30 이동 · insta Playwright Semaphore(1) · healthcheck 60s · uvicorn `--workers 1` · realestate 수집 병렬화
|
||||||
|
- **Redis 분산** (박재오 7결정): Redis 컨테이너 신설(7-alpine 256MB AOF) · insta/music/video-lab을 `queue:*-render` push 게이트웨이로 전환(렌더는 Windows web-ai 워커) · internal webhook + nginx 3-layer 차단 · stock webai_cache TTLCache
|
||||||
|
- **video-lab 신설** (18801) — Windows video-render의 NAS 짝 (sora/veo/kling/seedance)
|
||||||
|
- **로또 능동 시그널 v1** — lotto_signals/baselines, z-score, urgent/digest 텔레그램, cron 4종
|
||||||
|
- **weight-evolver 자율 학습 v2** — weight_trials/auto_picks, 주간 generate→apply→evaluate 루프
|
||||||
|
|
||||||
|
### 5/25~26 — tarot/saju 분리·신설 + UI
|
||||||
|
- **tarot-lab 분리** (18250) — agent-office에서 독립, Claude 3-card
|
||||||
|
- **saju-lab 신설** (18300) — saju-web TS→Python 포팅, lunar↔solar 내장, 궁합 포함
|
||||||
|
- **saju UI v1 + v2 리디자인** + fortune_scores/lucky/monthly_flow 추가
|
||||||
|
- image-lab public gateway + `/media/image/` 정적 서빙 · tarot max_tokens 2800 truncation fix
|
||||||
|
|
||||||
|
### 5/28 — 공유 로그 인프라
|
||||||
|
- **`_shared/access_log` 공용 모듈** (lotto/stock/music/insta/realestate 5종) — ring buffer + middleware + `/logs/recent`
|
||||||
|
- agent-office `/agents/{id}/logs`가 서비스 로그 merge · 매일 03:00 agent_logs 90일 retention
|
||||||
|
|
||||||
|
### 5/31 — 자율 인텔리전스 2종 (스마트에이전트 1·2번)
|
||||||
|
- **로또 자가학습 백테스트·캘리브레이션 v3** — backtest_runs/winner_calibration, forward 가상구매 3전략, ε-게이팅 lift 학습, 일요회고 cron. 역대 캘리브레이션 백필 1197/1197 (6/11)
|
||||||
|
- **주식 보유종목 인텔리전스** — holdings_signals, market_events/news_issues/portfolio_health, decide_action 매트릭스, EOD(16:50)+브리핑(08:30) cron
|
||||||
|
|
||||||
|
### 6/01~06 — 보안 + 인스타 카드뉴스
|
||||||
|
- nginx CVE 대응 (CVE-2026-42945 · CVE-2026-9256 → 1.30.2)
|
||||||
|
- **인스타 카드뉴스 품질 고도화 v2** + zip 패키지(10 PNG + caption.txt) + 글자수 가이드
|
||||||
|
|
||||||
|
### 6/11 — 자율 발급 + 오버사이트 (스마트에이전트 3번)
|
||||||
|
- **인스타 자율 카드 발급** — 4신호 선별(selection.py) + Claude Haiku 카드가치 판단 + 승인 게이트 + 발행 상태머신. 텔레그램 issue_approve/reject/regen 콜백. **autonomous_issue 기본 OFF**
|
||||||
|
- **에이전트 횡단 오버사이트(백엔드)** — `GET /api/agent-office/activity` 통합 피드 + 필터(agent_id/type/status/days). main `2c2828c` 배포
|
||||||
|
- CLAUDE.md 카탈로그 슬림화(966→484, 서비스별 메모리 분담) · packs jti SQLite 영속화 · lotto deep CuratorError fallthrough fix
|
||||||
|
|
||||||
|
### 6/12 — 음악 파이프라인 신뢰성·복구 (직전 작업)
|
||||||
|
- **자동 재시도**: orchestrator step 3회 backoff 재시도(publish 제외 — 업로드 비멱등)
|
||||||
|
- **수동 재개**: `POST /api/music/pipeline/{id}/retry` — 실패 step 판별·재개, retrying 레이스 가드, publish+업로드완료 시 409
|
||||||
|
- **실패 알림**: agent-office youtube_publisher가 신규 failed 감지 → 텔레그램 `⚠️실패` + `[🔄재시도]` 인라인 버튼 → music-lab retry 프록시
|
||||||
|
- 커밋·push·자동배포 완료 (main = origin/main)
|
||||||
|
|
||||||
|
> **스마트에이전트 3종 전부 가동**: stock(보유종목) · insta(자율발급) · lotto(진화). CEO 오버사이트(통합 활동 피드) 백엔드 완료.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔴 즉시 — 진행 중 / 대기
|
||||||
|
|
||||||
|
### 1. ✅ agent oversight 프론트 NAS 배포 — 완료 (2026-06-12)
|
||||||
|
- web-ui `ActivityTimeline`(AgentOffice 우측 기본 패널) main 머지(`d0bf5fd`) → NAS 라이브 반영·검증 완료 (index.html 갱신 + AgentOffice 번들 nginx 200)
|
||||||
|
- **배포 방법**: Z: 매핑이 `!` TTY로 안 돼서 **SSH 직접 배포**(`bgg8988@gahusb.synology.me:2300`, tar + `scp -O` → assets 교체). Synology SFTP off라 `scp -O` 필수, images/videos는 불변이라 미러 제외. 상세 → `memory/feedback_windows_frontend_ssh_deploy.md`
|
||||||
|
|
||||||
|
### 2. 운영 검증 (분산·자율 학습)
|
||||||
|
- [ ] Redis 분산 E2E (NAS push → Windows 워커 → webhook 전체 흐름)
|
||||||
|
- [ ] lotto weight-evolver 주간 사이클(월 generate+apply → 토 evaluate) 정상 동작 + evolution report 텔레그램(토 22:15)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 미완성 큰 기능
|
||||||
|
|
||||||
|
### Video Studio 프론트 `/studio` — 백엔드 완료, UI 미구현
|
||||||
|
- **백엔드 완료·배포**: image-lab(NAS 18802) ✅ + image-render(Windows web-ai) ✅ + video-lab(기존) ✅ (`plans/2026-05-23-video-studio-backend.md` 전부)
|
||||||
|
- **빠진 것**: web-ui React Flow 노드 캔버스(ImageGenNode → ImageToVideoNode). 백엔드 plan이 "프론트는 Plan 2"로 미뤘으나 Plan 2 미생성
|
||||||
|
- spec: `docs/superpowers/specs/2026-05-23-video-studio-node-canvas-design.md` (untracked — 커밋 필요)
|
||||||
|
- 목적: 무신사·우리카드 AI 영상 공모전 실전 제작 도구
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 후속 (직전 작업 범위 밖)
|
||||||
|
|
||||||
|
### music 파이프라인 stuck 감지
|
||||||
|
- 6/12 신뢰성 작업이 명시적으로 남긴 갭: `*_running` hang · `*_pending` 방치 · retrying 중 컨테이너 재시작 시 stuck(현 retry 가드가 state=failed라 재retry 불가)
|
||||||
|
- 상세: `memory/service_music.md` "파이프라인 신뢰성/복구 — 범위 밖"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟢 백로그 아이디어
|
||||||
|
|
||||||
|
- **Redis 큐 통합 모니터링** — agent-office에 `queue:*-render`/`queue:paused` 길이·상태 패널 (NAS↔Windows 작업 흐름 가시화)
|
||||||
|
- **weight-evolver 성과 대시보드** — auto_picks 적중 추이 + weight_base 진화 그래프 (자율 학습 실효성 검증)
|
||||||
|
- **lotto-signals 패턴 확장** — adaptive baseline + z-score + urgent 텔레그램을 stock(이상치)·realestate(경쟁률 급변)에 재사용
|
||||||
|
- **nginx internal 차단 표준화** — insta/music/video/image 3-layer 차단을 공통 include로 추출
|
||||||
|
- **agent-office 레거시 정리** — tarot_readings 테이블 잔존(tarot-lab 분리 후), seed "blog" 죽은 에이전트
|
||||||
|
|
||||||
|
### 보류 유지 (박재오 판단 대기)
|
||||||
|
- stock 뉴스 스크랩 비동기화 — BackgroundScheduler I/O wait라 CPU 미미, 큰 리팩토링 vs 효과 불명확
|
||||||
|
- lotto Monte Carlo 빈도(6→3회/일) — CPU 50%↓ vs 자율 학습 정확도 trade-off
|
||||||
|
- 컨테이너 리소스 제한 — ❌ 박재오 금지(J4025 2C throughput 손해) · NAS 업그레이드 ⏸️ 보류(Redis 분산으로 우선순위↓)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 진단 커맨드 (NAS bash)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
top -b -n 1 | head -25 # CPU 상위
|
||||||
|
docker stats --no-stream # 컨테이너별 CPU/메모리
|
||||||
|
docker exec redis redis-cli PING # Redis 헬스
|
||||||
|
docker exec redis redis-cli KEYS 'queue:*' # 큐 키 목록
|
||||||
|
docker exec redis redis-cli LLEN queue:insta-render # 큐 길이
|
||||||
|
docker logs agent-office 2>&1 | grep -E "APScheduler|executing" | tail -50
|
||||||
|
docker exec insta-lab ps aux | grep chromium | wc -l # (분할 후 0이어야 정상)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 참고
|
||||||
|
|
||||||
|
- 메모리 인덱스: `memory/MEMORY.md` (14 서비스 × `service_<name>.md` authoritative)
|
||||||
|
- Windows 워커 짝: web-ai 레포 (insta/music/video/image-render)
|
||||||
|
- spec/plan: `docs/superpowers/specs|plans/`
|
||||||
|
- docker-compose.yml: 루트
|
||||||
|
|
||||||
|
## 변경 이력
|
||||||
|
|
||||||
|
- 2026-05-18: 페이지 신설. CPU 진단 즉시 5건 + 7결정 분산 가이드.
|
||||||
|
- 2026-05-22: 분산·자율화 구현 반영. Redis 분할·lotto 능동시그널·weight-evolver.
|
||||||
|
- 2026-06-12: **5/25~6/12 전체 작업 반영** — tarot/saju 분리·신설, _shared 로그, lotto v3 백테스트, stock 보유종목 인텔, nginx CVE, insta 카드뉴스 v2 + 자율발급, 에이전트 오버사이트, music 파이프라인 신뢰성. 미완성 큰 기능(Video Studio 프론트) + 후속(music stuck 감지) + 백로그 재편. 현재 트랙(oversight 프론트 배포) 명시.
|
||||||
846
CLAUDE.md
846
CLAUDE.md
@@ -1,15 +1,31 @@
|
|||||||
# CLAUDE.md — web-backend 프로젝트 가이드
|
# CLAUDE.md — web-backend 프로젝트 가이드
|
||||||
|
|
||||||
> Claude Code가 이 프로젝트를 작업할 때 참조하는 설정 및 구조 문서.
|
> Claude Code가 이 프로젝트를 작업할 때 참조하는 **안정적 카탈로그**.
|
||||||
|
> 포트·nginx 라우팅·서비스별 API 엔드포인트 목록·공통 규칙만 담는다.
|
||||||
|
> **DB 스키마 세부·스케줄러 잡·환경변수 세부·최근 기능 히스토리는 서비스별 메모리(`service_<name>.md`)가 authoritative** — 9번 섹션 각 서비스 끝의 메모리 포인터 참조.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 메모리 구조 규칙 (하네스 엔지니어링)
|
||||||
|
|
||||||
|
이 모노레포는 **서비스당 1개 메모리 파일**(`memory/service_<name>.md`)로 운영 상태를 관리한다.
|
||||||
|
|
||||||
|
- **CLAUDE.md (이 파일, 항상 로딩)** = 변하지 않는 지도: 포트, nginx 라우팅, 서비스 한 줄 역할, API 엔드포인트 목록, cross-cutting 규칙.
|
||||||
|
- **`service_<name>.md` (관련 시 recall)** = 휘발성 상세: DB 테이블+컬럼, 스케줄러 cron, 환경변수, provider/큐 흐름, 비자명한 함정, 최근 기능 작업 히스토리.
|
||||||
|
|
||||||
|
**작업 시작 전**: 해당 서비스의 `service_<name>.md`를 먼저 읽어 최신 운영 상태·함정을 확인할 것. 14개 서비스 전부 메모리 파일이 있다(`MEMORY.md` 인덱스 참조).
|
||||||
|
**변경 후**: DB 스키마/스케줄러/운영 흐름이 바뀌면 CLAUDE.md가 아니라 해당 서비스 메모리를 갱신할 것.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 프로젝트 개요
|
## 1. 프로젝트 개요
|
||||||
|
|
||||||
Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||||
- **서비스**: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, packs-lab, deployer (10개)
|
- **서비스 14개**: lotto, stock, music-lab, video-lab, image-lab, insta-lab, realestate-lab, agent-office, tarot-lab, saju-lab, personal, packs-lab, travel-proxy, deployer
|
||||||
|
- **공유 인프라**: `_shared/access_log` 모듈 (5개 서비스 공유), `redis` (music/video/image/insta-lab 큐 공유)
|
||||||
|
- **렌더/생성 위임**: music/video/image/insta의 무거운 생성·렌더는 **Windows AI 워커**(`web-ai` 별도 레포)가 담당. NAS 서비스는 Redis 큐 push + 결과 webhook 수신만 한다.
|
||||||
- **프론트엔드**: 별도 레포 (React + Vite SPA), 빌드 산출물만 NAS에 배포
|
- **프론트엔드**: 별도 레포 (React + Vite SPA), 빌드 산출물만 NAS에 배포
|
||||||
- **인프라**: Docker Compose (10컨테이너) + Nginx(리버스 프록시) + Gitea Webhook 자동 배포
|
- **인프라**: Docker Compose (16+ 컨테이너) + Nginx(리버스 프록시) + Gitea Webhook 자동 배포
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -22,7 +38,7 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
|||||||
| 메모리 | 18 GB |
|
| 메모리 | 18 GB |
|
||||||
| Docker | Synology Container Manager |
|
| Docker | Synology Container Manager |
|
||||||
| Git 서버 | Gitea (self-hosted, NAS 내부) |
|
| Git 서버 | Gitea (self-hosted, NAS 내부) |
|
||||||
| AI 서버 | Windows PC (192.168.45.59:8000) — NVIDIA RTX 5070 Ti (16GB VRAM) + Ollama |
|
| AI 서버 | Windows PC (192.168.45.59) — NVIDIA RTX 5070 Ti (16GB VRAM) + Ollama. 상세 → `infra_windows_ai.md` 메모리 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -31,22 +47,20 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
|||||||
```
|
```
|
||||||
/volume1
|
/volume1
|
||||||
├── docker/webpage/ # 운영 런타임 (Docker Compose 실행 위치)
|
├── docker/webpage/ # 운영 런타임 (Docker Compose 실행 위치)
|
||||||
│ ├── lotto/ # lotto 소스 (rsync 동기화)
|
│ ├── <service>/ # 각 서비스 소스 (rsync 동기화)
|
||||||
│ ├── stock-lab/ # stock-lab 소스 (rsync 동기화)
|
|
||||||
│ ├── travel-proxy/ # travel-proxy 소스 (rsync 동기화)
|
|
||||||
│ ├── deployer/ # deployer 소스 (rsync 동기화)
|
|
||||||
│ ├── nginx/default.conf # Nginx 설정
|
│ ├── nginx/default.conf # Nginx 설정
|
||||||
│ ├── scripts/deploy.sh # Webhook 트리거 배포 스크립트
|
│ ├── scripts/deploy.sh # Webhook 트리거 배포 스크립트
|
||||||
│ ├── docker-compose.yml
|
│ ├── docker-compose.yml
|
||||||
│ ├── .env # 운영 환경변수
|
│ ├── .env # 운영 환경변수
|
||||||
│ ├── data/lotto.db # SQLite DB
|
│ └── data/ # SQLite DB + 생성 미디어 (*.db, music/, video/, image/, insta_cards/ 등)
|
||||||
│ └── data/music/ # 생성된 오디오 파일 (music-lab)
|
|
||||||
│
|
│
|
||||||
├── workspace/web-page-backend/ # Git 레포 클론 위치 (REPO_PATH)
|
├── workspace/web-page-backend/ # Git 레포 클론 위치 (REPO_PATH)
|
||||||
│
|
│
|
||||||
└── web/images/webPage/travel/ # 원본 여행 사진 (RO 마운트)
|
└── web/images/webPage/travel/ # 원본 여행 사진 (RO 마운트)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
배포 흐름·런타임 함정 상세 → `service_deployer.md`, `feedback_nas_deploy_runtime.md` 메모리.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Docker 서비스 & 포트
|
## 4. Docker 서비스 & 포트
|
||||||
@@ -54,14 +68,19 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
|||||||
| 컨테이너 | 포트 | 역할 |
|
| 컨테이너 | 포트 | 역할 |
|
||||||
|---------|------|------|
|
|---------|------|------|
|
||||||
| `lotto` | 18000 | 로또 데이터 수집·분석·추천 API |
|
| `lotto` | 18000 | 로또 데이터 수집·분석·추천 API |
|
||||||
| `stock-lab` | 18500 | 주식 뉴스·AI 분석·KIS API 연동 |
|
| `stock` | 18500 | 주식 뉴스·AI 분석·KIS API 연동 + 보유종목 인텔리전스 |
|
||||||
| `music-lab` | 18600 | AI 음악 생성·라이브러리 관리 API |
|
| `music-lab` | 18600 | AI 음악 생성 게이트웨이 (Suno/MusicGen 호출은 Windows 워커, NAS는 Redis push) |
|
||||||
| `blog-lab` | 18700 | 블로그 마케팅 수익화 API |
|
| `video-lab` | 18801 | 동영상 생성 게이트웨이 (sora/veo/kling/seedance, Redis 큐) |
|
||||||
|
| `image-lab` | 18802 | 이미지 생성 게이트웨이 (gpt_image/nano_banana/flux, Redis 큐) |
|
||||||
|
| `insta-lab` | 18700 | 인스타 카드 피드 자동 생성 (렌더는 Windows insta-render 워커) |
|
||||||
| `realestate-lab` | 18800 | 부동산 청약 자동 수집·매칭 API |
|
| `realestate-lab` | 18800 | 부동산 청약 자동 수집·매칭 API |
|
||||||
| `agent-office` | 18900 | AI 에이전트 오피스 (실시간 WebSocket + 텔레그램 연동) |
|
| `agent-office` | 18900 | AI 에이전트 오피스 (실시간 WebSocket + 텔레그램 연동) |
|
||||||
|
| `tarot-lab` | 18250 | 타로 카드 해석 (Claude Sonnet 3-card, agent-office에서 분리) |
|
||||||
|
| `saju-lab` | 18300 | 사주 분석 + 궁합 (Claude Sonnet, TS→Python 포팅, lunar↔solar 내장) |
|
||||||
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 업로드, Vercel SaaS와 HMAC 통신) |
|
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 업로드, Vercel SaaS와 HMAC 통신) |
|
||||||
| `personal` | 18850 | 개인 서비스 (포트폴리오·블로그·투두 통합) |
|
| `personal` | 18850 | 개인 서비스 (포트폴리오·블로그·투두 통합) |
|
||||||
| `travel-proxy` | 19000 | 여행 사진 API + 썸네일 생성 |
|
| `travel-proxy` | 19000 | 여행 사진 API + 썸네일 생성 |
|
||||||
|
| `redis` | 6379 | 비동기 큐 (music/video/image/insta-lab 공유) |
|
||||||
| `frontend` (nginx) | 8080 | 정적 SPA 서빙 + API 리버스 프록시 |
|
| `frontend` (nginx) | 8080 | 정적 SPA 서빙 + API 리버스 프록시 |
|
||||||
| `webpage-deployer` | 19010 | Gitea Webhook 수신 → 자동 배포 |
|
| `webpage-deployer` | 19010 | Gitea Webhook 수신 → 자동 배포 |
|
||||||
|
|
||||||
@@ -71,26 +90,41 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
|||||||
|
|
||||||
| 경로 | 프록시 대상 | 비고 |
|
| 경로 | 프록시 대상 | 비고 |
|
||||||
|------|------------|------|
|
|------|------------|------|
|
||||||
| `/api/` | `lotto:8000` | lotto API (기본) |
|
| `/api/` | `lotto:8000` | lotto API (catch-all fallback) |
|
||||||
| `/api/travel/` | `travel-proxy:8000` | travel API |
|
| `/api/travel/` | `travel-proxy:8000` | travel API (proxy_read_timeout 600s) |
|
||||||
| `/api/stock/` | `stock-lab:8000` | stock API |
|
| `/api/stock/` | `stock:8000` | stock API |
|
||||||
| `/api/trade/` | `stock-lab:8000` | KIS 실계좌 API |
|
| `/api/trade/` | `stock:8000` | KIS 실계좌 API |
|
||||||
| `/api/portfolio` | `stock-lab:8000` | trailing slash 유무 모두 매칭 |
|
| `/api/portfolio` | `stock:8000` | trailing slash 유무 모두 매칭 |
|
||||||
| `/api/music/` | `music-lab:8000` | AI 음악 생성·라이브러리 API |
|
| `/api/music/` | `music-lab:8000` | AI 음악 생성·라이브러리 API (660s) |
|
||||||
| `/api/blog-marketing/` | `blog-lab:8000` | 블로그 마케팅 수익화 API |
|
| `/api/video/` | `video-lab:8000` | 동영상 생성 게이트웨이 (120s) |
|
||||||
|
| `/api/image/` | `image-lab:8000` | 이미지 생성 게이트웨이 (120s) |
|
||||||
|
| `/api/insta/` | `insta-lab:8000` | 인스타 카드 자동 생성 API (300s) |
|
||||||
| `/api/realestate/` | `realestate-lab:8000` | 부동산 청약 API |
|
| `/api/realestate/` | `realestate-lab:8000` | 부동산 청약 API |
|
||||||
|
| `/api/tarot/` | `tarot-lab:8000` | 타로 해석 (proxy_read/send_timeout **600s**, Claude 3-card 응답) |
|
||||||
|
| `/api/saju/` | `saju-lab:8000` | 사주 분석 (300s) |
|
||||||
| `/api/todos` | `personal:8000` | 투두 API |
|
| `/api/todos` | `personal:8000` | 투두 API |
|
||||||
| `/api/blog/` | `personal:8000` | 블로그 API |
|
| `/api/blog/` | `personal:8000` | 블로그 API |
|
||||||
| `/api/profile/` | `personal:8000` | 포트폴리오 API |
|
| `/api/profile/` | `personal:8000` | 포트폴리오 API |
|
||||||
| `/api/agent-office/` | `agent-office:8000` | AI 에이전트 오피스 API + WebSocket |
|
| `/api/agent-office/` | `agent-office:8000` | AI 에이전트 오피스 API + WebSocket (86400s) |
|
||||||
| `/api/packs/` | `packs-lab:8000` | 5GB 업로드 대응 (`client_max_body_size 5G`, `proxy_request_buffering off`, 1800s timeout) |
|
| `/api/packs/upload` | `packs-lab:8000` | 5GB multipart 업로드 (`client_max_body_size 5G`, `proxy_request_buffering off`, **1800s** timeout) |
|
||||||
|
| `/api/packs/` | `packs-lab:8000` | 다운로드/list |
|
||||||
|
| `/api/internal/insta/` | `insta-lab:8000` | Windows 워커 webhook (nginx IP 화이트리스트 + 앱 `X-Internal-Key`) |
|
||||||
|
| `/api/internal/music/` | `music-lab:8000` | Windows 워커 webhook (IP 화이트리스트 + `X-Internal-Key`) |
|
||||||
|
| `/api/internal/video/` | `video-lab:8000` | Windows 워커 webhook (IP 화이트리스트 + `X-Internal-Key`) |
|
||||||
|
| `/api/internal/image/` | `image-lab:8000` | Windows 워커 webhook (IP 화이트리스트 + `X-Internal-Key`) |
|
||||||
|
| `/api/webai/` | `stock:8000` | Windows AI 서버 프록시 (rate-limited 60r/m) |
|
||||||
| `/webhook`, `/webhook/` | `deployer:9000` | Gitea Webhook |
|
| `/webhook`, `/webhook/` | `deployer:9000` | Gitea Webhook |
|
||||||
| `/media/music/` | `/data/music/` (파일 직접 서빙) | 생성된 오디오 파일 |
|
| `/ext/feargreed` | CNN API | 공포탐욕지수 외부 프록시 |
|
||||||
| `/media/videos/` | `/data/videos/` (파일 직접 서빙) | YouTube 영상 MP4 |
|
| `/ext/vix`, `/ext/treasury`, `/ext/wti`, `/ext/brent` | Yahoo Finance | 시장 지표 외부 프록시 |
|
||||||
| `/media/travel/.thumb/` | `/data/thumbs/` (파일 직접 서빙) | 썸네일 캐시 |
|
| `/media/music/` | `/data/music/` (파일 직접 서빙) | 생성된 오디오 파일 (30d cache) |
|
||||||
| `/media/travel/` | `/data/travel/` (파일 직접 서빙) | 원본 사진 |
|
| `/media/video/` | `/data/video/` (파일 직접 서빙) | video-lab 생성 영상 (1d cache). **단수 `video`** |
|
||||||
| `/assets/` | 정적 파일 (장기 캐시) | Vite 해시 파일 |
|
| `/media/videos/` | `/data/videos/` (파일 직접 서빙) | music-lab 뮤직비디오 (1d). **복수 `videos`** |
|
||||||
| `/` | SPA fallback (`try_files → index.html`) | |
|
| `/media/image/` | `/data/image/` (파일 직접 서빙) | image-lab 생성 이미지 (1d cache) |
|
||||||
|
| `/media/insta/` | `/data/insta_cards/` (파일 직접 서빙) | 카드 PNG (1h cache) |
|
||||||
|
| `/media/travel/.thumb/` | `/data/thumbs/` (파일 직접 서빙) | 썸네일 캐시 (30d). nginx miss 시 앱 라우트 폴백 생성 |
|
||||||
|
| `/media/travel/` | `/data/travel/` (파일 직접 서빙) | 원본 사진 (7d) |
|
||||||
|
| `/assets/` | 정적 파일 (장기 캐시) | Vite 해시 파일 (1y immutable) |
|
||||||
|
| `/` | SPA fallback (`try_files → index.html`) | `index.html` no-cache |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -103,8 +137,9 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
|||||||
| DB | SQLite (`/app/data/*.db`) |
|
| DB | SQLite (`/app/data/*.db`) |
|
||||||
| 스케줄러 | APScheduler |
|
| 스케줄러 | APScheduler |
|
||||||
| 컨테이너 | Docker (`python:3.12-slim` 기반) |
|
| 컨테이너 | Docker (`python:3.12-slim` 기반) |
|
||||||
| AI 연동 | Ollama (Llama 3.1) — Windows PC (192.168.45.59) |
|
| AI 연동 | Claude API (Anthropic) + Ollama (Llama 3.1, Windows PC 192.168.45.59) |
|
||||||
| 주식 API | KIS (한국투자증권) Open API |
|
| 주식 API | KIS (한국투자증권) Open API |
|
||||||
|
| 생성 워커 | Windows `web-ai` 레포 (music/video/image/insta 렌더·생성) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -112,13 +147,14 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
|||||||
|
|
||||||
```
|
```
|
||||||
개발자 git push → Gitea → Webhook (HMAC SHA256 검증)
|
개발자 git push → Gitea → Webhook (HMAC SHA256 검증)
|
||||||
→ deployer 컨테이너 → /scripts/deploy.sh
|
→ deployer 컨테이너 → scripts/deploy.sh (오케스트레이터)
|
||||||
→ rsync(REPO→RUNTIME) → docker compose up -d --build
|
→ deploy-nas.sh (rsync REPO→RUNTIME) → docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
- **배포 스크립트 위치**: `scripts/deploy-nas.sh` (레포) / `scripts/deploy.sh` (런타임)
|
- 배포 스크립트 동기화 함정(6개 hardcoded 위치) → `feedback_deploy_script_sync.md` 메모리
|
||||||
- **환경변수 파일**: `.env` (RUNTIME_PATH, REPO_PATH, PHOTO_PATH, PUID, PGID 등)
|
- Webhook 검증·동시배포 락·헬스체크 게이트·`.releases` 백업 상세 → `service_deployer.md` 메모리
|
||||||
- **백업**: `.releases/` 디렉토리에 자동 백업
|
- 머지 후 첫 webhook이 깨지는 패턴 → `feedback_nas_deploy_runtime.md` 메모리
|
||||||
|
- **프론트엔드는 자동 배포 안 됨**: 로컬 Vite 빌드 후 NAS에 수동 업로드
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -129,47 +165,29 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
| 서비스 | 로컬 URL |
|
⚠️ **Docker는 NAS에서만 구동** — 로컬에서 docker 명령어 실행 금지 (`feedback_docker_nas.md`).
|
||||||
|--------|----------|
|
|
||||||
| Frontend + API | http://localhost:8080 |
|
| 서비스 | 로컬 URL | | 서비스 | 로컬 URL |
|
||||||
| Lotto Backend | http://localhost:18000 |
|
|--------|----------|--|--------|----------|
|
||||||
| Travel API | http://localhost:19000 |
|
| Frontend + API | http://localhost:8080 | | Tarot Lab | http://localhost:18250 |
|
||||||
| Stock Lab | http://localhost:18500 |
|
| Lotto | http://localhost:18000 | | Saju Lab | http://localhost:18300 |
|
||||||
| Blog Lab | http://localhost:18700 |
|
| Stock | http://localhost:18500 | | Video Lab | http://localhost:18801 |
|
||||||
| Realestate Lab | http://localhost:18800 |
|
| Music Lab | http://localhost:18600 | | Image Lab | http://localhost:18802 |
|
||||||
| Packs Lab | http://localhost:18950 |
|
| Insta Lab | http://localhost:18700 | | Personal | http://localhost:18850 |
|
||||||
|
| Realestate Lab | http://localhost:18800 | | Agent Office | http://localhost:18900 |
|
||||||
|
| Packs Lab | http://localhost:18950 | | Travel API | http://localhost:19000 |
|
||||||
|
| Redis | redis://localhost:6379 | | | |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. 서비스별 핵심 정보
|
## 9. 서비스별 API 엔드포인트
|
||||||
|
|
||||||
### lotto-lab (lotto/)
|
> 각 서비스의 DB 스키마·스케줄러·환경변수·운영 함정·최근 작업은 **해당 메모리 파일**을 읽을 것.
|
||||||
- DB: `/app/data/lotto.db`
|
|
||||||
- 데이터 소스: `smok95.github.io/lotto/results/`
|
|
||||||
- 파일 구조: `main.py`, `db.py`, `recommender.py`, `collector.py`, `checker.py`, `generator.py`, `analyzer.py`, `utils.py`, `purchase_manager.py`, `strategy_evolver.py`
|
|
||||||
|
|
||||||
**lotto.db 테이블**
|
### lotto (lotto/)
|
||||||
|
로또 데이터 수집·분석·추천 + 자율 학습(능동 시그널·가중치 진화·자가학습 백테스트).
|
||||||
| 테이블 | 설명 |
|
- 핵심 파일: `main.py`, `db.py`, `recommender.py`, `collector.py`, `checker.py`, `generator.py`, `analyzer.py`, `purchase_manager.py`, `strategy_evolver.py`, `backtest.py`, `routers/`(curator/briefing/review/backtest)
|
||||||
|--------|------|
|
- 📌 상세(DB 15테이블·스케줄러·v1~v3 자율학습): **`service_lotto.md`**
|
||||||
| `draws` | 로또 당첨번호 |
|
|
||||||
| `recommendations` | 추천 이력 (즐겨찾기·태그·채점 포함) |
|
|
||||||
| `simulation_runs` | 시뮬레이션 실행 기록 |
|
|
||||||
| `simulation_candidates` | 시뮬레이션 후보 (점수 5종) |
|
|
||||||
| `best_picks` | 현재 활성 최적 번호 20개 (`is_active` 플래그로 교체) |
|
|
||||||
| `purchase_history` | 구매 이력 (실제/가상, 번호, 전략 출처, 결과) |
|
|
||||||
| `strategy_performance` | 전략별 회차 성과 (EMA 입력 데이터) |
|
|
||||||
| `strategy_weights` | 메타 전략 가중치 (EMA + Softmax) |
|
|
||||||
| `weekly_reports` | 주간 공략 리포트 캐시 |
|
|
||||||
| `lotto_briefings` | AI 큐레이터 주간 브리핑 (5세트 + 내러티브 + 토큰·비용 집계) |
|
|
||||||
| `todos` | 투두리스트 (UUID PK) — personal 서비스로 이전됨, 레거시 테이블 유지 |
|
|
||||||
| `blog_posts` | 블로그 글 (tags: JSON 배열) — personal 서비스로 이전됨, 레거시 테이블 유지 |
|
|
||||||
|
|
||||||
**스케줄러 job**
|
|
||||||
- 09:10 / 21:10 매일 — 당첨번호 동기화 + 채점 (`sync_latest` → `check_results_for_draw`)
|
|
||||||
- 00:05, 04:05, 08:05, 12:05, 16:05, 20:05 — 몬테카를로 시뮬레이션 (20,000후보 → 상위100 → best_picks 20개 교체)
|
|
||||||
|
|
||||||
**lotto-lab API 목록**
|
|
||||||
|
|
||||||
| 메서드 | 경로 | 설명 |
|
| 메서드 | 경로 | 설명 |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
@@ -181,512 +199,300 @@ docker compose up -d
|
|||||||
| GET | `/api/lotto/simulation` | 시뮬레이션 상세 결과 |
|
| GET | `/api/lotto/simulation` | 시뮬레이션 상세 결과 |
|
||||||
| GET | `/api/lotto/recommend` | 통계 기반 추천 |
|
| GET | `/api/lotto/recommend` | 통계 기반 추천 |
|
||||||
| GET | `/api/lotto/recommend/heatmap` | 히트맵 기반 추천 |
|
| GET | `/api/lotto/recommend/heatmap` | 히트맵 기반 추천 |
|
||||||
| GET | `/api/lotto/recommend/batch` | 배치 추천 |
|
| GET/POST | `/api/lotto/recommend/batch` | 배치 추천 (조회/저장) |
|
||||||
| POST | `/api/lotto/recommend/batch` | 배치 추천 저장 |
|
|
||||||
| GET | `/api/lotto/recommend/smart` | 전략 진화 기반 메타 추천 |
|
| GET | `/api/lotto/recommend/smart` | 전략 진화 기반 메타 추천 |
|
||||||
| GET | `/api/lotto/purchase` | 구매 이력 조회 (is_real, strategy, draw_no, days 필터) |
|
| GET/POST | `/api/lotto/purchase` | 구매 이력 조회/등록 |
|
||||||
| POST | `/api/lotto/purchase` | 구매 등록 (실제/가상, 번호, 전략 출처 포함) |
|
| PUT/DELETE | `/api/lotto/purchase/{id}` | 구매 이력 수정/삭제 |
|
||||||
| PUT | `/api/lotto/purchase/{id}` | 구매 이력 수정 |
|
|
||||||
| DELETE | `/api/lotto/purchase/{id}` | 구매 이력 삭제 |
|
|
||||||
| GET | `/api/lotto/purchase/stats` | 구매 통계 (전체/실제/가상 + 전략별) |
|
| GET | `/api/lotto/purchase/stats` | 구매 통계 (전체/실제/가상 + 전략별) |
|
||||||
| GET | `/api/lotto/strategy/weights` | 전략별 가중치 + 성과 + trend |
|
| GET | `/api/lotto/strategy/weights` | 전략별 가중치 + 성과 + trend |
|
||||||
| GET | `/api/lotto/strategy/performance` | 전략별 회차 성과 이력 (차트용) |
|
| GET | `/api/lotto/strategy/performance` | 전략별 회차 성과 이력 (차트용) |
|
||||||
| POST | `/api/lotto/strategy/evolve` | 수동 가중치 재계산 |
|
| POST | `/api/lotto/strategy/evolve` | 수동 가중치 재계산 |
|
||||||
| POST | `/api/admin/simulate` | 시뮬레이션 수동 실행 |
|
| POST | `/api/admin/simulate`, `/api/admin/sync_latest` | 시뮬레이션·동기화 수동 실행 |
|
||||||
| POST | `/api/admin/sync_latest` | 당첨번호 수동 동기화 |
|
|
||||||
| GET | `/api/history` | 추천 이력 (limit, offset, favorite, tag, sort) |
|
| GET | `/api/history` | 추천 이력 (limit, offset, favorite, tag, sort) |
|
||||||
| PATCH | `/api/history/{id}` | 즐겨찾기·메모·태그 수정 |
|
| PATCH/DELETE | `/api/history/{id}` | 즐겨찾기·메모·태그 수정 / 삭제 |
|
||||||
| DELETE | `/api/history/{id}` | 삭제 |
|
| GET | `/api/lotto/curator/candidates`, `/curator/context`, `/curator/usage` | 큐레이터 후보·맥락·토큰비용 |
|
||||||
| GET | `/api/lotto/curator/candidates` | 큐레이터용 후보 N세트 + 피처 |
|
|
||||||
| GET | `/api/lotto/curator/context` | 주간 맥락(핫/콜드·직전 회차) |
|
|
||||||
| GET | `/api/lotto/curator/usage` | 큐레이터 토큰·비용 집계 |
|
|
||||||
| POST | `/api/lotto/briefing` | AI 브리핑 저장 |
|
| POST | `/api/lotto/briefing` | AI 브리핑 저장 |
|
||||||
| GET | `/api/lotto/briefing/latest` | 최신 브리핑 |
|
| GET | `/api/lotto/briefing/latest`, `/briefing/{draw_no}`, `/briefing` | 브리핑 조회/이력 |
|
||||||
| GET | `/api/lotto/briefing/{draw_no}` | 특정 회차 브리핑 |
|
| GET | `/api/lotto/evolver/status`, `/evolver/history`, `/evolver/trials/{week_start}` | 가중치 진화 상태/이력/주별 trial |
|
||||||
| GET | `/api/lotto/briefing` | 브리핑 이력 |
|
| POST | `/api/lotto/evolver/generate-now`, `/evolver/evaluate-now` | 진화 수동 트리거 |
|
||||||
|
| GET | `/api/lotto/backtest/track-record` | forward 가상구매 성적 |
|
||||||
|
| GET | `/api/lotto/backtest/calibration` | winner 캘리브레이션 percentile |
|
||||||
|
| GET | `/api/lotto/backtest/review/{draw_no}` | 특정 회차 백테스트 리뷰 |
|
||||||
|
| POST | `/api/lotto/backtest/run-forward` | forward 가상구매 수동 실행 |
|
||||||
|
| POST | `/api/lotto/backtest/backfill` | 역대 캘리브레이션 백필 (`batch`, `sample_m`) |
|
||||||
|
|
||||||
### stock-lab (stock-lab/)
|
### stock (stock/)
|
||||||
|
주식 뉴스·AI 분석·KIS 연동 + **보유종목 인텔리전스**(advisory 브리핑) + 스크리너.
|
||||||
|
- 핵심 파일: `main.py`, `db.py`, `scraper.py`, `price_fetcher.py`, `holdings_intel.py`, `screener/`, `holidays.json`
|
||||||
- Windows AI 서버 연동: `WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000`
|
- Windows AI 서버 연동: `WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000`
|
||||||
- KIS API 연동으로 실계좌 잔고·거래 조회
|
- 📌 상세(DB 테이블·스케줄러·보유종목 인텔 아키텍처): **`service_stock.md`**
|
||||||
- 뉴스 스크래핑: 네이버 증권 + 해외 사이트
|
|
||||||
- DB: `/app/data/stock.db` (articles, portfolio, broker_cash, asset_snapshots, sell_history 테이블)
|
|
||||||
- 파일 구조: `main.py`, `db.py`, `scraper.py`, `price_fetcher.py`, `holidays.json`
|
|
||||||
|
|
||||||
**stock-lab API 목록**
|
|
||||||
|
|
||||||
| 메서드 | 경로 | 설명 |
|
| 메서드 | 경로 | 설명 |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| GET | `/api/stock/news` | 뉴스 조회 (`limit`, `category` 파라미터) |
|
| GET | `/api/stock/news` | 뉴스 조회 (`limit`, `category`) |
|
||||||
| GET | `/api/stock/indices` | 주요 지표 실시간 조회 |
|
| GET | `/api/stock/indices` | 주요 지표 실시간 조회 |
|
||||||
|
| GET | `/api/stock/holidays` | KRX 공휴일 목록 |
|
||||||
| POST | `/api/stock/scrap` | 수동 뉴스 스크랩 트리거 |
|
| POST | `/api/stock/scrap` | 수동 뉴스 스크랩 트리거 |
|
||||||
| GET | `/api/trade/balance` | 실계좌 잔고 조회 (Windows AI 서버 프록시) |
|
| GET | `/api/stock/holdings/intel` | 보유종목 advisory (+ `/history`, `/run`) |
|
||||||
| POST | `/api/trade/order` | 주식 주문 (Windows AI 서버 프록시) |
|
| GET | `/api/trade/balance` | 실계좌 잔고 (Windows AI 프록시) |
|
||||||
| GET | `/api/portfolio` | 포트폴리오 전체 조회 (현재가·손익·예수금 포함) |
|
| POST | `/api/trade/order` | 주식 주문 (Windows AI 프록시) |
|
||||||
| POST | `/api/portfolio` | 종목 추가 |
|
| GET/POST | `/api/portfolio` | 포트폴리오 조회/추가 |
|
||||||
| PUT | `/api/portfolio/{id}` | 종목 수정 |
|
| PUT/DELETE | `/api/portfolio/{id}` | 종목 수정/삭제 |
|
||||||
| DELETE | `/api/portfolio/{id}` | 종목 삭제 |
|
| GET/PUT | `/api/portfolio/cash` | 예수금 조회/upsert |
|
||||||
| GET | `/api/portfolio/cash` | 예수금 전체 조회 |
|
|
||||||
| PUT | `/api/portfolio/cash` | 예수금 등록·수정 (upsert) |
|
|
||||||
| DELETE | `/api/portfolio/cash/{broker}` | 예수금 삭제 |
|
| DELETE | `/api/portfolio/cash/{broker}` | 예수금 삭제 |
|
||||||
| POST | `/api/portfolio/snapshot` | 총 자산 스냅샷 수동 저장 |
|
| POST | `/api/portfolio/snapshot` | 총 자산 스냅샷 수동 저장 |
|
||||||
| GET | `/api/portfolio/snapshot/history` | 스냅샷 이력 조회 (`days=0`: 전체, `days=N`: 최근 N건) |
|
| GET | `/api/portfolio/snapshot/history` | 스냅샷 이력 (`days`) |
|
||||||
| GET | `/api/portfolio/sell-history` | 매도 내역 조회 (`broker`, `days` 필터 선택) |
|
| GET/POST | `/api/portfolio/sell-history` | 매도 내역 조회/저장 |
|
||||||
| POST | `/api/portfolio/sell-history` | 매도 기록 저장 (id 포함 레코드 반환) |
|
| PUT/DELETE | `/api/portfolio/sell-history/{id}` | 매도 기록 수정/삭제 |
|
||||||
| PUT | `/api/portfolio/sell-history/{id}` | 매도 기록 수정 (수정된 레코드 반환) |
|
|
||||||
| DELETE | `/api/portfolio/sell-history/{id}` | 매도 기록 삭제 |
|
|
||||||
|
|
||||||
**매도 히스토리 (`sell_history`)**
|
|
||||||
- 독립 테이블 — `portfolio` 테이블과 별개로 관리
|
|
||||||
- `sold_at`: UTC ISO8601 형식 (`new Date().toISOString()`)
|
|
||||||
- `realized_profit` / `realized_rate`: 프론트 계산값 저장 (백엔드 재계산 무방)
|
|
||||||
- 응답 정렬: `sold_at DESC` (최신순)
|
|
||||||
|
|
||||||
**총 자산 스냅샷 (`asset_snapshots`)**
|
|
||||||
- 평일 15:40 APScheduler 자동 실행 (`save_daily_snapshot`)
|
|
||||||
- 공휴일 판별: `holidays.json` (매년 수동 갱신, KRX 기준) → `is_market_open()` 함수
|
|
||||||
- 같은 날 중복 저장 시 upsert (date UNIQUE 제약)
|
|
||||||
- 수동 저장: `POST /api/portfolio/snapshot`
|
|
||||||
- 이력 조회: `GET /api/portfolio/snapshot/history?days=30` (ASC 정렬, 차트용)
|
|
||||||
|
|
||||||
**스케줄러 job**
|
|
||||||
- 08:00 매일 — 뉴스 스크랩 (`run_scraping_job`)
|
|
||||||
- 15:40 평일 — 총 자산 스냅샷 저장 (`save_daily_snapshot`)
|
|
||||||
|
|
||||||
### music-lab (music-lab/)
|
### music-lab (music-lab/)
|
||||||
- 듀얼 프로바이더 음악 생성 서비스 (Suno API + 로컬 MusicGen) + YouTube 영상 제작 + 시장 조사 트렌드
|
듀얼 프로바이더 음악 생성(Suno + MusicGen) + YouTube 영상 자동화 파이프라인 + 시장 트렌드.
|
||||||
- 생성된 오디오 파일: `/app/data/music/` (Nginx가 `/media/music/`로 직접 서빙)
|
- ⚠️ **NAS는 게이트웨이** — Suno/MusicGen 호출은 Windows `music-render` 워커가 담당. NAS는 `queue:music-render` Redis push + `/api/internal/music/update` webhook 수신. `suno_provider.py`/`local_provider.py`는 DEPRECATED stub.
|
||||||
- 생성된 영상 파일: `/app/data/videos/` (Nginx가 `/media/videos/`로 직접 서빙)
|
- 핵심 파일: `main.py`, `db.py`, `batch_generator.py`, `compiler.py`, `internal_router.py`, `market.py`, `pipeline/`(orchestrator/cover/video/thumb/metadata/review/youtube/state_machine 등)
|
||||||
- DB: `/app/data/music.db` (music_tasks, music_library, video_projects, revenue_records, market_trends, trend_reports 테이블)
|
- 📌 상세(DB 14테이블·env·pipeline state machine·YouTube OAuth): **`service_music.md`**
|
||||||
- 파일 구조: `main.py`, `db.py`, `suno_provider.py`, `local_provider.py`, `video_producer.py`, `market.py`
|
|
||||||
- 생성 흐름: POST generate (provider 지정) → task_id 반환 → BackgroundTask → 파일 저장 → 라이브러리 자동 등록
|
|
||||||
|
|
||||||
**Provider 구조**
|
|
||||||
- `suno`: Suno REST API (`apicast.suno.ai/v1`) — 보컬·가사·인스트루멘탈 지원
|
|
||||||
- `local`: Windows AI 서버 (MusicGen) — 인스트루멘탈 전용
|
|
||||||
|
|
||||||
**music-lab API 목록**
|
|
||||||
|
|
||||||
| 메서드 | 경로 | 설명 |
|
| 메서드 | 경로 | 설명 |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| GET | `/api/music/providers` | 사용 가능한 프로바이더 목록 |
|
| GET | `/api/music/providers`, `/models`, `/credits`, `/genres` | 프로바이더·모델·크레딧·장르 |
|
||||||
| GET | `/api/music/models` | Suno 모델 목록 (V4~V5.5) |
|
| POST | `/api/music/generate` | 음악 생성 (provider/model/vocal 등) |
|
||||||
| GET | `/api/music/credits` | Suno 크레딧 조회 |
|
|
||||||
| POST | `/api/music/generate` | 음악 생성 (provider, model, vocal_gender, negative_tags, style_weight, audio_weight) |
|
|
||||||
| GET | `/api/music/status/{task_id}` | 생성 상태 폴링 |
|
| GET | `/api/music/status/{task_id}` | 생성 상태 폴링 |
|
||||||
| POST | `/api/music/lyrics` | Suno AI 가사 생성 |
|
| POST | `/api/music/lyrics`, `/style-boost` | 가사·스타일 프롬프트 생성 |
|
||||||
| GET | `/api/music/library` | 라이브러리 전체 조회 |
|
| GET/POST/DELETE | `/api/music/library` | 라이브러리 조회/추가/삭제 |
|
||||||
| POST | `/api/music/library` | 트랙 수동 추가 |
|
| POST | `/api/music/extend`, `/vocal-removal`, `/wav`, `/stem-split`, `/cover-image` | 곡 후처리 |
|
||||||
| DELETE | `/api/music/library/{id}` | 트랙 삭제 |
|
| POST | `/api/music/upload-cover`, `/upload-extend`, `/add-vocals`, `/add-instrumental` | 외부 음원 가공 |
|
||||||
| POST | `/api/music/extend` | 곡 연장 |
|
| GET | `/api/music/timestamped-lyrics` | 타임스탬프 가사 |
|
||||||
| POST | `/api/music/vocal-removal` | 보컬/인스트 분리 (2트랙) |
|
| GET/POST/PUT/DELETE | `/api/music/lyrics/library` | 가사 라이브러리 CRUD |
|
||||||
| POST | `/api/music/cover-image` | 커버 이미지 2장 생성 |
|
| POST/GET | `/api/music/generate-batch` | 배치 생성 |
|
||||||
| POST | `/api/music/wav` | WAV 고음질 변환 |
|
| POST/GET | `/api/music/compile` (+ `/compiles/{id}/export`) | 컴파일 |
|
||||||
| POST | `/api/music/stem-split` | 12스템 분리 (50cr) |
|
| POST/GET/DELETE | `/api/music/video-project` (+ `/{id}/render`, `/export`) | 영상 프로젝트 |
|
||||||
| GET | `/api/music/timestamped-lyrics` | 타임스탬프 가사 (가라오케) |
|
| ALL | `/api/music/pipeline` (생성/start/feedback/cancel/publish/retry/telegram-msg/lookup) | YouTube 자동화 파이프라인. `POST /{id}/retry`=실패 step 재개(publish+업로드완료 시 409) |
|
||||||
| POST | `/api/music/style-boost` | AI 스타일 프롬프트 생성 |
|
| GET/PUT | `/api/music/setup` | 파이프라인 설정 |
|
||||||
| POST | `/api/music/upload-cover` | 외부 음원 AI Cover |
|
| GET | `/api/music/youtube/auth-url`, `/callback`, `/status`; POST `/disconnect` | YouTube OAuth |
|
||||||
| POST | `/api/music/upload-extend` | 외부 음원 확장 |
|
| GET/POST/PUT/DELETE | `/api/music/revenue` (+ `/dashboard`) | 수익 기록 |
|
||||||
| POST | `/api/music/add-vocals` | 인스트에 AI 보컬 추가 |
|
| POST | `/api/music/market/ingest` | 트렌드 수신 + 리포트 |
|
||||||
| POST | `/api/music/add-instrumental` | 보컬에 AI 반주 추가 |
|
| GET | `/api/music/market/trends`, `/report/latest`, `/report`, `/suggest` | 트렌드 조회·추천 |
|
||||||
| POST | `/api/music/video` | 뮤직비디오 MP4 생성 |
|
| POST | `/api/internal/music/update` | Windows 워커 결과 webhook |
|
||||||
| GET | `/api/music/lyrics/library` | 저장된 가사 목록 |
|
|
||||||
| POST | `/api/music/lyrics/library` | 가사 저장 |
|
|
||||||
| PUT | `/api/music/lyrics/library/{id}` | 가사 수정 |
|
|
||||||
| DELETE | `/api/music/lyrics/library/{id}` | 가사 삭제 |
|
|
||||||
| POST | `/api/music/video-project` | 영상 프로젝트 생성 (track_id, format, target_countries) |
|
|
||||||
| GET | `/api/music/video-projects` | 영상 프로젝트 목록 |
|
|
||||||
| GET | `/api/music/video-project/{id}` | 영상 프로젝트 상세 |
|
|
||||||
| POST | `/api/music/video-project/{id}/render` | FFmpeg 렌더링 시작 (BackgroundTask) |
|
|
||||||
| GET | `/api/music/video-project/{id}/export` | 내보내기 패키지 (mp4+thumbnail+metadata.json) |
|
|
||||||
| DELETE | `/api/music/video-project/{id}` | 영상 프로젝트 삭제 |
|
|
||||||
| GET | `/api/music/revenue/dashboard` | 수익 대시보드 (총수익·조회수·가중평균 RPM) |
|
|
||||||
| GET | `/api/music/revenue` | 수익 기록 목록 |
|
|
||||||
| POST | `/api/music/revenue` | 수익 기록 추가 (UNIQUE: yt_video_id+record_month+country) |
|
|
||||||
| PUT | `/api/music/revenue/{id}` | 수익 기록 수정 |
|
|
||||||
| DELETE | `/api/music/revenue/{id}` | 수익 기록 삭제 |
|
|
||||||
| POST | `/api/music/market/ingest` | agent-office 트렌드 수신 + 리포트 생성 |
|
|
||||||
| GET | `/api/music/market/trends` | 트렌드 조회 (country, genre, source, days=7) |
|
|
||||||
| GET | `/api/music/market/report/latest` | 최신 트렌드 리포트 |
|
|
||||||
| GET | `/api/music/market/report` | 트렌드 리포트 목록 (limit=10) |
|
|
||||||
| GET | `/api/music/market/suggest` | Suno 프롬프트 추천 (limit=5) |
|
|
||||||
|
|
||||||
**환경변수**
|
### video-lab (video-lab/)
|
||||||
- `SUNO_API_KEY`: Suno API 키 (미설정 시 Suno provider 비활성화)
|
동영상 생성 게이트웨이 (Redis 비동기 큐 `queue:video-render`). provider: sora/veo/kling/seedance.
|
||||||
- `MUSIC_AI_SERVER_URL`: 로컬 MusicGen 서버 URL (미설정 시 local provider 비활성화)
|
- 핵심 파일: `main.py`, `db.py`, `internal_router.py`, `auth.py`
|
||||||
- `MUSIC_MEDIA_BASE`: 오디오 파일 공개 URL prefix (기본 `/media/music`)
|
- 흐름: POST generate → task_id + Redis push → Windows 워커 pop → `/api/internal/video/update` webhook → DB 업데이트. 출력 mp4 `/data/video/` → nginx `/media/video/` (1d)
|
||||||
- `MUSIC_DATA_PATH`: NAS 오디오 파일 저장 경로 (기본 `./data/music`)
|
- 📌 상세(`video_tasks` 스키마·큐 payload·`X-Internal-Key`): **`service_video.md`**
|
||||||
- `PEXELS_API_KEY`: Pexels 스톡 이미지 API 키 (미설정 시 슬라이드쇼 Pexels 이미지 비활성화)
|
|
||||||
- `ANTHROPIC_API_KEY`: Claude Haiku — YouTube 메타데이터 생성 + 시장 인사이트 (미설정 시 폴백 텍스트)
|
|
||||||
- `VIDEO_DATA_DIR`: 영상 파일 저장 경로 (기본 `/app/data/videos`)
|
|
||||||
|
|
||||||
**video_projects 테이블**
|
| 메서드 | 경로 | 설명 |
|
||||||
- format: `visualizer` | `slideshow`
|
|--------|------|------|
|
||||||
- status: `pending` → `rendering` → `done` | `failed`
|
| POST | `/api/video/generate` | 영상 생성 → task_id + Redis push |
|
||||||
- target_countries: JSON 배열 (예: `["BR","US"]`)
|
| GET | `/api/video/tasks/{id}` | 생성 상태 폴링 |
|
||||||
- render_params: JSON 객체 (FFmpeg 파라미터 캐시)
|
| GET | `/api/video/providers` | 지원 provider 목록 |
|
||||||
|
| POST | `/api/internal/video/update` | Windows 워커 결과 webhook |
|
||||||
|
|
||||||
**revenue_records 테이블**
|
### image-lab (image-lab/)
|
||||||
- UNIQUE(yt_video_id, record_month, country)
|
이미지 생성 게이트웨이 (Redis 비동기 큐 `queue:image-render`). provider: gpt_image/nano_banana/flux.
|
||||||
- avg_rpm 계산: 가중평균 `SUM(revenue_usd)/SUM(views)*1000` (단순 AVG 아님)
|
- 핵심 파일: `main.py`, `db.py`, `internal_router.py`, `auth.py`
|
||||||
|
- 흐름: video-lab과 동형. 출력 png/jpg `/data/image/` → nginx `/media/image/` (1d)
|
||||||
|
- 📌 상세(`image_tasks` 스키마·provider 모델 메타·`X-Internal-Key`): **`service_image.md`**
|
||||||
|
|
||||||
**market_trends 테이블**
|
| 메서드 | 경로 | 설명 |
|
||||||
- source: `youtube` | `google_trends` | `billboard`
|
|--------|------|------|
|
||||||
- metadata: JSON 객체 (원본 API 응답 부분)
|
| POST | `/api/image/generate` | 이미지 생성 → task_id + Redis push |
|
||||||
- 인덱스: `idx_mt_country_source` ON (country, source, collected_at DESC)
|
| GET | `/api/image/tasks/{id}` | 생성 상태 폴링 |
|
||||||
|
| GET | `/api/image/providers` | 지원 provider 목록 |
|
||||||
|
| POST | `/api/internal/image/update` | Windows 워커 결과 webhook |
|
||||||
|
|
||||||
**trend_reports 테이블**
|
### insta-lab (insta-lab/)
|
||||||
- report_date UNIQUE — 같은 날 두 번 ingest 시 upsert
|
인스타그램 카드 피드 자동 생성 — 뉴스→키워드→10페이지 카드 카피 → 텔레그램 푸시 → 사용자 수동 업로드.
|
||||||
- top_genres: JSON 배열 `[{genre, score, countries}]` (최대 10개, score 내림차순)
|
- ⚠️ **렌더는 NAS가 안 함** — `card_renderer.py`는 DEPRECATED stub. NAS는 `queue:insta-render` Redis push만, 실제 Jinja→Playwright 렌더는 Windows `insta-render` 워커(web-ai). 워커가 `GET /slates/{id}` fetch → 렌더 → `/api/internal/insta/update` webhook.
|
||||||
- recommended_styles: JSON 배열 `[{genre, suno_prompt, target_countries, reason}]` (최대 5개)
|
- 핵심 파일: `app/main.py`, `config.py`, `db.py`, `news_collector.py`, `keyword_extractor.py`, `card_writer.py`, `internal_router.py`, `trend_collector.py`, `design_importer.py`, `templates/<theme>/card.html.j2`
|
||||||
|
- 카드 사이즈 1080×1350 (4:5). 디자인 import는 **로컬 Python 실행 필수**(NAS docker exec 시 소실 → `feedback_container_ephemeral_artifacts.md`)
|
||||||
|
- 📌 상세(DB 스키마·디자인 import·v2 카드뉴스·렌더 아키텍처·미해결 갭): **`service_insta.md`**
|
||||||
|
|
||||||
**music_library 테이블 (확장 컬럼)**
|
| 메서드 | 경로 | 설명 |
|
||||||
- `provider`: `suno` | `local` — 생성에 사용된 프로바이더
|
|--------|------|------|
|
||||||
- `lyrics`: Suno 생성 가사 텍스트
|
| GET | `/api/insta/status` | 서비스 상태 (NAVER/ANTHROPIC 키 여부) |
|
||||||
- `image_url`: Suno 생성 커버 이미지 URL
|
| POST | `/api/insta/news/collect` | 뉴스 수집 트리거 |
|
||||||
- `suno_id`: Suno 곡 ID (CDN 참조용)
|
| GET | `/api/insta/news/articles` | 수집 기사 목록 |
|
||||||
- `file_hash`: MD5 해시 (rename 감지용)
|
| POST | `/api/insta/keywords/extract` | 키워드 추출 트리거 |
|
||||||
- `cover_images`: JSON 배열 — 커버 이미지 URL 목록
|
| GET | `/api/insta/keywords` | 트렌딩 키워드 목록 |
|
||||||
- `wav_url`: WAV 변환 URL
|
| GET/POST | `/api/insta/slates` | 슬레이트 목록/생성 |
|
||||||
- `video_url`: 뮤직비디오 URL
|
| GET/DELETE | `/api/insta/slates/{id}` | 슬레이트 상세/삭제 |
|
||||||
- `stem_urls`: JSON 객체 — 12스템 URL 맵
|
| POST | `/api/insta/slates/{id}/render` | 카드 렌더 재시도 |
|
||||||
|
| GET | `/api/insta/slates/{id}/assets/{page}` | 카드 PNG 다운로드 (1~10) |
|
||||||
**Suno 생성 특이사항**
|
| GET | `/api/insta/slates/{id}/package` | zip 패키지 (10 PNG + caption.txt) |
|
||||||
- 1회 생성 시 2개 변형(variation) 반환 → 둘 다 라이브러리에 저장
|
| GET | `/api/insta/keywords/ranked` | 4신호 선별 점수 + eligible (자율 발급용) |
|
||||||
- CDN URL(`cdn1.suno.ai`)은 임시 → 반드시 로컬 다운로드 필요
|
| POST | `/api/insta/slates/{id}/decision` | 승인/반려 (approved→published) |
|
||||||
- 가사 섹션 태그: `[Verse]`, `[Chorus]`, `[Bridge]`, `[Instrumental]` 등
|
| GET | `/api/insta/tasks/{task_id}` | BackgroundTask 상태 폴링 |
|
||||||
|
| GET/PUT | `/api/insta/templates/prompts/{name}` | 프롬프트 템플릿 CRUD |
|
||||||
|
| POST | `/api/internal/insta/update` | Windows 워커 결과 webhook |
|
||||||
|
|
||||||
### realestate-lab (realestate-lab/)
|
### realestate-lab (realestate-lab/)
|
||||||
- 공공데이터포털 API 연동: 한국부동산원 청약홈 분양정보 조회 + 자치구 5티어 매칭 + agent-office push 알림
|
공공데이터포털 청약 분양정보 수집 + 자치구 5티어 매칭 + agent-office push 알림.
|
||||||
- DB: `/app/data/realestate.db` (announcements, announcement_models, user_profile, match_results, collect_log 테이블)
|
- 핵심 파일: `main.py`, `db.py`, `collector.py`, `matcher.py`, `notifier.py`, `models.py`
|
||||||
- 파일 구조: `main.py`, `db.py`, `collector.py`, `matcher.py`, `notifier.py`, `models.py`
|
- 매칭 100점: 지역35 / 주택유형10 / 면적15 / 가격15 / 자격25
|
||||||
|
- 📌 상세(DB 스키마·스케줄러 4단계·매칭 모델·notifier 멱등 흐름·env): **`service_realestate.md`**
|
||||||
**환경변수**
|
|
||||||
- `DATA_GO_KR_API_KEY`: 공공데이터포털 API 키 (미설정 시 수동 등록만 가능)
|
|
||||||
- `AGENT_OFFICE_URL`: agent-office 내부 URL (기본 `http://agent-office:8000`) — 신규 매칭 push 대상
|
|
||||||
- `REALESTATE_NOTIFY_TIMEOUT`: agent-office push timeout 초 (기본 15)
|
|
||||||
|
|
||||||
**스케줄러 job (`scheduled_collect` 4단계 흐름)**
|
|
||||||
- 09:00 매일 — `collect → cleanup → match → notify`
|
|
||||||
1. `collect_all()` — 모집공고일 30일 윈도우(`RCRIT_PBLANC_DE_FROM`) 사전 좁힘 + 자치구 추출 + status='완료' skip
|
|
||||||
2. `delete_old_completed_announcements(grace_days=90)` — `winner_date + 90일` 경과한 완료 공고 정리 (FK CASCADE로 match_results도 삭제)
|
|
||||||
3. `run_matching()` — 자치구 5티어 가중치 + 자격 곡선 적용
|
|
||||||
4. `notify_new_matches()` — `notified_at IS NULL AND match_score >= profile.min_match_score AND profile.notify_enabled`인 매칭을 agent-office로 push
|
|
||||||
- 00:00 매일 — 상태 갱신 + 재매칭 (`scheduled_status_update`, notifier 미호출)
|
|
||||||
|
|
||||||
**매칭 점수 모델 (총 100점)**
|
|
||||||
- 지역 35점 — 광역 매칭 시 10점 + 자치구 5티어 가중치(S=25 / A=20 / B=15 / C=10 / D=5)
|
|
||||||
- `preferred_districts`가 모든 티어 비어있으면 광역 매칭만으로 35점 풀 점수 (legacy 호환)
|
|
||||||
- 주택유형 10점 — `preferred_types`에 매칭 (binary)
|
|
||||||
- 면적 15점 — `[min_area, max_area]` 범위 안 모델 1개 이상 (binary)
|
|
||||||
- 가격 15점 — `max_price` 이하 모델 1개 이상 (binary)
|
|
||||||
- 자격 25점 — `_check_eligible_types()` 결과 1개 이상이면 15점 + 추가당 5점, 최대 +10
|
|
||||||
- reasons 텍스트 예시: `"자치구 S티어: 강남구 (+25)"`, `"광역 일치: 서울"`, `"선호 지역 일치: 서울"` (legacy)
|
|
||||||
|
|
||||||
**user_profile 신규 컬럼 (Task 2026-04-28 마이그레이션)**
|
|
||||||
- `preferred_districts` TEXT — JSON `{"S":[...], "A":[...], "B":[...], "C":[...], "D":[...]}`. default `'{}'`
|
|
||||||
- `min_match_score` INTEGER — 알림 임계값. default 70
|
|
||||||
- `notify_enabled` INTEGER — 알림 ON/OFF. default 1
|
|
||||||
|
|
||||||
**announcements / match_results 신규 컬럼**
|
|
||||||
- `announcements.district` TEXT + `idx_ann_district` 인덱스 — collector가 주소/region_name에서 정규식 파싱
|
|
||||||
- `match_results.notified_at` TEXT NULL — agent-office push 성공 시 timestamp 기록 (멱등 마킹)
|
|
||||||
|
|
||||||
**notifier.py 흐름**
|
|
||||||
1. `get_profile()` → `notify_enabled=False`면 skip, `min_match_score` 가져옴
|
|
||||||
2. `get_unnotified_matches(min_score)` — JOIN으로 announcements 정보 포함 (district, status, receipt 등)
|
|
||||||
3. `POST {AGENT_OFFICE_URL}/api/agent-office/realestate/notify` body=`{"matches": [...]}`
|
|
||||||
4. 응답 `{sent_ids: [...]}` → `mark_matches_notified(sent_ids)` (notified_at = now)
|
|
||||||
5. RequestException 시 마킹 안 함 → 다음 사이클 재시도
|
|
||||||
|
|
||||||
**realestate-lab API 목록**
|
|
||||||
|
|
||||||
| 메서드 | 경로 | 설명 |
|
| 메서드 | 경로 | 설명 |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| GET | `/api/realestate/announcements` | 공고 목록. 응답에 `district`, `match_score`, `match_reasons`, `eligible_types` 포함 |
|
| GET/POST | `/api/realestate/announcements` | 공고 목록(district/match_score 포함)/수동 등록 |
|
||||||
| GET | `/api/realestate/announcements/{id}` | 공고 상세 (주택형별 + district 포함) |
|
| GET/PUT/DELETE | `/api/realestate/announcements/{id}` | 공고 상세/수정/삭제 |
|
||||||
| POST | `/api/realestate/announcements` | 수동 공고 등록 |
|
| PATCH | `/api/realestate/announcements/{id}/bookmark` | 북마크 토글 (텔레그램 콜백 대상) |
|
||||||
| PUT | `/api/realestate/announcements/{id}` | 공고 수정 |
|
| DELETE | `/api/realestate/announcements/closed` | 완료 공고 일괄 삭제 |
|
||||||
| PATCH | `/api/realestate/announcements/{id}/bookmark` | 북마크 토글 (텔레그램 인라인 키보드 콜백 대상) |
|
| POST/GET | `/api/realestate/collect` (+ `/collect/status`) | 수동 수집(collect→cleanup→match→notify)/상태 |
|
||||||
| DELETE | `/api/realestate/announcements/{id}` | 공고 삭제 |
|
| GET/PUT | `/api/realestate/profile` | 프로필 조회/수정 (preferred_districts, min_match_score, notify_enabled) |
|
||||||
| DELETE | `/api/realestate/announcements/closed` | status='완료' 공고 일괄 삭제 |
|
| GET | `/api/realestate/matches` | 매칭 결과 (district/status 포함) |
|
||||||
| POST | `/api/realestate/collect` | 수동 수집 트리거 (collect → cleanup → match → notify 전체 흐름) |
|
|
||||||
| GET | `/api/realestate/collect/status` | 마지막 수집 결과 |
|
|
||||||
| GET | `/api/realestate/profile` | 내 프로필 조회 (`preferred_districts`, `min_match_score`, `notify_enabled` 포함) |
|
|
||||||
| PUT | `/api/realestate/profile` | 프로필 수정 (upsert). body에 `preferred_districts: {S:[],...}`, `min_match_score: 0~100`, `notify_enabled: bool` 수용 |
|
|
||||||
| GET | `/api/realestate/matches` | 매칭 결과 목록 (응답에 `district`, `status` 포함) |
|
|
||||||
| POST | `/api/realestate/matches/refresh` | 매칭 재계산 |
|
| POST | `/api/realestate/matches/refresh` | 매칭 재계산 |
|
||||||
| PATCH | `/api/realestate/matches/{id}/read` | 신규 알림 읽음 처리 |
|
| PATCH | `/api/realestate/matches/{id}/read` | 신규 알림 읽음 |
|
||||||
| GET | `/api/realestate/dashboard` | 요약 (진행중 공고수, 신규 매칭수, 다가오는 일정) |
|
| GET | `/api/realestate/dashboard` | 요약 (진행중·신규매칭·일정) |
|
||||||
|
|
||||||
### travel-proxy (travel-proxy/)
|
|
||||||
- 원본 사진: `/data/travel/` (RO)
|
|
||||||
- 썸네일 캐시: `/data/thumbs/` (RW)
|
|
||||||
- DB: `/data/thumbs/travel.db` (photos, album_covers 테이블)
|
|
||||||
- 메타: `/data/travel/_meta/region_map.json`, `regions.geojson`
|
|
||||||
- 지역 오버라이드: `/data/thumbs/region_map_extra.json` (RW, `_regions_meta` 포함)
|
|
||||||
- 파일 구조: `main.py`, `db.py`, `indexer.py`
|
|
||||||
- 썸네일: 480×480 리사이징 (Pillow), 동기화 시 사전 생성 + 온디맨드 폴백
|
|
||||||
- 데이터 흐름: 수동 sync → 폴더 스캔 → SQLite 인덱싱 + 썸네일 일괄 생성
|
|
||||||
|
|
||||||
**travel.db 테이블**
|
|
||||||
|
|
||||||
| 테이블 | 설명 |
|
|
||||||
|--------|------|
|
|
||||||
| `photos` | 사진 인덱스 (album, filename, mtime, has_thumb) |
|
|
||||||
| `album_covers` | 앨범별 커버 사진 지정 |
|
|
||||||
|
|
||||||
**지역 관리 아키텍처**
|
|
||||||
- `region_map.json` (RO): 원본 지역→앨범 매핑 (`_meta/` 안에 위치)
|
|
||||||
- `region_map_extra.json` (RW): 사용자 수정분 오버라이드 (앨범 이동, 신규 지역)
|
|
||||||
- `_regions_meta`: 커스텀 지역의 이름·좌표 저장 (`{ "region_id": { "name": "...", "coordinates": [lng, lat] } }`)
|
|
||||||
- `regions.geojson` (RO): GeoJSON Polygon 지역 경계
|
|
||||||
- 커스텀 지역: `GET /api/travel/regions`에서 `region_map`에 있지만 GeoJSON에 없는 지역을 자동 추가 (Point geometry 또는 null)
|
|
||||||
|
|
||||||
**travel-proxy API 목록**
|
|
||||||
|
|
||||||
| 메서드 | 경로 | 설명 |
|
|
||||||
|--------|------|------|
|
|
||||||
| GET | `/api/travel/regions` | 지역 GeoJSON (커스텀 지역 동적 추가 포함) |
|
|
||||||
| GET | `/api/travel/photos` | 사진 목록 (region, page=1, size=20) |
|
|
||||||
| POST | `/api/travel/sync` | 폴더 스캔 → DB 동기화 + 썸네일 생성 |
|
|
||||||
| GET | `/api/travel/albums` | 앨범 목록 + 사진 수 + 커버 + region/regionName |
|
|
||||||
| PUT | `/api/travel/albums/{album}/cover` | 앨범 커버 지정 |
|
|
||||||
| PUT | `/api/travel/albums/{album}/region` | 앨범 지역 변경 (region_map_extra 수정) |
|
|
||||||
| PUT | `/api/travel/regions/{region_id}` | 커스텀 지역 이름/좌표 수정 (지도 핀 표시용) |
|
|
||||||
|
|
||||||
### blog-lab (blog-lab/)
|
|
||||||
- 블로그 마케팅 수익화 서비스 (키워드 분석 → AI 글 생성 → 마케팅 강화 → 품질 리뷰 → 포스팅 → 수익 추적)
|
|
||||||
- AI 엔진: Claude API (Anthropic, `claude-sonnet-4-20250514`)
|
|
||||||
- 웹 검색: Naver Search API (블로그 + 쇼핑) + 상위 블로그 본문 크롤링
|
|
||||||
- DB: `/app/data/blog_marketing.db`
|
|
||||||
- 파일 구조: `main.py`, `db.py`, `config.py`, `naver_search.py`, `content_generator.py`, `marketer.py`, `quality_reviewer.py`, `web_crawler.py`
|
|
||||||
|
|
||||||
**파이프라인**: 리서치(+크롤링) → 작가(초안) → 마케터(링크 삽입) → 평가자(6기준 60점)
|
|
||||||
**상태 흐름**: `draft` → `marketed` → `reviewed` → `published`
|
|
||||||
|
|
||||||
**blog_marketing.db 테이블**
|
|
||||||
|
|
||||||
| 테이블 | 설명 |
|
|
||||||
|--------|------|
|
|
||||||
| `keyword_analyses` | 키워드 분석 결과 (네이버 검색 데이터 + 경쟁도/기회 점수 + 크롤링 본문) |
|
|
||||||
| `blog_posts` | 블로그 글 (draft → marketed → reviewed → published) |
|
|
||||||
| `brand_links` | 브랜드커넥트 제휴 링크 (post_id/keyword_id FK) |
|
|
||||||
| `commissions` | 포스트별 월간 클릭/구매/수익 |
|
|
||||||
| `generation_tasks` | 비동기 작업 상태 (research/generate/market/review) |
|
|
||||||
| `prompt_templates` | AI 프롬프트 템플릿 (DB 저장, 코드 배포 없이 수정 가능) |
|
|
||||||
|
|
||||||
**blog-lab API 목록**
|
|
||||||
|
|
||||||
| 메서드 | 경로 | 설명 |
|
|
||||||
|--------|------|------|
|
|
||||||
| GET | `/api/blog-marketing/status` | 서비스 상태 (API 키 설정 현황) |
|
|
||||||
| POST | `/api/blog-marketing/research` | 키워드 분석 시작 (+ 상위 블로그 크롤링) |
|
|
||||||
| GET | `/api/blog-marketing/research/history` | 분석 이력 조회 |
|
|
||||||
| GET | `/api/blog-marketing/research/{id}` | 분석 상세 조회 |
|
|
||||||
| DELETE | `/api/blog-marketing/research/{id}` | 분석 삭제 |
|
|
||||||
| GET | `/api/blog-marketing/task/{task_id}` | 작업 상태 폴링 |
|
|
||||||
| POST | `/api/blog-marketing/generate` | 작가 단계: AI 글 생성 (크롤링 참고 + 링크 반영) |
|
|
||||||
| POST | `/api/blog-marketing/market/{post_id}` | 마케터 단계: 전환율 강화 + 링크 삽입 |
|
|
||||||
| POST | `/api/blog-marketing/review/{post_id}` | 평가자 단계: 품질 리뷰 (6기준 × 10점, 42/60 통과) |
|
|
||||||
| POST | `/api/blog-marketing/regenerate/{post_id}` | 피드백 기반 재생성 |
|
|
||||||
| POST | `/api/blog-marketing/links` | 브랜드커넥트 링크 등록 |
|
|
||||||
| GET | `/api/blog-marketing/links` | 링크 조회 (post_id, keyword_id 필터) |
|
|
||||||
| PUT | `/api/blog-marketing/links/{id}` | 링크 수정 |
|
|
||||||
| DELETE | `/api/blog-marketing/links/{id}` | 링크 삭제 |
|
|
||||||
| GET | `/api/blog-marketing/posts` | 포스트 목록 (status 필터) |
|
|
||||||
| GET | `/api/blog-marketing/posts/{id}` | 포스트 상세 |
|
|
||||||
| PUT | `/api/blog-marketing/posts/{id}` | 포스트 수정 |
|
|
||||||
| DELETE | `/api/blog-marketing/posts/{id}` | 포스트 삭제 |
|
|
||||||
| POST | `/api/blog-marketing/posts/{id}/publish` | 발행 (네이버 URL 등록) |
|
|
||||||
| GET | `/api/blog-marketing/commissions` | 수익 내역 조회 |
|
|
||||||
| POST | `/api/blog-marketing/commissions` | 수익 기록 추가 |
|
|
||||||
| PUT | `/api/blog-marketing/commissions/{id}` | 수익 기록 수정 |
|
|
||||||
| DELETE | `/api/blog-marketing/commissions/{id}` | 수익 기록 삭제 |
|
|
||||||
| GET | `/api/blog-marketing/dashboard` | 대시보드 집계 |
|
|
||||||
|
|
||||||
**환경변수**
|
|
||||||
- `ANTHROPIC_API_KEY`: Claude API 키 (미설정 시 AI 생성 비활성화)
|
|
||||||
- `NAVER_CLIENT_ID`: 네이버 검색 API 클라이언트 ID
|
|
||||||
- `NAVER_CLIENT_SECRET`: 네이버 검색 API 시크릿
|
|
||||||
- `BLOG_DATA_PATH`: SQLite DB 저장 경로 (기본 `./data/blog`)
|
|
||||||
|
|
||||||
### agent-office (agent-office/)
|
### agent-office (agent-office/)
|
||||||
- AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 에이전트가 실제 작업 수행
|
AI 에이전트 가상 오피스 — 기존 서비스 API를 프록시로 호출, 실시간 WebSocket + 텔레그램 봇.
|
||||||
- stock-lab/music-lab/realestate-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음)
|
- 핵심 파일: `main.py`, `db.py`, `config.py`, `websocket_manager.py`, `service_proxy.py`, `telegram_bot.py`, `scheduler.py`, `agents/`(stock/music/realestate/youtube/youtube_publisher/lotto/base)
|
||||||
- 실시간 상태 동기화: WebSocket (`/api/agent-office/ws`)
|
- 에이전트 7종 레지스트리. 명령 API body 필드명 → `reference_agent_office_command_api.md`
|
||||||
- 텔레그램 봇: 양방향 알림 + 승인 (인라인 키보드)
|
- 📌 상세(DB 9테이블·FSM·전체 cron 목록·AGENT_CONTAINER_MAP·텔레그램 캐싱·env): **`service_agent_office.md`**
|
||||||
- 청약 매칭 알림: realestate-lab이 신규 매칭 발견 시 push → `RealestateAgent.on_new_matches()` → 텔레그램 1통(인라인 [🔖 북마크]/[📄 공고] 또는 [전체 보기] 버튼)
|
|
||||||
- DB: `/app/data/agent_office.db` (agent_config, agent_tasks, agent_logs, telegram_state 테이블)
|
|
||||||
- 파일 구조: `main.py`, `db.py`, `config.py`, `models.py`, `websocket_manager.py`, `service_proxy.py`, `telegram_bot.py`, `scheduler.py`, `agents/base.py`, `agents/stock.py`, `agents/music.py`, `agents/realestate.py`, `telegram/realestate_message.py`
|
|
||||||
|
|
||||||
**에이전트 FSM 상태**: idle → working → waiting (승인 대기) → reporting → break (휴식)
|
|
||||||
|
|
||||||
**환경변수**
|
|
||||||
- `STOCK_LAB_URL`: stock-lab 내부 URL (기본 `http://stock-lab:8000`)
|
|
||||||
- `MUSIC_LAB_URL`: music-lab 내부 URL (기본 `http://music-lab:8000`)
|
|
||||||
- `REALESTATE_LAB_URL`: realestate-lab 내부 URL (기본 `http://realestate-lab:8000`) — 북마크 콜백 프록시 대상
|
|
||||||
- `REALESTATE_DASHBOARD_URL`: 텔레그램 [전체 보기] 버튼 URL (기본 `http://localhost:8080/realestate`)
|
|
||||||
- `TELEGRAM_BOT_TOKEN`: 텔레그램 봇 토큰 (미설정 시 알림 비활성화)
|
|
||||||
- `TELEGRAM_CHAT_ID`: 텔레그램 채팅 ID
|
|
||||||
- `TELEGRAM_WEBHOOK_URL`: 텔레그램 Webhook URL
|
|
||||||
- `TELEGRAM_WIFE_CHAT_ID`: 아내 chat.id (브리핑 공유 + 대화 허용)
|
|
||||||
- `ANTHROPIC_API_KEY`: 자연어 대화용 Claude API 키 (미설정 시 대화 비활성)
|
|
||||||
- `CONVERSATION_MODEL`: 대화 모델 (기본 `claude-haiku-4-5-20251001`)
|
|
||||||
- `CONVERSATION_HISTORY_LIMIT`: 이력 주입 수 (기본 20)
|
|
||||||
- `CONVERSATION_RATE_PER_MIN`: 채팅당 분당 최대 메시지 (기본 6)
|
|
||||||
- `LOTTO_BACKEND_URL`: 기본 `http://lotto:8000`
|
|
||||||
- `LOTTO_CURATOR_MODEL`: 기본 `claude-sonnet-4-5`
|
|
||||||
- `YOUTUBE_DATA_API_KEY`: YouTube Data API v3 키 (미설정 시 YouTube trending 수집 skip)
|
|
||||||
|
|
||||||
**YouTubeResearchAgent (`agents/youtube.py`)**
|
|
||||||
- `agent_id = "youtube"` — AGENT_REGISTRY에 등록
|
|
||||||
- 09:00 매일 `on_schedule()` → 국가별 YouTube 트렌딩 + Google Trends + Billboard Top20 수집 → music-lab push
|
|
||||||
- `on_command("research", {countries: []})` → 수동 트리거 (백그라운드 asyncio.create_task)
|
|
||||||
- 수집 소스: `youtube_researcher.py` (fetch_youtube_trending, fetch_google_trends, fetch_billboard_top20)
|
|
||||||
- DB: `youtube_research_jobs` 테이블에 실행 이력 기록
|
|
||||||
- 동시실행 방지: `self.state == "working"` 체크 후 거부
|
|
||||||
- 월요일 08:00 `send_weekly_report()` → music-lab 최신 리포트 → 텔레그램 발송
|
|
||||||
|
|
||||||
**텔레그램 자연어 대화 (옵션 B)**
|
|
||||||
- 슬래시 명령이 아닌 일반 문장을 보내면 Claude Haiku 4.5가 응답
|
|
||||||
- 프롬프트 캐싱: `system` 블록 + 히스토리 마지막 블록에 `cache_control: ephemeral` → 5분 TTL
|
|
||||||
- 허용 chat_id 화이트리스트: `TELEGRAM_CHAT_ID`, `TELEGRAM_WIFE_CHAT_ID`
|
|
||||||
- 평가 지표: `conversation_messages` 테이블에 tokens / cache_read / cache_write / latency 기록
|
|
||||||
- 조회: `GET /api/agent-office/conversation/stats?days=7`
|
|
||||||
|
|
||||||
**스케줄러 job**
|
|
||||||
- 07:30 매일 — 주식 뉴스 요약 (`stock_news_job`)
|
|
||||||
- 매주 월요일 07:00 — 로또 큐레이터 브리핑 (`lotto_curate`)
|
|
||||||
- 60초 간격 — 유휴 에이전트 휴식 체크 (`idle_check_job`)
|
|
||||||
- ~~09:15 매일 — 청약 매칭 데일리 리포트~~ (Task 2026-04-28에서 폐기. realestate-lab의 push 트리거로 전환)
|
|
||||||
- 09:00 매일 — YouTube 트렌드 수집 (`youtube_research`) → music-lab `/api/music/market/ingest` push
|
|
||||||
- 매주 월요일 08:00 — YouTube 주간 리포트 텔레그램 발송 (`youtube_weekly_report`)
|
|
||||||
|
|
||||||
**RealestateAgent (`agents/realestate.py`)**
|
|
||||||
- 진입점: `on_new_matches(matches: list[dict]) -> {sent, sent_ids, message_id}`
|
|
||||||
- realestate-lab의 push에서 트리거 → `format_realestate_matches()` + `build_match_keyboard()` → `messaging.send_raw()`
|
|
||||||
- 1~2건이면 풀 카드 + [🔖 북마크]/[📄 공고 보기] 행씩, 3건 이상이면 묶음 카드 + [📋 전체 보기] 단일 URL 버튼
|
|
||||||
- 인라인 키보드 콜백 `realestate_bookmark_{id}` → `webhook.py`의 `_handle_realestate_bookmark` → `service_proxy.realestate_bookmark_toggle()` → realestate-lab의 `PATCH /announcements/{id}/bookmark`
|
|
||||||
- 송신 성공 시 sent_ids 반환 → realestate-lab이 match_results.notified_at 마킹 (멱등)
|
|
||||||
- 실패 시 sent=0/sent_ids=[]/error 반환 → 마킹 안 됨 → 다음 사이클 재시도
|
|
||||||
- `on_command("fetch_matches")`: 수동 트리거 — service_proxy로 매치 가져와 `on_new_matches` 호출
|
|
||||||
- `on_schedule`: 폐기 (cron 등록 제거됨)
|
|
||||||
|
|
||||||
**agent-office API 목록**
|
|
||||||
|
|
||||||
| 메서드 | 경로 | 설명 |
|
| 메서드 | 경로 | 설명 |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| WS | `/api/agent-office/ws` | WebSocket (init, agent_state, task_complete, command_result) |
|
| WS | `/api/agent-office/ws` | WebSocket (init/agent_state/task_complete/command_result) |
|
||||||
| GET | `/api/agent-office/agents` | 에이전트 목록 |
|
| GET | `/api/agent-office/agents` (+ `/{id}`, `/{id}/tasks`, `/{id}/logs`) | 에이전트 목록·상세·이력·로그 |
|
||||||
| GET | `/api/agent-office/agents/{id}` | 에이전트 상세 (설정 + 상태) |
|
|
||||||
| PUT | `/api/agent-office/agents/{id}` | 에이전트 설정 수정 |
|
| PUT | `/api/agent-office/agents/{id}` | 에이전트 설정 수정 |
|
||||||
| GET | `/api/agent-office/agents/{id}/tasks` | 에이전트 작업 이력 |
|
| GET | `/api/agent-office/tasks/pending` (+ `/{id}`) | 승인 대기·작업 상세 |
|
||||||
| GET | `/api/agent-office/agents/{id}/logs` | 에이전트 로그 |
|
| POST | `/api/agent-office/command` | 에이전트 명령 전송 |
|
||||||
| GET | `/api/agent-office/tasks/pending` | 승인 대기 작업 목록 |
|
|
||||||
| GET | `/api/agent-office/tasks/{id}` | 작업 상세 |
|
|
||||||
| POST | `/api/agent-office/command` | 에이전트에 명령 전송 |
|
|
||||||
| POST | `/api/agent-office/approve` | 작업 승인/거부 |
|
| POST | `/api/agent-office/approve` | 작업 승인/거부 |
|
||||||
| POST | `/api/agent-office/telegram/webhook` | 텔레그램 Webhook 수신 (realestate_bookmark_* 콜백 포함) |
|
| POST | `/api/agent-office/telegram/webhook` | 텔레그램 Webhook (realestate_bookmark_* 콜백 포함) |
|
||||||
| POST | `/api/agent-office/realestate/notify` | realestate-lab 전용 push 수신 → 텔레그램 송신 |
|
| POST | `/api/agent-office/realestate/notify` | realestate-lab 전용 push 수신 → 텔레그램 |
|
||||||
| GET | `/api/agent-office/states` | 전체 에이전트 상태 조회 |
|
| GET | `/api/agent-office/states` | 전체 에이전트 상태 |
|
||||||
| GET | `/api/agent-office/conversation/stats` | 텔레그램 자연어 대화 토큰·캐시 통계 (`days` 필터) |
|
| GET | `/api/agent-office/activity` | 전 에이전트 통합 활동 피드 (tasks+logs UNION). 필터 `agent_id`/`type`(task\|log)/`status`/`days` + `limit`/`offset` |
|
||||||
| POST | `/api/agent-office/youtube/research` | YouTube 트렌드 수집 수동 트리거 (body: `{countries: []}`) |
|
| GET | `/api/agent-office/conversation/stats` | 텔레그램 대화 토큰·캐시 통계 (`days`) |
|
||||||
| GET | `/api/agent-office/youtube/research/status` | 마지막 수집 작업 상태 |
|
| POST/GET | `/api/agent-office/youtube/research` (+ `/status`) | YouTube 트렌드 수집 트리거/상태 |
|
||||||
|
| GET | `/api/agent-office/lotto/signals`, `/lotto/baselines` | 로또 시그널 이력·baseline |
|
||||||
|
| POST | `/api/agent-office/lotto/signal-check` | 로또 시그널 평가 트리거 (light/sim/deep) |
|
||||||
|
|
||||||
|
### tarot-lab (tarot-lab/)
|
||||||
|
타로 카드 해석 (Claude Sonnet, agent-office에서 2026-05-25 독립).
|
||||||
|
- 핵심 파일: `app/main.py`, `pipeline.py`, `prompt.py`, `schema.py`, `models.py`, `config.py`, `db.py`
|
||||||
|
- interpret(해석만, DB 저장 X) ↔ readings(저장) 2단계 분리. nginx 600s
|
||||||
|
- 📌 상세(`tarot_readings` 스키마·max_tokens/truncation/reroll·env): **`service_tarot.md`**
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| POST | `/api/tarot/interpret` | 카드 배치 → AI 해석 (저장 X) |
|
||||||
|
| POST/GET | `/api/tarot/readings` | 해석 결과 저장 / 기록 목록 (page, favorite, spread_type, category) |
|
||||||
|
| GET/PATCH/DELETE | `/api/tarot/readings/{id}` | 기록 상세 / 즐겨찾기·note 수정 / 삭제 |
|
||||||
|
|
||||||
|
### saju-lab (saju-lab/)
|
||||||
|
사주 분석 + 궁합 (Claude Sonnet 4.6 + prompt caching, saju-web TS→Python 포팅).
|
||||||
|
- 핵심 파일: `main.py`, `routers/`(saju+compat), `interpret/`(pipeline+prompt+schema), `calculator/`(core/analysis/daeun/lunar/shinsal/compatibility/fortune_scores/lucky/monthly_flow/solar_terms)
|
||||||
|
- TS 원본 버그도 동등 재현(`feedback_ts_python_reference_fixture.md`). DSM timeout 300s+
|
||||||
|
- 📌 상세(DB 스키마·계산엔진·TS 버그·UI v2 schema 매핑): **`service_saju.md`**
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| POST | `/api/saju/interpret` | 사주 계산 + AI 해석 + DB 저장 (fortune_scores/lucky/monthly_flow 포함) |
|
||||||
|
| GET | `/api/saju/readings` (+ `/{id}`) | 사주 기록 목록/상세 |
|
||||||
|
| PATCH/DELETE | `/api/saju/readings/{id}` | 즐겨찾기·메모 수정 / 삭제 |
|
||||||
|
| GET | `/api/saju/current-fortune` | 저장된 사주의 현재 연도 세운 (실시간 계산) |
|
||||||
|
| POST | `/api/saju/compat/interpret` | 두 사람 궁합 계산 + AI 해석 |
|
||||||
|
| GET | `/api/saju/compat/readings` (+ `/{id}`) | 궁합 기록 목록/상세 |
|
||||||
|
| PATCH/DELETE | `/api/saju/compat/readings/{id}` | 궁합 즐겨찾기·메모 수정 / 삭제 |
|
||||||
|
|
||||||
|
### packs-lab (packs-lab/)
|
||||||
|
NAS 자료 다운로드 자동화 — DSM 공유링크 발급 + 5GB chunked 업로드. Vercel SaaS와 HMAC 통신.
|
||||||
|
- 핵심 파일: `app/main.py`, `auth.py`, `dsm_client.py`, `routes.py`, `models.py`
|
||||||
|
- 외부 DB: Supabase `pack_files`. 경로 3분리(`PACK_DATA_PATH`→`PACK_BASE_DIR`→`PACK_HOST_DIR`)
|
||||||
|
- 📌 상세(HMAC 패턴·chunked contract·운영검증 DSM env·backlog): **`service_packs.md`**
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| POST | `/api/packs/sign-link` | HMAC → DSM 4시간 다운로드 URL 발급 |
|
||||||
|
| POST | `/api/packs/admin/mint-token` | HMAC → 일회성 upload 토큰 (30분 TTL) |
|
||||||
|
| POST | `/api/packs/upload` | Bearer → single-shot 5GB 저장 + Supabase INSERT |
|
||||||
|
| POST | `/api/packs/upload/init` | Bearer → chunked 세션 초기화 (jti consume) |
|
||||||
|
| PUT | `/api/packs/upload/{session_id}/chunk` | 부분파일 append (offset 불일치 시 409 + `X-Current-Offset`) |
|
||||||
|
| GET | `/api/packs/upload/{session_id}/status` | `{written, expected_size}` (재개용) |
|
||||||
|
| POST | `/api/packs/upload/{session_id}/complete` | rename + Supabase INSERT |
|
||||||
|
| DELETE | `/api/packs/upload/{session_id}` | 세션 중단 + 부분파일 정리 |
|
||||||
|
| GET | `/api/packs/list` | HMAC → 활성 pack_files |
|
||||||
|
| DELETE | `/api/packs/{file_id}` | HMAC → soft delete |
|
||||||
|
|
||||||
### personal (personal/)
|
### personal (personal/)
|
||||||
- 개인 서비스 (포트폴리오 + 블로그 + 투두 통합)
|
포트폴리오 + 블로그 + 투두 통합. 편집 인증 Bearer 24h TTL (인메모리).
|
||||||
- DB: `/app/data/personal.db` (profile, careers, projects, skills, introductions, todos, blog_posts 테이블)
|
- 핵심 파일: `main.py`, `db.py`, `models.py`, `auth.py`
|
||||||
- 편집 인증: `PORTFOLIO_EDIT_PASSWORD` 환경변수, Bearer 토큰 (24시간 TTL)
|
- ⚠️ `DELETE /api/todos/done`은 `DELETE /api/todos/{id}`보다 **반드시 먼저** 등록 (FastAPI prefix 매칭)
|
||||||
- 파일 구조: `main.py`, `db.py`, `models.py`, `auth.py`
|
- 📌 상세(DB 7테이블·인증 흐름·라우트 함정): **`service_personal.md`**
|
||||||
|
|
||||||
**환경변수**
|
|
||||||
- `PORTFOLIO_EDIT_PASSWORD`: 편집 모드 비밀번호 (미설정 시 편집 불가)
|
|
||||||
|
|
||||||
**personal API 목록**
|
|
||||||
|
|
||||||
| 메서드 | 경로 | 설명 |
|
| 메서드 | 경로 | 설명 |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| GET | `/api/profile/public` | 공개 데이터 일괄 조회 |
|
| GET | `/api/profile/public` | 공개 데이터 일괄 조회 |
|
||||||
| POST | `/api/profile/auth` | 비밀번호 인증 → 토큰 |
|
| POST | `/api/profile/auth` | 비밀번호 인증 → 토큰 |
|
||||||
| GET | `/api/profile/profile` | 프로필 조회 (인증) |
|
| GET/PUT | `/api/profile/profile` | 프로필 조회/수정 (인증) |
|
||||||
| PUT | `/api/profile/profile` | 프로필 수정 (인증) |
|
| GET/POST/PUT/DELETE | `/api/profile/careers`, `/projects`, `/skills`, `/introductions` | 각 섹션 CRUD (인증) |
|
||||||
| GET | `/api/profile/careers` | 경력 목록 (인증) |
|
|
||||||
| POST | `/api/profile/careers` | 경력 추가 (인증) |
|
|
||||||
| PUT | `/api/profile/careers/{id}` | 경력 수정 (인증) |
|
|
||||||
| DELETE | `/api/profile/careers/{id}` | 경력 삭제 (인증) |
|
|
||||||
| GET | `/api/profile/projects` | 프로젝트 목록 (인증) |
|
|
||||||
| POST | `/api/profile/projects` | 프로젝트 추가 (인증) |
|
|
||||||
| PUT | `/api/profile/projects/{id}` | 프로젝트 수정 (인증) |
|
|
||||||
| DELETE | `/api/profile/projects/{id}` | 프로젝트 삭제 (인증) |
|
|
||||||
| GET | `/api/profile/skills` | 기술 목록 (인증) |
|
|
||||||
| POST | `/api/profile/skills` | 기술 추가 (인증) |
|
|
||||||
| PUT | `/api/profile/skills/{id}` | 기술 수정 (인증) |
|
|
||||||
| DELETE | `/api/profile/skills/{id}` | 기술 삭제 (인증) |
|
|
||||||
| GET | `/api/profile/introductions` | 자기소개 목록 (인증) |
|
|
||||||
| POST | `/api/profile/introductions` | 자기소개 추가 (인증) |
|
|
||||||
| PUT | `/api/profile/introductions/{id}` | 자기소개 수정 (인증) |
|
|
||||||
| DELETE | `/api/profile/introductions/{id}` | 자기소개 삭제 (인증) |
|
|
||||||
| PATCH | `/api/profile/introductions/{id}/main` | 메인 자기소개 지정 (인증) |
|
| PATCH | `/api/profile/introductions/{id}/main` | 메인 자기소개 지정 (인증) |
|
||||||
| GET | `/api/todos` | 투두 전체 목록 |
|
| GET/POST | `/api/todos` | 투두 목록/생성 |
|
||||||
| POST | `/api/todos` | 투두 생성 |
|
| PUT/DELETE | `/api/todos/{id}` | 투두 수정/삭제 |
|
||||||
| PUT | `/api/todos/{id}` | 투두 수정 |
|
| DELETE | `/api/todos/done` | 완료 항목 일괄 삭제 (라우트 순서 주의) |
|
||||||
| DELETE | `/api/todos/done` | 완료 항목 일괄 삭제 |
|
| GET/POST/PUT/DELETE | `/api/blog/posts` (+ `/{id}`) | 블로그 글 CRUD |
|
||||||
| DELETE | `/api/todos/{id}` | 투두 개별 삭제 |
|
|
||||||
| GET | `/api/blog/posts` | 블로그 글 목록 |
|
|
||||||
| POST | `/api/blog/posts` | 블로그 글 생성 |
|
|
||||||
| PUT | `/api/blog/posts/{id}` | 블로그 글 수정 |
|
|
||||||
| DELETE | `/api/blog/posts/{id}` | 블로그 글 삭제 |
|
|
||||||
|
|
||||||
### packs-lab (packs-lab/)
|
### travel-proxy (travel-proxy/)
|
||||||
- NAS 자료 다운로드 자동화 — Synology DSM 공유링크 발급 + 5GB 멀티파트 업로드 수신
|
여행 사진 API + 썸네일(480×480 Pillow) + 지역 관리.
|
||||||
- Vercel SaaS와 HMAC 인증으로 통신, 사용자 인증은 Vercel이 Supabase로 처리 (본 서비스는 외부 인증 없음)
|
- 핵심 파일: `main.py`, `db.py`, `indexer.py`. DB `/data/thumbs/travel.db`
|
||||||
- DB: 외부 Supabase `pack_files` 테이블 (DDL: `packs-lab/supabase/pack_files.sql`)
|
- 지역 3중 파일: `region_map.json`(RO 원본) + `region_map_extra.json`(RW 오버라이드: `_regions_meta`/`_removes`/`미분류`) + `regions.geojson`. PUID/PGID 권한 주입
|
||||||
- 파일 구조: `app/main.py`, `app/auth.py`, `app/dsm_client.py`, `app/routes.py`, `app/models.py`
|
- 📌 상세(DB 스키마·지역 병합·sync 동작·썸네일 폴백): **`service_travel.md`**
|
||||||
- 경로 3분리: `PACK_DATA_PATH`(호스트 OS path, docker volume 좌측) → `PACK_BASE_DIR`(컨테이너 내부, upload 저장 target) → `PACK_HOST_DIR`(DSM API path, Supabase에 저장). 운영 NAS에서 `PACK_HOST_DIR` 미설정 시 sign-link가 컨테이너 경로를 DSM에 전달해 파일을 못 찾음.
|
|
||||||
- ⚠️ **DSM API path 형식**: Synology DSM API는 일반 사용자 권한일 때 `/<shared_folder>/...` 형식만 인식하고 `/volume1/...` 절대경로는 거부(error 408). 운영 NAS는 반드시 `PACK_HOST_DIR=/docker/webpage/media/packs` (shared folder 시점) 설정. admin 사용자만 `/volume1/...` 사용 가능하나 보안상 권장 안 함.
|
|
||||||
|
|
||||||
**환경변수**
|
|
||||||
- `DSM_HOST` / `DSM_USER` / `DSM_PASS`: Synology DSM 7.x 인증 (공유 링크 발급용)
|
|
||||||
- `DSM_VERIFY_SSL`: SSL 검증 (default `true`). LAN IP + self-signed cert 환경에서 IP mismatch 시 `false` 설정 (LAN 내부 통신이라 허용)
|
|
||||||
- `BACKEND_HMAC_SECRET`: Vercel SaaS와 양쪽 공유 시크릿 (HMAC SHA256)
|
|
||||||
- `SUPABASE_URL` / `SUPABASE_SERVICE_KEY`: Supabase pack_files 테이블 접근 (service_role, RLS 우회)
|
|
||||||
- `UPLOAD_TOKEN_TTL_SEC`: admin upload 토큰 TTL (기본 1800초 = 30분)
|
|
||||||
- `PACK_BASE_DIR`: 컨테이너 내부 저장 경로 (기본 `/app/data/packs`)
|
|
||||||
- `PACK_HOST_DIR`: DSM API용 path. **운영 NAS는 `/docker/webpage/media/packs` (shared folder 시점)**. 미설정 시 `PACK_BASE_DIR`로 fallback (DSM 호출 X 환경에서만 안전)
|
|
||||||
- `PACK_DATA_PATH`: docker-compose volume 마운트의 호스트 측 OS 경로 (로컬 `./data/packs`, NAS `/volume1/docker/webpage/media/packs`)
|
|
||||||
|
|
||||||
**HMAC 인증 패턴**
|
|
||||||
- Vercel → backend 요청: `X-Timestamp` (UNIX 초) + `X-Signature` (HMAC_SHA256(timestamp + "." + body, secret))
|
|
||||||
- Replay 방어: 타임스탬프 ±5분 윈도우
|
|
||||||
- admin browser → backend upload: `Authorization: Bearer <token>` (jti 단발성)
|
|
||||||
|
|
||||||
**packs-lab API 목록**
|
|
||||||
|
|
||||||
| 메서드 | 경로 | 설명 |
|
| 메서드 | 경로 | 설명 |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| POST | `/api/packs/sign-link` | Vercel HMAC → DSM Sharing.create로 4시간 유효 다운로드 URL 발급 |
|
| GET | `/api/travel/regions` | 지역 GeoJSON (커스텀 지역 동적 추가) |
|
||||||
| POST | `/api/packs/admin/mint-token` | Vercel HMAC → 일회성 upload 토큰 발급 (기본 30분 TTL) |
|
| GET | `/api/travel/photos` | 사진 목록 (region, page, size) |
|
||||||
| POST | `/api/packs/upload` | Bearer token → multipart 5GB 저장 + Supabase INSERT |
|
| POST | `/api/travel/sync` | 폴더 스캔 → DB 동기화 + 썸네일 생성 |
|
||||||
| GET | `/api/packs/list` | Vercel HMAC → 활성 pack_files 목록 (deleted_at IS NULL) |
|
| GET | `/api/travel/albums` | 앨범 목록 + 사진 수 + 커버 + region |
|
||||||
| DELETE | `/api/packs/{file_id}` | Vercel HMAC → soft delete (DSM 공유는 자동 만료) |
|
| PUT | `/api/travel/albums/{album}/cover` | 앨범 커버 지정 |
|
||||||
|
| PUT | `/api/travel/albums/{album}/region` | 앨범 지역 변경 |
|
||||||
|
| PUT | `/api/travel/regions/{region_id}` | 커스텀 지역 이름/좌표 수정 |
|
||||||
|
|
||||||
### deployer (deployer/)
|
### deployer (deployer/)
|
||||||
- Webhook 검증: `X-Gitea-Signature` (HMAC SHA256, `compare_digest` 사용)
|
Gitea Webhook 수신 → 자동 배포. HMAC SHA256 검증(`X-Gitea-Signature` 또는 `X-Hub-Signature-256`).
|
||||||
- `WEBHOOK_SECRET` 환경변수로 시크릿 관리
|
- 즉시 `{"ok": true}` 응답 후 BackgroundTask 배포. 동시 배포 락(threading.Lock + flock). 10분 타임아웃
|
||||||
- Webhook 수신 즉시 `{"ok": True}` 응답 후 BackgroundTask로 배포 실행
|
- 📌 상세(검증 흐름·배포 스크립트 2단 구조·`.releases` 백업·헬스체크 게이트): **`service_deployer.md`**
|
||||||
- 배포 타임아웃: 10분 (`scripts/deploy.sh`)
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| POST | `/webhook` | Gitea Webhook 수신 → HMAC 검증 → 배포 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. 주의사항
|
## 10. 공유 인프라
|
||||||
|
|
||||||
- **Nginx trailing slash**: `/api/portfolio`는 trailing slash 없이도 매칭되도록 두 location 블록으로 처리
|
### `_shared/access_log.py` 공용 모듈
|
||||||
- **라우트 순서**: `DELETE /api/todos/done`은 `DELETE /api/todos/{id}` 보다 **반드시 먼저** 등록 (personal 서비스, FastAPI prefix 매칭 순서)
|
5개 서비스(lotto, stock, music-lab, insta-lab, realestate-lab)가 공유. agent-office의 `/agents/{id}/logs`가 이를 통해 각 서비스 `/logs/recent`를 수집·병합.
|
||||||
- **PUID/PGID**: travel-proxy는 NAS 파일 권한을 위해 PUID/PGID를 환경변수로 주입
|
- `install(app)` 단일 진입점 → middleware(요청 계측 + ring buffer maxlen=500) + `BufferLogHandler` + `GET /logs/recent?limit&since&path_prefix`
|
||||||
- **캐시 전략**: `index.html`은 `no-store`, `assets/`는 1년 장기 캐시(immutable)
|
- docker-compose: `${RUNTIME_PATH}/_shared:/shared/_shared:ro` + `PYTHONPATH=/app:/shared`
|
||||||
- **Frontend 배포**: git push로 자동 배포되지 않음. 로컬 빌드 후 NAS에 수동 업로드
|
- 제외: `/health` `/docs` `/logs/recent` 등 + `OPTIONS`/`HEAD`
|
||||||
- **.env 파일**: 절대 커밋 금지. `.env.example`만 레포에 포함
|
|
||||||
- **공휴일 목록**: `stock-lab/app/holidays.json` 매년 수동 갱신 필요 (KRX 기준)
|
### `redis` 컨테이너 (6379)
|
||||||
- **Windows AI 서버 IP**: `192.168.45.59` — 공유기 DHCP 고정 예약으로 고정. Tailscale은 Synology에서 TCP 불가(userspace 모드)라 로컬 IP 사용
|
4개 서비스 비동기 큐 공유. 각 서비스가 `queue:<svc>-render` push → Windows AI 워커 pop → 완료 후 `/api/internal/<svc>/update` webhook → DB 업데이트.
|
||||||
- **현재가 조회**: 네이버 모바일 API → HTML 파싱 폴백, 3분 TTL 캐시 (`price_fetcher.py`)
|
- `queue:music-render`, `queue:video-render`, `queue:image-render`, `queue:insta-render`
|
||||||
- **시뮬레이션 교체 방식**: `best_picks`는 교체형 — 새 시뮬레이션 실행 시 `is_active=0`으로 비활성화 후 신규 입력
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 주의사항 (cross-cutting)
|
||||||
|
|
||||||
|
- **`.env` 절대 커밋 금지** (`.env.example`만 레포 포함)
|
||||||
|
- **커밋 경로**: web-ui / web-backend 별도 Git — 각 경로에서만 커밋 (`feedback_commit_repo.md`)
|
||||||
|
- **Docker는 NAS에서만 구동** (`feedback_docker_nas.md`)
|
||||||
|
- **Nginx trailing slash**: `/api/portfolio`는 두 location 블록으로 slash 유무 모두 매칭
|
||||||
|
- **라우트 순서**: personal `DELETE /api/todos/done`을 `/{id}`보다 먼저 등록
|
||||||
|
- **DB 마이그레이션**: 스키마 변경 시 ALTER TABLE 멱등 패턴 (각 서비스 메모리 참조)
|
||||||
|
- **공휴일 목록**: `stock/app/holidays.json` 매년 수동 갱신 (KRX 기준)
|
||||||
|
- **Windows AI 서버 IP**: `192.168.45.59` (DHCP 고정 예약). Tailscale은 Synology userspace 모드라 TCP 불가 → 로컬 IP 사용
|
||||||
|
- **렌더/생성 워커 분리**: music/video/image/insta 무거운 작업은 Windows `web-ai` 워커. NAS 코드의 `*_provider.py`/`card_renderer.py`가 DEPRECATED stub면 실 로직은 web-ai 쪽이 authoritative
|
||||||
|
- **Playwright Dockerfile**: bookworm 고정 + 수동 chromium deps, `--with-deps` 금지 (`feedback_playwright_dockerfile.md`)
|
||||||
|
- **lab 네이밍**: `-lab`은 개발/연구 단계에만, 정식 서비스엔 미사용 (`feedback_lab_naming.md`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 협업 팀 버스 (co-gahusb) — 이 세션의 역할: **BE**
|
||||||
|
|
||||||
|
이 세션은 백엔드(BE) 역할이다. co-gahusb MCP 툴로 다른 세션(FE/AI/Producer)과 협업한다.
|
||||||
|
- **소유권**: 이 세션은 `web-backend` repo만 쓴다(FE=web-ui, AI=web-ai).
|
||||||
|
- **공유 리소스 변경 전 반드시 `acquire_lock(resource, "BE")`**: 대상 = `nas-deploy`, `stock-db-schema`, `lotto-db-schema`, `memory-mirror`, `nginx-conf`, `compose`. 점유 중이면 대기, 긴 작업은 `heartbeat_lock`, 끝나면 `release_lock`.
|
||||||
|
- **모든 툴 호출에 `role="BE"`** (또는 `from_role`/`created_by`에 BE).
|
||||||
|
- **수신**: `/loop`로 주기적으로 `read_inbox("BE", after_id=<last>)` + `list_tasks(assignee_role="BE")` 확인.
|
||||||
|
- 키 `CO_BUS_KEY`는 환경변수로 주입(커밋 금지).
|
||||||
|
|||||||
142
README.md
142
README.md
@@ -1,7 +1,7 @@
|
|||||||
# web-backend
|
# web-backend
|
||||||
|
|
||||||
Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
|
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) │
|
│ ├── 정적 SPA 서빙 (React + Vite) │
|
||||||
│ └── API 리버스 프록시 │
|
│ └── API 리버스 프록시 │
|
||||||
│ ├── /api/ → lotto-backend:8000 (로또·블로그·투두)│
|
│ ├── /api/ → lotto:8000 (로또) │
|
||||||
│ ├── /api/stock/, /trade/ → stock-lab:8000 │
|
│ ├── /api/stock/, /trade/ → stock:8000 │
|
||||||
│ ├── /api/portfolio → stock-lab:8000 │
|
│ ├── /api/portfolio → stock:8000 │
|
||||||
│ ├── /api/music/ → music-lab:8000 │
|
│ ├── /api/music/ → music-lab:8000 │
|
||||||
│ ├── /api/blog-marketing/ → blog-lab:8000 │
|
│ ├── /api/insta/ → insta-lab:8000 │
|
||||||
│ ├── /api/realestate/ → realestate-lab:8000 │
|
│ ├── /api/realestate/ → realestate-lab:8000 │
|
||||||
│ ├── /api/agent-office/ → agent-office:8000 (+ WebSocket) │
|
│ ├── /api/agent-office/ → agent-office:8000 (+ WebSocket) │
|
||||||
│ ├── /api/travel/ → travel-proxy:8000 │
|
│ ├── /api/profile/, /todos, /blog/ → personal:8000 │
|
||||||
│ ├── /media/music/… (nginx 직접 서빙, 생성 오디오) │
|
│ ├── /api/packs/ → packs-lab:8000 (HMAC + 5GB upload) │
|
||||||
|
│ ├── /api/travel/ → travel-proxy:8000 │
|
||||||
|
│ ├── /media/music/, /media/videos/ (nginx 직접 서빙, 미디어) │
|
||||||
│ ├── /media/travel/… (nginx 직접 서빙, 사진/썸네일) │
|
│ ├── /media/travel/… (nginx 직접 서빙, 사진/썸네일) │
|
||||||
│ └── /webhook → deployer:9000 │
|
│ └── /webhook → deployer:9000 │
|
||||||
└──────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
| 컨테이너 | 포트 | 역할 |
|
| 컨테이너 | 포트 | 역할 |
|
||||||
|---------|------|------|
|
|---------|------|------|
|
||||||
| `lotto-backend` | 18000 | 로또 데이터 수집·분석·추천 + 블로그·투두 API |
|
| `lotto` | 18000 | 로또 데이터 수집·분석·추천 API |
|
||||||
| `stock-lab` | 18500 | 주식 뉴스·AI 요약·KIS 실계좌·포트폴리오·자산 추적 |
|
| `stock` | 18500 | 주식 뉴스·AI 요약·KIS 실계좌·포트폴리오·자산 추적 |
|
||||||
| `music-lab` | 18600 | AI 음악 생성 (Suno + 로컬 MusicGen 듀얼 프로바이더) |
|
| `music-lab` | 18600 | AI 음악 생성 (Suno + 로컬 MusicGen 듀얼 프로바이더) + YouTube 수익화 |
|
||||||
| `blog-lab` | 18700 | 블로그 마케팅 수익화 (키워드→글 생성→리뷰→발행) |
|
| `insta-lab` | 18700 | 인스타 카드 피드 자동 생성 (뉴스→키워드→10페이지 카드, Playwright) |
|
||||||
| `realestate-lab` | 18800 | 청약 공고 자동 수집·프로필 매칭 |
|
| `realestate-lab` | 18800 | 청약 공고 자동 수집·5티어 매칭·신규 매칭 push |
|
||||||
| `agent-office` | 18900 | AI 에이전트 가상 오피스 (WebSocket + 텔레그램 봇) |
|
| `agent-office` | 18900 | AI 에이전트 가상 오피스 (WebSocket + 텔레그램 봇) |
|
||||||
|
| `personal` | 18850 | 개인 서비스 — 포트폴리오·블로그·투두 통합 |
|
||||||
|
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 청크 업로드) |
|
||||||
| `travel-proxy` | 19000 | 여행 사진 API + 온디맨드 썸네일 |
|
| `travel-proxy` | 19000 | 여행 사진 API + 온디맨드 썸네일 |
|
||||||
| `lotto-frontend` | 8080 | SPA 서빙 + 리버스 프록시 |
|
| `frontend` | 8080 | SPA 서빙 + 리버스 프록시 |
|
||||||
| `webpage-deployer` | 19010 | Gitea Webhook → 자동 배포 |
|
| `webpage-deployer` | 19010 | Gitea Webhook → 자동 배포 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -44,12 +48,14 @@ Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
|
|||||||
|
|
||||||
```
|
```
|
||||||
web-backend/
|
web-backend/
|
||||||
├── backend/ # lotto-backend (로또·블로그·투두)
|
├── lotto/ # 로또 추천·통계·시뮬레이션
|
||||||
├── stock-lab/ # 주식·포트폴리오
|
├── stock/ # 주식·포트폴리오·KIS 연동
|
||||||
├── music-lab/ # AI 음악 생성
|
├── music-lab/ # AI 음악 생성 + YouTube 수익화
|
||||||
├── blog-lab/ # 블로그 마케팅 파이프라인
|
├── insta-lab/ # 인스타 카드 피드 자동 생성 (Playwright)
|
||||||
├── realestate-lab/ # 청약 자동 수집·매칭
|
├── realestate-lab/ # 청약 자동 수집·5티어 매칭
|
||||||
├── agent-office/ # AI 에이전트 오피스 (WS + 텔레그램)
|
├── agent-office/ # AI 에이전트 오피스 (WS + 텔레그램)
|
||||||
|
├── personal/ # 포트폴리오·블로그·투두 통합
|
||||||
|
├── packs-lab/ # NAS 자료 다운로드 자동화 (HMAC + Supabase)
|
||||||
├── travel-proxy/ # 여행 사진 + 썸네일
|
├── travel-proxy/ # 여행 사진 + 썸네일
|
||||||
├── deployer/ # Gitea Webhook 수신 → 자동 배포
|
├── deployer/ # Gitea Webhook 수신 → 자동 배포
|
||||||
├── nginx/default.conf # 리버스 프록시 + SPA + 캐시
|
├── nginx/default.conf # 리버스 프록시 + SPA + 캐시
|
||||||
@@ -74,12 +80,14 @@ curl http://localhost:18500/health
|
|||||||
| 서비스 | 로컬 URL |
|
| 서비스 | 로컬 URL |
|
||||||
|--------|----------|
|
|--------|----------|
|
||||||
| Frontend + API | http://localhost:8080 |
|
| Frontend + API | http://localhost:8080 |
|
||||||
| lotto-backend | http://localhost:18000 |
|
| lotto | http://localhost:18000 |
|
||||||
| stock-lab | http://localhost:18500 |
|
| stock | http://localhost:18500 |
|
||||||
| music-lab | http://localhost:18600 |
|
| music-lab | http://localhost:18600 |
|
||||||
| blog-lab | http://localhost:18700 |
|
| insta-lab | http://localhost:18700 |
|
||||||
| realestate-lab | http://localhost:18800 |
|
| realestate-lab | http://localhost:18800 |
|
||||||
|
| personal | http://localhost:18850 |
|
||||||
| agent-office | http://localhost:18900 |
|
| agent-office | http://localhost:18900 |
|
||||||
|
| packs-lab | http://localhost:18950 |
|
||||||
| travel-proxy | http://localhost:19000 |
|
| travel-proxy | http://localhost:19000 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -99,7 +107,7 @@ curl http://localhost:18500/health
|
|||||||
- 09:10 / 21:10 — 당첨번호 동기화 + 추천 채점
|
- 09:10 / 21:10 — 당첨번호 동기화 + 추천 채점
|
||||||
- 00:05, 04:05, 08:05, 12:05, 16:05, 20:05 — 몬테카를로 시뮬레이션 (후보 20,000 → 상위 100 → best_picks 20쌍 교체)
|
- 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 실계좌 연동 + 포트폴리오·자산 스냅샷.
|
주식 뉴스 스크래핑 + LLM 요약 + KIS 실계좌 연동 + 포트폴리오·자산 스냅샷.
|
||||||
|
|
||||||
@@ -123,20 +131,23 @@ curl http://localhost:18500/health
|
|||||||
- **라이브러리**: 생성 파일은 `/app/data/music/`에 저장되고 Nginx가 `/media/music/`으로 직접 서빙
|
- **라이브러리**: 생성 파일은 `/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 + 상위 블로그 본문 크롤링)
|
NAVER 뉴스 + YouTube 인기 (외부 트렌드)
|
||||||
→ 작가(AI 초안 생성)
|
→ 카테고리별 빈도 + Claude Haiku 정제 → 트렌딩 키워드
|
||||||
→ 마케터(전환율 강화 + 브랜드 링크 삽입)
|
→ 사용자가 키워드 선택
|
||||||
→ 평가자(6기준×10점, 42/60 통과 시 published)
|
→ Claude Sonnet으로 10페이지 카피 추론 (커버 1 + 본문 8 + CTA 1)
|
||||||
|
→ Jinja2 + Playwright 1080×1350 PNG 10장 렌더
|
||||||
|
→ 텔레그램 미디어 그룹 + 추천 캡션·해시태그
|
||||||
```
|
```
|
||||||
|
|
||||||
- **AI 엔진**: Claude API (`claude-sonnet-4-20250514`)
|
- **AI 엔진**: Claude Sonnet (카피) + Claude Haiku (키워드 분류)
|
||||||
- **키워드 분석**: 네이버 검색(블로그+쇼핑) API + 경쟁도/기회 점수
|
- **데이터 소스**: NAVER 뉴스 검색 + YouTube Data API v3 mostPopular(KR)
|
||||||
- **수익 추적**: 포스트별 월간 클릭/구매/수익 기록
|
- **카테고리 가중치**: 사용자가 economy/psychology/celebrity 등 카테고리별 가중치 설정 → 자동 추출 비율에 반영
|
||||||
|
- **카드 디자인**: `insta-lab/app/templates/default/card.html.j2` — 사용자가 자유 수정 (Tailwind 등)
|
||||||
- **프롬프트 템플릿**: DB에 저장 → 코드 배포 없이 수정 가능
|
- **프롬프트 템플릿**: DB에 저장 → 코드 배포 없이 수정 가능
|
||||||
|
|
||||||
### 5. realestate-lab (`/api/realestate/`)
|
### 5. realestate-lab (`/api/realestate/`)
|
||||||
@@ -152,7 +163,7 @@ curl http://localhost:18500/health
|
|||||||
|
|
||||||
AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 4명의 에이전트가 실제 작업을 수행한다.
|
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`
|
- **FSM 상태**: `idle → working → waiting(승인 대기) → reporting → break`
|
||||||
- **실시간 동기화**: WebSocket `/api/agent-office/ws` (init, agent_state, task_complete, command_result)
|
- **실시간 동기화**: WebSocket `/api/agent-office/ws` (init, agent_state, task_complete, command_result)
|
||||||
- **텔레그램 연동**: 양방향 알림 + 인라인 키보드 승인
|
- **텔레그램 연동**: 양방향 알림 + 인라인 키보드 승인
|
||||||
@@ -165,22 +176,28 @@ AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 4명의 에
|
|||||||
|---------|--------|-----|----------|
|
|---------|--------|-----|----------|
|
||||||
| 📈 **주식 트레이더** (`stock`) | 08:00 매일 | — | 뉴스 요약 (LLM) → 텔레그램 아침 브리핑, 종목 알람 등록 |
|
| 📈 **주식 트레이더** (`stock`) | 08:00 매일 | — | 뉴스 요약 (LLM) → 텔레그램 아침 브리핑, 종목 알람 등록 |
|
||||||
| 🎵 **음악 프로듀서** (`music`) | 수동 트리거 | ✅ 작곡 | 프롬프트 수신 → 승인 → Suno API 작곡 → 트랙 푸시 |
|
| 🎵 **음악 프로듀서** (`music`) | 수동 트리거 | ✅ 작곡 | 프롬프트 수신 → 승인 → Suno API 작곡 → 트랙 푸시 |
|
||||||
| ✍️ **블로그 마케터** (`blog`) | 10:00 매일 | ✅ 발행 | 트렌드 키워드 1개 선택 → 리서치→작가→마케터→평가 자동 실행 → 점수·본문을 텔레그램 승인 요청 → 승인 시 `published` 전환, 거절 시 재생성 |
|
| 🎴 **인스타 큐레이터** (`insta`) | 09:00 / 09:30 매일 | — | 09:00 외부 트렌드(NAVER + YouTube) 수집 → 09:30 가중치 기반 키워드 추출 → 텔레그램 후보 5개씩 카테고리당 인라인 버튼 푸시 → 사용자 선택 시 카드 10장 미디어 그룹 |
|
||||||
| 🏢 **청약 애널리스트** (`realestate`) | 09:15 매일 | — | realestate-lab 수집 트리거 → 신규 매칭 상위 5건 + 대시보드 요약을 텔레그램 리포트 (읽음 처리 자동) |
|
| 🏢 **청약 애널리스트** (`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`
|
**Stock** — `fetch_news`, `list_alerts`, `add_alert`, `test_telegram`
|
||||||
**Music** — `compose` (승인 필요), `credits`
|
**Music** — `compose` (승인 필요), `credits`
|
||||||
**Blog** — `research {keyword}`, `add_trend_keyword`, `list_trend_keywords`
|
**Insta** — `extract`, `render <keyword_id>`, `collect_trends`
|
||||||
**Realestate** — `fetch_matches`, `dashboard`
|
**Realestate** — `fetch_matches`, `dashboard`
|
||||||
|
**YouTube** — `research {countries: [...]}`
|
||||||
|
|
||||||
#### 스케줄러 잡
|
#### 스케줄러 잡
|
||||||
|
|
||||||
- 07:00 월요일 — Lotto: AI 큐레이터 브리핑 (5세트 + 내러티브)
|
- 07:00 월요일 — Lotto: AI 큐레이터 브리핑 (5세트 + 내러티브)
|
||||||
- 07:30 — Stock: 뉴스 요약
|
- 07:30 — Stock: 뉴스 요약
|
||||||
- 09:15 — Realestate: 매칭 리포트
|
- 08:00 평일 — Stock: AI 뉴스 sentiment 분석
|
||||||
- 10:00 — Blog: 자동 파이프라인 (리서치→생성→리뷰→승인 대기)
|
- 09:00 — YouTube: 한국 트렌딩 수집
|
||||||
|
- 09:00 — Insta: 외부 트렌드 수집 (NAVER 인기 + YouTube mostPopular)
|
||||||
|
- 09:30 — Insta: 키워드 추출 (가중치 적용) + 텔레그램 후보 푸시
|
||||||
|
- 15:40 평일 — Stock: 총 자산 스냅샷
|
||||||
|
- 16:30 평일 — Stock: 스크리너 실행
|
||||||
- 60초 interval — 유휴 에이전트 휴식 체크
|
- 60초 interval — 유휴 에이전트 휴식 체크
|
||||||
|
|
||||||
### 7. travel-proxy (`/api/travel/`)
|
### 7. travel-proxy (`/api/travel/`)
|
||||||
@@ -224,7 +241,7 @@ Gitea Webhook 수신 → NAS 자동 배포.
|
|||||||
| 공동 출현 | 15% | 번호 쌍 동시 출현 빈도 |
|
| 공동 출현 | 15% | 번호 쌍 동시 출현 빈도 |
|
||||||
| 다양성 | 10% | 연속번호·범위·구간 커버리지 |
|
| 다양성 | 10% | 연속번호·범위·구간 커버리지 |
|
||||||
|
|
||||||
### LLM 요약 provider 추상화 (stock-lab)
|
### LLM 요약 provider 추상화 (stock)
|
||||||
|
|
||||||
`ai_summarizer.py`는 provider 분리 구조. `summarize_news(articles)` 시그니처는 provider와 무관하게 고정.
|
`ai_summarizer.py`는 provider 분리 구조. `summarize_news(articles)` 시그니처는 provider와 무관하게 고정.
|
||||||
|
|
||||||
@@ -232,7 +249,7 @@ Gitea Webhook 수신 → NAS 자동 배포.
|
|||||||
- `_summarize_with_ollama`: Ollama `/api/generate` (타임아웃 180s, qwen3:14b 첫 로드 대응)
|
- `_summarize_with_ollama`: Ollama `/api/generate` (타임아웃 180s, qwen3:14b 첫 로드 대응)
|
||||||
- 실패 시 `LLMError` (구 `OllamaError` alias 유지)
|
- 실패 시 `LLMError` (구 `OllamaError` alias 유지)
|
||||||
|
|
||||||
### 총 자산 스냅샷 (stock-lab)
|
### 총 자산 스냅샷 (stock)
|
||||||
|
|
||||||
평일 15:40 자동 실행 → `holidays.json`으로 공휴일 스킵 → 포트폴리오 현재가 조회 + 예수금 합계 → `asset_snapshots` upsert (date UNIQUE).
|
평일 15:40 자동 실행 → `holidays.json`으로 공휴일 스킵 → 포트폴리오 현재가 조회 + 예수금 합계 → `asset_snapshots` upsert (date UNIQUE).
|
||||||
|
|
||||||
@@ -265,13 +282,15 @@ git push → Gitea → X-Gitea-Signature (HMAC SHA256)
|
|||||||
|
|
||||||
| DB | 소유 서비스 | 주요 테이블 |
|
| DB | 소유 서비스 | 주요 테이블 |
|
||||||
|----|------------|-----------|
|
|----|------------|-----------|
|
||||||
| `lotto.db` | lotto-backend | draws, recommendations, simulation_runs/candidates, best_picks, purchase_history, strategy_performance/weights, weekly_reports, lotto_briefings, todos, blog_posts |
|
| `lotto.db` | lotto | draws, recommendations, simulation_runs/candidates, best_picks, purchase_history, strategy_performance/weights, weekly_reports, lotto_briefings |
|
||||||
| `stock.db` | stock-lab | articles, portfolio, broker_cash, asset_snapshots, sell_history |
|
| `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) |
|
| `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 |
|
||||||
| `blog_marketing.db` | blog-lab | keyword_analyses, blog_posts, brand_links, commissions, generation_tasks, prompt_templates |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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
|
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
|
||||||
WEBHOOK_SECRET=your_secret_here
|
WEBHOOK_SECRET=your_secret_here
|
||||||
|
|
||||||
# LLM (stock-lab, blog-lab, agent-office 공통)
|
# LLM (stock, insta-lab, agent-office 공통)
|
||||||
ANTHROPIC_API_KEY=sk-ant-...
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
ANTHROPIC_MODEL=claude-haiku-4-5-20251001
|
ANTHROPIC_MODEL=claude-haiku-4-5-20251001
|
||||||
LLM_PROVIDER=claude # claude | ollama
|
LLM_PROVIDER=claude # claude | ollama
|
||||||
OLLAMA_URL=http://192.168.45.59:11435
|
OLLAMA_URL=http://192.168.45.59:11435
|
||||||
OLLAMA_MODEL=qwen3:14b
|
OLLAMA_MODEL=qwen3:14b
|
||||||
|
|
||||||
|
# stock admin protection (CODE_REVIEW F2)
|
||||||
|
ADMIN_API_KEY=
|
||||||
|
ALLOW_UNAUTHENTICATED_ADMIN=false
|
||||||
|
|
||||||
# music-lab
|
# music-lab
|
||||||
SUNO_API_KEY=
|
SUNO_API_KEY=
|
||||||
MUSIC_AI_SERVER_URL=
|
MUSIC_AI_SERVER_URL=
|
||||||
MUSIC_MEDIA_BASE=/media/music
|
MUSIC_MEDIA_BASE=/media/music
|
||||||
|
|
||||||
# blog-lab
|
# insta-lab + agent-office (NAVER 검색 + YouTube Data API 공유)
|
||||||
NAVER_CLIENT_ID=
|
NAVER_CLIENT_ID=
|
||||||
NAVER_CLIENT_SECRET=
|
NAVER_CLIENT_SECRET=
|
||||||
|
YOUTUBE_DATA_API_KEY=
|
||||||
|
|
||||||
# realestate-lab
|
# realestate-lab
|
||||||
DATA_GO_KR_API_KEY=
|
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
|
# agent-office
|
||||||
TELEGRAM_BOT_TOKEN=
|
TELEGRAM_BOT_TOKEN=
|
||||||
TELEGRAM_CHAT_ID=
|
TELEGRAM_CHAT_ID=
|
||||||
TELEGRAM_WEBHOOK_URL=
|
TELEGRAM_WEBHOOK_URL=
|
||||||
STOCK_LAB_URL=http://stock-lab:8000
|
STOCK_URL=http://stock:8000
|
||||||
MUSIC_LAB_URL=http://music-lab: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_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 매칭)
|
- **라우트 순서** — `DELETE /api/todos/done`은 `/api/todos/{id}` 보다 먼저 등록 필수 (FastAPI prefix 매칭)
|
||||||
- **캐시 전략** — `index.html`: no-store / `assets/`: 1년 immutable
|
- **캐시 전략** — `index.html`: no-store / `assets/`: 1년 immutable
|
||||||
- **PUID/PGID** — travel-proxy는 NAS 파일 권한을 위해 환경변수 주입 필수
|
- **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 사용
|
- **Windows AI 서버 IP** — `192.168.45.59` 공유기 DHCP 고정 예약. Synology Tailscale은 userspace 모드라 TCP 불가 → 로컬 IP 사용
|
||||||
- **Suno CDN** — `cdn1.suno.ai` URL은 임시 만료 → 생성 즉시 로컬 다운로드 필수
|
- **Suno CDN** — `cdn1.suno.ai` URL은 임시 만료 → 생성 즉시 로컬 다운로드 필수
|
||||||
- **LLM provider 롤백** — Claude API 장애 시 `.env`의 `LLM_PROVIDER=ollama`로 전환 후 `docker compose up -d`
|
- **LLM provider 롤백** — Claude API 장애 시 `.env`의 `LLM_PROVIDER=ollama`로 전환 후 `docker compose up -d`
|
||||||
|
|||||||
34
STATUS.md
34
STATUS.md
@@ -1,40 +1,42 @@
|
|||||||
# web-backend — 구현 현황 & 로드맵
|
# web-backend — 구현 현황 & 로드맵
|
||||||
|
|
||||||
> 최종 갱신: 2026-05-07
|
> 최종 갱신: 2026-05-17
|
||||||
> 자세한 서비스·환경변수·DB 표는 [CLAUDE.md](./CLAUDE.md), 설계는 `docs/superpowers/specs/`, 실행 계획은 `docs/superpowers/plans/` 참조.
|
> 자세한 서비스·환경변수·DB 표는 [CLAUDE.md](./CLAUDE.md), 설계는 `docs/superpowers/specs/`, 실행 계획은 `docs/superpowers/plans/` 참조.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 서비스 구현 현황
|
## 1. 서비스 구현 현황
|
||||||
|
|
||||||
### 1-1. 운영 중인 컨테이너 (10개)
|
### 1-1. 운영 중인 컨테이너 (11개)
|
||||||
|
|
||||||
| 서비스 | 포트 | 상태 | 핵심 기능 |
|
| 서비스 | 포트 | 상태 | 핵심 기능 |
|
||||||
|--------|------|------|-----------|
|
|--------|------|------|-----------|
|
||||||
| `lotto-backend` | 18000 | ✅ | 로또 추천·통계·리포트·구매내역 + 블로그·투두 |
|
| `lotto` | 18000 | ✅ | 로또 추천·통계·리포트·구매내역·AI 큐레이터 |
|
||||||
| `stock-lab` | 18500 | ✅ | 주식 뉴스·지수·트레이딩·포트폴리오·자산 스냅샷 |
|
| `stock` | 18500 | ✅ | 주식 뉴스·지수·트레이딩·포트폴리오·자산 스냅샷·스크리너 |
|
||||||
| `music-lab` | 18600 | ✅ | Suno + MusicGen + YouTube 수익화 + 컴파일 |
|
| `music-lab` | 18600 | ✅ | Suno + MusicGen + YouTube 수익화 + 컴파일 |
|
||||||
| `blog-lab` | 18700 | ✅ | 블로그 마케팅 수익화 파이프라인 |
|
| `insta-lab` | 18700 | ✅ | 인스타 카드 피드 자동 생성 (NAVER + YouTube 트렌드 → 10페이지 카드, Playwright) |
|
||||||
| `realestate-lab` | 18800 | ✅ | 청약 수집·5티어 매칭·매칭 알림 |
|
| `realestate-lab` | 18800 | ✅ | 청약 수집·5티어 매칭·매칭 알림 push |
|
||||||
| `agent-office` | 18900 | ✅ | AI 에이전트 (WebSocket + 텔레그램 + YouTubeResearcher) |
|
| `personal` | 18850 | ✅ | 포트폴리오·블로그·투두 통합 (개인 서비스) |
|
||||||
| `packs-lab` | 18950 | ✅ | NAS 자료 다운로드 자동화 (HMAC + Supabase) — 2026-05-05 |
|
| `agent-office` | 18900 | ✅ | AI 에이전트 (WebSocket + 텔레그램 + InstaAgent + YouTubeResearcher) |
|
||||||
|
| `packs-lab` | 18950 | ✅ | NAS 자료 다운로드 자동화 (HMAC + Supabase + 5GB chunked upload) |
|
||||||
| `travel-proxy` | 19000 | ✅ | 여행 사진 API + 썸네일 + 지역 관리 |
|
| `travel-proxy` | 19000 | ✅ | 여행 사진 API + 썸네일 + 지역 관리 |
|
||||||
| `nginx` | 8080 | ✅ | SPA + 리버스 프록시 (5GB body limit) |
|
| `frontend` (nginx) | 8080 | ✅ | SPA + 리버스 프록시 (5GB body limit, 인스타 라우팅 포함) |
|
||||||
| `webpage-deployer` | 19010 | ✅ | Gitea Webhook 자동 배포 |
|
| `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-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-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 | personal | personal 서비스 분리 마이그레이션 (블로그·투두·포트폴리오 인증) |
|
||||||
| 2026-04-27 | agent-office | v2 — youtube_researcher (YouTube API + pytrends + Billboard) + 알림 |
|
| 2026-04-27 | agent-office | v2 — youtube_researcher (YouTube API + pytrends + Billboard) + 알림 |
|
||||||
| 2026-04-24 | travel-proxy | 갤러리 리디자인 + 성능 개선 (썸네일/페이지네이션) |
|
| 2026-04-15 | lotto | AI 큐레이터 (Claude 기반 주간 브리핑 자동 생성) |
|
||||||
| 2026-04-15 | lotto-backend | AI 큐레이터 (Claude 기반 주간 브리핑 자동 생성) |
|
|
||||||
| 2026-04-08 | music-lab | Suno enhancement + MusicGen 통합 |
|
|
||||||
| 2026-04-06 | blog-lab | 마케팅 파이프라인 (research → generate → market → review) |
|
|
||||||
|
|
||||||
### 1-3. 인프라 / DX
|
### 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 . .
|
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 .stock import StockAgent
|
||||||
from .music import MusicAgent
|
from .music import MusicAgent
|
||||||
from .blog import BlogAgent
|
from .insta import InstaAgent
|
||||||
from .realestate import RealestateAgent
|
from .realestate import RealestateAgent
|
||||||
from .lotto import LottoAgent
|
from .lotto import LottoAgent
|
||||||
from .youtube import YouTubeResearchAgent
|
from .youtube import YouTubeResearchAgent
|
||||||
@@ -11,7 +11,7 @@ AGENT_REGISTRY = {}
|
|||||||
def init_agents():
|
def init_agents():
|
||||||
AGENT_REGISTRY["stock"] = StockAgent()
|
AGENT_REGISTRY["stock"] = StockAgent()
|
||||||
AGENT_REGISTRY["music"] = MusicAgent()
|
AGENT_REGISTRY["music"] = MusicAgent()
|
||||||
AGENT_REGISTRY["blog"] = BlogAgent()
|
AGENT_REGISTRY["insta"] = InstaAgent()
|
||||||
AGENT_REGISTRY["realestate"] = RealestateAgent()
|
AGENT_REGISTRY["realestate"] = RealestateAgent()
|
||||||
AGENT_REGISTRY["lotto"] = LottoAgent()
|
AGENT_REGISTRY["lotto"] = LottoAgent()
|
||||||
AGENT_REGISTRY["youtube"] = YouTubeResearchAgent()
|
AGENT_REGISTRY["youtube"] = YouTubeResearchAgent()
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import asyncio
|
|
||||||
import random
|
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from ..config import IDLE_BREAK_THRESHOLD, BREAK_DURATION_MIN, BREAK_DURATION_MAX
|
VALID_STATES = ("idle", "working", "waiting", "reporting")
|
||||||
from ..db import add_log
|
|
||||||
|
|
||||||
VALID_STATES = ("idle", "working", "waiting", "reporting", "break")
|
|
||||||
|
|
||||||
class BaseAgent:
|
class BaseAgent:
|
||||||
agent_id: str = ""
|
agent_id: str = ""
|
||||||
@@ -14,7 +9,6 @@ class BaseAgent:
|
|||||||
state: str = "idle"
|
state: str = "idle"
|
||||||
state_detail: str = ""
|
state_detail: str = ""
|
||||||
_idle_since: float = 0.0
|
_idle_since: float = 0.0
|
||||||
_break_until: float = 0.0
|
|
||||||
_ws_manager = None
|
_ws_manager = None
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -32,11 +26,6 @@ class BaseAgent:
|
|||||||
|
|
||||||
if new_state == "idle":
|
if new_state == "idle":
|
||||||
self._idle_since = time.time()
|
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:
|
if self._ws_manager:
|
||||||
await self._ws_manager.send_agent_state(self.agent_id, new_state, detail, task_id)
|
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(
|
await self._ws_manager.send_notification(
|
||||||
self.agent_id, "task_completed", task_id, detail or "작업 완료"
|
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:
|
async def on_schedule(self) -> None:
|
||||||
raise NotImplementedError
|
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}")
|
|
||||||
265
agent-office/app/agents/insta.py
Normal file
265
agent-office/app/agents/insta.py
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
"""인스타 카드 에이전트 — 매일 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))
|
||||||
|
autonomous = bool(custom.get("autonomous_issue", False))
|
||||||
|
threshold = float(custom.get("select_threshold", 0.6))
|
||||||
|
max_per_day = int(custom.get("max_per_day", 2))
|
||||||
|
dedup_window_days = int(custom.get("dedup_window_days", 14))
|
||||||
|
|
||||||
|
task_id = create_task(self.agent_id, "insta_daily", {"auto_select": auto_select},
|
||||||
|
requires_approval=False)
|
||||||
|
await self.transition("working", "뉴스 수집·키워드 추출", task_id)
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
prefs = await service_proxy.insta_get_preferences()
|
||||||
|
add_log(self.agent_id, f"insta preferences: {prefs}", "info", task_id)
|
||||||
|
except Exception as _pref_err:
|
||||||
|
add_log(self.agent_id, f"insta preferences unavailable: {_pref_err}", "warning", task_id)
|
||||||
|
await self._run_collect_and_extract()
|
||||||
|
if autonomous:
|
||||||
|
ranked = await service_proxy.insta_ranked(threshold=threshold, limit=20, dedup_window_days=dedup_window_days)
|
||||||
|
eligible = [r for r in ranked if r.get("eligible")][:max_per_day]
|
||||||
|
if not eligible:
|
||||||
|
await messaging.send_raw("📰 [인스타 큐레이터] 오늘은 발행할 가치 있는 주제가 없습니다.")
|
||||||
|
else:
|
||||||
|
for pick in eligible:
|
||||||
|
await self._generate_and_preview(pick)
|
||||||
|
update_task_status(task_id, "succeeded", {"issued": len(eligible)})
|
||||||
|
await self.transition("idle", "자율 발급 후보 프리뷰 완료")
|
||||||
|
return
|
||||||
|
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 _generate_and_preview(self, pick: dict) -> None:
|
||||||
|
"""eligible 픽 → 슬레이트 생성·렌더 → 커버 프리뷰 + 승인 버튼."""
|
||||||
|
created = await service_proxy.insta_create_slate(
|
||||||
|
keyword=pick["keyword"], category=pick["category"], keyword_id=pick["id"],
|
||||||
|
)
|
||||||
|
st = await self._wait_task(created["task_id"], step="slate", timeout_sec=600)
|
||||||
|
slate_id = st["result_id"]
|
||||||
|
cover = await service_proxy.insta_get_asset_bytes(slate_id, 1)
|
||||||
|
bd = pick.get("breakdown", {})
|
||||||
|
caption = (f"🎴 <b>{pick['keyword']}</b> ({pick['category']})\n"
|
||||||
|
f"점수 {pick.get('final_score')} · fresh {bd.get('freshness')} "
|
||||||
|
f"fit {bd.get('account_fit')} claude {bd.get('claude')}\n승인하시겠어요?")
|
||||||
|
kb = {"inline_keyboard": [[
|
||||||
|
{"text": "✅ 승인", "callback_data": f"issue_approve_{slate_id}"},
|
||||||
|
{"text": "❌ 반려", "callback_data": f"issue_reject_{slate_id}"},
|
||||||
|
{"text": "🔄 재생성", "callback_data": f"issue_regen_{slate_id}"},
|
||||||
|
]]}
|
||||||
|
await messaging.send_photo(cover, caption=caption, reply_markup=kb)
|
||||||
|
create_task(self.agent_id, "insta_issue", {"slate_id": slate_id, "keyword_id": pick["id"]},
|
||||||
|
requires_approval=True)
|
||||||
|
|
||||||
|
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}
|
||||||
|
if action in ("issue_approve", "issue_reject"):
|
||||||
|
sid = int(params.get("slate_id") or 0)
|
||||||
|
if not sid:
|
||||||
|
return {"ok": False}
|
||||||
|
decision = "approved" if action == "issue_approve" else "rejected"
|
||||||
|
await service_proxy.insta_decision(sid, decision)
|
||||||
|
if decision == "approved":
|
||||||
|
slate = await service_proxy.insta_get_slate(sid)
|
||||||
|
media = []
|
||||||
|
for a in slate["assets"][:10]:
|
||||||
|
data = await service_proxy.insta_get_asset_bytes(sid, a["page_index"])
|
||||||
|
media.append({"type": "photo", "_bytes": data})
|
||||||
|
cap = f"{slate.get('suggested_caption','')}\n\n{' '.join(slate.get('hashtags', []) or [])}".strip()
|
||||||
|
await _send_media_group(media, caption=cap)
|
||||||
|
await messaging.send_raw(f"✅ 발행 완료 (slate {sid})")
|
||||||
|
else:
|
||||||
|
await messaging.send_raw(f"❌ 반려됨 (slate {sid})")
|
||||||
|
return {"ok": True}
|
||||||
|
if action == "issue_regen":
|
||||||
|
sid = int(params.get("slate_id") or 0)
|
||||||
|
if not sid:
|
||||||
|
return {"ok": False}
|
||||||
|
slate = await service_proxy.insta_get_slate(sid)
|
||||||
|
await service_proxy.insta_decision(sid, "rejected")
|
||||||
|
await self._generate_and_preview({
|
||||||
|
"id": 0,
|
||||||
|
"keyword": slate["keyword"],
|
||||||
|
"category": slate["category"],
|
||||||
|
"final_score": None,
|
||||||
|
"breakdown": {},
|
||||||
|
})
|
||||||
|
return {"ok": True}
|
||||||
|
return {"ok": False}
|
||||||
|
|
||||||
|
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
|
||||||
|
return
|
||||||
@@ -2,13 +2,17 @@ from .base import BaseAgent
|
|||||||
from ..db import create_task, update_task_status, add_log
|
from ..db import create_task, update_task_status, add_log
|
||||||
from ..curator.pipeline import curate_weekly, CuratorError
|
from ..curator.pipeline import curate_weekly, CuratorError
|
||||||
|
|
||||||
|
# urgent 텔레그램 발송 재시도 (전송 실패가 시그널 평가/태스크를 중단시키지 않도록)
|
||||||
|
URGENT_SEND_MAX_ATTEMPTS = 3
|
||||||
|
URGENT_SEND_RETRY_SEC = 60
|
||||||
|
|
||||||
|
|
||||||
class LottoAgent(BaseAgent):
|
class LottoAgent(BaseAgent):
|
||||||
agent_id = "lotto"
|
agent_id = "lotto"
|
||||||
display_name = "로또 큐레이터"
|
display_name = "로또 큐레이터"
|
||||||
|
|
||||||
async def on_schedule(self) -> None:
|
async def on_schedule(self) -> None:
|
||||||
if self.state not in ("idle", "break"):
|
if self.state != "idle":
|
||||||
return
|
return
|
||||||
await self._run(source="auto")
|
await self._run(source="auto")
|
||||||
|
|
||||||
@@ -17,11 +21,276 @@ class LottoAgent(BaseAgent):
|
|||||||
return await self._run(source="manual")
|
return await self._run(source="manual")
|
||||||
if action == "status":
|
if action == "status":
|
||||||
return {"ok": True, "message": f"{self.state}: {self.state_detail}"}
|
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}"}
|
return {"ok": False, "message": f"unknown action: {action}"}
|
||||||
|
|
||||||
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
|
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
|
||||||
pass
|
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
|
||||||
|
try:
|
||||||
|
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")
|
||||||
|
except CuratorError as e:
|
||||||
|
# 큐레이션 실패는 confidence 시그널만 포기 — sim/drift 평가는 계속(fallthrough)
|
||||||
|
add_log("lotto", f"deep curate_weekly 실패 → sim/drift만 평가: {e}",
|
||||||
|
level="warning", task_id=task_id)
|
||||||
|
curate_result = None
|
||||||
|
|
||||||
|
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 self._send_urgent_with_retry(event, outcome["results"], 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 _send_urgent_with_retry(self, event: dict, results: list, task_id: str) -> bool:
|
||||||
|
"""urgent 텔레그램 발송 + 실패 시 재시도. 최종 실패해도 raise하지 않음(시그널 평가·태스크 보존).
|
||||||
|
성공 시 fired 시그널을 notified로 마킹. 반환: 발송 성공 여부."""
|
||||||
|
import asyncio
|
||||||
|
from ..db import add_log, mark_signal_notified
|
||||||
|
from ..notifiers.telegram_lotto import send_urgent_signal
|
||||||
|
for attempt in range(1, URGENT_SEND_MAX_ATTEMPTS + 1):
|
||||||
|
try:
|
||||||
|
await send_urgent_signal(event)
|
||||||
|
for r in results:
|
||||||
|
if r["fire_level"] in ("normal", "urgent"):
|
||||||
|
mark_signal_notified(r["signal_id"])
|
||||||
|
add_log("lotto", f"urgent 텔레그램 발송 ({len(results)}개 시그널, attempt {attempt})", task_id=task_id)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
if attempt < URGENT_SEND_MAX_ATTEMPTS:
|
||||||
|
add_log("lotto", f"urgent 발송 실패(attempt {attempt}) → {URGENT_SEND_RETRY_SEC}s 후 재시도: {e}",
|
||||||
|
level="warning", task_id=task_id)
|
||||||
|
await asyncio.sleep(URGENT_SEND_RETRY_SEC)
|
||||||
|
else:
|
||||||
|
add_log("lotto", f"urgent 발송 {URGENT_SEND_MAX_ATTEMPTS}회 실패 — 미발송: {e}",
|
||||||
|
level="error", task_id=task_id)
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
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:
|
async def _run(self, source: str) -> dict:
|
||||||
task_id = create_task(self.agent_id, "curate_weekly", {"source": source})
|
task_id = create_task(self.agent_id, "curate_weekly", {"source": source})
|
||||||
await self.transition("working", "후보 수집 및 AI 큐레이션 중...", task_id)
|
await self.transition("working", "후보 수집 및 AI 큐레이션 중...", task_id)
|
||||||
|
|||||||
@@ -44,14 +44,14 @@ class StockAgent(BaseAgent):
|
|||||||
display_name = "주식 트레이더"
|
display_name = "주식 트레이더"
|
||||||
|
|
||||||
async def on_schedule(self) -> None:
|
async def on_schedule(self) -> None:
|
||||||
if self.state not in ("idle", "break"):
|
if self.state != "idle":
|
||||||
return
|
return
|
||||||
|
|
||||||
task_id = create_task(self.agent_id, "news_summary", {"limit": 15})
|
task_id = create_task(self.agent_id, "news_summary", {"limit": 15})
|
||||||
await self.transition("working", "최신 뉴스 수집 중...", task_id)
|
await self.transition("working", "최신 뉴스 수집 중...", task_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# stock-lab cron(매일 8:00)이 7:30 브리핑보다 늦게 돌아 어제 뉴스가
|
# stock cron(매일 8:00)이 7:30 브리핑보다 늦게 돌아 어제 뉴스가
|
||||||
# 요약되던 문제 방지 — 요약 직전에 동기 스크랩으로 DB를 갱신한다.
|
# 요약되던 문제 방지 — 요약 직전에 동기 스크랩으로 DB를 갱신한다.
|
||||||
try:
|
try:
|
||||||
await service_proxy.scrape_stock_news()
|
await service_proxy.scrape_stock_news()
|
||||||
@@ -60,7 +60,7 @@ class StockAgent(BaseAgent):
|
|||||||
|
|
||||||
await self.transition("working", "AI 뉴스 요약 생성 중...")
|
await self.transition("working", "AI 뉴스 요약 생성 중...")
|
||||||
|
|
||||||
# AI 요약 호출 (LLM 처리는 stock-lab이 담당)
|
# AI 요약 호출 (LLM 처리는 stock이 담당)
|
||||||
result = await service_proxy.summarize_stock_news(limit=15)
|
result = await service_proxy.summarize_stock_news(limit=15)
|
||||||
|
|
||||||
await self.transition("reporting", "뉴스 요약 전송 중...")
|
await self.transition("reporting", "뉴스 요약 전송 중...")
|
||||||
@@ -119,7 +119,273 @@ class StockAgent(BaseAgent):
|
|||||||
update_task_status(task_id, "failed", {"error": str(e)})
|
update_task_status(task_id, "failed", {"error": str(e)})
|
||||||
await self.transition("idle", f"오류: {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:
|
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":
|
if command == "test_telegram":
|
||||||
from ..telegram import send_agent_message
|
from ..telegram import send_agent_message
|
||||||
result = await send_agent_message(
|
result = await send_agent_message(
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class YoutubePublisherAgent(BaseAgent):
|
|||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self._notified_state_per_pipeline: dict[int, tuple] = {}
|
self._notified_state_per_pipeline: dict[int, tuple] = {}
|
||||||
|
self._notified_failed: set[int] = set()
|
||||||
|
|
||||||
async def poll_state_changes(self) -> None:
|
async def poll_state_changes(self) -> None:
|
||||||
"""주기적으로 호출되어 *_pending 신규 진입 시 텔레그램 발송."""
|
"""주기적으로 호출되어 *_pending 신규 진입 시 텔레그램 발송."""
|
||||||
@@ -48,6 +49,32 @@ class YoutubePublisherAgent(BaseAgent):
|
|||||||
await self._notify_step(p)
|
await self._notify_step(p)
|
||||||
self._notified_state_per_pipeline[pid] = key
|
self._notified_state_per_pipeline[pid] = key
|
||||||
|
|
||||||
|
try:
|
||||||
|
failed = await service_proxy.list_failed_pipelines()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("failed 폴링 실패: %s", e)
|
||||||
|
failed = []
|
||||||
|
for p in failed:
|
||||||
|
pid = p.get("id")
|
||||||
|
if pid is None:
|
||||||
|
continue
|
||||||
|
if pid not in self._notified_failed:
|
||||||
|
await self._notify_failed(p)
|
||||||
|
self._notified_failed.add(pid)
|
||||||
|
# 재개되어 failed에서 벗어난 파이프라인은 재알림 가능하도록 해제
|
||||||
|
failed_ids = {p.get("id") for p in failed}
|
||||||
|
self._notified_failed &= failed_ids
|
||||||
|
|
||||||
|
async def _notify_failed(self, p: dict) -> None:
|
||||||
|
reason = p.get("failed_reason") or "?"
|
||||||
|
step = reason.split(":", 1)[0].strip()
|
||||||
|
title = p.get("track_title") or f"Pipeline #{p['id']}"
|
||||||
|
text = f"⚠️ [{title}] 파이프라인 #{p['id']} '{step}' 실패\n사유: {reason}"
|
||||||
|
kb = {"inline_keyboard": [[{"text": "🔄 재시도", "callback_data": f"ytpub_retry_{p['id']}"}]]}
|
||||||
|
sent = await send_raw(text=text, reply_markup=kb)
|
||||||
|
if sent.get("ok"):
|
||||||
|
add_log(self.agent_id, f"pipeline {p['id']} 실패 알림", "warning")
|
||||||
|
|
||||||
async def _notify_step(self, pipeline: dict) -> None:
|
async def _notify_step(self, pipeline: dict) -> None:
|
||||||
state = pipeline["state"]
|
state = pipeline["state"]
|
||||||
title_name, step = _STEP_TITLES[state]
|
title_name, step = _STEP_TITLES[state]
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
# Service URLs (Docker internal network)
|
# 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")
|
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")
|
REALESTATE_LAB_URL = os.getenv("REALESTATE_LAB_URL", "http://localhost:18800")
|
||||||
|
|
||||||
# Telegram
|
# Telegram
|
||||||
@@ -26,11 +26,28 @@ CORS_ALLOW_ORIGINS = os.getenv(
|
|||||||
"CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080"
|
"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 Curator
|
||||||
LOTTO_BACKEND_URL = os.getenv("LOTTO_BACKEND_URL", "http://lotto:8000")
|
LOTTO_BACKEND_URL = os.getenv("LOTTO_BACKEND_URL", "http://lotto:8000")
|
||||||
LOTTO_CURATOR_MODEL = os.getenv("LOTTO_CURATOR_MODEL", "claude-sonnet-4-5")
|
LOTTO_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")),
|
||||||
|
}
|
||||||
|
|||||||
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:
|
def _conn() -> sqlite3.Connection:
|
||||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
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.row_factory = sqlite3.Row
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA busy_timeout=120000")
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
@@ -97,6 +98,66 @@ def init_db() -> None:
|
|||||||
completed_at TEXT
|
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
|
# Seed default agent configs
|
||||||
for agent_id, name in [
|
for agent_id, name in [
|
||||||
("stock", "주식 트레이더"),
|
("stock", "주식 트레이더"),
|
||||||
@@ -202,12 +263,24 @@ def get_task(task_id: str) -> Optional[Dict[str, Any]]:
|
|||||||
return _task_to_dict(r) if r else None
|
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:
|
with _conn() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(sql, params).fetchall()
|
||||||
"SELECT * FROM agent_tasks WHERE agent_id=? ORDER BY created_at DESC LIMIT ?",
|
|
||||||
(agent_id, limit),
|
|
||||||
).fetchall()
|
|
||||||
return [_task_to_dict(r) for r in rows]
|
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]]:
|
def get_logs(agent_id: str, limit: int = 50) -> List[Dict[str, Any]]:
|
||||||
with _conn() as conn:
|
with _conn() as conn:
|
||||||
rows = conn.execute(
|
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),
|
(agent_id, limit),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [
|
return [
|
||||||
@@ -259,6 +338,7 @@ def get_logs(agent_id: str, limit: int = 50) -> List[Dict[str, Any]]:
|
|||||||
"level": r["level"],
|
"level": r["level"],
|
||||||
"message": r["message"],
|
"message": r["message"],
|
||||||
"created_at": r["created_at"],
|
"created_at": r["created_at"],
|
||||||
|
"source": "agent",
|
||||||
}
|
}
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
@@ -454,33 +534,58 @@ def get_conversation_stats(days: int = 7) -> Dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_activity_feed(limit: int = 50, offset: int = 0) -> dict:
|
def get_activity_feed(limit: int = 50, offset: int = 0, agent_id: str = None,
|
||||||
with _conn() as conn:
|
type: str = None, status: str = None, days: int = None) -> dict:
|
||||||
total_row = conn.execute("""
|
# 브랜치별 WHERE (값은 ? 바인딩, type은 브랜치 선택용). status는 task 전용 → 주면 log 제외.
|
||||||
SELECT (SELECT COUNT(*) FROM agent_tasks) + (SELECT COUNT(*) FROM agent_logs) AS total
|
task_where, task_params = [], []
|
||||||
""").fetchone()
|
log_where, log_params = [], []
|
||||||
total = total_row["total"] if total_row else 0
|
if agent_id:
|
||||||
|
task_where.append("agent_id=?"); task_params.append(agent_id)
|
||||||
|
log_where.append("agent_id=?"); log_params.append(agent_id)
|
||||||
|
if status:
|
||||||
|
task_where.append("status=?"); task_params.append(status)
|
||||||
|
if days and days > 0:
|
||||||
|
task_where.append("created_at >= datetime('now', ?)"); task_params.append(f"-{int(days)} days")
|
||||||
|
log_where.append("created_at >= datetime('now', ?)"); log_params.append(f"-{int(days)} days")
|
||||||
|
include_tasks = type in (None, "task")
|
||||||
|
include_logs = type in (None, "log") and not status
|
||||||
|
|
||||||
rows = conn.execute("""
|
task_clause = (" WHERE " + " AND ".join(task_where)) if task_where else ""
|
||||||
|
log_clause = (" WHERE " + " AND ".join(log_where)) if log_where else ""
|
||||||
|
|
||||||
|
branches, branch_params = [], []
|
||||||
|
if include_tasks:
|
||||||
|
branches.append(f"""
|
||||||
SELECT 'task' AS type, agent_id, id AS task_id, task_type,
|
SELECT 'task' AS type, agent_id, id AS task_id, task_type,
|
||||||
status, NULL AS level,
|
status, NULL AS level,
|
||||||
COALESCE(
|
COALESCE(json_extract(result_data, '$.summary'), task_type) AS message,
|
||||||
json_extract(result_data, '$.summary'),
|
created_at, completed_at, result_data
|
||||||
task_type
|
FROM agent_tasks{task_clause}""")
|
||||||
) AS message,
|
branch_params += task_params
|
||||||
created_at, completed_at,
|
if include_logs:
|
||||||
result_data
|
branches.append(f"""
|
||||||
FROM agent_tasks
|
|
||||||
UNION ALL
|
|
||||||
SELECT 'log' AS type, agent_id, task_id, NULL AS task_type,
|
SELECT 'log' AS type, agent_id, task_id, NULL AS task_type,
|
||||||
NULL AS status, level,
|
NULL AS status, level, message,
|
||||||
message,
|
created_at, NULL AS completed_at, NULL AS result_data
|
||||||
created_at, NULL AS completed_at,
|
FROM agent_logs{log_clause}""")
|
||||||
NULL AS result_data
|
branch_params += log_params
|
||||||
FROM agent_logs
|
|
||||||
ORDER BY created_at DESC
|
if not branches:
|
||||||
LIMIT ? OFFSET ?
|
return {"items": [], "total": 0}
|
||||||
""", (limit, offset)).fetchall()
|
|
||||||
|
union_sql = " UNION ALL ".join(branches) + " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||||||
|
|
||||||
|
with _conn() as conn:
|
||||||
|
total = 0
|
||||||
|
if include_tasks:
|
||||||
|
total += conn.execute(
|
||||||
|
f"SELECT COUNT(*) AS c FROM agent_tasks{task_clause}", task_params
|
||||||
|
).fetchone()["c"]
|
||||||
|
if include_logs:
|
||||||
|
total += conn.execute(
|
||||||
|
f"SELECT COUNT(*) AS c FROM agent_logs{log_clause}", log_params
|
||||||
|
).fetchone()["c"]
|
||||||
|
rows = conn.execute(union_sql, branch_params + [limit, offset]).fetchall()
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
@@ -515,6 +620,20 @@ def get_activity_feed(limit: int = 50, offset: int = 0) -> dict:
|
|||||||
return {"items": items, "total": total}
|
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 ────────────────────────────────────────────────
|
# ── youtube_research_jobs CRUD ────────────────────────────────────────────────
|
||||||
|
|
||||||
def add_youtube_research_job(countries: list) -> int:
|
def add_youtube_research_job(countries: list) -> int:
|
||||||
@@ -555,3 +674,170 @@ def get_latest_youtube_research_job() -> Optional[Dict[str, Any]]:
|
|||||||
"started_at": row["started_at"],
|
"started_at": row["started_at"],
|
||||||
"completed_at": row["completed_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 os
|
||||||
import json
|
import json
|
||||||
|
from typing import Optional
|
||||||
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
@@ -104,12 +105,29 @@ def update_agent(agent_id: str, body: AgentConfigUpdate):
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@app.get("/api/agent-office/agents/{agent_id}/tasks")
|
@app.get("/api/agent-office/agents/{agent_id}/tasks")
|
||||||
def agent_tasks(agent_id: str, limit: int = 20):
|
def agent_tasks(
|
||||||
return {"tasks": get_agent_tasks(agent_id, limit)}
|
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")
|
@app.get("/api/agent-office/agents/{agent_id}/logs")
|
||||||
def agent_logs(agent_id: str, limit: int = 50):
|
async def agent_logs(agent_id: str, limit: int = 50):
|
||||||
return {"logs": get_logs(agent_id, limit)}
|
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")
|
@app.get("/api/agent-office/tasks/pending")
|
||||||
def pending_tasks():
|
def pending_tasks():
|
||||||
@@ -180,8 +198,9 @@ def conversation_stats(days: int = 7):
|
|||||||
return get_conversation_stats(days)
|
return get_conversation_stats(days)
|
||||||
|
|
||||||
@app.get("/api/agent-office/activity")
|
@app.get("/api/agent-office/activity")
|
||||||
def activity_feed(limit: int = 50, offset: int = 0):
|
def activity_feed(limit: int = 50, offset: int = 0, agent_id: str | None = None,
|
||||||
return get_activity_feed(limit, offset)
|
type: str | None = None, status: str | None = None, days: int | None = None):
|
||||||
|
return get_activity_feed(limit, offset, agent_id=agent_id, type=type, status=status, days=days)
|
||||||
|
|
||||||
|
|
||||||
# --- Realestate Agent Push Endpoint ---
|
# --- Realestate Agent Push Endpoint ---
|
||||||
@@ -227,3 +246,30 @@ def youtube_research_status():
|
|||||||
if not job:
|
if not job:
|
||||||
return {"status": "never_run"}
|
return {"status": "never_run"}
|
||||||
return job
|
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 pydantic import BaseModel, Field
|
||||||
from typing import Optional
|
from typing import Optional, List, Literal
|
||||||
|
|
||||||
|
|
||||||
class CommandRequest(BaseModel):
|
class CommandRequest(BaseModel):
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""로또 큐레이션·당첨 알림 — 텔레그램 푸시."""
|
"""로또 큐레이션·당첨 알림 — 텔레그램 푸시."""
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any, List
|
||||||
|
|
||||||
# 기존 에이전트들과 동일한 패턴: send_raw(text, reply_markup=None, chat_id=None)
|
# 기존 에이전트들과 동일한 패턴: send_raw(text, reply_markup=None, chat_id=None)
|
||||||
# chat_id 생략 시 기본 TELEGRAM_CHAT_ID로 자동 발송.
|
# chat_id 생략 시 기본 TELEGRAM_CHAT_ID로 자동 발송.
|
||||||
@@ -59,3 +59,208 @@ async def send_prize_alert(event: Dict[str, Any]) -> None:
|
|||||||
await send_raw(text)
|
await send_raw(text)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[telegram_lotto] prize alert send failed: {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}")
|
||||||
@@ -1,29 +1,88 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
|
||||||
from .agents import AGENT_REGISTRY
|
from .agents import AGENT_REGISTRY
|
||||||
|
from .db import delete_old_logs
|
||||||
|
|
||||||
scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
|
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():
|
async def _run_stock_schedule():
|
||||||
agent = AGENT_REGISTRY.get("stock")
|
agent = AGENT_REGISTRY.get("stock")
|
||||||
if agent:
|
if agent:
|
||||||
await agent.on_schedule()
|
await agent.on_schedule()
|
||||||
|
|
||||||
async def _run_blog_schedule():
|
async def _run_stock_screener():
|
||||||
agent = AGENT_REGISTRY.get("blog")
|
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:
|
if agent:
|
||||||
await agent.on_schedule()
|
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():
|
async def _run_lotto_schedule():
|
||||||
agent = AGENT_REGISTRY.get("lotto")
|
agent = AGENT_REGISTRY.get("lotto")
|
||||||
if agent:
|
if agent:
|
||||||
await agent.on_schedule()
|
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():
|
async def _run_youtube_research():
|
||||||
agent = AGENT_REGISTRY.get("youtube")
|
agent = AGENT_REGISTRY.get("youtube")
|
||||||
if agent:
|
if agent:
|
||||||
@@ -39,12 +98,49 @@ async def _poll_pipelines():
|
|||||||
if agent:
|
if agent:
|
||||||
await agent.poll_state_changes()
|
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():
|
def init_scheduler():
|
||||||
scheduler.add_job(_run_stock_schedule, "cron", hour=7, minute=30, id="stock_news")
|
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(
|
||||||
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=9, minute=0, id="lotto_curate")
|
_run_stock_screener,
|
||||||
scheduler.add_job(_run_youtube_research, "cron", hour=9, minute=0, id="youtube_research")
|
"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(_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(_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()
|
scheduler.start()
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import httpx
|
import httpx
|
||||||
|
import logging
|
||||||
from typing import Any, Dict, List, Optional
|
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)
|
_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}
|
params = {"limit": limit}
|
||||||
if category:
|
if category:
|
||||||
params["category"] = 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()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
async def fetch_stock_indices() -> Dict[str, Any]:
|
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()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
async def summarize_stock_news(limit: int = 15) -> Dict[str, Any]:
|
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}
|
반환: {"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:
|
async with httpx.AsyncClient(timeout=200.0) as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"{STOCK_LAB_URL}/api/stock/news/summarize",
|
f"{STOCK_URL}/api/stock/news/summarize",
|
||||||
json={"limit": limit},
|
json={"limit": limit},
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
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]:
|
async def scrape_stock_news() -> Dict[str, Any]:
|
||||||
"""stock-lab의 수동 뉴스 스크랩 트리거 — DB에 최신 뉴스 저장.
|
"""stock의 수동 뉴스 스크랩 트리거 — DB에 최신 뉴스 저장.
|
||||||
|
|
||||||
아침 브리핑 직전 호출하여 어제 데이터가 아닌 오늘 새벽 뉴스를 보장한다.
|
아침 브리핑 직전 호출하여 어제 데이터가 아닌 오늘 새벽 뉴스를 보장한다.
|
||||||
네이버 금융 단일 요청이라 보통 수 초 내 완료, 여유있게 60s.
|
네이버 금융 단일 요청이라 보통 수 초 내 완료, 여유있게 60s.
|
||||||
"""
|
"""
|
||||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
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()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
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]:
|
async def generate_music(payload: dict) -> Dict[str, Any]:
|
||||||
resp = await _client.post(f"{MUSIC_LAB_URL}/api/music/generate", json=payload)
|
resp = await _client.post(f"{MUSIC_LAB_URL}/api/music/generate", json=payload)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
@@ -59,58 +127,125 @@ async def get_music_credits() -> Dict[str, Any]:
|
|||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
# --- blog-lab ---
|
# --- insta-lab ---
|
||||||
|
|
||||||
async def blog_research(keyword: str) -> Dict[str, Any]:
|
async def insta_collect(categories: Optional[list] = None) -> Dict[str, Any]:
|
||||||
"""키워드 리서치 시작 → task_id 반환"""
|
"""뉴스 수집 트리거 → 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(
|
resp = await _client.post(
|
||||||
f"{BLOG_LAB_URL}/api/blog-marketing/research",
|
f"{INSTA_LAB_URL}/api/insta/slates",
|
||||||
json={"keyword": keyword},
|
json={"keyword": keyword, "category": category, "keyword_id": keyword_id},
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
async def blog_task_status(task_id: str) -> Dict[str, Any]:
|
async def insta_task_status(task_id: str) -> Dict[str, Any]:
|
||||||
resp = await _client.get(f"{BLOG_LAB_URL}/api/blog-marketing/task/{task_id}")
|
resp = await _client.get(f"{INSTA_LAB_URL}/api/insta/tasks/{task_id}")
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
async def blog_generate(keyword_id: int) -> Dict[str, Any]:
|
async def insta_get_slate(slate_id: int) -> Dict[str, Any]:
|
||||||
resp = await _client.post(
|
resp = await _client.get(f"{INSTA_LAB_URL}/api/insta/slates/{slate_id}")
|
||||||
f"{BLOG_LAB_URL}/api/blog-marketing/generate",
|
resp.raise_for_status()
|
||||||
json={"keyword_id": keyword_id},
|
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()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
async def blog_market(post_id: int) -> Dict[str, Any]:
|
async def insta_ranked(threshold: float = 0.6, limit: int = 20, dedup_window_days: int = 14) -> list:
|
||||||
resp = await _client.post(f"{BLOG_LAB_URL}/api/blog-marketing/market/{post_id}")
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
resp.raise_for_status()
|
r = await client.get(
|
||||||
return resp.json()
|
f"{INSTA_LAB_URL}/api/insta/keywords/ranked",
|
||||||
|
params={"threshold": threshold, "limit": limit, "dedup_window_days": dedup_window_days},
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()["items"]
|
||||||
|
|
||||||
|
|
||||||
async def blog_review(post_id: int) -> Dict[str, Any]:
|
async def insta_decision(slate_id: int, decision: str) -> dict:
|
||||||
resp = await _client.post(f"{BLOG_LAB_URL}/api/blog-marketing/review/{post_id}")
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
resp.raise_for_status()
|
r = await client.post(
|
||||||
return resp.json()
|
f"{INSTA_LAB_URL}/api/insta/slates/{slate_id}/decision",
|
||||||
|
json={"decision": decision},
|
||||||
|
)
|
||||||
async def blog_publish(post_id: int, url: str = "") -> Dict[str, Any]:
|
r.raise_for_status()
|
||||||
resp = await _client.post(
|
return r.json()
|
||||||
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 ---
|
# --- realestate-lab ---
|
||||||
@@ -217,6 +352,25 @@ async def list_active_pipelines() -> list[dict]:
|
|||||||
return resp.json().get("pipelines", [])
|
return resp.json().get("pipelines", [])
|
||||||
|
|
||||||
|
|
||||||
|
async def list_failed_pipelines() -> list[dict]:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.get(f"{MUSIC_LAB_URL}/api/music/pipeline?status=failed")
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
return data if isinstance(data, list) else data.get("items", data.get("pipelines", []))
|
||||||
|
|
||||||
|
|
||||||
|
async def pipeline_retry(pid: int) -> dict:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
resp = await client.post(f"{MUSIC_LAB_URL}/api/music/pipeline/{pid}/retry")
|
||||||
|
out = {"status_code": resp.status_code}
|
||||||
|
try:
|
||||||
|
out.update(resp.json())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def get_pipeline(pid: int) -> dict:
|
async def get_pipeline(pid: int) -> dict:
|
||||||
async with httpx.AsyncClient(timeout=15) as client:
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
resp = await client.get(f"{MUSIC_LAB_URL}/api/music/pipeline/{pid}")
|
resp = await client.get(f"{MUSIC_LAB_URL}/api/music/pipeline/{pid}")
|
||||||
@@ -249,3 +403,102 @@ async def lookup_pipeline_by_msg(msg_id: int) -> Optional[dict]:
|
|||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
return resp.json()
|
return resp.json()
|
||||||
return None
|
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 "")
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,21 +1,32 @@
|
|||||||
"""고수준 메시지 전송 API."""
|
"""고수준 메시지 전송 API."""
|
||||||
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from ..config import TELEGRAM_CHAT_ID
|
import httpx
|
||||||
|
|
||||||
|
from ..config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID
|
||||||
from ..db import save_telegram_callback
|
from ..db import save_telegram_callback
|
||||||
from .client import _enabled, api_call
|
from .client import _enabled, api_call
|
||||||
from .formatter import MessageKind, format_agent_message
|
from .formatter import MessageKind, format_agent_message
|
||||||
|
|
||||||
|
|
||||||
async def send_raw(text: str, reply_markup: Optional[dict] = None, chat_id: Optional[str] = None) -> dict:
|
async def send_raw(
|
||||||
"""가장 저수준. 원문 텍스트 그대로 전송. chat_id 생략 시 기본 TELEGRAM_CHAT_ID로."""
|
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():
|
if not _enabled():
|
||||||
return {"ok": False, "message_id": None}
|
return {"ok": False, "message_id": None}
|
||||||
payload = {
|
payload = {
|
||||||
"chat_id": chat_id or TELEGRAM_CHAT_ID,
|
"chat_id": chat_id or TELEGRAM_CHAT_ID,
|
||||||
"text": text,
|
"text": text,
|
||||||
"parse_mode": "HTML",
|
"parse_mode": parse_mode,
|
||||||
}
|
}
|
||||||
if reply_markup:
|
if reply_markup:
|
||||||
payload["reply_markup"] = reply_markup
|
payload["reply_markup"] = reply_markup
|
||||||
@@ -73,3 +84,26 @@ async def send_approval_request(
|
|||||||
{"label": "❌ 거절", "action": "reject"},
|
{"label": "❌ 거절", "action": "reject"},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_photo(
|
||||||
|
photo_bytes: bytes,
|
||||||
|
caption: str = "",
|
||||||
|
reply_markup: Optional[dict] = None,
|
||||||
|
chat_id: Optional[str] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""PNG/JPEG 바이트를 sendPhoto로 전송. reply_markup으로 인라인 키보드 첨부 가능."""
|
||||||
|
if not TELEGRAM_BOT_TOKEN:
|
||||||
|
return {"ok": False, "reason": "no token"}
|
||||||
|
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendPhoto"
|
||||||
|
data: dict = {
|
||||||
|
"chat_id": chat_id or TELEGRAM_CHAT_ID,
|
||||||
|
"caption": caption[:1024],
|
||||||
|
"parse_mode": "HTML",
|
||||||
|
}
|
||||||
|
if reply_markup:
|
||||||
|
data["reply_markup"] = json.dumps(reply_markup, ensure_ascii=False)
|
||||||
|
files = {"photo": ("cover.png", photo_bytes, "image/png")}
|
||||||
|
async with httpx.AsyncClient(timeout=60) as client:
|
||||||
|
resp = await client.post(url, data=data, files=files)
|
||||||
|
return resp.json()
|
||||||
|
|||||||
@@ -37,6 +37,15 @@ async def _handle_callback(callback_query: dict) -> Optional[dict]:
|
|||||||
if callback_id.startswith("realestate_bookmark_"):
|
if callback_id.startswith("realestate_bookmark_"):
|
||||||
return await _handle_realestate_bookmark(callback_query, callback_id)
|
return await _handle_realestate_bookmark(callback_query, callback_id)
|
||||||
|
|
||||||
|
if callback_id.startswith("render_"):
|
||||||
|
return await _handle_insta_render(callback_query, callback_id)
|
||||||
|
|
||||||
|
if callback_id.startswith("issue_"):
|
||||||
|
return await _handle_insta_issue(callback_query, callback_id)
|
||||||
|
|
||||||
|
if callback_id.startswith("ytpub_retry_"):
|
||||||
|
return await _handle_ytpub_retry(callback_query, callback_id)
|
||||||
|
|
||||||
cb = get_telegram_callback(callback_id)
|
cb = get_telegram_callback(callback_id)
|
||||||
if not cb:
|
if not cb:
|
||||||
return None
|
return None
|
||||||
@@ -97,6 +106,96 @@ async def _handle_realestate_bookmark(callback_query: dict, callback_id: str) ->
|
|||||||
return {"ok": False, "error": str(e)}
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_insta_render(callback_query: dict, callback_id: str) -> dict:
|
||||||
|
"""render_{keyword_id} 콜백 → InstaAgent.on_callback('render', ...).
|
||||||
|
|
||||||
|
텔레그램 인라인 버튼이 보낸 callback_data가 `render_<keyword_id>` 형식.
|
||||||
|
InstaAgent._push_keyword_candidates가 callback_data를 그대로 박아 보내며,
|
||||||
|
별도 DB lookup 없이 keyword_id를 파싱해 dispatch한다."""
|
||||||
|
from .messaging import send_raw
|
||||||
|
from ..agents import AGENT_REGISTRY
|
||||||
|
|
||||||
|
await api_call(
|
||||||
|
"answerCallbackQuery",
|
||||||
|
{"callback_query_id": callback_query["id"], "text": "카드 생성 시작"},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
keyword_id = int(callback_id.removeprefix("render_"))
|
||||||
|
except ValueError:
|
||||||
|
await send_raw("⚠️ 잘못된 render 콜백 데이터")
|
||||||
|
return {"ok": False, "error": "invalid_callback_data"}
|
||||||
|
|
||||||
|
agent = AGENT_REGISTRY.get("insta")
|
||||||
|
if not agent:
|
||||||
|
await send_raw("⚠️ insta agent 미등록")
|
||||||
|
return {"ok": False, "error": "agent_missing"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await agent.on_callback("render", {"keyword_id": keyword_id})
|
||||||
|
except Exception as e:
|
||||||
|
await send_raw(f"⚠️ 카드 생성 실패: {e}")
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_insta_issue(callback_query: dict, callback_id: str) -> dict:
|
||||||
|
"""issue_{approve|reject|regen}_{slate_id} 콜백 → InstaAgent.on_callback.
|
||||||
|
|
||||||
|
callback_data 예시: issue_approve_8, issue_reject_8, issue_regen_8
|
||||||
|
InstaAgent.on_callback("issue_approve" | "issue_reject" | "issue_regen", {"slate_id": <int>}) 로 dispatch.
|
||||||
|
"""
|
||||||
|
from .messaging import send_raw
|
||||||
|
from ..agents import AGENT_REGISTRY
|
||||||
|
|
||||||
|
await api_call(
|
||||||
|
"answerCallbackQuery",
|
||||||
|
{"callback_query_id": callback_query["id"], "text": "처리 중..."},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
rest = callback_id.removeprefix("issue_") # 예: "approve_8"
|
||||||
|
verb, sid = rest.rsplit("_", 1) # ("approve", "8")
|
||||||
|
slate_id = int(sid)
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
await send_raw("⚠️ 잘못된 issue 콜백 데이터")
|
||||||
|
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(f"issue_{verb}", {"slate_id": slate_id})
|
||||||
|
except Exception as e:
|
||||||
|
await send_raw(f"⚠️ issue 콜백 처리 실패: {e}")
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_ytpub_retry(callback_query: dict, callback_id: str) -> dict:
|
||||||
|
"""ytpub_retry_{pipeline_id} 콜백 → music-lab pipeline retry 프록시."""
|
||||||
|
from .. import service_proxy
|
||||||
|
from .messaging import send_raw
|
||||||
|
|
||||||
|
await api_call(
|
||||||
|
"answerCallbackQuery",
|
||||||
|
{"callback_query_id": callback_query["id"], "text": "재시도 요청 중..."},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
pid = int(callback_id.removeprefix("ytpub_retry_"))
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return {"ok": False, "error": "invalid_callback_data"}
|
||||||
|
|
||||||
|
res = await service_proxy.pipeline_retry(pid)
|
||||||
|
sc = res.get("status_code")
|
||||||
|
if sc in (200, 202):
|
||||||
|
await send_raw(text=f"🔄 파이프라인 #{pid} 재개: {res.get('retrying_step', '?')}")
|
||||||
|
else:
|
||||||
|
await send_raw(text=f"⚠️ 재개 불가 (#{pid}): {res.get('detail', sc)}")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
async def _handle_message(message: dict, agent_dispatcher) -> Optional[dict]:
|
async def _handle_message(message: dict, agent_dispatcher) -> Optional[dict]:
|
||||||
"""슬래시 명령 메시지 처리."""
|
"""슬래시 명령 메시지 처리."""
|
||||||
from .router import parse_command, resolve_agent_command, HELP_TEXT
|
from .router import parse_command, resolve_agent_command, HELP_TEXT
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ from app.db import (
|
|||||||
def test_init_and_seed():
|
def test_init_and_seed():
|
||||||
init_db()
|
init_db()
|
||||||
agents = get_all_agents()
|
agents = get_all_agents()
|
||||||
assert len(agents) == 2, f"Expected 2 agents, got {len(agents)}"
|
|
||||||
ids = {a["agent_id"] for a in agents}
|
ids = {a["agent_id"] for a in agents}
|
||||||
assert ids == {"stock", "music"}, f"Unexpected agent ids: {ids}"
|
# 시드된 핵심 에이전트 존재 검증 — 레지스트리 확장(insta/lotto/realestate/youtube 등)에 견고하도록
|
||||||
|
# 고정 개수/집합이 아닌 subset으로 단언 (이전 len==2/{stock,music} 고정 단언은 stale였음).
|
||||||
|
assert {"stock", "music"} <= ids, f"core agents missing: {ids}"
|
||||||
|
assert len(agents) >= 2
|
||||||
print(" [PASS] test_init_and_seed")
|
print(" [PASS] test_init_and_seed")
|
||||||
|
|
||||||
|
|
||||||
@@ -93,6 +95,41 @@ def test_telegram_state():
|
|||||||
print(" [PASS] 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__":
|
if __name__ == "__main__":
|
||||||
test_init_and_seed()
|
test_init_and_seed()
|
||||||
test_agent_config_update()
|
test_agent_config_update()
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ apscheduler==3.10.4
|
|||||||
websockets>=12.0
|
websockets>=12.0
|
||||||
httpx>=0.27
|
httpx>=0.27
|
||||||
respx>=0.21
|
respx>=0.21
|
||||||
|
pytest-asyncio>=0.23
|
||||||
google-api-python-client>=2.100.0
|
google-api-python-client>=2.100.0
|
||||||
pytrends>=4.9.2
|
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)
|
||||||
76
agent-office/tests/test_activity_feed_filters.py
Normal file
76
agent-office/tests/test_activity_feed_filters.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# agent-office/tests/test_activity_feed_filters.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():
|
||||||
|
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 test_filter_by_agent_id():
|
||||||
|
db.create_task("lotto", "curate", {})
|
||||||
|
db.create_task("stock", "brief", {})
|
||||||
|
db.add_log("stock", "stock 로그")
|
||||||
|
feed = db.get_activity_feed(limit=50, offset=0, agent_id="lotto")
|
||||||
|
assert feed["total"] == 1
|
||||||
|
assert all(i["agent_id"] == "lotto" for i in feed["items"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_type_task_excludes_logs():
|
||||||
|
db.create_task("lotto", "curate", {})
|
||||||
|
db.add_log("lotto", "로그 한 줄")
|
||||||
|
feed = db.get_activity_feed(limit=50, offset=0, type="task")
|
||||||
|
assert feed["total"] == 1
|
||||||
|
assert all(i["type"] == "task" for i in feed["items"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_type_log_excludes_tasks():
|
||||||
|
db.create_task("lotto", "curate", {})
|
||||||
|
db.add_log("lotto", "로그 한 줄")
|
||||||
|
feed = db.get_activity_feed(limit=50, offset=0, type="log")
|
||||||
|
assert feed["total"] == 1
|
||||||
|
assert all(i["type"] == "log" for i in feed["items"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_status_tasks_only():
|
||||||
|
t1 = db.create_task("lotto", "curate", {})
|
||||||
|
t2 = db.create_task("lotto", "curate", {})
|
||||||
|
db.update_task_status(t1, "succeeded", {})
|
||||||
|
db.update_task_status(t2, "failed", {})
|
||||||
|
db.add_log("lotto", "로그 한 줄") # status 필터 시 log는 제외돼야 함
|
||||||
|
feed = db.get_activity_feed(limit=50, offset=0, status="succeeded")
|
||||||
|
assert feed["total"] == 1
|
||||||
|
assert all(i["type"] == "task" and i["status"] == "succeeded" for i in feed["items"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_filters_returns_all():
|
||||||
|
db.create_task("lotto", "curate", {})
|
||||||
|
db.add_log("stock", "로그")
|
||||||
|
feed = db.get_activity_feed(limit=50, offset=0)
|
||||||
|
assert feed["total"] == 2
|
||||||
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()
|
||||||
169
agent-office/tests/test_insta_autonomous.py
Normal file
169
agent-office/tests/test_insta_autonomous.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
_fd, _TMP = tempfile.mkstemp(suffix=".db")
|
||||||
|
os.close(_fd)
|
||||||
|
os.unlink(_TMP)
|
||||||
|
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
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_autonomous_issue_previews_eligible(monkeypatch):
|
||||||
|
agent = InstaAgent()
|
||||||
|
agent.state = "idle"
|
||||||
|
monkeypatch.setattr("app.agents.insta.get_agent_config",
|
||||||
|
lambda aid: {"custom_config": {"autonomous_issue": True,
|
||||||
|
"select_threshold": 0.5, "max_per_day": 2}})
|
||||||
|
monkeypatch.setattr(agent, "transition", AsyncMock())
|
||||||
|
monkeypatch.setattr(agent, "_run_collect_and_extract", AsyncMock())
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_ranked", AsyncMock(return_value=[
|
||||||
|
{"id": 1, "keyword": "금리", "category": "economy", "eligible": True, "final_score": 0.8, "breakdown": {}},
|
||||||
|
{"id": 2, "keyword": "x", "category": "economy", "eligible": False, "final_score": 0.1, "breakdown": {}},
|
||||||
|
]))
|
||||||
|
preview = AsyncMock()
|
||||||
|
monkeypatch.setattr(agent, "_generate_and_preview", preview)
|
||||||
|
monkeypatch.setattr("app.agents.insta.create_task", lambda *a, **k: "t1")
|
||||||
|
monkeypatch.setattr("app.agents.insta.update_task_status", lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr("app.agents.insta.add_log", lambda *a, **k: None)
|
||||||
|
await agent.on_schedule()
|
||||||
|
assert preview.await_count == 1
|
||||||
|
assert preview.await_args.args[0]["id"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_callback_approve_publishes_and_delivers(monkeypatch):
|
||||||
|
agent = InstaAgent()
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_decision",
|
||||||
|
AsyncMock(return_value={"status": "published"}))
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_slate", AsyncMock(return_value={
|
||||||
|
"assets": [{"page_index": i} for i in range(1, 11)],
|
||||||
|
"suggested_caption": "cap", "hashtags": ["#a"]}))
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_asset_bytes", AsyncMock(return_value=b"png"))
|
||||||
|
monkeypatch.setattr("app.agents.insta._send_media_group", AsyncMock(return_value={"ok": True}))
|
||||||
|
monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock())
|
||||||
|
res = await agent.on_callback("issue_approve", {"slate_id": 8})
|
||||||
|
assert res["ok"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_callback_reject_marks_rejected(monkeypatch):
|
||||||
|
agent = InstaAgent()
|
||||||
|
dec = AsyncMock(return_value={"status": "rejected"})
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_decision", dec)
|
||||||
|
monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock())
|
||||||
|
res = await agent.on_callback("issue_reject", {"slate_id": 8})
|
||||||
|
assert res["ok"] is True
|
||||||
|
dec.assert_awaited_once_with(8, "rejected")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_insta_issue_dispatch(monkeypatch):
|
||||||
|
"""_handle_insta_issue: issue_approve_8 → on_callback('issue_approve', {slate_id:8})."""
|
||||||
|
import sys
|
||||||
|
# stub api_call so answerCallbackQuery doesn't hit real Telegram
|
||||||
|
import app.telegram.webhook as wh
|
||||||
|
monkeypatch.setattr(wh, "api_call", AsyncMock(return_value={"ok": True}))
|
||||||
|
|
||||||
|
agent = InstaAgent()
|
||||||
|
on_cb = AsyncMock(return_value={"ok": True})
|
||||||
|
monkeypatch.setattr(agent, "on_callback", on_cb)
|
||||||
|
|
||||||
|
from app.agents import AGENT_REGISTRY
|
||||||
|
old = AGENT_REGISTRY.get("insta")
|
||||||
|
AGENT_REGISTRY["insta"] = agent
|
||||||
|
try:
|
||||||
|
result = await wh._handle_insta_issue(
|
||||||
|
{"id": "cq1", "data": "issue_approve_8"},
|
||||||
|
"issue_approve_8",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if old is None:
|
||||||
|
AGENT_REGISTRY.pop("insta", None)
|
||||||
|
else:
|
||||||
|
AGENT_REGISTRY["insta"] = old
|
||||||
|
|
||||||
|
on_cb.assert_awaited_once_with("issue_approve", {"slate_id": 8})
|
||||||
|
assert result["ok"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_insta_issue_invalid_data(monkeypatch):
|
||||||
|
"""_handle_insta_issue: 잘못된 callback_data → ok=False, error=invalid_callback_data."""
|
||||||
|
import app.telegram.webhook as wh
|
||||||
|
monkeypatch.setattr(wh, "api_call", AsyncMock(return_value={"ok": True}))
|
||||||
|
monkeypatch.setattr("app.telegram.messaging.send_raw", AsyncMock())
|
||||||
|
|
||||||
|
result = await wh._handle_insta_issue(
|
||||||
|
{"id": "cq2", "data": "issue_bad"},
|
||||||
|
"issue_bad",
|
||||||
|
)
|
||||||
|
assert result["ok"] is False
|
||||||
|
assert result["error"] == "invalid_callback_data"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_backward_compat_non_autonomous_uses_legacy_path(monkeypatch):
|
||||||
|
"""autonomous_issue=False, auto_select=False → insta_ranked 미호출, _push_keyword_candidates 호출."""
|
||||||
|
agent = InstaAgent()
|
||||||
|
agent.state = "idle"
|
||||||
|
monkeypatch.setattr("app.agents.insta.get_agent_config",
|
||||||
|
lambda aid: {"custom_config": {"autonomous_issue": False, "auto_select": False}})
|
||||||
|
monkeypatch.setattr(agent, "transition", AsyncMock())
|
||||||
|
monkeypatch.setattr(agent, "_run_collect_and_extract", AsyncMock())
|
||||||
|
# insta_get_preferences는 try/except 안에 있으므로 예외를 던져도 안전하지만 깔끔하게 mock
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_preferences",
|
||||||
|
AsyncMock(return_value={}))
|
||||||
|
# 비자율 경로에서 insta_ranked는 호출되면 안 된다
|
||||||
|
ranked = AsyncMock()
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_ranked", ranked)
|
||||||
|
# insta_list_keywords: 비자율 경로에서 반드시 호출
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_list_keywords",
|
||||||
|
AsyncMock(return_value=[]))
|
||||||
|
# auto_select=False → _push_keyword_candidates 경로
|
||||||
|
push = AsyncMock()
|
||||||
|
monkeypatch.setattr(agent, "_push_keyword_candidates", push)
|
||||||
|
gen = AsyncMock()
|
||||||
|
monkeypatch.setattr(agent, "_generate_and_preview", gen)
|
||||||
|
monkeypatch.setattr("app.agents.insta.create_task", lambda *a, **k: "t1")
|
||||||
|
monkeypatch.setattr("app.agents.insta.update_task_status", lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr("app.agents.insta.add_log", lambda *a, **k: None)
|
||||||
|
await agent.on_schedule()
|
||||||
|
ranked.assert_not_awaited() # 자율 경로(insta_ranked) 미진입 확인
|
||||||
|
gen.assert_not_awaited() # _generate_and_preview 미호출 확인
|
||||||
|
push.assert_awaited_once() # 기존 candidate-push 경로 진입 확인
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_callback_regen_rejects_old_and_regenerates(monkeypatch):
|
||||||
|
"""issue_regen: 기존 슬레이트 rejected 처리 후 같은 키워드로 _generate_and_preview 재호출."""
|
||||||
|
agent = InstaAgent()
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_slate",
|
||||||
|
AsyncMock(return_value={"keyword": "금리", "category": "economy"}))
|
||||||
|
dec = AsyncMock(return_value={"status": "rejected"})
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_decision", dec)
|
||||||
|
gen = AsyncMock()
|
||||||
|
monkeypatch.setattr(agent, "_generate_and_preview", gen)
|
||||||
|
res = await agent.on_callback("issue_regen", {"slate_id": 8})
|
||||||
|
assert res["ok"] is True
|
||||||
|
dec.assert_awaited_once_with(8, "rejected") # 이전 슬레이트 폐기
|
||||||
|
gen.assert_awaited_once() # 같은 키워드로 재생성
|
||||||
|
assert gen.await_args.args[0]["keyword"] == "금리"
|
||||||
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"
|
||||||
229
agent-office/tests/test_lotto_task_wrap.py
Normal file
229
agent-office/tests/test_lotto_task_wrap.py
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
# 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_deep_curate_error_still_evaluates_signals(monkeypatch):
|
||||||
|
"""deep: curate_weekly가 CuratorError여도 sim/drift 시그널 평가는 계속(fallthrough)."""
|
||||||
|
from app.agents.lotto import LottoAgent
|
||||||
|
from app.curator import signal_runner, pipeline
|
||||||
|
from app import service_proxy
|
||||||
|
from app.notifiers import telegram_lotto
|
||||||
|
|
||||||
|
async def boom_curate(**kwargs):
|
||||||
|
raise pipeline.CuratorError("curation 실패")
|
||||||
|
monkeypatch.setattr(pipeline, "curate_weekly", boom_curate)
|
||||||
|
|
||||||
|
called = {"signal": False, "curate_result": "UNSET"}
|
||||||
|
async def fake_signal(**kwargs):
|
||||||
|
called["signal"] = True
|
||||||
|
called["curate_result"] = kwargs.get("curate_result")
|
||||||
|
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_signal)
|
||||||
|
|
||||||
|
async def fake_latest():
|
||||||
|
return 1226
|
||||||
|
monkeypatch.setattr(service_proxy, "lotto_latest_draw", fake_latest)
|
||||||
|
async def fake_send(_e):
|
||||||
|
pass
|
||||||
|
monkeypatch.setattr(telegram_lotto, "send_urgent_signal", fake_send)
|
||||||
|
|
||||||
|
agent = LottoAgent()
|
||||||
|
result = await agent.run_signal_check(source="deep")
|
||||||
|
assert result["ok"] is True # CuratorError로 중단되지 않음
|
||||||
|
assert called["signal"] is True # sim/drift 평가 계속됨
|
||||||
|
assert called["curate_result"] is None # confidence는 None으로 fallthrough
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_urgent_send_retries_then_succeeds(monkeypatch):
|
||||||
|
"""urgent 발송이 실패하면 재시도하고, 성공하면 True."""
|
||||||
|
from app.agents.lotto import LottoAgent
|
||||||
|
from app.notifiers import telegram_lotto
|
||||||
|
import app.agents.lotto as lotto_mod
|
||||||
|
monkeypatch.setattr(lotto_mod, "URGENT_SEND_RETRY_SEC", 0) # 실대기 제거
|
||||||
|
|
||||||
|
attempts = {"n": 0}
|
||||||
|
async def flaky_send(_event):
|
||||||
|
attempts["n"] += 1
|
||||||
|
if attempts["n"] < 3:
|
||||||
|
raise RuntimeError("telegram down")
|
||||||
|
monkeypatch.setattr(telegram_lotto, "send_urgent_signal", flaky_send)
|
||||||
|
|
||||||
|
agent = LottoAgent()
|
||||||
|
results = [{"signal_id": 1, "fire_level": "urgent"}]
|
||||||
|
ok = await agent._send_urgent_with_retry({"x": 1}, results, task_id="t1")
|
||||||
|
assert ok is True
|
||||||
|
assert attempts["n"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_urgent_send_all_fail_returns_false_no_raise(monkeypatch):
|
||||||
|
"""urgent 발송이 끝까지 실패해도 raise하지 않고 False (시그널 평가/태스크 보존)."""
|
||||||
|
from app.agents.lotto import LottoAgent
|
||||||
|
from app.notifiers import telegram_lotto
|
||||||
|
import app.agents.lotto as lotto_mod
|
||||||
|
monkeypatch.setattr(lotto_mod, "URGENT_SEND_RETRY_SEC", 0)
|
||||||
|
|
||||||
|
async def always_fail(_event):
|
||||||
|
raise RuntimeError("telegram down")
|
||||||
|
monkeypatch.setattr(telegram_lotto, "send_urgent_signal", always_fail)
|
||||||
|
|
||||||
|
agent = LottoAgent()
|
||||||
|
ok = await agent._send_urgent_with_retry(
|
||||||
|
{"x": 1}, [{"signal_id": 1, "fire_level": "urgent"}], task_id="t1")
|
||||||
|
assert ok is False
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
@@ -40,6 +40,9 @@ async def test_poll_notifies_once_per_state():
|
|||||||
with patch(
|
with patch(
|
||||||
"app.agents.youtube_publisher.service_proxy.list_active_pipelines",
|
"app.agents.youtube_publisher.service_proxy.list_active_pipelines",
|
||||||
new=AsyncMock(return_value=pipelines),
|
new=AsyncMock(return_value=pipelines),
|
||||||
|
), patch(
|
||||||
|
"app.agents.youtube_publisher.service_proxy.list_failed_pipelines",
|
||||||
|
new=AsyncMock(return_value=[]),
|
||||||
), patch(
|
), patch(
|
||||||
"app.agents.youtube_publisher.send_raw",
|
"app.agents.youtube_publisher.send_raw",
|
||||||
new=AsyncMock(return_value={"ok": True, "message_id": 99}),
|
new=AsyncMock(return_value={"ok": True, "message_id": 99}),
|
||||||
@@ -63,6 +66,8 @@ async def test_poll_renotifies_on_reject_regen(monkeypatch):
|
|||||||
"track_title": "Test", "feedback_count_per_step": {"cover": 1}}]
|
"track_title": "Test", "feedback_count_per_step": {"cover": 1}}]
|
||||||
list_mock = AsyncMock(side_effect=[pipelines_v1, pipelines_v2])
|
list_mock = AsyncMock(side_effect=[pipelines_v1, pipelines_v2])
|
||||||
with patch("app.agents.youtube_publisher.service_proxy.list_active_pipelines", list_mock), \
|
with patch("app.agents.youtube_publisher.service_proxy.list_active_pipelines", list_mock), \
|
||||||
|
patch("app.agents.youtube_publisher.service_proxy.list_failed_pipelines",
|
||||||
|
new=AsyncMock(return_value=[])), \
|
||||||
patch("app.agents.youtube_publisher.send_raw",
|
patch("app.agents.youtube_publisher.send_raw",
|
||||||
new=AsyncMock(return_value={"ok": True, "message_id": 99})), \
|
new=AsyncMock(return_value={"ok": True, "message_id": 99})), \
|
||||||
patch("app.agents.youtube_publisher.service_proxy.save_pipeline_telegram_msg",
|
patch("app.agents.youtube_publisher.service_proxy.save_pipeline_telegram_msg",
|
||||||
@@ -83,7 +88,7 @@ async def test_on_telegram_reply_approve_calls_feedback():
|
|||||||
new=AsyncMock(),
|
new=AsyncMock(),
|
||||||
) as mock_fb, patch(
|
) as mock_fb, patch(
|
||||||
"app.agents.youtube_publisher.send_raw",
|
"app.agents.youtube_publisher.send_raw",
|
||||||
new=AsyncMock(),
|
new=AsyncMock(return_value={"ok": True, "message_id": 1}),
|
||||||
):
|
):
|
||||||
a = YoutubePublisherAgent()
|
a = YoutubePublisherAgent()
|
||||||
await a.on_telegram_reply(pipeline_id=42, step="cover", user_text="승인")
|
await a.on_telegram_reply(pipeline_id=42, step="cover", user_text="승인")
|
||||||
@@ -99,7 +104,7 @@ async def test_on_telegram_reply_reject_with_feedback():
|
|||||||
new=AsyncMock(),
|
new=AsyncMock(),
|
||||||
) as mock_fb, patch(
|
) as mock_fb, patch(
|
||||||
"app.agents.youtube_publisher.send_raw",
|
"app.agents.youtube_publisher.send_raw",
|
||||||
new=AsyncMock(),
|
new=AsyncMock(return_value={"ok": True, "message_id": 1}),
|
||||||
):
|
):
|
||||||
a = YoutubePublisherAgent()
|
a = YoutubePublisherAgent()
|
||||||
await a.on_telegram_reply(pipeline_id=43, step="meta", user_text="반려, 제목 짧게")
|
await a.on_telegram_reply(pipeline_id=43, step="meta", user_text="반려, 제목 짧게")
|
||||||
|
|||||||
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
|
||||||
213
agent-office/tests/test_youtube_publisher_retry.py
Normal file
213
agent-office/tests/test_youtube_publisher_retry.py
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
_fd, _TMP = tempfile.mkstemp(suffix=".db")
|
||||||
|
os.close(_fd)
|
||||||
|
os.unlink(_TMP)
|
||||||
|
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _init_db():
|
||||||
|
import gc
|
||||||
|
gc.collect()
|
||||||
|
if os.path.exists(_TMP):
|
||||||
|
os.remove(_TMP)
|
||||||
|
from app.db import init_db
|
||||||
|
init_db()
|
||||||
|
yield
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_failed_pipeline_notified_with_retry_button():
|
||||||
|
from app.agents.youtube_publisher import YoutubePublisherAgent
|
||||||
|
|
||||||
|
agent = YoutubePublisherAgent()
|
||||||
|
failed_pipeline = {
|
||||||
|
"id": 7,
|
||||||
|
"state": "failed",
|
||||||
|
"failed_reason": "video: boom",
|
||||||
|
"track_title": "T",
|
||||||
|
}
|
||||||
|
sent = AsyncMock(return_value={"ok": True, "message_id": 1})
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.agents.youtube_publisher.service_proxy.list_active_pipelines",
|
||||||
|
new=AsyncMock(return_value=[]),
|
||||||
|
), patch(
|
||||||
|
"app.agents.youtube_publisher.service_proxy.list_failed_pipelines",
|
||||||
|
new=AsyncMock(return_value=[failed_pipeline]),
|
||||||
|
), patch(
|
||||||
|
"app.agents.youtube_publisher.send_raw",
|
||||||
|
new=sent,
|
||||||
|
):
|
||||||
|
await agent.poll_state_changes()
|
||||||
|
|
||||||
|
assert sent.await_count == 1
|
||||||
|
_, kwargs = sent.await_args
|
||||||
|
assert "실패" in (kwargs.get("text") or "")
|
||||||
|
assert kwargs["reply_markup"]["inline_keyboard"][0][0]["callback_data"] == "ytpub_retry_7"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_failed_pipeline_no_duplicate_notification():
|
||||||
|
"""같은 failed 파이프라인은 두 번째 poll에서 알림 안 함."""
|
||||||
|
from app.agents.youtube_publisher import YoutubePublisherAgent
|
||||||
|
|
||||||
|
agent = YoutubePublisherAgent()
|
||||||
|
failed_pipeline = {
|
||||||
|
"id": 7,
|
||||||
|
"state": "failed",
|
||||||
|
"failed_reason": "video: boom",
|
||||||
|
"track_title": "T",
|
||||||
|
}
|
||||||
|
sent = AsyncMock(return_value={"ok": True, "message_id": 1})
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.agents.youtube_publisher.service_proxy.list_active_pipelines",
|
||||||
|
new=AsyncMock(return_value=[]),
|
||||||
|
), patch(
|
||||||
|
"app.agents.youtube_publisher.service_proxy.list_failed_pipelines",
|
||||||
|
new=AsyncMock(return_value=[failed_pipeline]),
|
||||||
|
), patch(
|
||||||
|
"app.agents.youtube_publisher.send_raw",
|
||||||
|
new=sent,
|
||||||
|
):
|
||||||
|
await agent.poll_state_changes()
|
||||||
|
await agent.poll_state_changes()
|
||||||
|
|
||||||
|
# 중복 방지: 같은 failed 파이프라인에 대해 1회만 알림
|
||||||
|
assert sent.await_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_failed_pipeline_renotify_after_recovery():
|
||||||
|
"""failed에서 벗어난 파이프라인이 다시 failed 되면 재알림."""
|
||||||
|
from app.agents.youtube_publisher import YoutubePublisherAgent
|
||||||
|
|
||||||
|
agent = YoutubePublisherAgent()
|
||||||
|
failed_pipeline = {
|
||||||
|
"id": 7,
|
||||||
|
"state": "failed",
|
||||||
|
"failed_reason": "video: boom",
|
||||||
|
"track_title": "T",
|
||||||
|
}
|
||||||
|
sent = AsyncMock(return_value={"ok": True, "message_id": 1})
|
||||||
|
|
||||||
|
# 첫 번째 poll: failed 존재 → 알림
|
||||||
|
with patch(
|
||||||
|
"app.agents.youtube_publisher.service_proxy.list_active_pipelines",
|
||||||
|
new=AsyncMock(return_value=[]),
|
||||||
|
), patch(
|
||||||
|
"app.agents.youtube_publisher.service_proxy.list_failed_pipelines",
|
||||||
|
new=AsyncMock(return_value=[failed_pipeline]),
|
||||||
|
), patch(
|
||||||
|
"app.agents.youtube_publisher.send_raw",
|
||||||
|
new=sent,
|
||||||
|
):
|
||||||
|
await agent.poll_state_changes()
|
||||||
|
|
||||||
|
assert sent.await_count == 1
|
||||||
|
|
||||||
|
# 두 번째 poll: failed 목록에서 사라짐(재개됨) → _notified_failed에서 제거
|
||||||
|
with patch(
|
||||||
|
"app.agents.youtube_publisher.service_proxy.list_active_pipelines",
|
||||||
|
new=AsyncMock(return_value=[]),
|
||||||
|
), patch(
|
||||||
|
"app.agents.youtube_publisher.service_proxy.list_failed_pipelines",
|
||||||
|
new=AsyncMock(return_value=[]),
|
||||||
|
), patch(
|
||||||
|
"app.agents.youtube_publisher.send_raw",
|
||||||
|
new=sent,
|
||||||
|
):
|
||||||
|
await agent.poll_state_changes()
|
||||||
|
|
||||||
|
assert sent.await_count == 1 # 아직 추가 알림 없음
|
||||||
|
|
||||||
|
# 세 번째 poll: 다시 failed → 재알림 가능
|
||||||
|
with patch(
|
||||||
|
"app.agents.youtube_publisher.service_proxy.list_active_pipelines",
|
||||||
|
new=AsyncMock(return_value=[]),
|
||||||
|
), patch(
|
||||||
|
"app.agents.youtube_publisher.service_proxy.list_failed_pipelines",
|
||||||
|
new=AsyncMock(return_value=[failed_pipeline]),
|
||||||
|
), patch(
|
||||||
|
"app.agents.youtube_publisher.send_raw",
|
||||||
|
new=sent,
|
||||||
|
):
|
||||||
|
await agent.poll_state_changes()
|
||||||
|
|
||||||
|
assert sent.await_count == 2 # 재알림
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_ytpub_retry_calls_proxy():
|
||||||
|
from app import service_proxy
|
||||||
|
from app.telegram import webhook
|
||||||
|
|
||||||
|
retry = AsyncMock(return_value={"status_code": 202, "ok": True, "retrying_step": "video"})
|
||||||
|
fake_send = AsyncMock(return_value={"ok": True})
|
||||||
|
fake_api_call = AsyncMock(return_value={"ok": True})
|
||||||
|
|
||||||
|
with patch.object(service_proxy, "pipeline_retry", retry), \
|
||||||
|
patch("app.telegram.messaging.send_raw", fake_send), \
|
||||||
|
patch("app.telegram.webhook.api_call", fake_api_call):
|
||||||
|
res = await webhook._handle_ytpub_retry({"id": 1}, "ytpub_retry_7")
|
||||||
|
|
||||||
|
retry.assert_awaited_once_with(7)
|
||||||
|
assert res["ok"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_ytpub_retry_invalid_data():
|
||||||
|
from app.telegram import webhook
|
||||||
|
|
||||||
|
fake_send = AsyncMock(return_value={"ok": True})
|
||||||
|
fake_api_call = AsyncMock(return_value={"ok": True})
|
||||||
|
|
||||||
|
with patch("app.telegram.messaging.send_raw", fake_send), \
|
||||||
|
patch("app.telegram.webhook.api_call", fake_api_call):
|
||||||
|
res = await webhook._handle_ytpub_retry({"id": 1}, "ytpub_retry_abc")
|
||||||
|
|
||||||
|
assert res["ok"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_failed_poll_exception_is_silent():
|
||||||
|
"""list_failed_pipelines 예외 시 poll이 조용히 넘어감 (active 알림에 영향 없음)."""
|
||||||
|
from app.agents.youtube_publisher import YoutubePublisherAgent
|
||||||
|
|
||||||
|
agent = YoutubePublisherAgent()
|
||||||
|
active_pipeline = {
|
||||||
|
"id": 1,
|
||||||
|
"state": "cover_pending",
|
||||||
|
"cover_url": "/x.jpg",
|
||||||
|
"track_title": "Track",
|
||||||
|
"feedback_count_per_step": {},
|
||||||
|
}
|
||||||
|
sent = AsyncMock(return_value={"ok": True, "message_id": 1})
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.agents.youtube_publisher.service_proxy.list_active_pipelines",
|
||||||
|
new=AsyncMock(return_value=[active_pipeline]),
|
||||||
|
), patch(
|
||||||
|
"app.agents.youtube_publisher.service_proxy.list_failed_pipelines",
|
||||||
|
new=AsyncMock(side_effect=Exception("network error")),
|
||||||
|
), patch(
|
||||||
|
"app.agents.youtube_publisher.service_proxy.save_pipeline_telegram_msg",
|
||||||
|
new=AsyncMock(),
|
||||||
|
), patch(
|
||||||
|
"app.agents.youtube_publisher.send_raw",
|
||||||
|
new=sent,
|
||||||
|
):
|
||||||
|
await agent.poll_state_changes()
|
||||||
|
|
||||||
|
# active 알림은 정상 발송
|
||||||
|
assert sent.await_count == 1
|
||||||
@@ -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"
|
|
||||||
3
co-gahusb/.gitignore
vendored
Normal file
3
co-gahusb/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
19
co-gahusb/CLIENT_SETUP.md
Normal file
19
co-gahusb/CLIENT_SETUP.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# co-gahusb 클라이언트 설정
|
||||||
|
|
||||||
|
## 공통
|
||||||
|
1. `CO_BUS_KEY` 환경변수를 각 머신에 설정(서버 `.env`의 값과 동일).
|
||||||
|
2. 해당 repo 루트 `.mcp.json`에 co-gahusb HTTP MCP 등록(이 repo의 예시 참고).
|
||||||
|
3. CLAUDE.md 역할 블록의 `/loop` 폴링 규약을 따른다.
|
||||||
|
|
||||||
|
## web-ai (다른 머신)
|
||||||
|
web-ai 머신의 repo 루트에 아래 `.mcp.json` 생성, 역할 = **AI**:
|
||||||
|
```json
|
||||||
|
{ "mcpServers": { "co-gahusb": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://gahusb.synology.me/api/co/mcp",
|
||||||
|
"headers": { "Authorization": "Bearer ${CO_BUS_KEY}" } } } }
|
||||||
|
```
|
||||||
|
web-ai CLAUDE.md에 역할 블록 추가(role="AI", 소유권=web-ai repo, 동일 락 규약).
|
||||||
|
|
||||||
|
## Producer (오케스트레이터 세션)
|
||||||
|
별도 repo 없이 조율 담당. `team_log()`로 전체 활동 감시, `create_task`로 분배, `acquire_lock`로 교차 작업 직렬화.
|
||||||
12
co-gahusb/Dockerfile
Normal file
12
co-gahusb/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM python:3.12-slim-bookworm
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uvicorn", "app.server:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||||
21
co-gahusb/app/config.py
Normal file
21
co-gahusb/app/config.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# co-gahusb/app/config.py
|
||||||
|
import os
|
||||||
|
|
||||||
|
REDIS_URL = os.environ.get("REDIS_URL", "redis://redis:6379")
|
||||||
|
CO_BUS_KEY = os.environ.get("CO_BUS_KEY", "")
|
||||||
|
|
||||||
|
# 협업 역할 (세션별 1:1)
|
||||||
|
ROLES = ("FE", "BE", "AI", "Producer")
|
||||||
|
|
||||||
|
# 교차 리소스 어드바이저리 락 대상 (이 외 이름도 락은 가능하나, 규약상 명시 대상)
|
||||||
|
LOCKABLE_RESOURCES = (
|
||||||
|
"nas-deploy",
|
||||||
|
"stock-db-schema",
|
||||||
|
"lotto-db-schema",
|
||||||
|
"memory-mirror",
|
||||||
|
"nginx-conf",
|
||||||
|
"compose",
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_LOCK_TTL = 300
|
||||||
|
TEAM_LOG_MAXLEN = 500
|
||||||
66
co-gahusb/app/locks.py
Normal file
66
co-gahusb/app/locks.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# co-gahusb/app/locks.py
|
||||||
|
from redis.exceptions import WatchError
|
||||||
|
|
||||||
|
LOCK_PREFIX = "co:lock:"
|
||||||
|
|
||||||
|
|
||||||
|
async def acquire_lock(r, resource, role, ttl_sec=300):
|
||||||
|
key = LOCK_PREFIX + resource
|
||||||
|
ok = await r.set(key, role, nx=True, ex=ttl_sec)
|
||||||
|
if ok:
|
||||||
|
return {"acquired": True}
|
||||||
|
held_by = await r.get(key)
|
||||||
|
ttl = await r.ttl(key)
|
||||||
|
return {"acquired": False, "held_by": held_by, "ttl_remaining": max(ttl, 0)}
|
||||||
|
|
||||||
|
|
||||||
|
async def release_lock(r, resource, role):
|
||||||
|
key = LOCK_PREFIX + resource
|
||||||
|
async with r.pipeline() as pipe:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await pipe.watch(key)
|
||||||
|
owner = await pipe.get(key)
|
||||||
|
if owner != role:
|
||||||
|
await pipe.unwatch()
|
||||||
|
return {"released": False, "held_by": owner}
|
||||||
|
pipe.multi()
|
||||||
|
pipe.delete(key)
|
||||||
|
await pipe.execute()
|
||||||
|
return {"released": True}
|
||||||
|
except WatchError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
async def heartbeat_lock(r, resource, role, ttl_sec=300):
|
||||||
|
key = LOCK_PREFIX + resource
|
||||||
|
async with r.pipeline() as pipe:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await pipe.watch(key)
|
||||||
|
owner = await pipe.get(key)
|
||||||
|
if owner != role:
|
||||||
|
await pipe.unwatch()
|
||||||
|
return {"renewed": False, "held_by": owner}
|
||||||
|
pipe.multi()
|
||||||
|
pipe.expire(key, ttl_sec)
|
||||||
|
await pipe.execute()
|
||||||
|
return {"renewed": True}
|
||||||
|
except WatchError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
async def list_locks(r):
|
||||||
|
keys = await r.keys(LOCK_PREFIX + "*")
|
||||||
|
out = []
|
||||||
|
for key in keys:
|
||||||
|
held_by = await r.get(key)
|
||||||
|
if held_by is None:
|
||||||
|
continue
|
||||||
|
ttl = await r.ttl(key)
|
||||||
|
out.append({
|
||||||
|
"resource": key[len(LOCK_PREFIX):],
|
||||||
|
"held_by": held_by,
|
||||||
|
"ttl_remaining": max(ttl, 0),
|
||||||
|
})
|
||||||
|
return {"locks": out}
|
||||||
138
co-gahusb/app/server.py
Normal file
138
co-gahusb/app/server.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# co-gahusb/app/server.py
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
from mcp.server.transport_security import TransportSecuritySettings
|
||||||
|
from starlette.applications import Starlette
|
||||||
|
from starlette.middleware import Middleware
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
from starlette.routing import Mount, Route
|
||||||
|
|
||||||
|
from app import config, locks, store
|
||||||
|
|
||||||
|
log = logging.getLogger("co-gahusb")
|
||||||
|
_auth_failed_logged = False
|
||||||
|
|
||||||
|
_redis = aioredis.from_url(config.REDIS_URL, decode_responses=True)
|
||||||
|
|
||||||
|
# DNS-rebinding 보호 비활성화: 실 보안은 nginx 앞단 Bearer 인증(MCP 도달 전 401)이다.
|
||||||
|
# 원격 HTTPS + 정적키 모델이라 Host 화이트리스트는 보안가치 ~0이고, 도메인 변경 시 또 깨진다.
|
||||||
|
mcp = FastMCP(
|
||||||
|
"co-gahusb",
|
||||||
|
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 메시지 ----
|
||||||
|
@mcp.tool()
|
||||||
|
async def post_message(from_role: str, to_role: str, body: str, thread_id: str = "") -> dict:
|
||||||
|
"""다른 역할의 우편함에 메시지를 보낸다."""
|
||||||
|
res = await store.post_message(_redis, from_role, to_role, body, thread_id or None)
|
||||||
|
await store.log_event(_redis, "message", f"{from_role}→{to_role}: {body[:60]}")
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def read_inbox(role: str, after_id: int = 0, mark_read: bool = False) -> dict:
|
||||||
|
"""내 역할 우편함을 커서 기반으로 읽는다."""
|
||||||
|
return await store.read_inbox(_redis, role, after_id, mark_read)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 작업 ----
|
||||||
|
@mcp.tool()
|
||||||
|
async def create_task(title: str, assignee_role: str, created_by: str, detail: str = "") -> dict:
|
||||||
|
"""작업을 만들어 특정 역할에 배정한다."""
|
||||||
|
res = await store.create_task(_redis, title, assignee_role, created_by, detail or None)
|
||||||
|
await store.log_event(_redis, "task", f"{created_by} created '{title}' → {assignee_role}")
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def claim_task(task_id: int, role: str) -> dict:
|
||||||
|
"""open 작업을 점유(in_progress)한다. 이미 점유면 거부."""
|
||||||
|
res = await store.claim_task(_redis, task_id, role)
|
||||||
|
if res.get("ok"):
|
||||||
|
await store.log_event(_redis, "task", f"{role} claimed task#{task_id}")
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def update_task(task_id: int, status: str, role: str, note: str = "") -> dict:
|
||||||
|
"""작업 상태를 갱신한다 (open/in_progress/blocked/done)."""
|
||||||
|
res = await store.update_task(_redis, task_id, status, role, note or None)
|
||||||
|
await store.log_event(_redis, "task", f"{role} set task#{task_id} → {status}")
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def list_tasks(status: str = "", assignee_role: str = "") -> dict:
|
||||||
|
"""작업 목록을 조회한다(상태/담당 필터)."""
|
||||||
|
return await store.list_tasks(_redis, status or None, assignee_role or None)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 락 ----
|
||||||
|
@mcp.tool()
|
||||||
|
async def acquire_lock(resource: str, role: str, ttl_sec: int = config.DEFAULT_LOCK_TTL) -> dict:
|
||||||
|
"""공유 리소스 변경 전 어드바이저리 락을 획득한다. 점유 중이면 acquired=false."""
|
||||||
|
res = await locks.acquire_lock(_redis, resource, role, ttl_sec)
|
||||||
|
if res.get("acquired"):
|
||||||
|
await store.log_event(_redis, "lock", f"{role} acquired {resource}")
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def release_lock(resource: str, role: str) -> dict:
|
||||||
|
"""소유한 락을 해제한다."""
|
||||||
|
res = await locks.release_lock(_redis, resource, role)
|
||||||
|
if res.get("released"):
|
||||||
|
await store.log_event(_redis, "lock", f"{role} released {resource}")
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def heartbeat_lock(resource: str, role: str, ttl_sec: int = config.DEFAULT_LOCK_TTL) -> dict:
|
||||||
|
"""긴 작업 중 락 TTL을 갱신한다(소유자만)."""
|
||||||
|
return await locks.heartbeat_lock(_redis, resource, role, ttl_sec)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def list_locks() -> dict:
|
||||||
|
"""현재 점유 중인 모든 락을 조회한다."""
|
||||||
|
return await locks.list_locks(_redis)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 가시성 ----
|
||||||
|
@mcp.tool()
|
||||||
|
async def team_log(after_id: int = 0) -> dict:
|
||||||
|
"""팀 전체 최근 활동 피드(메시지·작업·락)를 조회한다."""
|
||||||
|
return await store.read_team_log(_redis, after_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Bearer 인증 미들웨어 ----
|
||||||
|
class BearerAuth(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request, call_next):
|
||||||
|
global _auth_failed_logged
|
||||||
|
if request.url.path.startswith("/health"):
|
||||||
|
return await call_next(request)
|
||||||
|
expected = f"Bearer {config.CO_BUS_KEY}"
|
||||||
|
if not config.CO_BUS_KEY or request.headers.get("authorization") != expected:
|
||||||
|
if not _auth_failed_logged:
|
||||||
|
log.error("co-gahusb 인증 실패 (이후 동일 로그 생략)")
|
||||||
|
_auth_failed_logged = True
|
||||||
|
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
async def _health(request):
|
||||||
|
return JSONResponse({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
_mcp_app = mcp.streamable_http_app()
|
||||||
|
|
||||||
|
app = Starlette(
|
||||||
|
routes=[Route("/health", _health), Mount("/", app=_mcp_app)],
|
||||||
|
middleware=[Middleware(BearerAuth)],
|
||||||
|
lifespan=_mcp_app.router.lifespan_context,
|
||||||
|
)
|
||||||
157
co-gahusb/app/store.py
Normal file
157
co-gahusb/app/store.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# co-gahusb/app/store.py
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
from app.config import TEAM_LOG_MAXLEN
|
||||||
|
|
||||||
|
MSG_SEQ = "co:msgseq"
|
||||||
|
INBOX_PREFIX = "co:inbox:" # list of message ids per role
|
||||||
|
MSG_PREFIX = "co:msg:" # hash per message
|
||||||
|
READ_PREFIX = "co:read:" # last-read cursor per role
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso():
|
||||||
|
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||||
|
|
||||||
|
|
||||||
|
async def post_message(r, from_role, to_role, body, thread_id=None):
|
||||||
|
mid = await r.incr(MSG_SEQ)
|
||||||
|
payload = {
|
||||||
|
"id": str(mid),
|
||||||
|
"from_role": from_role,
|
||||||
|
"to_role": to_role,
|
||||||
|
"body": body,
|
||||||
|
"thread_id": thread_id or "",
|
||||||
|
"ts": _now_iso(),
|
||||||
|
}
|
||||||
|
await r.set(MSG_PREFIX + str(mid), json.dumps(payload))
|
||||||
|
await r.rpush(INBOX_PREFIX + to_role, mid)
|
||||||
|
return {"message_id": mid}
|
||||||
|
|
||||||
|
|
||||||
|
async def read_inbox(r, role, after_id=0, mark_read=False):
|
||||||
|
ids = await r.lrange(INBOX_PREFIX + role, 0, -1)
|
||||||
|
ids = [int(x) for x in ids if int(x) > int(after_id)]
|
||||||
|
messages = []
|
||||||
|
for mid in ids:
|
||||||
|
raw = await r.get(MSG_PREFIX + str(mid))
|
||||||
|
if raw:
|
||||||
|
d = json.loads(raw)
|
||||||
|
d["id"] = int(d["id"])
|
||||||
|
messages.append(d)
|
||||||
|
cursor = ids[-1] if ids else int(after_id)
|
||||||
|
if mark_read and ids:
|
||||||
|
await r.set(READ_PREFIX + role, cursor)
|
||||||
|
return {"messages": messages, "cursor": cursor}
|
||||||
|
|
||||||
|
|
||||||
|
TASK_SEQ = "co:taskseq"
|
||||||
|
TASK_PREFIX = "co:task:" # hash per task
|
||||||
|
TASK_SET = "co:tasks" # set of task ids
|
||||||
|
|
||||||
|
VALID_STATUS = ("open", "in_progress", "blocked", "done")
|
||||||
|
|
||||||
|
|
||||||
|
async def create_task(r, title, assignee_role, created_by, detail=None):
|
||||||
|
tid = await r.incr(TASK_SEQ)
|
||||||
|
task = {
|
||||||
|
"id": str(tid),
|
||||||
|
"title": title,
|
||||||
|
"assignee_role": assignee_role,
|
||||||
|
"status": "open",
|
||||||
|
"detail": detail or "",
|
||||||
|
"created_by": created_by,
|
||||||
|
"note": "",
|
||||||
|
"ts": _now_iso(),
|
||||||
|
}
|
||||||
|
await r.hset(TASK_PREFIX + str(tid), mapping=task)
|
||||||
|
await r.sadd(TASK_SET, tid)
|
||||||
|
return {"task_id": tid}
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_task(r, task_id):
|
||||||
|
d = await r.hgetall(TASK_PREFIX + str(task_id))
|
||||||
|
if not d:
|
||||||
|
return None
|
||||||
|
d["id"] = int(d["id"])
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
async def claim_task(r, task_id, role):
|
||||||
|
key = TASK_PREFIX + str(task_id)
|
||||||
|
async with r.pipeline() as pipe:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await pipe.watch(key)
|
||||||
|
status = await pipe.hget(key, "status")
|
||||||
|
if status is None:
|
||||||
|
await pipe.unwatch()
|
||||||
|
return {"ok": False, "error": "not_found"}
|
||||||
|
if status != "open":
|
||||||
|
held = await pipe.hget(key, "assignee_role")
|
||||||
|
await pipe.unwatch()
|
||||||
|
return {"ok": False, "held_by": held}
|
||||||
|
pipe.multi()
|
||||||
|
pipe.hset(key, mapping={"status": "in_progress", "assignee_role": role})
|
||||||
|
await pipe.execute()
|
||||||
|
return {"ok": True, "task": await _get_task(r, task_id)}
|
||||||
|
except Exception as e:
|
||||||
|
from redis.exceptions import WatchError
|
||||||
|
if isinstance(e, WatchError):
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def update_task(r, task_id, status, role, note=None):
|
||||||
|
if status not in VALID_STATUS:
|
||||||
|
raise ValueError(f"invalid status: {status}")
|
||||||
|
key = TASK_PREFIX + str(task_id)
|
||||||
|
if not await r.exists(key):
|
||||||
|
return {"ok": False, "error": "not_found"}
|
||||||
|
mapping = {"status": status}
|
||||||
|
if note is not None:
|
||||||
|
mapping["note"] = note
|
||||||
|
await r.hset(key, mapping=mapping)
|
||||||
|
return {"ok": True, "task": await _get_task(r, task_id)}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_tasks(r, status=None, assignee_role=None):
|
||||||
|
ids = sorted(int(x) for x in await r.smembers(TASK_SET))
|
||||||
|
tasks = []
|
||||||
|
for tid in ids:
|
||||||
|
t = await _get_task(r, tid)
|
||||||
|
if t is None:
|
||||||
|
continue
|
||||||
|
if status and t["status"] != status:
|
||||||
|
continue
|
||||||
|
if assignee_role and t["assignee_role"] != assignee_role:
|
||||||
|
continue
|
||||||
|
tasks.append(t)
|
||||||
|
return {"tasks": tasks}
|
||||||
|
|
||||||
|
|
||||||
|
LOG_SEQ = "co:logseq"
|
||||||
|
LOG_LIST = "co:log" # list of event ids (capped)
|
||||||
|
LOG_PREFIX = "co:logitem:"
|
||||||
|
|
||||||
|
|
||||||
|
async def log_event(r, kind, text):
|
||||||
|
eid = await r.incr(LOG_SEQ)
|
||||||
|
item = {"id": eid, "kind": kind, "text": text, "ts": _now_iso()}
|
||||||
|
await r.set(LOG_PREFIX + str(eid), json.dumps(item))
|
||||||
|
await r.rpush(LOG_LIST, eid)
|
||||||
|
await r.ltrim(LOG_LIST, -TEAM_LOG_MAXLEN, -1)
|
||||||
|
return {"event_id": eid}
|
||||||
|
|
||||||
|
|
||||||
|
async def read_team_log(r, after_id=0, limit=100):
|
||||||
|
ids = [int(x) for x in await r.lrange(LOG_LIST, 0, -1)]
|
||||||
|
ids = [i for i in ids if i > int(after_id)]
|
||||||
|
ids = ids[-limit:]
|
||||||
|
events = []
|
||||||
|
for eid in ids:
|
||||||
|
raw = await r.get(LOG_PREFIX + str(eid))
|
||||||
|
if raw:
|
||||||
|
events.append(json.loads(raw))
|
||||||
|
cursor = ids[-1] if ids else int(after_id)
|
||||||
|
return {"events": events, "cursor": cursor}
|
||||||
3
co-gahusb/pytest.ini
Normal file
3
co-gahusb/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
|
testpaths = tests
|
||||||
7
co-gahusb/requirements.txt
Normal file
7
co-gahusb/requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
mcp>=1.2.0
|
||||||
|
starlette>=0.37
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
redis>=5.0
|
||||||
|
pytest>=8.0
|
||||||
|
pytest-asyncio>=0.24
|
||||||
|
fakeredis>=2.21
|
||||||
0
co-gahusb/tests/__init__.py
Normal file
0
co-gahusb/tests/__init__.py
Normal file
11
co-gahusb/tests/conftest.py
Normal file
11
co-gahusb/tests/conftest.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# co-gahusb/tests/conftest.py
|
||||||
|
import pytest_asyncio
|
||||||
|
import fakeredis.aioredis
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def r():
|
||||||
|
client = fakeredis.aioredis.FakeRedis(decode_responses=True)
|
||||||
|
await client.flushall()
|
||||||
|
yield client
|
||||||
|
await client.aclose()
|
||||||
51
co-gahusb/tests/test_locks.py
Normal file
51
co-gahusb/tests/test_locks.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# co-gahusb/tests/test_locks.py
|
||||||
|
from app import locks
|
||||||
|
|
||||||
|
|
||||||
|
async def test_acquire_succeeds_then_blocks_other(r):
|
||||||
|
res = await locks.acquire_lock(r, "nas-deploy", "BE", ttl_sec=300)
|
||||||
|
assert res["acquired"] is True
|
||||||
|
|
||||||
|
res2 = await locks.acquire_lock(r, "nas-deploy", "FE", ttl_sec=300)
|
||||||
|
assert res2["acquired"] is False
|
||||||
|
assert res2["held_by"] == "BE"
|
||||||
|
assert res2["ttl_remaining"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_release_only_by_owner(r):
|
||||||
|
await locks.acquire_lock(r, "compose", "BE", ttl_sec=300)
|
||||||
|
|
||||||
|
bad = await locks.release_lock(r, "compose", "FE")
|
||||||
|
assert bad["released"] is False
|
||||||
|
|
||||||
|
ok = await locks.release_lock(r, "compose", "BE")
|
||||||
|
assert ok["released"] is True
|
||||||
|
|
||||||
|
again = await locks.acquire_lock(r, "compose", "FE", ttl_sec=300)
|
||||||
|
assert again["acquired"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_heartbeat_only_by_owner_renews_ttl(r):
|
||||||
|
await locks.acquire_lock(r, "nginx-conf", "BE", ttl_sec=10)
|
||||||
|
|
||||||
|
bad = await locks.heartbeat_lock(r, "nginx-conf", "FE", ttl_sec=300)
|
||||||
|
assert bad["renewed"] is False
|
||||||
|
|
||||||
|
ok = await locks.heartbeat_lock(r, "nginx-conf", "BE", ttl_sec=300)
|
||||||
|
assert ok["renewed"] is True
|
||||||
|
assert await r.ttl("co:lock:nginx-conf") > 100
|
||||||
|
|
||||||
|
|
||||||
|
async def test_expired_lock_is_reacquirable(r):
|
||||||
|
await locks.acquire_lock(r, "memory-mirror", "AI", ttl_sec=1)
|
||||||
|
await r.delete("co:lock:memory-mirror")
|
||||||
|
res = await locks.acquire_lock(r, "memory-mirror", "FE", ttl_sec=300)
|
||||||
|
assert res["acquired"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_locks(r):
|
||||||
|
await locks.acquire_lock(r, "nas-deploy", "BE", ttl_sec=300)
|
||||||
|
await locks.acquire_lock(r, "compose", "FE", ttl_sec=300)
|
||||||
|
listed = await locks.list_locks(r)
|
||||||
|
held = {l["resource"]: l["held_by"] for l in listed["locks"]}
|
||||||
|
assert held == {"nas-deploy": "BE", "compose": "FE"}
|
||||||
47
co-gahusb/tests/test_messages.py
Normal file
47
co-gahusb/tests/test_messages.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# co-gahusb/tests/test_messages.py
|
||||||
|
from app import store
|
||||||
|
|
||||||
|
|
||||||
|
async def test_post_and_read_ordering(r):
|
||||||
|
id1 = (await store.post_message(r, "Producer", "BE", "first"))["message_id"]
|
||||||
|
id2 = (await store.post_message(r, "Producer", "BE", "second"))["message_id"]
|
||||||
|
assert id2 > id1
|
||||||
|
|
||||||
|
res = await store.read_inbox(r, "BE")
|
||||||
|
bodies = [m["body"] for m in res["messages"]]
|
||||||
|
assert bodies == ["first", "second"]
|
||||||
|
assert res["cursor"] == id2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_read_inbox_after_id(r):
|
||||||
|
id1 = (await store.post_message(r, "Producer", "BE", "first"))["message_id"]
|
||||||
|
await store.post_message(r, "Producer", "BE", "second")
|
||||||
|
res = await store.read_inbox(r, "BE", after_id=id1)
|
||||||
|
assert [m["body"] for m in res["messages"]] == ["second"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_inboxes_isolated_per_role(r):
|
||||||
|
await store.post_message(r, "Producer", "BE", "for-be")
|
||||||
|
await store.post_message(r, "Producer", "FE", "for-fe")
|
||||||
|
be = await store.read_inbox(r, "BE")
|
||||||
|
fe = await store.read_inbox(r, "FE")
|
||||||
|
assert [m["body"] for m in be["messages"]] == ["for-be"]
|
||||||
|
assert [m["body"] for m in fe["messages"]] == ["for-fe"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mark_read_advances_cursor(r):
|
||||||
|
await store.post_message(r, "Producer", "BE", "first")
|
||||||
|
res = await store.read_inbox(r, "BE", mark_read=True)
|
||||||
|
last = res["cursor"]
|
||||||
|
await store.post_message(r, "Producer", "BE", "second")
|
||||||
|
res2 = await store.read_inbox(r, "BE", after_id=last)
|
||||||
|
assert [m["body"] for m in res2["messages"]] == ["second"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_message_fields(r):
|
||||||
|
await store.post_message(r, "Producer", "BE", "hi", thread_id="t1")
|
||||||
|
res = await store.read_inbox(r, "BE")
|
||||||
|
m = res["messages"][0]
|
||||||
|
assert m["from_role"] == "Producer"
|
||||||
|
assert m["thread_id"] == "t1"
|
||||||
|
assert "ts" in m and "id" in m
|
||||||
54
co-gahusb/tests/test_server.py
Normal file
54
co-gahusb/tests/test_server.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# co-gahusb/tests/test_server.py
|
||||||
|
import os
|
||||||
|
os.environ["CO_BUS_KEY"] = "test-key"
|
||||||
|
|
||||||
|
# config.CO_BUS_KEY는 import 시점에 한 번 읽히므로, 다른 테스트 모듈이 app.config를
|
||||||
|
# 먼저 import하면 빈 값으로 굳는다. import 순서와 무관하게 모듈 속성을 직접 강제한다.
|
||||||
|
from app import config
|
||||||
|
config.CO_BUS_KEY = "test-key"
|
||||||
|
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
from app.server import app
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_open_without_auth():
|
||||||
|
client = TestClient(app)
|
||||||
|
res = client.get("/health")
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_requires_bearer():
|
||||||
|
client = TestClient(app)
|
||||||
|
res = client.post("/mcp", json={})
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_wrong_key_rejected():
|
||||||
|
client = TestClient(app)
|
||||||
|
res = client.post("/mcp", json={}, headers={"Authorization": "Bearer wrong"})
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_valid_auth_passes_dns_host_check():
|
||||||
|
# 유효한 키는 인증 게이트를 통과하고, MCP DNS-rebinding Host 검증에 막혀선 안 된다.
|
||||||
|
# TestClient 기본 Host="testserver"는 localhost가 아니므로, 보호가 켜져 있으면 421.
|
||||||
|
# 컨텍스트 매니저로 써야 lifespan(세션 매니저 task group)이 기동되어 MCP 핸들러까지 도달.
|
||||||
|
with TestClient(app) as client:
|
||||||
|
res = client.post(
|
||||||
|
"/mcp",
|
||||||
|
headers={
|
||||||
|
"Authorization": "Bearer test-key",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json, text/event-stream",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||||
|
"params": {
|
||||||
|
"protocolVersion": "2024-11-05", "capabilities": {},
|
||||||
|
"clientInfo": {"name": "smoke", "version": "0"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert res.status_code != 401 # 인증 통과
|
||||||
|
assert res.status_code != 421 # Host 검증에 막히면 안 됨
|
||||||
56
co-gahusb/tests/test_tasks.py
Normal file
56
co-gahusb/tests/test_tasks.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# co-gahusb/tests/test_tasks.py
|
||||||
|
import pytest
|
||||||
|
from app import store
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_and_list(r):
|
||||||
|
res = await store.create_task(r, "deploy FE", "FE", created_by="Producer", detail="ship it")
|
||||||
|
tid = res["task_id"]
|
||||||
|
listed = await store.list_tasks(r)
|
||||||
|
t = [t for t in listed["tasks"] if t["id"] == tid][0]
|
||||||
|
assert t["title"] == "deploy FE"
|
||||||
|
assert t["assignee_role"] == "FE"
|
||||||
|
assert t["status"] == "open"
|
||||||
|
assert t["created_by"] == "Producer"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_claim_then_duplicate_claim_rejected(r):
|
||||||
|
tid = (await store.create_task(r, "x", "FE", created_by="Producer"))["task_id"]
|
||||||
|
ok = await store.claim_task(r, tid, "FE")
|
||||||
|
assert ok["ok"] is True
|
||||||
|
assert ok["task"]["status"] == "in_progress"
|
||||||
|
|
||||||
|
dup = await store.claim_task(r, tid, "BE")
|
||||||
|
assert dup["ok"] is False
|
||||||
|
assert dup["held_by"] == "FE"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_status(r):
|
||||||
|
tid = (await store.create_task(r, "x", "FE", created_by="Producer"))["task_id"]
|
||||||
|
await store.claim_task(r, tid, "FE")
|
||||||
|
res = await store.update_task(r, tid, "done", "FE", note="finished")
|
||||||
|
assert res["ok"] is True
|
||||||
|
assert res["task"]["status"] == "done"
|
||||||
|
assert res["task"]["note"] == "finished"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_filters(r):
|
||||||
|
t1 = (await store.create_task(r, "a", "FE", created_by="Producer"))["task_id"]
|
||||||
|
await store.create_task(r, "b", "BE", created_by="Producer")
|
||||||
|
await store.claim_task(r, t1, "FE")
|
||||||
|
fe = await store.list_tasks(r, assignee_role="FE")
|
||||||
|
assert [t["title"] for t in fe["tasks"]] == ["a"]
|
||||||
|
in_prog = await store.list_tasks(r, status="in_progress")
|
||||||
|
assert [t["title"] for t in in_prog["tasks"]] == ["a"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_invalid_status_rejected(r):
|
||||||
|
tid = (await store.create_task(r, "x", "FE", created_by="Producer"))["task_id"]
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await store.update_task(r, tid, "bogus", "FE")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_nonexistent_task_returns_not_found(r):
|
||||||
|
res = await store.update_task(r, 999, "done", "FE")
|
||||||
|
assert res["ok"] is False
|
||||||
|
assert res["error"] == "not_found"
|
||||||
25
co-gahusb/tests/test_teamlog.py
Normal file
25
co-gahusb/tests/test_teamlog.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# co-gahusb/tests/test_teamlog.py
|
||||||
|
from app import store
|
||||||
|
|
||||||
|
|
||||||
|
async def test_log_event_and_read(r):
|
||||||
|
await store.log_event(r, "message", "Producer→BE: hi")
|
||||||
|
await store.log_event(r, "lock", "BE acquired nas-deploy")
|
||||||
|
res = await store.read_team_log(r)
|
||||||
|
msgs = [e["text"] for e in res["events"]]
|
||||||
|
assert msgs == ["Producer→BE: hi", "BE acquired nas-deploy"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_team_log_after_id(r):
|
||||||
|
e1 = (await store.log_event(r, "message", "a"))["event_id"]
|
||||||
|
await store.log_event(r, "message", "b")
|
||||||
|
res = await store.read_team_log(r, after_id=e1)
|
||||||
|
assert [e["text"] for e in res["events"]] == ["b"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_team_log_capped(r):
|
||||||
|
for i in range(10):
|
||||||
|
await store.log_event(r, "message", f"m{i}")
|
||||||
|
res = await store.read_team_log(r, limit=3)
|
||||||
|
assert len(res["events"]) == 3
|
||||||
|
assert res["events"][-1]["text"] == "m9"
|
||||||
@@ -14,20 +14,27 @@ services:
|
|||||||
- TZ=${TZ:-Asia/Seoul}
|
- TZ=${TZ:-Asia/Seoul}
|
||||||
- LOTTO_ALL_URL=${LOTTO_ALL_URL:-https://smok95.github.io/lotto/results/all.json}
|
- LOTTO_ALL_URL=${LOTTO_ALL_URL:-https://smok95.github.io/lotto/results/all.json}
|
||||||
- LOTTO_LATEST_URL=${LOTTO_LATEST_URL:-https://smok95.github.io/lotto/results/latest.json}
|
- LOTTO_LATEST_URL=${LOTTO_LATEST_URL:-https://smok95.github.io/lotto/results/latest.json}
|
||||||
|
- PYTHONPATH=/app:/shared
|
||||||
volumes:
|
volumes:
|
||||||
- ${RUNTIME_PATH}/data:/app/data
|
- ${RUNTIME_PATH}/data:/app/data
|
||||||
|
- ${RUNTIME_PATH}/_shared:/shared/_shared:ro
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
interval: 30s
|
interval: 60s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
stock-lab:
|
stock:
|
||||||
build:
|
build:
|
||||||
context: ./stock-lab
|
context: ./stock
|
||||||
args:
|
args:
|
||||||
APP_VERSION: ${APP_VERSION:-dev}
|
APP_VERSION: ${APP_VERSION:-dev}
|
||||||
container_name: stock-lab
|
container_name: stock
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "18500:8000"
|
- "18500:8000"
|
||||||
@@ -43,11 +50,19 @@ services:
|
|||||||
- OLLAMA_URL=${OLLAMA_URL:-http://192.168.45.59:11435}
|
- OLLAMA_URL=${OLLAMA_URL:-http://192.168.45.59:11435}
|
||||||
- OLLAMA_MODEL=${OLLAMA_MODEL:-qwen3:14b}
|
- OLLAMA_MODEL=${OLLAMA_MODEL:-qwen3:14b}
|
||||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||||
|
- WEBAI_API_KEY=${WEBAI_API_KEY:-}
|
||||||
|
- PYTHONPATH=/app:/shared
|
||||||
volumes:
|
volumes:
|
||||||
- ${RUNTIME_PATH}/data/stock:/app/data
|
- ${RUNTIME_PATH}/data/stock:/app/data
|
||||||
|
- ${RUNTIME_PATH}/_shared:/shared/_shared:ro
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
interval: 30s
|
interval: 60s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
@@ -61,7 +76,6 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- TZ=${TZ:-Asia/Seoul}
|
- TZ=${TZ:-Asia/Seoul}
|
||||||
- MUSIC_AI_SERVER_URL=${MUSIC_AI_SERVER_URL:-}
|
- MUSIC_AI_SERVER_URL=${MUSIC_AI_SERVER_URL:-}
|
||||||
- SUNO_API_KEY=${SUNO_API_KEY:-}
|
|
||||||
- MUSIC_MEDIA_BASE=${MUSIC_MEDIA_BASE:-/media/music}
|
- MUSIC_MEDIA_BASE=${MUSIC_MEDIA_BASE:-/media/music}
|
||||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||||
- PEXELS_API_KEY=${PEXELS_API_KEY:-}
|
- PEXELS_API_KEY=${PEXELS_API_KEY:-}
|
||||||
@@ -76,33 +90,107 @@ services:
|
|||||||
- WINDOWS_VIDEO_ENCODER_URL=${WINDOWS_VIDEO_ENCODER_URL:-}
|
- WINDOWS_VIDEO_ENCODER_URL=${WINDOWS_VIDEO_ENCODER_URL:-}
|
||||||
- NAS_VIDEOS_ROOT=${NAS_VIDEOS_ROOT:-/volume1/docker/webpage/data/videos}
|
- NAS_VIDEOS_ROOT=${NAS_VIDEOS_ROOT:-/volume1/docker/webpage/data/videos}
|
||||||
- NAS_MUSIC_ROOT=${NAS_MUSIC_ROOT:-/volume1/docker/webpage/data/music}
|
- 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:
|
volumes:
|
||||||
- ${RUNTIME_PATH}/data/music:/app/data
|
- ${RUNTIME_PATH}/data/music:/app/data
|
||||||
- ${RUNTIME_PATH:-.}/data/videos:/app/data/videos
|
- ${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:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
interval: 30s
|
interval: 60s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
blog-lab:
|
video-lab:
|
||||||
build:
|
build:
|
||||||
context: ./blog-lab
|
context: ./video-lab
|
||||||
container_name: blog-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
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "18700:8000"
|
- "18700:8000"
|
||||||
environment:
|
environment:
|
||||||
- TZ=${TZ:-Asia/Seoul}
|
- TZ=${TZ:-Asia/Seoul}
|
||||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
- 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_ID=${NAVER_CLIENT_ID:-}
|
||||||
- NAVER_CLIENT_SECRET=${NAVER_CLIENT_SECRET:-}
|
- 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}
|
- 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:
|
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:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
interval: 30s
|
interval: 60s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
@@ -118,11 +206,37 @@ services:
|
|||||||
- DATA_GO_KR_API_KEY=${DATA_GO_KR_API_KEY:-}
|
- DATA_GO_KR_API_KEY=${DATA_GO_KR_API_KEY:-}
|
||||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||||
- AGENT_OFFICE_URL=${AGENT_OFFICE_URL:-http://agent-office:8000}
|
- AGENT_OFFICE_URL=${AGENT_OFFICE_URL:-http://agent-office:8000}
|
||||||
|
- PYTHONPATH=/app:/shared
|
||||||
volumes:
|
volumes:
|
||||||
- ${RUNTIME_PATH}/data/realestate:/app/data
|
- ${RUNTIME_PATH}/data/realestate:/app/data
|
||||||
|
- ${RUNTIME_PATH}/_shared:/shared/_shared:ro
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
interval: 30s
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
co-gahusb:
|
||||||
|
build:
|
||||||
|
context: ./co-gahusb
|
||||||
|
container_name: co-gahusb
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "18920:8000"
|
||||||
|
environment:
|
||||||
|
- TZ=${TZ:-Asia/Seoul}
|
||||||
|
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||||
|
- CO_BUS_KEY=${CO_BUS_KEY:-}
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
|
interval: 60s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
@@ -136,9 +250,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- TZ=${TZ:-Asia/Seoul}
|
- TZ=${TZ:-Asia/Seoul}
|
||||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
- 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
|
- 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_LAB_URL=http://realestate-lab:8000
|
||||||
- REALESTATE_DASHBOARD_URL=${REALESTATE_DASHBOARD_URL:-http://localhost:8080/realestate}
|
- REALESTATE_DASHBOARD_URL=${REALESTATE_DASHBOARD_URL:-http://localhost:8080/realestate}
|
||||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
|
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
|
||||||
@@ -157,13 +271,61 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ${RUNTIME_PATH:-.}/data/agent-office:/app/data
|
- ${RUNTIME_PATH:-.}/data/agent-office:/app/data
|
||||||
depends_on:
|
depends_on:
|
||||||
- stock-lab
|
- stock
|
||||||
- music-lab
|
- music-lab
|
||||||
- blog-lab
|
- insta-lab
|
||||||
- realestate-lab
|
- realestate-lab
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
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
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
@@ -182,7 +344,7 @@ services:
|
|||||||
- ${RUNTIME_PATH:-.}/data/personal:/app/data
|
- ${RUNTIME_PATH:-.}/data/personal:/app/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
interval: 30s
|
interval: 60s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
@@ -209,7 +371,7 @@ services:
|
|||||||
- ${PACK_DATA_PATH:-./data/packs}:${PACK_BASE_DIR:-/app/data/packs}
|
- ${PACK_DATA_PATH:-./data/packs}:${PACK_BASE_DIR:-/app/data/packs}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
interval: 30s
|
interval: 60s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
@@ -232,18 +394,30 @@ services:
|
|||||||
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:rw
|
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:rw
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
interval: 30s
|
interval: 60s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
frontend:
|
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
|
container_name: frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
|
- lotto
|
||||||
|
- stock
|
||||||
- music-lab
|
- music-lab
|
||||||
- blog-lab
|
- insta-lab
|
||||||
- realestate-lab
|
- realestate-lab
|
||||||
|
- agent-office
|
||||||
|
- personal
|
||||||
|
- packs-lab
|
||||||
|
- travel-proxy
|
||||||
|
- video-lab
|
||||||
|
- image-lab
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "8080:80"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -253,11 +427,13 @@ services:
|
|||||||
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:ro
|
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:ro
|
||||||
- ${RUNTIME_PATH}/data/music:/data/music:ro
|
- ${RUNTIME_PATH}/data/music:/data/music:ro
|
||||||
- ${RUNTIME_PATH}/data/videos:/data/videos: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:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:80/"]
|
test: ["CMD", "wget", "-q", "--spider", "http://localhost:80/"]
|
||||||
interval: 30s
|
interval: 60s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
@@ -277,3 +453,18 @@ services:
|
|||||||
- ${RUNTIME_PATH}:/runtime:rw
|
- ${RUNTIME_PATH}:/runtime:rw
|
||||||
- ${RUNTIME_PATH}/scripts:/scripts:ro
|
- ${RUNTIME_PATH}/scripts:/scripts:ro
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /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
|
||||||
|
|||||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user