Compare commits
228 Commits
feat/insta
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b6b251225 | |||
| 1efe3d3a48 | |||
| 3a9d6e986e | |||
| bb0280274e | |||
| cd9a73254b | |||
| 332525a6f0 | |||
| 11f591e3d4 | |||
| 8788763b3d | |||
| b89e92440a | |||
| 5ad0adf719 | |||
| d98cd9afbe | |||
| 4e846a2d5f | |||
| 5d9be51dba | |||
| cd4fb27d5a | |||
| b94b5973d6 | |||
| f54ade2c0d | |||
| 2cbc830004 | |||
| d0c057358a | |||
| 7d7064ae93 | |||
| 789785fe3a | |||
| c3a3055060 | |||
| 3056e8d35f | |||
| 4ed3794f71 | |||
| 241c24943f | |||
| c756b20c77 | |||
| fba6dbf1fd | |||
| b13c088739 | |||
| 116b2540c2 | |||
| 62169ad33f | |||
| 0ef7d414b7 | |||
| 885d52d8f5 | |||
| e3088f7cc6 | |||
| 2996cf16d1 | |||
| 03ee5ce147 | |||
| 11212c4afd | |||
| 1b8548a73f | |||
| c4ba7e81e6 | |||
| e8270c5a63 | |||
| 4063f29cd3 | |||
| 03056a4747 | |||
| 8e7b4adabd | |||
| add433233a | |||
| 74f385c7bd | |||
| 3bc4f423db | |||
| a425bb8809 | |||
| 850638ae58 | |||
| 94a94e260c | |||
| c196da4902 | |||
| aaba4fbc46 | |||
| 9f897ea4a0 | |||
| 77efa9b653 | |||
| 8dbb1abaeb | |||
| 41ad56e3ef | |||
| bb0e771a4a | |||
| 160fc27279 | |||
| f3f6cccd33 | |||
| 2bfbd1dd93 | |||
| c5c260aefc | |||
| 378f5210d4 | |||
| cfbb3c24b8 | |||
| c7214b8896 | |||
| 4224333219 | |||
| 5613497367 | |||
| b25abea80a | |||
| ed30790f22 | |||
| 1d723764b4 | |||
| c0c4422c7c | |||
| fe4d3912a5 | |||
| f461f05ac0 | |||
| dfd3b1bb17 | |||
| 809eec9b15 | |||
| 512ed59dcd | |||
| 4ee4a1ae7d | |||
| fd40777177 | |||
| be9165efd2 | |||
| 99dca8df64 | |||
| 03e1dc1dbb | |||
| f57c790437 | |||
| 030367da6c | |||
| 429e3448e5 | |||
| 579e7387be | |||
| 8ef0ba81f2 | |||
| afb4175bd5 | |||
| af836df1ac | |||
| 8123f758a8 | |||
| 8ec3abb800 | |||
| 6d752acbe1 | |||
| f995f8739f | |||
| cad65dc869 | |||
| f4f518fc80 | |||
| db1f69c7a5 | |||
| ebfade655a | |||
| 234ccfe857 | |||
| 3f0b7bcd74 | |||
| f91a74237b | |||
| 95243a7f1f | |||
| 07b5c32f2f | |||
| 4ddcd75453 | |||
| 018459db88 | |||
| 42182014f0 | |||
| 03edfb04aa | |||
| 8b0c12b595 | |||
| e52e47fe3b | |||
| 8d25a1467a | |||
| 901d3535ee | |||
| 91caddb4b2 | |||
| abdfcbb144 | |||
| a94c73b134 | |||
| 387d2465b0 | |||
| 4073370e1b | |||
| 1775f7dd2d | |||
| 677d05fc31 | |||
| d87ad2421d | |||
| 20691b5057 | |||
| 3bf87a93fb | |||
| 4623c68d4e | |||
| f79dc87d75 | |||
| d4302acb6a | |||
| b7fd98c8c7 | |||
| 0b29283043 | |||
| 9dba1e74b0 | |||
| 4c9fe11fc9 | |||
| a356a5895f | |||
| 2e042e18c5 | |||
| 83e74ad1f4 | |||
| b70caddff1 | |||
| d6e34973a4 | |||
| 7007c90665 | |||
| ca7a502514 | |||
| dc471ecc60 | |||
| e91715bf2c | |||
| 1e4c1b42b7 | |||
| 0190a6c206 | |||
| 6ef4160da2 | |||
| 078c9f008a | |||
| 918151bda8 | |||
| 2ce6721c35 | |||
| c5303151c0 | |||
| ee61405ff1 | |||
| fef5f7a835 | |||
| e47ccdb762 | |||
| 4b6996b0f7 | |||
| 0f65aa53e4 | |||
| ea3485cde6 | |||
| d6366a38f3 | |||
| 0f8c71c552 | |||
| 1401c5703d | |||
| 92329f6fd5 | |||
| d0047c2b9d | |||
| 088944499c | |||
| a9fdbf8a93 | |||
| f46851d481 | |||
| 11b3700959 | |||
| 1db8a0063d | |||
| f017a61c79 | |||
| 1694823129 | |||
| a4614ebeae | |||
| 875e750f77 | |||
| 9cb40fb4e5 | |||
| 383f48c71e | |||
| 6be74737c2 | |||
| 3106716e70 | |||
| a126155948 | |||
| f509339cbb | |||
| e72a52a950 | |||
| eecaefc26d | |||
| b3c0683364 | |||
| 17321d948e | |||
| 8552cbc184 | |||
| b1c786e59d | |||
| b885d02ac4 | |||
| b35fab777e | |||
| 43081bea0e | |||
| bebe5797e7 | |||
| 9e1001b935 | |||
| e5465ad136 | |||
| 21d46d95dd | |||
| ac4a574ef2 | |||
| c985d2c605 | |||
| b4e873b5b0 | |||
| 6c5e93f64e | |||
| 6b7eb5a9c1 | |||
| 4b28ef3afa | |||
| 211aff1e45 | |||
| 37ca8e594e | |||
| c9a094969d | |||
| e8dbf8092a | |||
| 21cf0114f4 | |||
| 20f83cee33 | |||
| 1e77123394 | |||
| fbd8d26ec6 | |||
| 6f505b8cb1 | |||
| e1722e3963 | |||
| b1e28aa725 | |||
| 532b794c11 | |||
| e7f6edf7c5 | |||
| 42cf39d0da | |||
| 74196396c5 | |||
| 4393ba706b | |||
| 714224a9b4 | |||
| ea93dc522b | |||
| 408b6a3df7 | |||
| e6ff234031 | |||
| 912cd18e48 | |||
| a06cc424ca | |||
| e87c43a7a4 | |||
| 0c12c3527f | |||
| 5ed9d265f6 | |||
| 24229d00ae | |||
| 43f8b111ad | |||
| a9f38e1248 | |||
| 87651c9449 | |||
| a1a37ead9e | |||
| 978aa14f8b | |||
| 030365bed0 | |||
| 8c5bfa453f | |||
| 11d86450c3 | |||
| 90f6af6ab3 | |||
| 83113ab50c | |||
| 20514193e8 | |||
| 7a470aad44 | |||
| de8adaeadd | |||
| 5cde24115b | |||
| 318190c93f | |||
| c8684280af | |||
| 6895e2f8dc | |||
| 34619dc70b | |||
| 47cdc43aa5 |
209
CHECK_POINT.md
Normal file
209
CHECK_POINT.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# web-backend CHECK_POINT
|
||||
|
||||
> NAS Docker 11 컨테이너(9 백엔드 + frontend + deployer). Synology Celeron J4025 (2C 2.0GHz) 18GB.
|
||||
> 2026-05-18 작성 — uvicorn CPU 폭주 진단 결과 정리.
|
||||
|
||||
## 🔴 즉시 (오늘, 총 1시간 5분)
|
||||
|
||||
### 1. 09:00 cron 5분 스태거링 ⭐ 가장 큰 효과
|
||||
|
||||
**파일**: `agent-office/app/scheduler.py:72-76`
|
||||
```python
|
||||
# 변경 전 — 09:00 동시 실행 (CPU 폭주 원인 #1)
|
||||
scheduler.add_job(_run_insta_trends_collect, "cron", hour=9, minute=0)
|
||||
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=9, minute=0)
|
||||
scheduler.add_job(_run_youtube_research, "cron", hour=9, minute=0)
|
||||
|
||||
# 변경 후 — 5분 스태거링
|
||||
scheduler.add_job(_run_insta_trends_collect, "cron", hour=9, minute=0, id="insta_trends")
|
||||
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=9, minute=5, id="lotto_curate")
|
||||
scheduler.add_job(_run_youtube_research, "cron", hour=9, minute=10, id="youtube_research")
|
||||
```
|
||||
|
||||
**파일**: `realestate-lab/app/main.py:51`
|
||||
```python
|
||||
# 변경 전
|
||||
scheduler.add_job(scheduled_collect, "cron", hour=9, minute=0, id="collect")
|
||||
|
||||
# 변경 후
|
||||
scheduler.add_job(scheduled_collect, "cron", hour=9, minute=15, id="collect")
|
||||
```
|
||||
|
||||
- [x] agent-office scheduler.py 수정 (2026-05-18)
|
||||
- [x] realestate-lab main.py 수정 (2026-05-18)
|
||||
- [ ] git commit + push (Gitea Webhook 자동 빌드)
|
||||
|
||||
---
|
||||
|
||||
### 2. insta-lab Playwright Semaphore(1) ⭐
|
||||
|
||||
**파일**: `insta-lab/app/main.py` (모듈 레벨 추가)
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
# 모듈 레벨에 한 번만 선언
|
||||
RENDER_SEMAPHORE = asyncio.Semaphore(1) # Chromium 동시 실행 1개로 제한
|
||||
|
||||
# 카드 렌더 백그라운드 함수에 감싸기
|
||||
async def _bg_render(task_id: str, slate_id: int):
|
||||
async with RENDER_SEMAPHORE:
|
||||
await card_renderer.render_slate(slate_id, ...)
|
||||
```
|
||||
|
||||
- [x] card_renderer.render_slate를 Semaphore(1)로 감쌈 (2026-05-18, lazy init)
|
||||
- [ ] 동시 2개 요청 테스트 (curl 동시 2회 → 순차 처리되는지 확인)
|
||||
|
||||
---
|
||||
|
||||
### 3. healthcheck interval 60s
|
||||
|
||||
**파일**: `docker-compose.yml` (모든 9 컨테이너)
|
||||
```yaml
|
||||
# 변경 전
|
||||
healthcheck:
|
||||
interval: 30s
|
||||
|
||||
# 변경 후
|
||||
healthcheck:
|
||||
interval: 60s
|
||||
```
|
||||
|
||||
- [x] docker-compose.yml 10개 healthcheck 일괄 변경 (9 백엔드 + frontend, 2026-05-18)
|
||||
- [ ] `docker compose up -d` 재기동
|
||||
- [ ] `docker stats` 로 CPU 5% 정도 감소 확인
|
||||
|
||||
---
|
||||
|
||||
### 4. uvicorn --workers 1 명시
|
||||
|
||||
**모든 Dockerfile CMD**:
|
||||
```dockerfile
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
```
|
||||
|
||||
영향 9 파일 (모두 2026-05-18 적용):
|
||||
- [x] lotto/Dockerfile
|
||||
- [x] stock/Dockerfile
|
||||
- [x] music-lab/Dockerfile
|
||||
- [x] insta-lab/Dockerfile
|
||||
- [x] realestate-lab/Dockerfile
|
||||
- [x] agent-office/Dockerfile
|
||||
- [x] personal/Dockerfile
|
||||
- [x] packs-lab/Dockerfile
|
||||
- [x] travel-proxy/Dockerfile
|
||||
|
||||
→ `docker compose build --no-cache` 후 재기동.
|
||||
|
||||
---
|
||||
|
||||
### 5. lotto Monte Carlo 08:05 → 08:30
|
||||
|
||||
**파일**: `lotto/app/main.py:86`
|
||||
```python
|
||||
# 변경 전 — stock 08:00과 5분 차이로 겹침
|
||||
scheduler.add_job(_run_simulation_job, "cron", hour="0,4,8,12,16,20", minute=5)
|
||||
|
||||
# 변경 후 — 25분 분리
|
||||
scheduler.add_job(_run_simulation_job, "cron", hour="0,4,8,12,16,20", minute=30)
|
||||
```
|
||||
|
||||
- [x] lotto/app/main.py 수정 (2026-05-18)
|
||||
|
||||
---
|
||||
|
||||
## 🟡 중기 (1~2주)
|
||||
|
||||
### 6. Chromium Browser Pool 재설계 (insta-lab) ✅ 2026-05-18
|
||||
- 매번 launch X → 1개 인스턴스 재사용
|
||||
- 카드 10장 렌더 시간 30% 단축 기대
|
||||
- [x] `card_renderer.py` 내부에 모듈 레벨 `_PLAYWRIGHT`/`_BROWSER` + `init_browser`/`shutdown_browser` 함수 (별도 모듈 분리 안 함, 같은 파일에 인접 배치)
|
||||
- [x] `_render_slate_locked` 본체에서 `_get_browser()` 재사용 (crashed 시 lazy 재초기화)
|
||||
- [x] `main.py` startup hook에서 `init_browser()`, shutdown hook에서 `shutdown_browser()`
|
||||
|
||||
### 7. stock 뉴스 스크랩 비동기화 — ⚠️ 보류 2026-05-18
|
||||
- **재진단**: stock은 `BackgroundScheduler` 사용 중 → main loop 블로킹 없음 (이미 별도 thread)
|
||||
- `fetch_market_news`의 4개 동기 `requests.get`은 network I/O wait라 CPU 거의 사용 안 함
|
||||
- `to_thread`로 wrap해도 BackgroundScheduler 환경에서 사실상 의미 없음
|
||||
- 진짜 효과를 보려면 AsyncIOScheduler 전환 + scraper.py 4개 fetch를 `aiohttp` 병렬로 — **큰 리팩토링 vs 효과 불명확**
|
||||
- [ ] 박재오 판단: 큰 리팩토링 진행 여부
|
||||
|
||||
### 8. realestate 수집 병렬화 ✅ 2026-05-18
|
||||
- **파일**: `realestate-lab/app/main.py:scheduled_collect`
|
||||
- `collect_all()` + `delete_old_completed_announcements()` 병렬
|
||||
- BackgroundScheduler 환경이라 `asyncio.gather` 대신 `ThreadPoolExecutor(max_workers=2)` 사용 (효과 동일)
|
||||
- 매칭은 순차 유지 (DB 일관성)
|
||||
- [x] ThreadPoolExecutor 적용
|
||||
|
||||
### 9. lotto Monte Carlo 시뮬레이션 빈도 검토
|
||||
- 현재 6회/일 (00·04·08·12·16·20)
|
||||
- 실제 필요 빈도 박재오 결정 — 3회/일(아침·점심·저녁)로 줄이면 CPU 50% 감소
|
||||
- [ ] 박재오 의사결정 후 cron 변경
|
||||
|
||||
---
|
||||
|
||||
## 🟢 장기 (1개월+)
|
||||
|
||||
### 10. 무거운 작업 Windows AI 서버로 이전 ✅ 이미 적용 상태 (2026-05-18 확인)
|
||||
- **확인 결과**: NAS `.env`가 이미 `LLM_PROVIDER=claude` + `OLLAMA_URL=http://192.168.45.59:11435`로 설정됨
|
||||
- 실 운영은 Anthropic Claude (원격 API) — NAS Celeron에서 LLM 추론 안 함
|
||||
- Ollama fallback 사용 시에도 Windows AI 서버로 통일
|
||||
- stock 외 다른 컨테이너에 ollama/qwen 호출 코드 없음
|
||||
- 결론: 코드/설정 변경 불필요
|
||||
|
||||
### 11. 컨테이너 리소스 제한 — ❌ 진행 금지 (박재오 명시 2026-05-18)
|
||||
- J4025 2C 환경에서 cpus 0.5 제한은 오히려 throughput 손해
|
||||
- 향후 작업자 무심코 도입하지 말 것
|
||||
|
||||
### 12. NAS 업그레이드 검토 — ⏸️ 보류 (박재오 명시 2026-05-18)
|
||||
- 현재: Celeron J4025 (2C 2.0GHz)
|
||||
- 대안: Ryzen N5105 (4C 2.0GHz) NAS — 4코어로 병렬성 2배
|
||||
- 자금·우선순위 결정 대기
|
||||
|
||||
---
|
||||
|
||||
## ✅ 최근 완료 (참고)
|
||||
|
||||
- 2026-05-15: insta-lab 신설 (포트 18700, Jinja2 + Playwright + Claude Sonnet)
|
||||
- 2026-05-16: insta-lab Playwright 1080×1350 PNG 렌더 완성
|
||||
- 2026-05-17: agent-office random idle 제거, ADMIN_API_KEY 강화 (stock)
|
||||
- 2026-05-17: insta-lab minimal theme + design_importer 추가
|
||||
- 2026-05-17: blog-lab 트랙 완전 폐기 (docker-compose에 없음, 위키 정정 완료)
|
||||
- 2026-05-18: 🔴 즉시 5건 일괄 적용 — 09:00 cron 스태거링(insta/lotto/youtube/realestate), lotto Monte Carlo 08:30, insta-lab Semaphore(1), healthcheck 60s, uvicorn --workers 1 명시 (사용자 push + NAS deployer 재기동 대기)
|
||||
- 2026-05-18: 🟡 중기 2건 적용 — #6 insta-lab Chromium Browser Pool (lifecycle hook), #8 realestate ThreadPoolExecutor 병렬 (collect/delete). #7 stock async는 BackgroundScheduler 사용 중이라 재진단 후 보류 (효과 미미). #9 Monte Carlo 빈도는 박재오 결정 대기.
|
||||
- 2026-05-18: 🟢 장기 진단·결정 — #10은 이미 적용 상태 확인 (LLM_PROVIDER=claude, OLLAMA_URL=Windows AI). #11 컨테이너 리소스 제한 박재오 진행 금지. #12 NAS 업그레이드 보류. web-ai V1(:8000)+V2(:8001) 4개 process 종료 — NAS API polling 부담 즉시 감소.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 진단 커맨드 (NAS bash)
|
||||
|
||||
```bash
|
||||
# 실시간 CPU 사용 (상위 15)
|
||||
top -b -n 1 | head -25
|
||||
|
||||
# 프로세스별 CPU 정렬
|
||||
ps aux --sort=-%cpu | head -15
|
||||
|
||||
# uvicorn·chromium·python 프로세스만
|
||||
ps aux | grep -E "uvicorn|chromium|python" | grep -v grep
|
||||
|
||||
# 스케줄러 실행 로그 (최근 50)
|
||||
docker logs agent-office 2>&1 | grep -E "APScheduler|executing" | tail -50
|
||||
|
||||
# insta-lab Chromium 프로세스 개수
|
||||
docker exec insta-lab ps aux | grep chromium | wc -l
|
||||
|
||||
# 컨테이너별 CPU/메모리 실시간
|
||||
docker stats --no-stream
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 참고
|
||||
|
||||
- 진단 풀 보고서: `C:\Users\jaeoh\Documents\Obsidian Vault\raw\2026-05-18-NAS-uvicorn-CPU-진단-개선안.md`
|
||||
- 위키 페이지: [[사업-개인-웹-플랫폼]] (CPU 부하 진단 섹션 + 컨테이너 표)
|
||||
- docker-compose.yml: 본 디렉토리 루트
|
||||
|
||||
## 변경 이력
|
||||
|
||||
- 2026-05-18: 페이지 신설. 즉시 5건 + 중기 4건 + 장기 3건. 진단 커맨드.
|
||||
44
CLAUDE.md
44
CLAUDE.md
@@ -164,10 +164,16 @@ docker compose up -d
|
||||
| `lotto_briefings` | AI 큐레이터 주간 브리핑 (5세트 + 내러티브 + 토큰·비용 집계) |
|
||||
| `todos` | 투두리스트 (UUID PK) — personal 서비스로 이전됨, 레거시 테이블 유지 |
|
||||
| `blog_posts` | 블로그 글 (tags: JSON 배열) — personal 서비스로 이전됨, 레거시 테이블 유지 |
|
||||
| `weight_trials` | 주별 6일치 후보 가중치 (4 perturb + 2 dirichlet) |
|
||||
| `auto_picks` | 매일 N=5 시도 번호 + 채점 결과 |
|
||||
| `weight_base_history` | base 갱신 이력 (winner_4plus / ema_blend / unchanged / cold_start) |
|
||||
|
||||
**스케줄러 job**
|
||||
- 09:10 / 21:10 매일 — 당첨번호 동기화 + 채점 (`sync_latest` → `check_results_for_draw`)
|
||||
- 00:05, 04:05, 08:05, 12:05, 16:05, 20:05 — 몬테카를로 시뮬레이션 (20,000후보 → 상위100 → best_picks 20개 교체)
|
||||
- 월요일 09:00 — weight_evolver_weekly (6개 후보 생성 + 그날 N=5 추출)
|
||||
- 매일 09:00 — weight_evolver_daily (월요일 제외, 오늘 W로 N=5 추출)
|
||||
- 토요일 22:00 — weight_evolver_eval (회고 + 다음주 base 갱신)
|
||||
|
||||
**lotto-lab API 목록**
|
||||
|
||||
@@ -204,6 +210,11 @@ docker compose up -d
|
||||
| GET | `/api/lotto/briefing/latest` | 최신 브리핑 |
|
||||
| GET | `/api/lotto/briefing/{draw_no}` | 특정 회차 브리핑 |
|
||||
| GET | `/api/lotto/briefing` | 브리핑 이력 |
|
||||
| GET | `/api/lotto/evolver/status` | weight_evolver 이번주 trials + current_base + 진행 상황 |
|
||||
| GET | `/api/lotto/evolver/history?weeks=12` | base 변경 이력 |
|
||||
| GET | `/api/lotto/evolver/trials/{week_start}` | 특정 주 6 trials + 채점 결과 |
|
||||
| POST | `/api/lotto/evolver/generate-now` | 수동 트리거 — 이번주 후보 생성 |
|
||||
| POST | `/api/lotto/evolver/evaluate-now` | 수동 회고 + 다음주 base 갱신 |
|
||||
|
||||
### stock (stock/)
|
||||
- Windows AI 서버 연동: `WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000`
|
||||
@@ -484,16 +495,30 @@ docker compose up -d
|
||||
- `agent_config.custom_config.auto_select=True`이면 카테고리당 1위 키워드 자동 슬레이트 생성·발송
|
||||
|
||||
**디자인 import (사용자 디자인 PNG → Claude Vision → Jinja HTML 자동 생성)**
|
||||
- `insta-lab/app/templates/<theme>/pages/*.png` (10장, 1080×1350, placeholder 텍스트 박혀있는 형태) → Claude Sonnet Vision → `templates/<theme>/card.html.j2` 자동 생성
|
||||
- CLI: `docker exec insta-lab python -m app.design_importer <theme>`
|
||||
- `insta-lab/app/templates/<theme>/pages/*.png` (10장, 4:5 비율 권장 1080×1350, placeholder 텍스트 박혀있는 형태) → Claude Sonnet Vision → `templates/<theme>/card.html.j2` 자동 생성
|
||||
- 파일명 자동 매핑: `cover`/`start`/`intro` → page 1, `cta`/`outro`/`finish`/`end` → page 10, 나머지 알파벳 순 → page 2~9
|
||||
- 매핑 override: `pages/_order.json`에 `{filename: page_no}` 명시 (10장 + page 1~10 완전 매핑일 때만 적용)
|
||||
- Vision prompt에 placeholder 마스킹 요구 포함 (2-layer: 마스킹 박스 + 동적 텍스트 layer)
|
||||
- 기존 HTML 자동 백업 (`card.html.j2.bak.YYYYMMDD-HHMMSS`)
|
||||
- Jinja 문법 깨진 응답은 `card.html.j2.error.txt`로 보존 + ValueError
|
||||
- 활성화: NAS `.env`에 `INSTA_DEFAULT_THEME=<theme>` 추가 + `docker compose restart insta-lab`
|
||||
- 활성화: `.env`에 `INSTA_DEFAULT_THEME=<theme>` 추가 + `docker compose restart insta-lab` (테마 디렉토리에 `card.html.j2` 없으면 렌더러가 default로 폴백)
|
||||
- 토큰 비용: 1회당 ~15K tokens (~$0.05 Sonnet 기준)
|
||||
|
||||
**⚠️ 실행 위치 — 로컬 권장, NAS docker exec 금지**
|
||||
- docker-compose의 insta-lab volume은 `/app/data`만 마운트. **`/app/app/templates`는 컨테이너 ephemeral state**.
|
||||
- NAS에서 `docker exec insta-lab python -m app.design_importer <theme>`로 돌리면 `card.html.j2`가 컨테이너 안에만 생성되고 다음 image rebuild(다른 push의 webhook이라도) 때 사라짐 → 렌더러가 default로 폴백.
|
||||
- **로컬 실행** (host repo working tree에 영속화 → git push → 자동 배포):
|
||||
```bash
|
||||
cd insta-lab
|
||||
pip install anthropic Pillow jinja2 # 이미 있으면 skip
|
||||
export ANTHROPIC_API_KEY=sk-ant-...
|
||||
python -m app.design_importer <theme> --templates-dir ./app/templates
|
||||
git add app/templates/<theme>/card.html.j2
|
||||
git commit -m "feat(insta-lab): <theme> 디자인 import"
|
||||
git push # → Gitea webhook → NAS rebuild → 영구 활성화
|
||||
```
|
||||
- 응급 hotfix로 NAS에서 돌렸다면 `docker cp insta-lab:/app/app/templates/<theme>/card.html.j2 ./` 후 즉시 host repo에 commit + push 필요
|
||||
|
||||
**insta-lab API 목록**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
@@ -539,6 +564,11 @@ docker compose up -d
|
||||
- `LOTTO_BACKEND_URL`: 기본 `http://lotto:8000`
|
||||
- `LOTTO_CURATOR_MODEL`: 기본 `claude-sonnet-4-5`
|
||||
- `YOUTUBE_DATA_API_KEY`: YouTube Data API v3 키 (미설정 시 YouTube trending 수집 skip)
|
||||
- `LOTTO_SIGNAL_WINDOW`: baseline 윈도우 크기 (기본 8)
|
||||
- `LOTTO_Z_NORMAL`: normal fire 임계치 (기본 1.5)
|
||||
- `LOTTO_Z_URGENT`: urgent fire 임계치 (기본 2.5)
|
||||
- `LOTTO_THROTTLE_HOURS`: 같은 메트릭 재발화 throttle (기본 6시간)
|
||||
- `LOTTO_URGENT_DAILY_MAX`: urgent 하루 cap (기본 3통)
|
||||
|
||||
**YouTubeResearchAgent (`agents/youtube.py`)**
|
||||
- `agent_id = "youtube"` — AGENT_REGISTRY에 등록
|
||||
@@ -563,6 +593,11 @@ docker compose up -d
|
||||
- ~~09:15 매일 — 청약 매칭 데일리 리포트~~ (Task 2026-04-28에서 폐기. realestate-lab의 push 트리거로 전환)
|
||||
- 09:00 매일 — YouTube 트렌드 수집 (`youtube_research`) → music-lab `/api/music/market/ingest` push
|
||||
- 매주 월요일 08:00 — YouTube 주간 리포트 텔레그램 발송 (`youtube_weekly_report`)
|
||||
- 09:15 매일 — 로또 light_check (시뮬·전략 가중치 평가)
|
||||
- 매 4시간 :15 — 로또 sim_check (00/04/08/12/16/20시)
|
||||
- 일/수 21:15 — 로또 deep_check (큐레이션 후 confidence 포함 평가)
|
||||
- 09:25 매일 — 로또 daily_digest (지난 24h 발화 텔레그램 1통)
|
||||
- 토요일 22:15 — 로또 weight_evolver 주간 텔레그램 리포트
|
||||
|
||||
**RealestateAgent (`agents/realestate.py`)**
|
||||
- 진입점: `on_new_matches(matches: list[dict]) -> {sent, sent_ids, message_id}`
|
||||
@@ -594,6 +629,9 @@ docker compose up -d
|
||||
| GET | `/api/agent-office/conversation/stats` | 텔레그램 자연어 대화 토큰·캐시 통계 (`days` 필터) |
|
||||
| POST | `/api/agent-office/youtube/research` | YouTube 트렌드 수집 수동 트리거 (body: `{countries: []}`) |
|
||||
| GET | `/api/agent-office/youtube/research/status` | 마지막 수집 작업 상태 |
|
||||
| GET | `/api/agent-office/lotto/signals?days=7` | 로또 능동 시그널 이력 (모든 fire_level) |
|
||||
| GET | `/api/agent-office/lotto/baselines` | 로또 메트릭별 baseline μ/σ + 윈도우 상태 |
|
||||
| POST | `/api/agent-office/lotto/signal-check?source=light` | 로또 시그널 평가 수동 트리거 (light/sim/deep) |
|
||||
|
||||
### personal (personal/)
|
||||
- 개인 서비스 (포트폴리오 + 블로그 + 투두 통합)
|
||||
|
||||
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())
|
||||
0
_shared/tests/__init__.py
Normal file
0
_shared/tests/__init__.py
Normal file
129
_shared/tests/test_access_log.py
Normal file
129
_shared/tests/test_access_log.py
Normal file
@@ -0,0 +1,129 @@
|
||||
import logging
|
||||
import time
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from _shared.access_log import (
|
||||
AccessLogMiddleware,
|
||||
BufferLogHandler,
|
||||
router as logs_router,
|
||||
install,
|
||||
_BUFFER,
|
||||
)
|
||||
|
||||
|
||||
def _reset_buffer():
|
||||
_BUFFER.clear()
|
||||
|
||||
|
||||
def test_access_middleware_records_request():
|
||||
_reset_buffer()
|
||||
app = FastAPI()
|
||||
app.add_middleware(AccessLogMiddleware)
|
||||
|
||||
@app.get("/api/lotto/recommend")
|
||||
def recommend():
|
||||
return {"ok": True}
|
||||
|
||||
client = TestClient(app)
|
||||
client.get("/api/lotto/recommend")
|
||||
|
||||
items = [x for x in _BUFFER if x["source"] == "access"]
|
||||
assert len(items) == 1
|
||||
assert items[0]["method"] == "GET"
|
||||
assert items[0]["path"] == "/api/lotto/recommend"
|
||||
assert items[0]["status"] == 200
|
||||
assert items[0]["ms"] >= 0
|
||||
|
||||
|
||||
def test_access_middleware_skips_health():
|
||||
_reset_buffer()
|
||||
app = FastAPI()
|
||||
app.add_middleware(AccessLogMiddleware)
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True}
|
||||
|
||||
client = TestClient(app)
|
||||
client.get("/health")
|
||||
|
||||
items = [x for x in _BUFFER if x["source"] == "access"]
|
||||
assert items == []
|
||||
|
||||
|
||||
def test_access_middleware_skips_options():
|
||||
_reset_buffer()
|
||||
app = FastAPI()
|
||||
app.add_middleware(AccessLogMiddleware)
|
||||
|
||||
@app.get("/api/lotto/recommend")
|
||||
def recommend():
|
||||
return {"ok": True}
|
||||
|
||||
client = TestClient(app)
|
||||
client.options("/api/lotto/recommend")
|
||||
|
||||
items = [x for x in _BUFFER if x["source"] == "access"]
|
||||
assert items == []
|
||||
|
||||
|
||||
def test_buffer_log_handler_captures_logger_info():
|
||||
_reset_buffer()
|
||||
root = logging.getLogger("")
|
||||
handler = BufferLogHandler()
|
||||
root.addHandler(handler)
|
||||
try:
|
||||
lg = logging.getLogger("lotto.test")
|
||||
lg.setLevel(logging.INFO)
|
||||
lg.info("뉴스 스크래핑 완료: 국내 12건")
|
||||
finally:
|
||||
root.removeHandler(handler)
|
||||
|
||||
items = [x for x in _BUFFER if x["source"] == "log"]
|
||||
assert len(items) == 1
|
||||
assert items[0]["message"] == "뉴스 스크래핑 완료: 국내 12건"
|
||||
assert items[0]["level"] == "info"
|
||||
assert items[0]["logger"] == "lotto.test"
|
||||
|
||||
|
||||
def test_logs_recent_endpoint_returns_recent_items():
|
||||
_reset_buffer()
|
||||
app = FastAPI()
|
||||
install(app)
|
||||
|
||||
@app.get("/api/lotto/recommend")
|
||||
def recommend():
|
||||
return {"ok": True}
|
||||
|
||||
client = TestClient(app)
|
||||
client.get("/api/lotto/recommend")
|
||||
client.get("/api/lotto/recommend")
|
||||
client.get("/health") # 제외되어야 함
|
||||
|
||||
resp = client.get("/logs/recent")
|
||||
assert resp.status_code == 200
|
||||
logs = resp.json()["logs"]
|
||||
access_items = [x for x in logs if x["source"] == "access"]
|
||||
assert len(access_items) == 2
|
||||
|
||||
|
||||
def test_logs_recent_with_since_filter():
|
||||
_reset_buffer()
|
||||
app = FastAPI()
|
||||
install(app)
|
||||
|
||||
@app.get("/api/lotto/recommend")
|
||||
def recommend():
|
||||
return {"ok": True}
|
||||
|
||||
client = TestClient(app)
|
||||
client.get("/api/lotto/recommend")
|
||||
time.sleep(0.01)
|
||||
cursor_resp = client.get("/logs/recent")
|
||||
cursor_ts = cursor_resp.json()["logs"][-1]["ts"]
|
||||
client.get("/api/lotto/recommend")
|
||||
|
||||
resp = client.get(f"/logs/recent?since={cursor_ts}")
|
||||
items = [x for x in resp.json()["logs"] if x["source"] == "access"]
|
||||
assert len(items) == 1
|
||||
@@ -7,4 +7,4 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import asyncio
|
||||
import random
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from ..config import IDLE_BREAK_THRESHOLD, BREAK_DURATION_MIN, BREAK_DURATION_MAX
|
||||
from ..db import add_log
|
||||
|
||||
VALID_STATES = ("idle", "working", "waiting", "reporting", "break")
|
||||
VALID_STATES = ("idle", "working", "waiting", "reporting")
|
||||
|
||||
class BaseAgent:
|
||||
agent_id: str = ""
|
||||
@@ -14,7 +9,6 @@ class BaseAgent:
|
||||
state: str = "idle"
|
||||
state_detail: str = ""
|
||||
_idle_since: float = 0.0
|
||||
_break_until: float = 0.0
|
||||
_ws_manager = None
|
||||
|
||||
def __init__(self):
|
||||
@@ -32,11 +26,6 @@ class BaseAgent:
|
||||
|
||||
if new_state == "idle":
|
||||
self._idle_since = time.time()
|
||||
elif new_state == "break":
|
||||
duration = random.randint(BREAK_DURATION_MIN, BREAK_DURATION_MAX)
|
||||
self._break_until = time.time() + duration
|
||||
|
||||
add_log(self.agent_id, f"State: {old} -> {new_state} ({detail})")
|
||||
|
||||
if self._ws_manager:
|
||||
await self._ws_manager.send_agent_state(self.agent_id, new_state, detail, task_id)
|
||||
@@ -48,19 +37,6 @@ class BaseAgent:
|
||||
await self._ws_manager.send_notification(
|
||||
self.agent_id, "task_completed", task_id, detail or "작업 완료"
|
||||
)
|
||||
if new_state == "break":
|
||||
await self._ws_manager.send_agent_move(self.agent_id, "break_room")
|
||||
elif old == "break" and new_state == "idle":
|
||||
await self._ws_manager.send_agent_move(self.agent_id, "desk")
|
||||
|
||||
async def check_idle_break(self) -> None:
|
||||
now = time.time()
|
||||
if self.state == "idle" and (now - self._idle_since) > IDLE_BREAK_THRESHOLD:
|
||||
if random.random() < 0.5:
|
||||
break_type = random.choice(["커피 타임", "잠깐 산책", "졸고 있음"])
|
||||
await self.transition("break", break_type)
|
||||
elif self.state == "break" and now > self._break_until:
|
||||
await self.transition("idle", "휴식 완료")
|
||||
|
||||
async def on_schedule(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -18,6 +18,26 @@ 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.
|
||||
@@ -46,7 +66,7 @@ class InstaAgent(BaseAgent):
|
||||
async def on_schedule(self) -> None:
|
||||
"""09:30 매일: 뉴스 수집 → 키워드 추출 → 텔레그램 후보 푸시.
|
||||
custom_config.auto_select=True면 카테고리당 1위 키워드 자동 슬레이트 생성."""
|
||||
if self.state not in ("idle", "break"):
|
||||
if self.state != "idle":
|
||||
return
|
||||
config = get_agent_config(self.agent_id) or {}
|
||||
custom = config.get("custom_config", {}) or {}
|
||||
@@ -89,14 +109,18 @@ class InstaAgent(BaseAgent):
|
||||
raise TimeoutError(f"{step} timeout {timeout_sec}s")
|
||||
|
||||
async def _push_keyword_candidates(self, keywords: List[Dict[str, Any]]) -> None:
|
||||
by_cat: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for k in keywords:
|
||||
by_cat.setdefault(k["category"], []).append(k)
|
||||
if not by_cat:
|
||||
await messaging.send_raw("📰 [인스타 큐레이터] 오늘은 추천할 키워드가 없습니다.")
|
||||
# 중복 제거 + 신뢰도(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 = ["📰 <b>[인스타 큐레이터]</b> 오늘의 키워드 후보"]
|
||||
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]:
|
||||
|
||||
@@ -8,7 +8,7 @@ class LottoAgent(BaseAgent):
|
||||
display_name = "로또 큐레이터"
|
||||
|
||||
async def on_schedule(self) -> None:
|
||||
if self.state not in ("idle", "break"):
|
||||
if self.state != "idle":
|
||||
return
|
||||
await self._run(source="auto")
|
||||
|
||||
@@ -17,11 +17,249 @@ class LottoAgent(BaseAgent):
|
||||
return await self._run(source="manual")
|
||||
if action == "status":
|
||||
return {"ok": True, "message": f"{self.state}: {self.state_detail}"}
|
||||
if action in ("signal_check", "light_check", "sim_check", "deep_check"):
|
||||
source = action.replace("_check", "") if action != "signal_check" else "light"
|
||||
return await self.run_signal_check(source=source)
|
||||
if action == "daily_digest":
|
||||
return await self.run_daily_digest()
|
||||
if action == "sunday_review":
|
||||
return await self.run_sunday_review()
|
||||
return {"ok": False, "message": f"unknown action: {action}"}
|
||||
|
||||
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
|
||||
pass
|
||||
|
||||
async def run_signal_check(self, source: str = "light") -> dict:
|
||||
"""비-LLM 시그널 평가. task_id wrap 적용."""
|
||||
from ..curator.signal_runner import run_signal_check
|
||||
from ..config import (
|
||||
LOTTO_Z_NORMAL, LOTTO_Z_URGENT,
|
||||
LOTTO_THROTTLE_HOURS, LOTTO_URGENT_DAILY_MAX,
|
||||
)
|
||||
from ..db import (
|
||||
create_task, update_task_status, add_log,
|
||||
get_last_signal_notification, get_recent_urgent_count,
|
||||
mark_signal_notified,
|
||||
)
|
||||
from ..notifiers.telegram_lotto import send_urgent_signal
|
||||
from ..service_proxy import lotto_latest_draw
|
||||
|
||||
if self.state not in ("idle", "reporting"):
|
||||
return {"ok": False, "message": f"busy ({self.state})"}
|
||||
|
||||
task_id = create_task("lotto", "signal_check", {"source": source})
|
||||
try:
|
||||
curate_result = None
|
||||
current_draw_no = await lotto_latest_draw()
|
||||
|
||||
if source == "deep":
|
||||
from ..curator.pipeline import curate_weekly
|
||||
cw = await curate_weekly(source="signal_deep")
|
||||
curate_result = {"confidence": cw.get("confidence")}
|
||||
if cw.get("draw_no"):
|
||||
current_draw_no = cw.get("draw_no")
|
||||
|
||||
outcome = await run_signal_check(
|
||||
source=source,
|
||||
z_normal=LOTTO_Z_NORMAL,
|
||||
z_urgent=LOTTO_Z_URGENT,
|
||||
curate_result=curate_result,
|
||||
current_draw_no=current_draw_no,
|
||||
)
|
||||
|
||||
# urgent 텔레그램 + throttle (기존 동작 유지)
|
||||
if outcome["overall_fire"] == "urgent":
|
||||
if get_recent_urgent_count(hours=24) >= LOTTO_URGENT_DAILY_MAX:
|
||||
add_log("lotto", "urgent daily cap 도달 → normal로 강등", level="warning", task_id=task_id)
|
||||
else:
|
||||
blocked = False
|
||||
for r in outcome["results"]:
|
||||
if r["fire_level"] in ("normal", "urgent"):
|
||||
if get_last_signal_notification(
|
||||
metric=r["metric"], fire_level=r["fire_level"],
|
||||
hours=LOTTO_THROTTLE_HOURS,
|
||||
):
|
||||
blocked = True
|
||||
break
|
||||
if not blocked:
|
||||
from datetime import datetime, timezone
|
||||
event = {
|
||||
"fire_level": "urgent",
|
||||
"triggered_at": datetime.now(timezone.utc).isoformat(),
|
||||
"results": outcome["results"],
|
||||
}
|
||||
await send_urgent_signal(event)
|
||||
for r in outcome["results"]:
|
||||
if r["fire_level"] in ("normal", "urgent"):
|
||||
mark_signal_notified(r["signal_id"])
|
||||
add_log("lotto", f"urgent 텔레그램 발송 ({len(outcome['results'])}개 시그널)", task_id=task_id)
|
||||
|
||||
fired_metrics = [
|
||||
r["metric"] for r in outcome["results"]
|
||||
if r["fire_level"] not in ("noop", "warmup")
|
||||
]
|
||||
update_task_status(task_id, "succeeded", result_data={
|
||||
"source": source,
|
||||
"overall_fire": outcome["overall_fire"],
|
||||
"n_results": len(outcome["results"]),
|
||||
"fired_metrics": fired_metrics,
|
||||
})
|
||||
add_log("lotto", f"signal_check({source}) → {outcome['overall_fire']} results={len(outcome['results'])}", task_id=task_id)
|
||||
return {"ok": True, **outcome}
|
||||
except Exception as e:
|
||||
update_task_status(task_id, "failed", result_data={"error": str(e)})
|
||||
add_log("lotto", f"signal_check 예외: {e}", level="error", task_id=task_id)
|
||||
return {"ok": False, "message": f"{type(e).__name__}: {e}"}
|
||||
|
||||
async def run_daily_digest(self) -> dict:
|
||||
"""일일 요약 — 지난 24h normal/urgent 발화 텔레그램 1통. task_id wrap."""
|
||||
from ..db import (
|
||||
create_task, update_task_status, add_log,
|
||||
get_recent_lotto_signals, get_signals_history, get_baseline,
|
||||
)
|
||||
from ..notifiers.telegram_lotto import send_signal_summary
|
||||
|
||||
task_id = create_task("lotto", "daily_digest", {})
|
||||
try:
|
||||
sigs = get_recent_lotto_signals(hours=24, min_fire="normal")
|
||||
total_24h = get_signals_history(days=1)
|
||||
evaluated = len(total_24h)
|
||||
|
||||
trend = {}
|
||||
try:
|
||||
cache = get_baseline("drift_weights_cache")
|
||||
if cache and isinstance(cache["window_values"], list) and len(cache["window_values"]) >= 2:
|
||||
prev_w = cache["window_values"][-2]
|
||||
curr_w = cache["window_values"][-1]
|
||||
trend = {
|
||||
k: curr_w.get(k, 0.0) - prev_w.get(k, 0.0)
|
||||
for k in (set(prev_w) | set(curr_w))
|
||||
}
|
||||
except Exception as e:
|
||||
add_log("lotto", f"weights_trend 계산 실패: {e}", level="warning", task_id=task_id)
|
||||
|
||||
digest = {
|
||||
"evaluated": evaluated,
|
||||
"fired": len(sigs),
|
||||
"signals": sigs,
|
||||
"weights_trend": trend,
|
||||
}
|
||||
await send_signal_summary(digest)
|
||||
update_task_status(task_id, "succeeded", result_data={
|
||||
"evaluated": evaluated,
|
||||
"fired": len(sigs),
|
||||
"signals_count": len(sigs),
|
||||
})
|
||||
add_log("lotto", f"daily_digest 발송: 평가 {evaluated} / 발화 {len(sigs)}", task_id=task_id)
|
||||
return {"ok": True, **digest}
|
||||
except Exception as e:
|
||||
update_task_status(task_id, "failed", result_data={"error": str(e)})
|
||||
add_log("lotto", f"daily_digest 예외: {e}", level="error", task_id=task_id)
|
||||
return {"ok": False, "message": f"{type(e).__name__}: {e}"}
|
||||
|
||||
async def run_sunday_review(self) -> dict:
|
||||
"""일 09:00 — 최신 회차 forward+calibration 보장 후 회고 텔레그램."""
|
||||
from ..service_proxy import lotto_latest_draw, lotto_backtest_review
|
||||
from ..notifiers.telegram_lotto import send_sunday_review
|
||||
from ..db import create_task, update_task_status, add_log
|
||||
|
||||
task_id = create_task("lotto", "sunday_review", {})
|
||||
try:
|
||||
draw_no = await lotto_latest_draw()
|
||||
if not draw_no:
|
||||
update_task_status(task_id, "failed", result_data={"reason": "no_draw"})
|
||||
return {"ok": False, "message": "no latest draw"}
|
||||
# forward는 lotto cron이 이미 돌렸을 수 있으나 멱등이라 안전 — review만 호출
|
||||
payload = await lotto_backtest_review(draw_no)
|
||||
await send_sunday_review(payload)
|
||||
update_task_status(task_id, "succeeded", result_data={"draw_no": draw_no})
|
||||
add_log("lotto", f"sunday_review 발송: #{draw_no}", task_id=task_id)
|
||||
return {"ok": True, "draw_no": draw_no}
|
||||
except Exception as e:
|
||||
update_task_status(task_id, "failed", result_data={"error": str(e)})
|
||||
add_log("lotto", f"sunday_review 예외: {e}", level="error", task_id=task_id)
|
||||
return {"ok": False, "message": f"{type(e).__name__}: {e}"}
|
||||
|
||||
async def run_weekly_evolution_report(self) -> dict:
|
||||
"""토 22:15 — lotto-lab evaluate-now 트리거 후 텔레그램 리포트. task_id wrap."""
|
||||
from ..service_proxy import lotto_evolver_evaluate, lotto_evolver_status
|
||||
from ..notifiers.telegram_lotto import send_evolution_report
|
||||
from ..db import create_task, update_task_status, add_log
|
||||
|
||||
task_id = create_task("lotto", "weekly_evolution_report", {})
|
||||
try:
|
||||
eval_result = await lotto_evolver_evaluate()
|
||||
status = await lotto_evolver_status()
|
||||
current_base = status.get("current_base") or [0.2] * 5
|
||||
await send_evolution_report(eval_result, current_base)
|
||||
|
||||
winner = eval_result.get("winner") or {}
|
||||
update_task_status(task_id, "succeeded", result_data={
|
||||
"draw_no": eval_result.get("draw_no"),
|
||||
"update_reason": eval_result.get("update_reason"),
|
||||
"winner_day_of_week": winner.get("day_of_week"),
|
||||
"winner_max_correct": winner.get("max_correct"),
|
||||
})
|
||||
add_log("lotto", f"weekly_evolution_report 발송: draw={eval_result.get('draw_no')} reason={eval_result.get('update_reason')}", task_id=task_id)
|
||||
return {"ok": True, **eval_result}
|
||||
except Exception as e:
|
||||
update_task_status(task_id, "failed", result_data={"error": str(e)})
|
||||
add_log("lotto", f"weekly_evolution_report 예외: {e}", level="error", task_id=task_id)
|
||||
return {"ok": False, "message": f"{type(e).__name__}: {e}"}
|
||||
|
||||
async def sync_evolver_activity(self) -> dict:
|
||||
"""매일 09:30 — lotto-lab evolver 상태 polling → agent_office.db에 task+log 거울. 멱등."""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from ..service_proxy import lotto_evolver_status
|
||||
from ..db import (
|
||||
create_task, update_task_status, add_log,
|
||||
get_tasks_by_agent_date_kind,
|
||||
)
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
today_kst = datetime.now(KST).date()
|
||||
# created_at은 UTC로 저장되므로 idempotency guard는 UTC 날짜 기준
|
||||
today_utc_iso = datetime.now(timezone.utc).date().isoformat()
|
||||
dow = today_kst.weekday()
|
||||
if dow == 6:
|
||||
dow = 5
|
||||
|
||||
try:
|
||||
status = await lotto_evolver_status()
|
||||
except Exception as e:
|
||||
add_log("lotto", f"sync_evolver_activity: lotto-lab status fetch 실패: {e}", level="warning")
|
||||
return {"ok": False, "reason": "status_fetch_failed", "error": str(e)}
|
||||
|
||||
results = {"created": []}
|
||||
|
||||
today_trial = next((t for t in status.get("trials", []) if t.get("day_of_week") == dow), None)
|
||||
if today_trial and today_trial.get("picks"):
|
||||
if not get_tasks_by_agent_date_kind("lotto", today_utc_iso, "evolver_apply"):
|
||||
tid = create_task("lotto", "evolver_apply", {
|
||||
"date": today_utc_iso,
|
||||
"trial_id": today_trial["id"],
|
||||
"day_of_week": dow,
|
||||
"weight": today_trial["weight"],
|
||||
})
|
||||
update_task_status(tid, "succeeded", result_data={
|
||||
"n_picks": len(today_trial["picks"]),
|
||||
"meta_scores": [p.get("meta_score") for p in today_trial["picks"]],
|
||||
})
|
||||
add_log("lotto", f"evolver_apply: 오늘({dow}) W로 {len(today_trial['picks'])}세트 추출", task_id=tid)
|
||||
results["created"].append("evolver_apply")
|
||||
|
||||
if today_kst.weekday() == 0 and len(status.get("trials", [])) == 6:
|
||||
if not get_tasks_by_agent_date_kind("lotto", today_utc_iso, "evolver_generate"):
|
||||
tid = create_task("lotto", "evolver_generate", {"week_start": status.get("week_start")})
|
||||
update_task_status(tid, "succeeded", result_data={
|
||||
"trials_count": 6,
|
||||
"candidates_per_source": {"perturb": 4, "dirichlet": 2},
|
||||
})
|
||||
add_log("lotto", f"evolver_generate: {status.get('week_start')} 주의 6 trials 생성", task_id=tid)
|
||||
results["created"].append("evolver_generate")
|
||||
|
||||
return {"ok": True, **results}
|
||||
|
||||
async def _run(self, source: str) -> dict:
|
||||
task_id = create_task(self.agent_id, "curate_weekly", {"source": source})
|
||||
await self.transition("working", "후보 수집 및 AI 큐레이션 중...", task_id)
|
||||
|
||||
@@ -44,7 +44,7 @@ class StockAgent(BaseAgent):
|
||||
display_name = "주식 트레이더"
|
||||
|
||||
async def on_schedule(self) -> None:
|
||||
if self.state not in ("idle", "break"):
|
||||
if self.state != "idle":
|
||||
return
|
||||
|
||||
task_id = create_task(self.agent_id, "news_summary", {"limit": 15})
|
||||
@@ -129,7 +129,7 @@ class StockAgent(BaseAgent):
|
||||
4) status=='success' → telegram_payload.text 를 parse_mode 그대로 전송
|
||||
5) 예외/실패 → 운영자에게 별도 텔레그램 알림 (HTML)
|
||||
"""
|
||||
if self.state not in ("idle", "break"):
|
||||
if self.state != "idle":
|
||||
return
|
||||
|
||||
task_id = create_task(self.agent_id, "screener_run", {"mode": "auto"})
|
||||
@@ -243,7 +243,7 @@ class StockAgent(BaseAgent):
|
||||
4) failures > 30% → 경고 알림 후 메인 메시지 발송
|
||||
5) 정상 → Top 5 호재/악재 메시지 발송 (MarkdownV2)
|
||||
"""
|
||||
if self.state not in ("idle", "break"):
|
||||
if self.state != "idle":
|
||||
return
|
||||
|
||||
task_id = create_task(self.agent_id, "ai_news_sentiment", {})
|
||||
@@ -336,7 +336,48 @@ class StockAgent(BaseAgent):
|
||||
|
||||
await self.transition("idle", "AI 뉴스 완료")
|
||||
|
||||
async def run_holdings_eod(self) -> dict:
|
||||
"""평일 16:50 — 보유종목 시그널 계산·저장."""
|
||||
# idle 가드 없음(의도적): 스크리너 진행 중에도 EOD/브리핑은 독립적으로 실행되어야 함
|
||||
from ..service_proxy import stock_holdings_run
|
||||
from ..db import create_task, update_task_status, add_log
|
||||
task_id = create_task(self.agent_id, "holdings_eod", {})
|
||||
try:
|
||||
res = await stock_holdings_run()
|
||||
update_task_status(task_id, "succeeded", res)
|
||||
add_log(self.agent_id, f"holdings_eod: {res}", "info", task_id)
|
||||
return {"ok": True, **res}
|
||||
except Exception as e:
|
||||
update_task_status(task_id, "failed", {"error": str(e)})
|
||||
add_log(self.agent_id, f"holdings_eod 실패: {e}", "error", task_id)
|
||||
return {"ok": False, "message": str(e)}
|
||||
|
||||
async def run_holdings_brief(self) -> dict:
|
||||
"""평일 08:30 — 저장된 시그널 브리핑 텔레그램."""
|
||||
# idle 가드 없음(의도적): 스크리너 진행 중에도 EOD/브리핑은 독립적으로 실행되어야 함
|
||||
from ..service_proxy import stock_holdings_brief
|
||||
from ..notifiers.telegram_stock import send_holdings_brief
|
||||
from ..db import create_task, update_task_status, add_log
|
||||
task_id = create_task(self.agent_id, "holdings_brief", {})
|
||||
try:
|
||||
payload = await stock_holdings_brief()
|
||||
await send_holdings_brief(payload)
|
||||
update_task_status(task_id, "succeeded", {"date": payload.get("date"),
|
||||
"count": len(payload.get("holdings", []))})
|
||||
add_log(self.agent_id, f"holdings_brief 발송: {payload.get('date')}", "info", task_id)
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
update_task_status(task_id, "failed", {"error": str(e)})
|
||||
add_log(self.agent_id, f"holdings_brief 실패: {e}", "error", task_id)
|
||||
return {"ok": False, "message": str(e)}
|
||||
|
||||
async def on_command(self, command: str, params: dict) -> dict:
|
||||
if command == "holdings_eod":
|
||||
return await self.run_holdings_eod()
|
||||
|
||||
if command == "holdings_brief":
|
||||
return await self.run_holdings_brief()
|
||||
|
||||
if command == "run_screener":
|
||||
await self.on_screener_schedule()
|
||||
return {"ok": True, "message": "스크리너 실행 트리거 완료"}
|
||||
|
||||
@@ -26,11 +26,28 @@ CORS_ALLOW_ORIGINS = os.getenv(
|
||||
"CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080"
|
||||
)
|
||||
|
||||
# Idle break threshold (seconds)
|
||||
IDLE_BREAK_THRESHOLD = int(os.getenv("IDLE_BREAK_THRESHOLD", "300")) # 5 min
|
||||
BREAK_DURATION_MIN = int(os.getenv("BREAK_DURATION_MIN", "60")) # 1 min
|
||||
BREAK_DURATION_MAX = int(os.getenv("BREAK_DURATION_MAX", "180")) # 3 min
|
||||
|
||||
# Lotto Curator
|
||||
LOTTO_BACKEND_URL = os.getenv("LOTTO_BACKEND_URL", "http://lotto:8000")
|
||||
LOTTO_CURATOR_MODEL = os.getenv("LOTTO_CURATOR_MODEL", "claude-sonnet-4-5")
|
||||
|
||||
# Lotto Active Signals
|
||||
LOTTO_SIGNAL_WINDOW = int(os.getenv("LOTTO_SIGNAL_WINDOW", "8"))
|
||||
LOTTO_Z_NORMAL = float(os.getenv("LOTTO_Z_NORMAL", "1.5"))
|
||||
LOTTO_Z_URGENT = float(os.getenv("LOTTO_Z_URGENT", "2.5"))
|
||||
LOTTO_DIGEST_HOUR = int(os.getenv("LOTTO_DIGEST_HOUR", "9"))
|
||||
LOTTO_DIGEST_MIN = int(os.getenv("LOTTO_DIGEST_MIN", "25"))
|
||||
LOTTO_THROTTLE_HOURS = int(os.getenv("LOTTO_THROTTLE_HOURS", "6"))
|
||||
LOTTO_URGENT_DAILY_MAX = int(os.getenv("LOTTO_URGENT_DAILY_MAX", "3"))
|
||||
|
||||
import re as _re
|
||||
|
||||
# 에이전트 → (container_host, port, path_prefix_regex)
|
||||
# path_prefix_regex: lotto 컨테이너에 personal/blog/todo 도 같이 있어
|
||||
# /api/lotto 만 골라내기 위한 정규식. business log (source='log') 는 모두 통과.
|
||||
AGENT_CONTAINER_MAP: dict[str, tuple[str, int, _re.Pattern]] = {
|
||||
"lotto": ("lotto", 8000, _re.compile(r"^/api/lotto")),
|
||||
"stock": ("stock", 8000, _re.compile(r"^/api/(stock|trade|portfolio)")),
|
||||
"music": ("music-lab", 8000, _re.compile(r"^/api/music")),
|
||||
"insta": ("insta-lab", 8000, _re.compile(r"^/api/insta")),
|
||||
"realestate": ("realestate-lab", 8000, _re.compile(r"^/api/realestate")),
|
||||
}
|
||||
|
||||
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"
|
||||
@@ -98,6 +98,66 @@ def init_db() -> None:
|
||||
completed_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS lotto_signals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
triggered_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
source TEXT NOT NULL,
|
||||
metric TEXT NOT NULL,
|
||||
value REAL NOT NULL,
|
||||
baseline_mu REAL,
|
||||
baseline_sigma REAL,
|
||||
z_score REAL,
|
||||
fire_level TEXT NOT NULL,
|
||||
notified_at TEXT,
|
||||
payload TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_ls_triggered
|
||||
ON lotto_signals(triggered_at DESC)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_ls_fire
|
||||
ON lotto_signals(fire_level, notified_at)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS lotto_baselines (
|
||||
metric TEXT PRIMARY KEY,
|
||||
window_values TEXT NOT NULL DEFAULT '[]',
|
||||
mu REAL NOT NULL DEFAULT 0.0,
|
||||
sigma REAL NOT NULL DEFAULT 0.0,
|
||||
last_pushed_draw_no INTEGER,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS tarot_readings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
spread_type TEXT NOT NULL,
|
||||
category TEXT,
|
||||
question TEXT,
|
||||
cards TEXT NOT NULL,
|
||||
interpretation_json TEXT,
|
||||
summary TEXT,
|
||||
model TEXT,
|
||||
tokens_in INTEGER,
|
||||
tokens_out INTEGER,
|
||||
cost_usd REAL,
|
||||
confidence TEXT,
|
||||
favorite INTEGER NOT NULL DEFAULT 0,
|
||||
note TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_tarot_created
|
||||
ON tarot_readings(created_at DESC)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_tarot_favorite
|
||||
ON tarot_readings(favorite, created_at DESC)
|
||||
""")
|
||||
# Seed default agent configs
|
||||
for agent_id, name in [
|
||||
("stock", "주식 트레이더"),
|
||||
@@ -203,12 +263,24 @@ def get_task(task_id: str) -> Optional[Dict[str, Any]]:
|
||||
return _task_to_dict(r) if r else None
|
||||
|
||||
|
||||
def get_agent_tasks(agent_id: str, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
def get_agent_tasks(
|
||||
agent_id: str,
|
||||
limit: int = 20,
|
||||
task_type: Optional[str] = None,
|
||||
days: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
sql = "SELECT * FROM agent_tasks WHERE agent_id=?"
|
||||
params: List[Any] = [agent_id]
|
||||
if task_type is not None:
|
||||
sql += " AND task_type=?"
|
||||
params.append(task_type)
|
||||
if days is not None and days > 0:
|
||||
sql += " AND created_at >= datetime('now', ?)"
|
||||
params.append(f"-{int(days)} days")
|
||||
sql += " ORDER BY created_at DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM agent_tasks WHERE agent_id=? ORDER BY created_at DESC LIMIT ?",
|
||||
(agent_id, limit),
|
||||
).fetchall()
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
return [_task_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
@@ -249,7 +321,13 @@ def add_log(agent_id: str, message: str, level: str = "info", task_id: str = Non
|
||||
def get_logs(agent_id: str, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM agent_logs WHERE agent_id=? ORDER BY created_at DESC LIMIT ?",
|
||||
"""
|
||||
SELECT * FROM agent_logs
|
||||
WHERE agent_id = ?
|
||||
AND message NOT LIKE 'State: %'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(agent_id, limit),
|
||||
).fetchall()
|
||||
return [
|
||||
@@ -260,6 +338,7 @@ def get_logs(agent_id: str, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
"level": r["level"],
|
||||
"message": r["message"],
|
||||
"created_at": r["created_at"],
|
||||
"source": "agent",
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -516,6 +595,20 @@ def get_activity_feed(limit: int = 50, offset: int = 0) -> dict:
|
||||
return {"items": items, "total": total}
|
||||
|
||||
|
||||
import datetime as _dt
|
||||
|
||||
|
||||
def delete_old_logs(days: int = 90) -> int:
|
||||
"""retention 정책: N일 이전 agent_logs 삭제. 매일 03:00 스케줄러가 호출."""
|
||||
cutoff = (_dt.datetime.utcnow() - _dt.timedelta(days=days)).isoformat()
|
||||
with _conn() as conn:
|
||||
c = conn.execute(
|
||||
"DELETE FROM agent_logs WHERE created_at < ?",
|
||||
(cutoff,),
|
||||
)
|
||||
return c.rowcount
|
||||
|
||||
|
||||
# ── youtube_research_jobs CRUD ────────────────────────────────────────────────
|
||||
|
||||
def add_youtube_research_job(countries: list) -> int:
|
||||
@@ -556,3 +649,170 @@ def get_latest_youtube_research_job() -> Optional[Dict[str, Any]]:
|
||||
"started_at": row["started_at"],
|
||||
"completed_at": row["completed_at"],
|
||||
}
|
||||
|
||||
|
||||
# --- lotto_signals / lotto_baselines CRUD ---
|
||||
|
||||
def insert_lotto_signal(
|
||||
source: str,
|
||||
metric: str,
|
||||
value: float,
|
||||
baseline_mu: Optional[float],
|
||||
baseline_sigma: Optional[float],
|
||||
z_score: Optional[float],
|
||||
fire_level: str,
|
||||
payload: Optional[Dict[str, Any]] = None,
|
||||
) -> int:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO lotto_signals
|
||||
(source, metric, value, baseline_mu, baseline_sigma, z_score, fire_level, payload)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
source, metric, value,
|
||||
baseline_mu, baseline_sigma, z_score, fire_level,
|
||||
json.dumps(payload or {}, ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def mark_signal_notified(signal_id: int) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE lotto_signals SET notified_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
|
||||
(signal_id,),
|
||||
)
|
||||
|
||||
|
||||
def get_recent_lotto_signals(hours: int = 24, min_fire: str = "normal") -> List[Dict[str, Any]]:
|
||||
"""지난 N시간 발화 시그널. min_fire='normal'이면 normal+urgent."""
|
||||
levels = ("urgent",) if min_fire == "urgent" else ("normal", "urgent")
|
||||
placeholders = ",".join("?" * len(levels))
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT * FROM lotto_signals
|
||||
WHERE triggered_at >= datetime('now', ?)
|
||||
AND fire_level IN ({placeholders})
|
||||
ORDER BY triggered_at DESC
|
||||
""",
|
||||
(f"-{int(hours)} hours", *levels),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_signals_history(days: int = 7) -> List[Dict[str, Any]]:
|
||||
"""차트/이력 페이지용 — 모든 fire_level 포함."""
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM lotto_signals
|
||||
WHERE triggered_at >= datetime('now', ?)
|
||||
ORDER BY triggered_at DESC
|
||||
""",
|
||||
(f"-{int(days)} days",),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_recent_urgent_count(hours: int = 24) -> int:
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS c FROM lotto_signals
|
||||
WHERE triggered_at >= datetime('now', ?)
|
||||
AND fire_level = 'urgent'
|
||||
AND notified_at IS NOT NULL
|
||||
""",
|
||||
(f"-{int(hours)} hours",),
|
||||
).fetchone()
|
||||
return int(row["c"]) if row else 0
|
||||
|
||||
|
||||
def get_last_signal_notification(metric: str, fire_level: str, hours: int) -> Optional[str]:
|
||||
"""같은 metric+fire_level이 hours 내에 알림 발송된 마지막 시각. throttle용."""
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT notified_at FROM lotto_signals
|
||||
WHERE metric = ?
|
||||
AND fire_level = ?
|
||||
AND notified_at IS NOT NULL
|
||||
AND notified_at >= datetime('now', ?)
|
||||
ORDER BY notified_at DESC LIMIT 1
|
||||
""",
|
||||
(metric, fire_level, f"-{int(hours)} hours"),
|
||||
).fetchone()
|
||||
return row["notified_at"] if row else None
|
||||
|
||||
|
||||
def get_baseline(metric: str) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM lotto_baselines WHERE metric = ?",
|
||||
(metric,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
d = dict(row)
|
||||
d["window_values"] = json.loads(d["window_values"])
|
||||
return d
|
||||
|
||||
|
||||
def upsert_baseline(
|
||||
metric: str,
|
||||
window_values: List[float],
|
||||
mu: float,
|
||||
sigma: float,
|
||||
last_pushed_draw_no: Optional[int],
|
||||
) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO lotto_baselines
|
||||
(metric, window_values, mu, sigma, last_pushed_draw_no, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
ON CONFLICT(metric) DO UPDATE SET
|
||||
window_values = excluded.window_values,
|
||||
mu = excluded.mu,
|
||||
sigma = excluded.sigma,
|
||||
last_pushed_draw_no = excluded.last_pushed_draw_no,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
metric,
|
||||
json.dumps(window_values),
|
||||
mu, sigma, last_pushed_draw_no,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_all_baselines() -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM lotto_baselines ORDER BY metric").fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["window_values"] = json.loads(d["window_values"])
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def get_tasks_by_agent_date_kind(agent_id: str, date_iso: str, task_type: str) -> List[Dict[str, Any]]:
|
||||
"""같은 (agent, date, task_type)으로 이미 생성된 task 조회. 멱등 guard."""
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM agent_tasks
|
||||
WHERE agent_id = ? AND task_type = ?
|
||||
AND substr(created_at, 1, 10) = ?
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(agent_id, task_type, date_iso),
|
||||
).fetchall()
|
||||
return [_task_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import json
|
||||
from typing import Optional
|
||||
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
@@ -104,12 +105,29 @@ def update_agent(agent_id: str, body: AgentConfigUpdate):
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/agent-office/agents/{agent_id}/tasks")
|
||||
def agent_tasks(agent_id: str, limit: int = 20):
|
||||
return {"tasks": get_agent_tasks(agent_id, limit)}
|
||||
def agent_tasks(
|
||||
agent_id: str,
|
||||
limit: int = 20,
|
||||
task_type: Optional[str] = None,
|
||||
days: Optional[int] = None,
|
||||
):
|
||||
tasks_list = get_agent_tasks(agent_id, limit=limit, task_type=task_type, days=days)
|
||||
# Backward compat: 기존 client는 'tasks', 신규 client는 'items' 사용
|
||||
return {"tasks": tasks_list, "items": tasks_list}
|
||||
|
||||
@app.get("/api/agent-office/agents/{agent_id}/logs")
|
||||
def agent_logs(agent_id: str, limit: int = 50):
|
||||
return {"logs": get_logs(agent_id, limit)}
|
||||
async def agent_logs(agent_id: str, limit: int = 50):
|
||||
from .service_proxy import fetch_service_logs
|
||||
|
||||
agent_items = get_logs(agent_id, limit=limit)
|
||||
service_items = await fetch_service_logs(agent_id, limit=limit)
|
||||
|
||||
def _sort_key(x):
|
||||
# agent_logs: created_at, service: ts
|
||||
return x.get("ts") or x.get("created_at") or ""
|
||||
|
||||
merged = sorted(agent_items + service_items, key=_sort_key, reverse=True)
|
||||
return {"logs": merged[:limit]}
|
||||
|
||||
@app.get("/api/agent-office/tasks/pending")
|
||||
def pending_tasks():
|
||||
@@ -227,3 +245,30 @@ def youtube_research_status():
|
||||
if not job:
|
||||
return {"status": "never_run"}
|
||||
return job
|
||||
|
||||
|
||||
# --- Lotto Signal Endpoints ---
|
||||
|
||||
@app.get("/api/agent-office/lotto/signals")
|
||||
async def list_lotto_signals(days: int = 7):
|
||||
"""시그널 이력 (모든 fire_level)."""
|
||||
from .db import get_signals_history
|
||||
return {"items": get_signals_history(days=days)}
|
||||
|
||||
|
||||
@app.get("/api/agent-office/lotto/baselines")
|
||||
async def list_lotto_baselines():
|
||||
"""현재 baseline μ/σ + window 상태."""
|
||||
from .db import get_all_baselines
|
||||
return {"items": get_all_baselines()}
|
||||
|
||||
|
||||
@app.post("/api/agent-office/lotto/signal-check")
|
||||
async def trigger_signal_check(source: str = "light"):
|
||||
"""수동 트리거 (디버그·테스트용). source ∈ {light, sim, deep}."""
|
||||
if source not in ("light", "sim", "deep"):
|
||||
raise HTTPException(status_code=400, detail="source must be light/sim/deep")
|
||||
agent = AGENT_REGISTRY.get("lotto")
|
||||
if not agent:
|
||||
raise HTTPException(status_code=503, detail="lotto agent not registered")
|
||||
return await agent.run_signal_check(source=source)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Literal
|
||||
|
||||
|
||||
class CommandRequest(BaseModel):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""로또 큐레이션·당첨 알림 — 텔레그램 푸시."""
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
from typing import Dict, Any, List
|
||||
|
||||
# 기존 에이전트들과 동일한 패턴: send_raw(text, reply_markup=None, chat_id=None)
|
||||
# chat_id 생략 시 기본 TELEGRAM_CHAT_ID로 자동 발송.
|
||||
@@ -59,3 +59,208 @@ async def send_prize_alert(event: Dict[str, Any]) -> None:
|
||||
await send_raw(text)
|
||||
except Exception as e:
|
||||
logger.warning(f"[telegram_lotto] prize alert send failed: {e}")
|
||||
|
||||
|
||||
# ---------- 능동 시그널 알림 (urgent + digest) ----------
|
||||
|
||||
_METRIC_LABEL = {
|
||||
"sim_signal": "Sim Consensus",
|
||||
"drift": "Strategy Drift",
|
||||
"confidence": "Confidence",
|
||||
}
|
||||
|
||||
|
||||
def _format_urgent_signal(event: Dict[str, Any]) -> str:
|
||||
"""긴급 시그널 텔레그램 메시지 포맷."""
|
||||
triggered = event.get("triggered_at", "")[:19].replace("T", " ")
|
||||
results = event.get("results", [])
|
||||
fired = [r for r in results if r.get("fire_level") in ("normal", "urgent")]
|
||||
|
||||
lines = [
|
||||
"🚨 로또 능동 신호",
|
||||
"",
|
||||
f"[{triggered}]",
|
||||
f"강한 시그널 {len(fired)}종 발화:",
|
||||
]
|
||||
for r in fired:
|
||||
label = _METRIC_LABEL.get(r["metric"], r["metric"])
|
||||
v = r.get("value")
|
||||
mu = r.get("baseline_mu")
|
||||
sigma = r.get("baseline_sigma")
|
||||
z = r.get("z_score")
|
||||
v_text = f"{v:.2f}" if v is not None else "N/A"
|
||||
if mu is not None and sigma is not None and z is not None:
|
||||
lines.append(f"• {label} {v_text} (μ={mu:.2f}, σ={sigma:.2f}) z={z:.1f}")
|
||||
else:
|
||||
lines.append(f"• {label} {v_text}")
|
||||
|
||||
# drift 페이로드 — 어떤 전략이 변동했는지 한 줄
|
||||
for r in fired:
|
||||
if r["metric"] == "drift":
|
||||
wn = (r.get("payload") or {}).get("weights_now") or {}
|
||||
wp = (r.get("payload") or {}).get("weights_prev") or {}
|
||||
if wn and wp:
|
||||
diffs = {k: wn.get(k, 0) - wp.get(k, 0) for k in (set(wn) | set(wp))}
|
||||
top = sorted(diffs.items(), key=lambda kv: abs(kv[1]), reverse=True)[:2]
|
||||
detail = ", ".join(f"{k} {'+' if d>=0 else ''}{d*100:.0f}%p" for k, d in top)
|
||||
lines.append("")
|
||||
lines.append(f"요인: {detail}")
|
||||
break
|
||||
|
||||
lines.append("")
|
||||
lines.append(f"[자세히 보기] ({LOTTO_URL}/agent)")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_signal_digest(digest: Dict[str, Any]) -> str:
|
||||
"""일일 요약 메시지. 발화 0건이면 빈 문자열 (발송 skip 신호)."""
|
||||
fired = int(digest.get("fired", 0))
|
||||
if fired == 0:
|
||||
return ""
|
||||
|
||||
signals_list = digest.get("signals", [])
|
||||
evaluated = digest.get("evaluated", 0)
|
||||
|
||||
lines = [
|
||||
"📊 로또 일일 요약 (지난 24h)",
|
||||
"",
|
||||
f"평가 {evaluated}회 / 발화 {fired}회",
|
||||
]
|
||||
for s in signals_list:
|
||||
label = _METRIC_LABEL.get(s["metric"], s["metric"])
|
||||
z = s.get("z_score")
|
||||
when = (s.get("triggered_at") or "")[11:16] # HH:MM
|
||||
z_text = f"z={z:.1f}" if z is not None else "z=-"
|
||||
lines.append(f"• {label:14s} {s['fire_level']:6s} {z_text} ({when})")
|
||||
|
||||
weights_trend = digest.get("weights_trend") or {}
|
||||
if weights_trend:
|
||||
lines += ["", "전략 가중치 추세 (최근 8회 baseline):"]
|
||||
for strategy, delta in sorted(weights_trend.items(), key=lambda kv: -abs(kv[1])):
|
||||
arrow = "↑" if delta > 0.01 else ("↓" if delta < -0.01 else "→")
|
||||
lines.append(f" {strategy:12s} {arrow} {delta*100:+.0f}%")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def send_urgent_signal(event: Dict[str, Any]) -> None:
|
||||
text = _format_urgent_signal(event)
|
||||
try:
|
||||
await send_raw(text)
|
||||
except Exception as e:
|
||||
logger.warning(f"[telegram_lotto] urgent signal send failed: {e}")
|
||||
|
||||
|
||||
async def send_signal_summary(digest: Dict[str, Any]) -> None:
|
||||
text = _format_signal_digest(digest)
|
||||
if not text:
|
||||
return # 발화 0건이면 발송 skip
|
||||
try:
|
||||
await send_raw(text)
|
||||
except Exception as e:
|
||||
logger.warning(f"[telegram_lotto] digest send failed: {e}")
|
||||
|
||||
|
||||
# ---------- Weight Evolver 주간 리포트 ----------
|
||||
|
||||
_DAY_NAMES = ["월", "화", "수", "목", "금", "토"]
|
||||
_METRIC_NAMES = ["freq", "finger", "gap", "cooccur", "divers"]
|
||||
_REASON_LABEL = {
|
||||
"winner_4plus": "4개 이상 일치 → base 교체",
|
||||
"ema_blend": "3개 일치 → EMA blend (0.3)",
|
||||
"unchanged": "유효 성과 없음 → base 유지",
|
||||
"cold_start": "초기 균등 적용",
|
||||
}
|
||||
|
||||
|
||||
def _format_evolution_report(eval_result: Dict[str, Any], current_base: List[float]) -> str:
|
||||
"""주간 weight evolution 텔레그램 메시지. ok=False 또는 winner 없으면 빈 문자열."""
|
||||
if not eval_result or "winner" not in eval_result:
|
||||
return ""
|
||||
|
||||
draw_no = eval_result.get("draw_no", "?")
|
||||
winner = eval_result["winner"]
|
||||
new_base = eval_result.get("new_base") or [0.0] * 5
|
||||
reason = eval_result.get("update_reason", "")
|
||||
dow = winner.get("day_of_week", 0)
|
||||
day_name = _DAY_NAMES[dow] if 0 <= dow < len(_DAY_NAMES) else "?"
|
||||
|
||||
lines = [
|
||||
f"🧬 로또 학습 주간 리포트 ({draw_no}회차)",
|
||||
"",
|
||||
f"이번주 시도: 6일 × {winner.get('n_picks', 5)}세트",
|
||||
"",
|
||||
f"🏆 Winner: {day_name}요일",
|
||||
f" W = [" + ", ".join(
|
||||
f"{name} {w:.2f}" for name, w in zip(_METRIC_NAMES, winner["weight"])
|
||||
) + "]",
|
||||
f" 최고 적중: {winner.get('max_correct', 0)}개 일치 (max={winner.get('max_correct', 0)})",
|
||||
f" 평균 점수: {winner.get('avg_score', 0):.2f}",
|
||||
"",
|
||||
f"📊 다음주 base 변경 ({reason}):",
|
||||
]
|
||||
# 우선순위: eval_result.previous_base > current_base (eval 직후 stale) > 균등 fallback
|
||||
base_now = eval_result.get("previous_base") or current_base or [0.2] * 5
|
||||
for i, (cur, new) in enumerate(zip(base_now, new_base)):
|
||||
diff = new - cur
|
||||
if abs(diff) < 0.005:
|
||||
marker = "="
|
||||
elif diff > 0:
|
||||
marker = "+" if diff < 0.05 else "++"
|
||||
else:
|
||||
marker = "-" if diff > -0.05 else "--"
|
||||
lines.append(f" {_METRIC_NAMES[i]:8s} {cur:.2f} → {new:.2f} ({marker})")
|
||||
lines.append("")
|
||||
lines.append(f" → {_REASON_LABEL.get(reason, reason)}")
|
||||
lines.append("")
|
||||
lines.append(f"[웹에서 차트 보기] ({LOTTO_URL}/evolver)")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def send_evolution_report(eval_result: Dict[str, Any], current_base: List[float]) -> None:
|
||||
text = _format_evolution_report(eval_result, current_base)
|
||||
if not text:
|
||||
return
|
||||
try:
|
||||
await send_raw(text)
|
||||
except Exception as e:
|
||||
logger.warning(f"[telegram_lotto] evolution report send failed: {e}")
|
||||
|
||||
|
||||
# ---------- 일요 회고 브리핑 ----------
|
||||
|
||||
def format_sunday_review(payload: Dict[str, Any]) -> str:
|
||||
"""일요 회고 브리핑 텍스트 (HTML parse_mode)."""
|
||||
wa = payload.get("winner_analysis") or {}
|
||||
draw_no = payload.get("draw_no") or "?"
|
||||
pct = wa.get("percentile")
|
||||
pct_txt = f"{pct*100:.0f}%" if pct is not None else "—"
|
||||
lines = [f"🔍 <b>로또 #{draw_no} 일요 회고</b>", ""]
|
||||
if wa:
|
||||
lines.append(f"이번 당첨조합 분석치: <b>{wa.get('score_total',0):.2f}</b> "
|
||||
f"(무작위 분포 상위 {pct_txt})")
|
||||
lines.append(f" 빈도 {wa.get('score_frequency',0):.2f} · 지문 {wa.get('score_fingerprint',0):.2f} "
|
||||
f"· 갭 {wa.get('score_gap',0):.2f} · 공동출현 {wa.get('score_cooccur',0):.2f} "
|
||||
f"· 다양성 {wa.get('score_diversity',0):.2f}")
|
||||
lines.append("")
|
||||
if payload.get("forward"):
|
||||
lines.append("📊 <b>이번 회차 가상구매 성적</b>")
|
||||
for f in payload.get("forward", []):
|
||||
p = f.get("prizes") or {}
|
||||
name = {"engine_w": f"엔진({f.get('label','')})", "random_null": "무작위", "coverage": "커버리지"}.get(
|
||||
f.get("strategy", ""), f.get("strategy", "?"))
|
||||
lines.append(f" {name}: 최고 {f.get('best_match','?')}일치 / "
|
||||
f"4등 {p.get('4th', 0)} · 5등 {p.get('5th', 0)}")
|
||||
else:
|
||||
lines.append("📊 <b>이번 회차 가상구매 성적</b>: 데이터 없음 (아직 집계 전)")
|
||||
lines.append("")
|
||||
lines.append("ℹ️ 무작위 대비 우위가 통계적으로 의미있을 때만 가중치가 진화합니다.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def send_sunday_review(payload: Dict[str, Any]) -> None:
|
||||
text = format_sunday_review(payload)
|
||||
try:
|
||||
await send_raw(text)
|
||||
except Exception as e:
|
||||
logger.warning(f"[telegram_lotto] sunday review send failed: {e}")
|
||||
|
||||
42
agent-office/app/notifiers/telegram_stock.py
Normal file
42
agent-office/app/notifiers/telegram_stock.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""보유종목 인텔리전스 텔레그램 포매터 (advisory)."""
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from ..telegram.messaging import send_raw
|
||||
|
||||
logger = logging.getLogger("agent-office")
|
||||
|
||||
_ACTION_KR = {"add": "🟢 추가매수", "hold": "⚪ 보유", "trim": "🟡 축소", "sell": "🔴 매도"}
|
||||
_SEV = {"high": "🔴", "med": "🟠", "low": "🟡"}
|
||||
|
||||
|
||||
def format_holdings_brief(payload: Dict[str, Any]) -> str:
|
||||
date = payload.get("date") or "?"
|
||||
lines = [f"📊 <b>보유종목 인텔리전스</b> ({date})", ""]
|
||||
ph = payload.get("portfolio_health") or {}
|
||||
if ph:
|
||||
lines.append(f"포트 손익 {ph.get('total_pnl_rate',0):+.1f}% · "
|
||||
f"종목 {ph.get('positions',0)} · 최대비중 {ph.get('max_weight',0)*100:.0f}% · "
|
||||
f"현금 {ph.get('cash_ratio',0)*100:.0f}%")
|
||||
lines.append("")
|
||||
for h in payload.get("holdings", []):
|
||||
act = _ACTION_KR.get(h.get("action"), h.get("action", "?"))
|
||||
pnl = h.get("pnl_rate")
|
||||
pnl_txt = f"{pnl:+.1f}%" if pnl is not None else "—"
|
||||
line = f"{act} <b>{h.get('name') or h.get('ticker')}</b> ({pnl_txt})"
|
||||
if h.get("reasons"):
|
||||
line += f" — {h['reasons']}"
|
||||
lines.append(line)
|
||||
for iss in (h.get("issues") or [])[:3]:
|
||||
lines.append(f" {_SEV.get(iss.get('severity'),'•')} {iss.get('summary','')}")
|
||||
lines.append("")
|
||||
lines.append("ℹ️ 투자 판단 보조용 제안입니다(자동매매 아님).")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def send_holdings_brief(payload: Dict[str, Any]) -> None:
|
||||
text = format_holdings_brief(payload)
|
||||
try:
|
||||
await send_raw(text)
|
||||
except Exception as e:
|
||||
logger.warning(f"[telegram_stock] holdings brief send failed: {e}")
|
||||
@@ -1,14 +1,12 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
|
||||
from .agents import AGENT_REGISTRY
|
||||
from .db import delete_old_logs
|
||||
|
||||
scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
|
||||
|
||||
async def _check_idle_breaks():
|
||||
for agent in AGENT_REGISTRY.values():
|
||||
await agent.check_idle_break()
|
||||
|
||||
async def _run_stock_schedule():
|
||||
agent = AGENT_REGISTRY.get("stock")
|
||||
if agent:
|
||||
@@ -24,6 +22,16 @@ async def _run_stock_ai_news():
|
||||
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:
|
||||
@@ -40,6 +48,41 @@ async def _run_lotto_schedule():
|
||||
if agent:
|
||||
await agent.on_schedule()
|
||||
|
||||
async def _run_lotto_light_check():
|
||||
agent = AGENT_REGISTRY.get("lotto")
|
||||
if agent:
|
||||
await agent.run_signal_check(source="light")
|
||||
|
||||
async def _run_lotto_sim_check():
|
||||
agent = AGENT_REGISTRY.get("lotto")
|
||||
if agent:
|
||||
await agent.run_signal_check(source="sim")
|
||||
|
||||
async def _run_lotto_deep_check():
|
||||
agent = AGENT_REGISTRY.get("lotto")
|
||||
if agent:
|
||||
await agent.run_signal_check(source="deep")
|
||||
|
||||
async def _run_lotto_daily_digest():
|
||||
agent = AGENT_REGISTRY.get("lotto")
|
||||
if agent:
|
||||
await agent.run_daily_digest()
|
||||
|
||||
async def _run_lotto_weekly_evolution_report():
|
||||
agent = AGENT_REGISTRY.get("lotto")
|
||||
if agent:
|
||||
await agent.run_weekly_evolution_report()
|
||||
|
||||
async def _run_lotto_sync_evolver_activity():
|
||||
agent = AGENT_REGISTRY.get("lotto")
|
||||
if agent:
|
||||
await agent.sync_evolver_activity()
|
||||
|
||||
async def _run_lotto_sunday_review():
|
||||
agent = AGENT_REGISTRY.get("lotto")
|
||||
if agent:
|
||||
await agent.run_sunday_review()
|
||||
|
||||
async def _run_youtube_research():
|
||||
agent = AGENT_REGISTRY.get("youtube")
|
||||
if agent:
|
||||
@@ -55,6 +98,11 @@ async def _poll_pipelines():
|
||||
if agent:
|
||||
await agent.poll_state_changes()
|
||||
|
||||
def _cleanup_old_logs():
|
||||
n = delete_old_logs(days=90)
|
||||
if n:
|
||||
logging.getLogger(__name__).info("delete_old_logs: %d rows removed", n)
|
||||
|
||||
def init_scheduler():
|
||||
scheduler.add_job(_run_stock_schedule, "cron", hour=7, minute=30, id="stock_news")
|
||||
scheduler.add_job(
|
||||
@@ -73,11 +121,26 @@ def init_scheduler():
|
||||
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")
|
||||
scheduler.add_job(_run_insta_trends_collect, "cron", hour=9, minute=0, id="insta_trends_collect")
|
||||
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=9, minute=0, id="lotto_curate")
|
||||
scheduler.add_job(_run_youtube_research, "cron", hour=9, minute=0, id="youtube_research")
|
||||
# 외부 트렌드 수집은 장 마감 후 16:40 — 9시 주식 활발 시간대 NAS 자원 회피.
|
||||
# screener(16:30)와 10분 스태거: Celeron 2C/2.0GHz 동시 실행 시 CPU 폭주 방지 (CHECK_POINT FU-A)
|
||||
scheduler.add_job(_run_insta_trends_collect, "cron", hour=16, minute=40, id="insta_trends_collect")
|
||||
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=9, minute=5, id="lotto_curate")
|
||||
scheduler.add_job(_run_lotto_light_check, "cron", hour=9, minute=15, id="lotto_light_check")
|
||||
scheduler.add_job(_run_lotto_sim_check, "cron", minute=15, hour="0,4,8,12,16,20", id="lotto_sim_check")
|
||||
scheduler.add_job(_run_lotto_deep_check, "cron", day_of_week="sun,wed", hour=21, minute=15, id="lotto_deep_check")
|
||||
scheduler.add_job(_run_lotto_daily_digest, "cron", hour=9, minute=25, id="lotto_digest")
|
||||
scheduler.add_job(_run_lotto_weekly_evolution_report, "cron", day_of_week="sat", hour=22, minute=15, id="lotto_evolution_weekly")
|
||||
scheduler.add_job(_run_lotto_sunday_review, "cron", day_of_week="sun", hour=9, minute=0, id="lotto_sunday_review")
|
||||
scheduler.add_job(
|
||||
_run_lotto_sync_evolver_activity,
|
||||
"cron", hour=9, minute=30,
|
||||
id="lotto_evolver_activity_sync",
|
||||
)
|
||||
scheduler.add_job(_run_youtube_research, "cron", hour=9, minute=10, id="youtube_research")
|
||||
scheduler.add_job(_send_youtube_weekly_report, "cron", day_of_week="mon", hour=8, minute=0, id="youtube_weekly_report")
|
||||
scheduler.add_job(_check_idle_breaks, "interval", seconds=60, id="idle_check")
|
||||
scheduler.add_job(_poll_pipelines, "interval", seconds=30, id="pipeline_poll")
|
||||
scheduler.add_job(_cleanup_old_logs, "cron", hour=3, minute=0, id="cleanup_old_logs", replace_existing=True)
|
||||
scheduler.start()
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import httpx
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .config import STOCK_URL, MUSIC_LAB_URL, INSTA_LAB_URL, REALESTATE_LAB_URL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_client = httpx.AsyncClient(timeout=30.0)
|
||||
|
||||
async def fetch_stock_news(limit: int = 10, category: str = None) -> List[Dict[str, Any]]:
|
||||
@@ -85,6 +88,29 @@ async def scrape_stock_news() -> Dict[str, Any]:
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def stock_holdings_run() -> Dict[str, Any]:
|
||||
"""보유종목 시그널 계산 트리거 (EOD, use_llm=True).
|
||||
|
||||
stock BackgroundTask 등록 후 즉시 {ok, queued} 반환.
|
||||
실제 계산은 stock 컨테이너 백그라운드에서 진행 — 여유있게 120s.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
resp = await client.post(
|
||||
f"{STOCK_URL}/api/stock/holdings/intel/run",
|
||||
params={"use_llm": True},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def stock_holdings_brief() -> Dict[str, Any]:
|
||||
"""보유종목 최신 브리핑 payload 조회 (GET, 모듈 레벨 _client 사용)."""
|
||||
resp = await _client.get(f"{STOCK_URL}/api/stock/holdings/intel")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def generate_music(payload: dict) -> Dict[str, Any]:
|
||||
resp = await _client.post(f"{MUSIC_LAB_URL}/api/music/generate", json=payload)
|
||||
resp.raise_for_status()
|
||||
@@ -338,3 +364,102 @@ async def lookup_pipeline_by_msg(msg_id: int) -> Optional[dict]:
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
return None
|
||||
|
||||
|
||||
async def lotto_best() -> List[Dict[str, Any]]:
|
||||
"""GET /api/lotto/best — best_picks 20개 (numbers + scores 5종)."""
|
||||
from .config import LOTTO_BACKEND_URL
|
||||
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/best")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
items = data.get("items") if isinstance(data, dict) else data
|
||||
return items or []
|
||||
|
||||
|
||||
async def lotto_strategy_weights() -> Dict[str, float]:
|
||||
"""GET /api/lotto/strategy/weights — 전략별 가중치 dict."""
|
||||
from .config import LOTTO_BACKEND_URL
|
||||
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/strategy/weights")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
weights = data.get("weights") if isinstance(data, dict) else data
|
||||
if isinstance(weights, list):
|
||||
return {item["strategy"]: float(item["weight"]) for item in weights}
|
||||
return {k: float(v) for k, v in (weights or {}).items()}
|
||||
|
||||
|
||||
async def lotto_latest_draw() -> Optional[int]:
|
||||
"""GET /api/lotto/latest — 최신 회차 번호만 반환."""
|
||||
from .config import LOTTO_BACKEND_URL
|
||||
try:
|
||||
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/latest")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
# /api/lotto/latest 응답 키: {"drawNo": N, ...}
|
||||
# 하위 호환을 위해 drawNo, draw_no, drwNo, draw 순서로 시도
|
||||
for key in ("drawNo", "draw_no", "drwNo", "draw"):
|
||||
if isinstance(data, dict) and data.get(key):
|
||||
return int(data[key])
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def lotto_evolver_status() -> Dict[str, Any]:
|
||||
"""GET /api/lotto/evolver/status — 이번주 trials + 다음주 base 정보."""
|
||||
from .config import LOTTO_BACKEND_URL
|
||||
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/evolver/status")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def lotto_evolver_evaluate() -> Dict[str, Any]:
|
||||
"""POST /api/lotto/evolver/evaluate-now — 회고 트리거 (텔레그램 리포트용)."""
|
||||
from .config import LOTTO_BACKEND_URL
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
resp = await client.post(f"{LOTTO_BACKEND_URL}/api/lotto/evolver/evaluate-now")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def lotto_backtest_review(draw_no: int) -> Dict[str, Any]:
|
||||
from .config import LOTTO_BACKEND_URL
|
||||
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/backtest/review/{draw_no}")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
|
||||
from .config import AGENT_CONTAINER_MAP
|
||||
|
||||
|
||||
async def fetch_service_logs(
|
||||
agent_id: str,
|
||||
since: Optional[str] = None,
|
||||
limit: int = 200,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""해당 에이전트가 가리키는 컨테이너의 /logs/recent 를 호출해서
|
||||
path_prefix 정규식으로 필터한 결과를 반환.
|
||||
|
||||
네트워크 실패 시 빈 리스트를 반환하고 warning 만 남김 (LogTab 이 죽지 않게).
|
||||
"""
|
||||
mapping = AGENT_CONTAINER_MAP.get(agent_id)
|
||||
if not mapping:
|
||||
return []
|
||||
host, port, path_re = mapping
|
||||
url = f"http://{host}:{port}/logs/recent"
|
||||
params: Dict[str, Any] = {"limit": limit}
|
||||
if since:
|
||||
params["since"] = since
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
resp = await client.get(url, params=params)
|
||||
data = resp.json().get("logs", [])
|
||||
except Exception as e:
|
||||
logger.warning("fetch_service_logs(%s) 실패: %s", agent_id, e)
|
||||
return []
|
||||
return [
|
||||
x for x in data
|
||||
if x.get("source") == "log"
|
||||
or path_re.match(x.get("path", "") or "")
|
||||
]
|
||||
|
||||
@@ -93,6 +93,41 @@ def test_telegram_state():
|
||||
print(" [PASS] test_telegram_state")
|
||||
|
||||
|
||||
def test_get_logs_excludes_state_messages():
|
||||
init_db()
|
||||
add_log("stock", "State: idle -> working (큐레이션 시작)")
|
||||
add_log("stock", "뉴스 12건 스크랩 완료")
|
||||
add_log("stock", "State: working -> idle ()")
|
||||
|
||||
logs = get_logs("stock", limit=10)
|
||||
messages = [x["message"] for x in logs]
|
||||
assert "뉴스 12건 스크랩 완료" in messages
|
||||
assert not any(m.startswith("State: ") for m in messages)
|
||||
|
||||
|
||||
def test_delete_old_logs_removes_beyond_retention():
|
||||
import datetime as _dt
|
||||
from app.db import delete_old_logs, _conn
|
||||
|
||||
init_db()
|
||||
add_log("stock", "오래된 로그")
|
||||
# 강제로 200일 전으로 옮김
|
||||
cutoff = (_dt.datetime.utcnow() - _dt.timedelta(days=200)).isoformat()
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE agent_logs SET created_at = ? WHERE message = '오래된 로그'",
|
||||
(cutoff,),
|
||||
)
|
||||
|
||||
add_log("stock", "최근 로그")
|
||||
deleted = delete_old_logs(days=90)
|
||||
assert deleted >= 1
|
||||
|
||||
msgs = [x["message"] for x in get_logs("stock", limit=20)]
|
||||
assert "최근 로그" in msgs
|
||||
assert "오래된 로그" not in msgs
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_init_and_seed()
|
||||
test_agent_config_update()
|
||||
|
||||
@@ -4,5 +4,6 @@ apscheduler==3.10.4
|
||||
websockets>=12.0
|
||||
httpx>=0.27
|
||||
respx>=0.21
|
||||
pytest-asyncio>=0.23
|
||||
google-api-python-client>=2.100.0
|
||||
pytrends>=4.9.2
|
||||
|
||||
81
agent-office/scripts/migrate_tarot_to_lab.py
Normal file
81
agent-office/scripts/migrate_tarot_to_lab.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""1회성 마이그레이션 — agent_office.db.tarot_readings → tarot.db.tarot_readings.
|
||||
|
||||
멱등성: 이미 존재하는 id는 SKIP.
|
||||
|
||||
실행:
|
||||
docker exec agent-office python /app/scripts/migrate_tarot_to_lab.py
|
||||
|
||||
또는 호스트에서 직접:
|
||||
AGENT_OFFICE_DB=/path/to/agent_office.db TAROT_DB=/path/to/tarot.db \\
|
||||
python scripts/migrate_tarot_to_lab.py
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
|
||||
SRC = os.getenv("AGENT_OFFICE_DB", "/app/data/agent_office.db")
|
||||
DST = os.getenv("TAROT_DB", "/app/data/tarot.db")
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS tarot_readings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
spread_type TEXT NOT NULL,
|
||||
category TEXT,
|
||||
question TEXT,
|
||||
cards TEXT NOT NULL,
|
||||
interpretation_json TEXT,
|
||||
summary TEXT,
|
||||
model TEXT,
|
||||
tokens_in INTEGER,
|
||||
tokens_out INTEGER,
|
||||
cost_usd REAL,
|
||||
confidence TEXT,
|
||||
favorite INTEGER NOT NULL DEFAULT 0,
|
||||
note TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def migrate() -> int:
|
||||
"""이관된 row 수 반환."""
|
||||
src = sqlite3.connect(SRC)
|
||||
src.row_factory = sqlite3.Row
|
||||
dst = sqlite3.connect(DST)
|
||||
dst.execute("PRAGMA journal_mode=WAL")
|
||||
dst.executescript(SCHEMA)
|
||||
|
||||
rows = src.execute("SELECT * FROM tarot_readings").fetchall()
|
||||
if not rows:
|
||||
src.close(); dst.close()
|
||||
return 0
|
||||
|
||||
all_cols = list(rows[0].keys())
|
||||
|
||||
moved = 0
|
||||
for r in rows:
|
||||
exists = dst.execute("SELECT 1 FROM tarot_readings WHERE id=?", (r["id"],)).fetchone()
|
||||
if exists:
|
||||
continue
|
||||
# NULL 값은 INSERT에서 제외 → 목적지 스키마의 DEFAULT가 적용되도록 함
|
||||
# (예: created_at이 NULL이면 strftime() 기본값 사용)
|
||||
cols = [c for c in all_cols if r[c] is not None]
|
||||
placeholders = ",".join("?" * len(cols))
|
||||
cols_str = ",".join(cols)
|
||||
dst.execute(
|
||||
f"INSERT INTO tarot_readings ({cols_str}) VALUES ({placeholders})",
|
||||
tuple(r[c] for c in cols),
|
||||
)
|
||||
moved += 1
|
||||
dst.commit()
|
||||
src.close(); dst.close()
|
||||
return moved
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
moved = migrate()
|
||||
total = sqlite3.connect(SRC).execute("SELECT COUNT(*) FROM tarot_readings").fetchone()[0]
|
||||
print(f"migrated {moved} / {total} rows from {SRC} to {DST}")
|
||||
sys.exit(0)
|
||||
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
|
||||
55
agent-office/tests/test_insta_keyword_filter.py
Normal file
55
agent-office/tests/test_insta_keyword_filter.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
_fd, _TMP = tempfile.mkstemp(suffix=".db")
|
||||
os.close(_fd)
|
||||
os.unlink(_TMP)
|
||||
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app.agents.insta import _dedup_and_filter_keywords, KEYWORD_MIN_SCORE
|
||||
|
||||
|
||||
def test_filters_below_threshold():
|
||||
"""score < 임계값(0.7) 키워드는 제외."""
|
||||
kws = [
|
||||
{"id": 1, "keyword": "금리인하", "category": "경제", "score": 0.9},
|
||||
{"id": 2, "keyword": "환율", "category": "경제", "score": 0.6}, # 컷
|
||||
{"id": 3, "keyword": "반도체", "category": "경제", "score": 0.71},
|
||||
]
|
||||
out = _dedup_and_filter_keywords(kws, min_score=0.7)
|
||||
kept = {k["keyword"] for k in out}
|
||||
assert kept == {"금리인하", "반도체"}
|
||||
|
||||
|
||||
def test_dedup_keeps_highest_score():
|
||||
"""동일 keyword 중복 시 최고 score 1개만 유지."""
|
||||
kws = [
|
||||
{"id": 1, "keyword": "AI", "category": "경제", "score": 0.75},
|
||||
{"id": 2, "keyword": "AI", "category": "기술", "score": 0.92}, # 같은 키워드, 더 높음
|
||||
]
|
||||
out = _dedup_and_filter_keywords(kws, min_score=0.7)
|
||||
assert len(out) == 1
|
||||
assert out[0]["id"] == 2
|
||||
assert out[0]["score"] == 0.92
|
||||
|
||||
|
||||
def test_sorted_by_score_desc():
|
||||
kws = [
|
||||
{"id": 1, "keyword": "a", "category": "c", "score": 0.72},
|
||||
{"id": 2, "keyword": "b", "category": "c", "score": 0.95},
|
||||
{"id": 3, "keyword": "c", "category": "c", "score": 0.80},
|
||||
]
|
||||
out = _dedup_and_filter_keywords(kws, min_score=0.7)
|
||||
assert [k["keyword"] for k in out] == ["b", "c", "a"]
|
||||
|
||||
|
||||
def test_empty_when_all_below_threshold():
|
||||
kws = [{"id": 1, "keyword": "x", "category": "c", "score": 0.4}]
|
||||
assert _dedup_and_filter_keywords(kws, min_score=0.7) == []
|
||||
|
||||
|
||||
def test_default_threshold_is_0_7():
|
||||
assert KEYWORD_MIN_SCORE == 0.7
|
||||
47
agent-office/tests/test_log_merge.py
Normal file
47
agent-office/tests/test_log_merge.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import pytest
|
||||
import respx
|
||||
import httpx
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.db import add_log, _conn
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_logs():
|
||||
with _conn() as conn:
|
||||
conn.execute("DELETE FROM agent_logs WHERE agent_id = 'lotto'")
|
||||
yield
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_agent_logs_endpoint_merges_db_and_service_logs():
|
||||
add_log("lotto", "큐레이션 완료: #1234 conf=0.78")
|
||||
respx.get("http://lotto:8000/logs/recent").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"logs": [
|
||||
{"ts": "2026-05-28T10:00:00Z", "source": "access",
|
||||
"method": "GET", "path": "/api/lotto/latest",
|
||||
"status": 200, "ms": 8,
|
||||
"message": "GET /api/lotto/latest → 200 (8ms)"},
|
||||
{"ts": "2026-05-28T10:00:02Z", "source": "log",
|
||||
"logger": "lotto", "level": "info",
|
||||
"message": "성과 통계 캐시 갱신"},
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/agent-office/agents/lotto/logs?limit=20")
|
||||
assert resp.status_code == 200
|
||||
logs = resp.json()["logs"]
|
||||
|
||||
sources = {x["source"] for x in logs}
|
||||
assert "agent" in sources
|
||||
assert "access" in sources
|
||||
assert "log" in sources
|
||||
|
||||
messages = [x["message"] for x in logs]
|
||||
assert any("큐레이션 완료" in m for m in messages)
|
||||
assert any("성과 통계 캐시 갱신" in m for m in messages)
|
||||
assert any("/api/lotto/latest" in m for m in messages)
|
||||
87
agent-office/tests/test_lotto_evolution_format.py
Normal file
87
agent-office/tests/test_lotto_evolution_format.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from app.notifiers.telegram_lotto import _format_evolution_report
|
||||
|
||||
|
||||
def test_evolution_report_winner_4plus():
|
||||
eval_result = {
|
||||
"ok": True,
|
||||
"draw_no": 1225,
|
||||
"week_start": "2026-05-18",
|
||||
"winner": {
|
||||
"day_of_week": 3,
|
||||
"weight": [0.18, 0.32, 0.20, 0.22, 0.08],
|
||||
"avg_score": 0.42,
|
||||
"max_correct": 4,
|
||||
"n_picks": 5,
|
||||
},
|
||||
"new_base": [0.18, 0.32, 0.20, 0.22, 0.08],
|
||||
"previous_base": [0.20, 0.20, 0.20, 0.20, 0.20],
|
||||
"update_reason": "winner_4plus",
|
||||
"per_day": [
|
||||
{"day_of_week": 0, "avg_score": 0.20, "max_correct": 2},
|
||||
{"day_of_week": 3, "avg_score": 0.42, "max_correct": 4},
|
||||
],
|
||||
}
|
||||
current_base = [0.20, 0.20, 0.20, 0.20, 0.20]
|
||||
text = _format_evolution_report(eval_result, current_base)
|
||||
assert "🧬" in text
|
||||
assert "1225" in text
|
||||
assert "목요일" in text or "Winner" in text
|
||||
assert "4개 일치" in text or "max=4" in text
|
||||
assert "winner_4plus" in text
|
||||
|
||||
|
||||
def test_evolution_report_unchanged():
|
||||
eval_result = {
|
||||
"ok": True,
|
||||
"draw_no": 1226,
|
||||
"week_start": "2026-05-25",
|
||||
"winner": {
|
||||
"day_of_week": 1,
|
||||
"weight": [0.21, 0.19, 0.20, 0.20, 0.20],
|
||||
"avg_score": 0.10,
|
||||
"max_correct": 2,
|
||||
"n_picks": 5,
|
||||
},
|
||||
"new_base": [0.20, 0.20, 0.20, 0.20, 0.20],
|
||||
"update_reason": "unchanged",
|
||||
"per_day": [],
|
||||
}
|
||||
current_base = [0.20, 0.20, 0.20, 0.20, 0.20]
|
||||
text = _format_evolution_report(eval_result, current_base)
|
||||
assert "unchanged" in text or "유지" in text
|
||||
assert "2개 일치" in text or "max=2" in text
|
||||
|
||||
|
||||
def test_evolution_report_empty_returns_empty():
|
||||
"""evaluate가 ok=False면 빈 문자열 (발송 skip)."""
|
||||
text = _format_evolution_report({"ok": False, "reason": "no_trials"}, [0.2]*5)
|
||||
assert text == ""
|
||||
|
||||
|
||||
def test_evolution_report_uses_previous_base_for_diff():
|
||||
"""previous_base와 new_base 차이가 메시지 diff에 정확히 반영됨."""
|
||||
eval_result = {
|
||||
"ok": True,
|
||||
"draw_no": 1227,
|
||||
"winner": {
|
||||
"day_of_week": 0,
|
||||
"weight": [0.30, 0.20, 0.20, 0.20, 0.10],
|
||||
"avg_score": 0.50,
|
||||
"max_correct": 4,
|
||||
"n_picks": 5,
|
||||
},
|
||||
"new_base": [0.30, 0.20, 0.20, 0.20, 0.10],
|
||||
"previous_base": [0.20, 0.20, 0.20, 0.20, 0.20],
|
||||
"update_reason": "winner_4plus",
|
||||
}
|
||||
# current_base는 stale (post-update 값) — previous_base가 우선 적용되어야 함
|
||||
text = _format_evolution_report(eval_result, [0.30, 0.20, 0.20, 0.20, 0.10])
|
||||
# freq: 0.20 → 0.30 (+0.10 = "++")
|
||||
# divers: 0.20 → 0.10 (-0.10 = "--")
|
||||
assert "0.20 → 0.30" in text # freq 증가
|
||||
assert "0.20 → 0.10" in text # divers 감소
|
||||
assert "(++)" in text or "(+)" in text # freq marker
|
||||
assert "(--)" in text or "(-)" in text # divers marker
|
||||
116
agent-office/tests/test_lotto_signal_runner.py
Normal file
116
agent-office/tests/test_lotto_signal_runner.py
Normal file
@@ -0,0 +1,116 @@
|
||||
import gc
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
_fd, _TMP = tempfile.mkstemp(suffix=".db")
|
||||
os.close(_fd)
|
||||
os.unlink(_TMP)
|
||||
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import pytest
|
||||
|
||||
from app.curator import signal_runner
|
||||
from app import db
|
||||
|
||||
db.DB_PATH = _TMP # patch frozen module-level DB_PATH (import order safety)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db():
|
||||
gc.collect()
|
||||
if os.path.exists(_TMP):
|
||||
os.remove(_TMP)
|
||||
db.init_db()
|
||||
yield
|
||||
gc.collect()
|
||||
if os.path.exists(_TMP):
|
||||
try:
|
||||
os.remove(_TMP)
|
||||
except PermissionError:
|
||||
pass # Windows: WAL-mode file locked; DB is ephemeral anyway
|
||||
|
||||
|
||||
def test_evaluate_and_persist_cold_start():
|
||||
"""첫 호출은 warmup으로 기록되고 baseline에 값이 들어간다."""
|
||||
result = signal_runner.evaluate_metric_and_persist(
|
||||
source="light",
|
||||
metric="sim_signal",
|
||||
value=1.5,
|
||||
draw_no=None,
|
||||
z_normal=1.5,
|
||||
z_urgent=2.5,
|
||||
push_to_window=True,
|
||||
)
|
||||
assert result["fire_level"] == "warmup"
|
||||
assert result["z_score"] is None
|
||||
|
||||
bl = db.get_baseline("sim_signal")
|
||||
assert bl is not None
|
||||
assert bl["window_values"] == [1.5]
|
||||
|
||||
|
||||
def test_evaluate_after_window_filled_normal_fire():
|
||||
"""8회 push 후 정상 운영, 평균 대비 z≥1.5면 normal."""
|
||||
for v in [1.0, 1.1, 0.9, 1.0, 1.0, 1.1, 0.9, 1.0]:
|
||||
signal_runner.evaluate_metric_and_persist(
|
||||
source="sim",
|
||||
metric="sim_signal",
|
||||
value=v,
|
||||
draw_no=None,
|
||||
z_normal=1.5,
|
||||
z_urgent=2.5,
|
||||
push_to_window=True,
|
||||
)
|
||||
|
||||
result = signal_runner.evaluate_metric_and_persist(
|
||||
source="sim",
|
||||
metric="sim_signal",
|
||||
value=1.12,
|
||||
draw_no=None,
|
||||
z_normal=1.5,
|
||||
z_urgent=2.5,
|
||||
push_to_window=True,
|
||||
)
|
||||
assert result["fire_level"] in ("normal", "urgent")
|
||||
assert result["z_score"] is not None and result["z_score"] >= 1.5
|
||||
|
||||
|
||||
def test_evaluate_drift_skips_same_draw_push():
|
||||
"""drift는 회차 단위. 같은 회차에서 두 번 호출하면 두 번째는 window push X."""
|
||||
signal_runner.evaluate_metric_and_persist(
|
||||
source="sim", metric="drift", value=0.05, draw_no=1100,
|
||||
z_normal=1.5, z_urgent=2.5, push_to_window=True,
|
||||
)
|
||||
bl_before = db.get_baseline("drift")
|
||||
assert bl_before["window_values"] == [0.05]
|
||||
assert bl_before["last_pushed_draw_no"] == 1100
|
||||
|
||||
signal_runner.evaluate_metric_and_persist(
|
||||
source="sim", metric="drift", value=0.08, draw_no=1100,
|
||||
z_normal=1.5, z_urgent=2.5, push_to_window=True,
|
||||
)
|
||||
bl_after = db.get_baseline("drift")
|
||||
assert bl_after["window_values"] == [0.05]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_signal_check_aggregates_three_metrics(monkeypatch):
|
||||
"""run_signal_check이 3종 메트릭 모두 평가하고 overall fire를 반환."""
|
||||
async def fake_lotto_best():
|
||||
return [{"numbers": [1,2,3,4,5,6], "scores": [10,10,10,10,10]}] * 20
|
||||
|
||||
async def fake_lotto_strategy_weights():
|
||||
return {"gap_focus": 0.4, "hot_focus": 0.3, "pair_bias": 0.3}
|
||||
|
||||
monkeypatch.setattr(signal_runner, "_fetch_best_picks", fake_lotto_best)
|
||||
monkeypatch.setattr(signal_runner, "_fetch_strategy_weights", fake_lotto_strategy_weights)
|
||||
|
||||
out = await signal_runner.run_signal_check(source="light", curate_result=None, current_draw_no=1101)
|
||||
assert "overall_fire" in out
|
||||
assert "results" in out
|
||||
assert any(r["metric"] == "sim_signal" for r in out["results"])
|
||||
# light_check는 confidence 평가 안 함
|
||||
assert not any(r["metric"] == "confidence" for r in out["results"])
|
||||
130
agent-office/tests/test_lotto_signals.py
Normal file
130
agent-office/tests/test_lotto_signals.py
Normal file
@@ -0,0 +1,130 @@
|
||||
# agent-office/tests/test_lotto_signals.py
|
||||
import pytest
|
||||
|
||||
from app.curator import signals
|
||||
|
||||
|
||||
def test_sim_consensus_top10_geomean():
|
||||
"""top-10 consensus 평균이 기하평균 기반인지."""
|
||||
best_picks = [
|
||||
{"scores": [10, 10, 10, 10, 10]}, # high & uniform
|
||||
{"scores": [9, 9, 9, 9, 9]},
|
||||
{"scores": [8, 8, 8, 8, 8]},
|
||||
{"scores": [7, 7, 7, 7, 7]},
|
||||
{"scores": [6, 6, 6, 6, 6]},
|
||||
{"scores": [5, 5, 5, 5, 5]},
|
||||
{"scores": [4, 4, 4, 4, 4]},
|
||||
{"scores": [3, 3, 3, 3, 3]},
|
||||
{"scores": [2, 2, 2, 2, 2]},
|
||||
{"scores": [1, 1, 1, 1, 1]}, # top 10
|
||||
{"scores": [0, 0, 0, 0, 0]}, # bottom 10
|
||||
] * 1 + [{"scores": [0, 0, 0, 0, 0]}] * 10
|
||||
result = signals.sim_consensus_score(best_picks)
|
||||
assert 0.0 <= result <= 1.0
|
||||
assert result > 0.4
|
||||
|
||||
|
||||
def test_sim_consensus_geomean_penalizes_imbalance():
|
||||
"""5종 중 한 종만 폭주하는 outlier 후보는 균형 후보보다 작아야 한다."""
|
||||
balanced = [{"scores": [5, 5, 5, 5, 5]}] * 20
|
||||
imbalanced = [{"scores": [25, 0, 0, 0, 0]}] * 20
|
||||
s_balanced = signals.sim_consensus_score(balanced)
|
||||
s_imbalanced = signals.sim_consensus_score(imbalanced)
|
||||
assert s_imbalanced < s_balanced
|
||||
|
||||
|
||||
def test_strategy_drift_score():
|
||||
"""drift = 전략별 가중치 변화 절댓값 합."""
|
||||
w_prev = {"gap_focus": 0.30, "hot_focus": 0.25, "pair_bias": 0.45}
|
||||
w_curr = {"gap_focus": 0.40, "hot_focus": 0.20, "pair_bias": 0.40}
|
||||
result = signals.strategy_drift_score(w_prev, w_curr)
|
||||
assert abs(result - 0.20) < 1e-9
|
||||
|
||||
|
||||
def test_strategy_drift_new_strategy_appears():
|
||||
"""이전에 없던 전략이 등장하면 그 가중치 전체가 drift에 가산."""
|
||||
w_prev = {"gap_focus": 0.5, "hot_focus": 0.5}
|
||||
w_curr = {"gap_focus": 0.4, "hot_focus": 0.4, "newbie": 0.2}
|
||||
result = signals.strategy_drift_score(w_prev, w_curr)
|
||||
assert abs(result - 0.4) < 1e-9
|
||||
|
||||
|
||||
def test_confidence_score_passthrough():
|
||||
"""confidence는 큐레이션 결과의 값 그대로 (0~1 clamp 확인)."""
|
||||
assert signals.confidence_score({"confidence": 0.85}) == 0.85
|
||||
assert signals.confidence_score({"confidence": 1.2}) == 1.0
|
||||
assert signals.confidence_score({"confidence": -0.1}) == 0.0
|
||||
assert signals.confidence_score({}) is None
|
||||
|
||||
|
||||
def test_adaptive_baseline_cold_start():
|
||||
"""window 크기 < 4 → warmup, z=None."""
|
||||
bl = signals.AdaptiveBaseline(window=[1.0, 1.1, 0.9], window_max=8)
|
||||
z, fire = bl.evaluate(value=1.5, z_normal=1.5, z_urgent=2.5)
|
||||
assert fire == "warmup"
|
||||
assert z is None
|
||||
|
||||
|
||||
def test_adaptive_baseline_preparing():
|
||||
"""window 4~7 → 보수적 임계치 z=2.0."""
|
||||
bl = signals.AdaptiveBaseline(window=[1.0, 1.0, 1.0, 1.0], window_max=8)
|
||||
z, fire = bl.evaluate(value=3.0, z_normal=1.5, z_urgent=2.5)
|
||||
assert fire in ("normal", "urgent")
|
||||
|
||||
|
||||
def test_adaptive_baseline_normal_window_full():
|
||||
"""window 8 풀, value가 평균보다 1.5σ 이상이면 normal."""
|
||||
bl = signals.AdaptiveBaseline(
|
||||
window=[1.0, 1.1, 0.9, 1.0, 1.0, 1.1, 0.9, 1.0],
|
||||
window_max=8,
|
||||
)
|
||||
z, fire = bl.evaluate(value=1.12, z_normal=1.5, z_urgent=2.5)
|
||||
assert fire == "normal"
|
||||
assert z is not None and z >= 1.5
|
||||
|
||||
|
||||
def test_adaptive_baseline_urgent():
|
||||
"""z >= 2.5 → urgent."""
|
||||
bl = signals.AdaptiveBaseline(
|
||||
window=[1.0, 1.1, 0.9, 1.0, 1.0, 1.1, 0.9, 1.0],
|
||||
window_max=8,
|
||||
)
|
||||
z, fire = bl.evaluate(value=2.0, z_normal=1.5, z_urgent=2.5)
|
||||
assert fire == "urgent"
|
||||
|
||||
|
||||
def test_adaptive_baseline_push_updates_window():
|
||||
"""push 시 FIFO 동작."""
|
||||
bl = signals.AdaptiveBaseline(window=[1, 2, 3, 4, 5, 6, 7, 8], window_max=8)
|
||||
bl.push(9.0)
|
||||
assert bl.window == [2, 3, 4, 5, 6, 7, 8, 9.0]
|
||||
|
||||
|
||||
def test_decide_fire_level_two_normals_escalate():
|
||||
sigs = [
|
||||
{"metric": "sim", "z": 1.6, "fire": "normal"},
|
||||
{"metric": "drift", "z": 1.7, "fire": "normal"},
|
||||
{"metric": "conf", "z": 0.5, "fire": "noop"},
|
||||
]
|
||||
assert signals.decide_overall_fire(sigs) == "urgent"
|
||||
|
||||
|
||||
def test_decide_fire_level_single_normal():
|
||||
sigs = [
|
||||
{"metric": "sim", "z": 1.6, "fire": "normal"},
|
||||
{"metric": "drift", "z": 0.3, "fire": "noop"},
|
||||
]
|
||||
assert signals.decide_overall_fire(sigs) == "normal"
|
||||
|
||||
|
||||
def test_decide_fire_level_single_urgent():
|
||||
sigs = [
|
||||
{"metric": "sim", "z": 3.0, "fire": "urgent"},
|
||||
{"metric": "drift", "z": 0.2, "fire": "noop"},
|
||||
]
|
||||
assert signals.decide_overall_fire(sigs) == "urgent"
|
||||
|
||||
|
||||
def test_decide_fire_level_all_noop():
|
||||
sigs = [{"metric": "sim", "z": 0.5, "fire": "noop"}]
|
||||
assert signals.decide_overall_fire(sigs) == "noop"
|
||||
154
agent-office/tests/test_lotto_task_wrap.py
Normal file
154
agent-office/tests/test_lotto_task_wrap.py
Normal file
@@ -0,0 +1,154 @@
|
||||
# agent-office/tests/test_lotto_task_wrap.py
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import gc
|
||||
|
||||
_fd, _TMP = tempfile.mkstemp(suffix=".db")
|
||||
os.close(_fd)
|
||||
os.unlink(_TMP)
|
||||
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import pytest
|
||||
from app import db
|
||||
db.DB_PATH = _TMP
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db():
|
||||
# Re-patch DB_PATH at the start of every test (cross-file isolation)
|
||||
db.DB_PATH = _TMP
|
||||
gc.collect()
|
||||
if os.path.exists(_TMP):
|
||||
os.remove(_TMP)
|
||||
db.init_db()
|
||||
yield
|
||||
gc.collect()
|
||||
if os.path.exists(_TMP):
|
||||
try:
|
||||
os.remove(_TMP)
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_signal_check_creates_task_row(monkeypatch):
|
||||
"""run_signal_check이 agent_tasks에 row를 만들고 result_data를 저장."""
|
||||
from app.agents.lotto import LottoAgent
|
||||
from app.curator import signal_runner
|
||||
|
||||
async def fake_run_signal_check(**kwargs):
|
||||
return {
|
||||
"overall_fire": "normal",
|
||||
"results": [
|
||||
{"signal_id": 1, "metric": "sim_signal",
|
||||
"value": 0.6, "z_score": 1.7, "fire_level": "normal",
|
||||
"baseline_mu": 0.5, "baseline_sigma": 0.05, "payload": {}},
|
||||
],
|
||||
}
|
||||
monkeypatch.setattr(signal_runner, "run_signal_check", fake_run_signal_check)
|
||||
|
||||
from app import service_proxy
|
||||
async def fake_latest():
|
||||
return 1226
|
||||
monkeypatch.setattr(service_proxy, "lotto_latest_draw", fake_latest)
|
||||
|
||||
from app.notifiers import telegram_lotto
|
||||
async def fake_send(_event): pass
|
||||
monkeypatch.setattr(telegram_lotto, "send_urgent_signal", fake_send)
|
||||
|
||||
agent = LottoAgent()
|
||||
result = await agent.run_signal_check(source="light")
|
||||
assert result["ok"] is True
|
||||
|
||||
tasks = db.get_agent_tasks("lotto", task_type="signal_check", days=1)
|
||||
assert len(tasks) == 1
|
||||
t = tasks[0]
|
||||
assert t["status"] == "succeeded"
|
||||
assert t["result_data"]["source"] == "light"
|
||||
assert t["result_data"]["overall_fire"] == "normal"
|
||||
assert "sim_signal" in t["result_data"]["fired_metrics"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_signal_check_failure_marks_task_failed(monkeypatch):
|
||||
from app.agents.lotto import LottoAgent
|
||||
from app.curator import signal_runner
|
||||
from app import service_proxy
|
||||
|
||||
async def boom(**kwargs):
|
||||
raise RuntimeError("boom")
|
||||
monkeypatch.setattr(signal_runner, "run_signal_check", boom)
|
||||
|
||||
async def fake_latest():
|
||||
return 1226
|
||||
monkeypatch.setattr(service_proxy, "lotto_latest_draw", fake_latest)
|
||||
|
||||
agent = LottoAgent()
|
||||
result = await agent.run_signal_check(source="sim")
|
||||
assert result["ok"] is False
|
||||
|
||||
tasks = db.get_agent_tasks("lotto", task_type="signal_check", days=1)
|
||||
assert len(tasks) == 1
|
||||
assert tasks[0]["status"] == "failed"
|
||||
assert "boom" in tasks[0]["result_data"]["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_daily_digest_creates_task(monkeypatch):
|
||||
"""run_daily_digest이 agent_tasks에 task 생성 + result_data 저장."""
|
||||
from app.agents.lotto import LottoAgent
|
||||
from app.notifiers import telegram_lotto
|
||||
|
||||
async def fake_send(_d): pass
|
||||
monkeypatch.setattr(telegram_lotto, "send_signal_summary", fake_send)
|
||||
|
||||
agent = LottoAgent()
|
||||
result = await agent.run_daily_digest()
|
||||
assert result["ok"] is True
|
||||
|
||||
tasks = db.get_agent_tasks("lotto", task_type="daily_digest", days=1)
|
||||
assert len(tasks) == 1
|
||||
assert tasks[0]["status"] == "succeeded"
|
||||
assert "fired" in tasks[0]["result_data"]
|
||||
assert "evaluated" in tasks[0]["result_data"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_weekly_evolution_report_creates_task(monkeypatch):
|
||||
"""run_weekly_evolution_report이 task 생성 + result_data 저장."""
|
||||
from app.agents.lotto import LottoAgent
|
||||
from app import service_proxy
|
||||
from app.notifiers import telegram_lotto
|
||||
|
||||
async def fake_eval():
|
||||
return {
|
||||
"ok": True, "draw_no": 1225,
|
||||
"winner": {"day_of_week": 3, "weight": [0.18, 0.32, 0.20, 0.22, 0.08],
|
||||
"avg_score": 0.42, "max_correct": 4, "n_picks": 5},
|
||||
"new_base": [0.18, 0.32, 0.20, 0.22, 0.08],
|
||||
"previous_base": [0.2] * 5,
|
||||
"update_reason": "winner_4plus",
|
||||
}
|
||||
async def fake_status():
|
||||
return {"current_base": [0.2] * 5}
|
||||
async def fake_send(_e, _b): pass
|
||||
|
||||
monkeypatch.setattr(service_proxy, "lotto_evolver_evaluate", fake_eval)
|
||||
monkeypatch.setattr(service_proxy, "lotto_evolver_status", fake_status)
|
||||
monkeypatch.setattr(telegram_lotto, "send_evolution_report", fake_send)
|
||||
|
||||
agent = LottoAgent()
|
||||
result = await agent.run_weekly_evolution_report()
|
||||
assert result["ok"] is True
|
||||
|
||||
tasks = db.get_agent_tasks("lotto", task_type="weekly_evolution_report", days=1)
|
||||
assert len(tasks) == 1
|
||||
r = tasks[0]["result_data"]
|
||||
assert tasks[0]["status"] == "succeeded"
|
||||
assert r["draw_no"] == 1225
|
||||
assert r["update_reason"] == "winner_4plus"
|
||||
assert r["winner_day_of_week"] == 3
|
||||
assert r["winner_max_correct"] == 4
|
||||
49
agent-office/tests/test_lotto_telegram_signal.py
Normal file
49
agent-office/tests/test_lotto_telegram_signal.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from app.notifiers.telegram_lotto import (
|
||||
_format_urgent_signal,
|
||||
_format_signal_digest,
|
||||
)
|
||||
|
||||
|
||||
def test_urgent_signal_format_basic():
|
||||
event = {
|
||||
"fire_level": "urgent",
|
||||
"triggered_at": "2026-05-20T07:18:00.000Z",
|
||||
"results": [
|
||||
{"metric": "sim_signal", "value": 1.84, "z_score": 3.9,
|
||||
"baseline_mu": 1.02, "baseline_sigma": 0.21, "payload": {},
|
||||
"fire_level": "urgent"},
|
||||
{"metric": "drift", "value": 0.18, "z_score": 3.0,
|
||||
"baseline_mu": 0.06, "baseline_sigma": 0.04, "fire_level": "normal",
|
||||
"payload": {"weights_now": {"gap_focus": 0.5, "hot_focus": 0.5},
|
||||
"weights_prev": {"gap_focus": 0.3, "hot_focus": 0.7}}},
|
||||
],
|
||||
}
|
||||
text = _format_urgent_signal(event)
|
||||
assert "🚨" in text
|
||||
assert "Sim Consensus" in text
|
||||
assert "z=3.9" in text
|
||||
assert "Strategy Drift" in text
|
||||
|
||||
|
||||
def test_signal_digest_format_with_signals():
|
||||
digest = {
|
||||
"evaluated": 6,
|
||||
"fired": 2,
|
||||
"signals": [
|
||||
{"metric": "sim_signal", "fire_level": "normal", "z_score": 1.7,
|
||||
"triggered_at": "2026-05-20T16:18:00Z", "payload": {}},
|
||||
{"metric": "confidence", "fire_level": "normal", "z_score": 1.6,
|
||||
"triggered_at": "2026-05-20T09:05:00Z", "payload": {}},
|
||||
],
|
||||
"weights_trend": {"gap_focus": +0.12, "hot_focus": -0.02, "pair_bias": -0.08},
|
||||
}
|
||||
text = _format_signal_digest(digest)
|
||||
assert "📊" in text
|
||||
assert "지난 24h" in text
|
||||
assert "z=1.7" in text
|
||||
|
||||
|
||||
def test_signal_digest_empty_returns_empty_string():
|
||||
"""발화 0건이면 빈 문자열 → 발송 자체 skip 가능."""
|
||||
text = _format_signal_digest({"evaluated": 6, "fired": 0, "signals": [], "weights_trend": {}})
|
||||
assert text == ""
|
||||
72
agent-office/tests/test_migrate_tarot.py
Normal file
72
agent-office/tests/test_migrate_tarot.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""migrate_tarot_to_lab.py 단위 테스트 — 멱등성 + 데이터 보존."""
|
||||
import sqlite3
|
||||
import sys
|
||||
import os
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def src_db(tmp_path):
|
||||
p = tmp_path / "agent_office.db"
|
||||
conn = sqlite3.connect(str(p))
|
||||
conn.execute("""
|
||||
CREATE TABLE tarot_readings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT, spread_type TEXT, category TEXT, question TEXT,
|
||||
cards TEXT, interpretation_json TEXT, summary TEXT, model TEXT,
|
||||
tokens_in INTEGER, tokens_out INTEGER, cost_usd REAL,
|
||||
confidence TEXT, favorite INTEGER, note TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
INSERT INTO tarot_readings (id, spread_type, category, cards, model, favorite)
|
||||
VALUES (1, 'three_card', '연애', '[]', 'm', 0),
|
||||
(2, 'one_card', '재물', '[]', 'm', 1)
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return str(p)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dst_db(tmp_path):
|
||||
return str(tmp_path / "tarot.db")
|
||||
|
||||
|
||||
def _import_migrate(src, dst, monkeypatch):
|
||||
monkeypatch.setenv("AGENT_OFFICE_DB", src)
|
||||
monkeypatch.setenv("TAROT_DB", dst)
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
|
||||
import migrate_tarot_to_lab as m
|
||||
import importlib
|
||||
importlib.reload(m)
|
||||
return m
|
||||
|
||||
|
||||
def test_first_run_copies_all_rows(src_db, dst_db, monkeypatch):
|
||||
m = _import_migrate(src_db, dst_db, monkeypatch)
|
||||
moved = m.migrate()
|
||||
assert moved == 2
|
||||
conn = sqlite3.connect(dst_db)
|
||||
rows = conn.execute("SELECT id, spread_type, category FROM tarot_readings ORDER BY id").fetchall()
|
||||
conn.close()
|
||||
assert rows == [(1, "three_card", "연애"), (2, "one_card", "재물")]
|
||||
|
||||
|
||||
def test_idempotent_second_run(src_db, dst_db, monkeypatch):
|
||||
m = _import_migrate(src_db, dst_db, monkeypatch)
|
||||
m.migrate()
|
||||
moved2 = m.migrate()
|
||||
assert moved2 == 0
|
||||
|
||||
|
||||
def test_partial_migration(src_db, dst_db, monkeypatch):
|
||||
"""dst에 id=1만 있는 상태에서 다시 돌리면 id=2만 옮김."""
|
||||
m = _import_migrate(src_db, dst_db, monkeypatch)
|
||||
m.migrate()
|
||||
conn = sqlite3.connect(dst_db)
|
||||
conn.execute("DELETE FROM tarot_readings WHERE id=2")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
moved = m.migrate()
|
||||
assert moved == 1
|
||||
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 == []
|
||||
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
|
||||
@@ -14,11 +14,18 @@ services:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- LOTTO_ALL_URL=${LOTTO_ALL_URL:-https://smok95.github.io/lotto/results/all.json}
|
||||
- LOTTO_LATEST_URL=${LOTTO_LATEST_URL:-https://smok95.github.io/lotto/results/latest.json}
|
||||
- PYTHONPATH=/app:/shared
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/data:/app/data
|
||||
- ${RUNTIME_PATH}/_shared:/shared/_shared:ro
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -44,11 +51,18 @@ services:
|
||||
- OLLAMA_MODEL=${OLLAMA_MODEL:-qwen3:14b}
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
- WEBAI_API_KEY=${WEBAI_API_KEY:-}
|
||||
- PYTHONPATH=/app:/shared
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/data/stock:/app/data
|
||||
- ${RUNTIME_PATH}/_shared:/shared/_shared:ro
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -62,7 +76,6 @@ services:
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- MUSIC_AI_SERVER_URL=${MUSIC_AI_SERVER_URL:-}
|
||||
- SUNO_API_KEY=${SUNO_API_KEY:-}
|
||||
- MUSIC_MEDIA_BASE=${MUSIC_MEDIA_BASE:-/media/music}
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
- PEXELS_API_KEY=${PEXELS_API_KEY:-}
|
||||
@@ -77,12 +90,69 @@ services:
|
||||
- WINDOWS_VIDEO_ENCODER_URL=${WINDOWS_VIDEO_ENCODER_URL:-}
|
||||
- NAS_VIDEOS_ROOT=${NAS_VIDEOS_ROOT:-/volume1/docker/webpage/data/videos}
|
||||
- NAS_MUSIC_ROOT=${NAS_MUSIC_ROOT:-/volume1/docker/webpage/data/music}
|
||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
|
||||
- MUSIC_RENDER_URL=${MUSIC_RENDER_URL:-http://192.168.45.59:18711}
|
||||
- PYTHONPATH=/app:/shared
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/data/music:/app/data
|
||||
- ${RUNTIME_PATH:-.}/data/videos:/app/data/videos
|
||||
- ${RUNTIME_PATH}/_shared:/shared/_shared:ro
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
depends_on:
|
||||
- redis
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
video-lab:
|
||||
build:
|
||||
context: ./video-lab
|
||||
container_name: video-lab
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18801:8000"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
|
||||
- VIDEO_DATA_DIR=/app/data
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/data/video:/app/data
|
||||
depends_on:
|
||||
- redis
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
image-lab:
|
||||
build: ./image-lab
|
||||
container_name: image-lab
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18802:8000"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
|
||||
- IMAGE_DATA_DIR=/app/data
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/data/image:/app/data
|
||||
depends_on:
|
||||
- redis
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -105,11 +175,22 @@ services:
|
||||
- CARD_TEMPLATE_DIR=/app/app/templates
|
||||
- INSTA_DEFAULT_THEME=${INSTA_DEFAULT_THEME:-default}
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
|
||||
- PYTHONPATH=/app:/shared
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/data/insta:/app/data
|
||||
- ${RUNTIME_PATH}/_shared:/shared/_shared:ro
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
depends_on:
|
||||
- redis
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -125,11 +206,18 @@ services:
|
||||
- DATA_GO_KR_API_KEY=${DATA_GO_KR_API_KEY:-}
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
- AGENT_OFFICE_URL=${AGENT_OFFICE_URL:-http://agent-office:8000}
|
||||
- PYTHONPATH=/app:/shared
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/data/realestate:/app/data
|
||||
- ${RUNTIME_PATH}/_shared:/shared/_shared:ro
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -170,7 +258,55 @@ services:
|
||||
- realestate-lab
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
tarot-lab:
|
||||
build:
|
||||
context: ./tarot-lab
|
||||
container_name: tarot-lab
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18250:8000"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
- TAROT_MODEL=${TAROT_MODEL:-claude-sonnet-4-6}
|
||||
- TAROT_COST_INPUT_PER_M=${TAROT_COST_INPUT_PER_M:-3.0}
|
||||
- TAROT_COST_OUTPUT_PER_M=${TAROT_COST_OUTPUT_PER_M:-15.0}
|
||||
- TAROT_TIMEOUT_SEC=${TAROT_TIMEOUT_SEC:-180}
|
||||
- TAROT_DATA_PATH=/app/data
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
volumes:
|
||||
- ${RUNTIME_PATH:-.}/data/tarot:/app/data
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
saju-lab:
|
||||
build:
|
||||
context: ./saju-lab
|
||||
container_name: saju-lab
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18300:8000"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
- SAJU_MODEL=${SAJU_MODEL:-claude-sonnet-4-6}
|
||||
- SAJU_COST_INPUT_PER_M=${SAJU_COST_INPUT_PER_M:-3.0}
|
||||
- SAJU_COST_OUTPUT_PER_M=${SAJU_COST_OUTPUT_PER_M:-15.0}
|
||||
- SAJU_TIMEOUT_SEC=${SAJU_TIMEOUT_SEC:-240}
|
||||
- SAJU_DATA_PATH=/app/data
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
volumes:
|
||||
- ${RUNTIME_PATH:-.}/data/saju:/app/data
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -189,7 +325,7 @@ services:
|
||||
- ${RUNTIME_PATH:-.}/data/personal:/app/data
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -216,7 +352,7 @@ services:
|
||||
- ${PACK_DATA_PATH:-./data/packs}:${PACK_BASE_DIR:-/app/data/packs}
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -239,12 +375,16 @@ services:
|
||||
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:rw
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
frontend:
|
||||
image: nginx:alpine
|
||||
# ngx_http_rewrite_module 힙 오버플로우 2건 대응 (미고정 nginx:alpine → 패치 stable 고정)
|
||||
# - CVE-2026-42945 (NGINX Rift, CVSS 9.2): fixed in 1.30.1+ / 1.31.0+
|
||||
# - CVE-2026-9256 (nginx-poolslip, 영향 ~1.31.0): fixed in 1.30.2+ / 1.31.1+
|
||||
# → 둘 다 커버하는 최소 stable = 1.30.2
|
||||
image: nginx:1.30.2-alpine
|
||||
container_name: frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
@@ -257,6 +397,8 @@ services:
|
||||
- personal
|
||||
- packs-lab
|
||||
- travel-proxy
|
||||
- video-lab
|
||||
- image-lab
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
@@ -266,11 +408,13 @@ services:
|
||||
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:ro
|
||||
- ${RUNTIME_PATH}/data/music:/data/music:ro
|
||||
- ${RUNTIME_PATH}/data/videos:/data/videos:ro
|
||||
- ${RUNTIME_PATH}/data/video:/data/video:ro
|
||||
- ${RUNTIME_PATH}/data/insta/insta_cards:/data/insta_cards:ro
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:80/"]
|
||||
interval: 30s
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -290,3 +434,18 @@ services:
|
||||
- ${RUNTIME_PATH}:/runtime:rw
|
||||
- ${RUNTIME_PATH}/scripts:/scripts:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/redis-data:/data
|
||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
635
docs/superpowers/plans/2026-05-18-plan-b-base-redis-wsl2.md
Normal file
635
docs/superpowers/plans/2026-05-18-plan-b-base-redis-wsl2.md
Normal file
@@ -0,0 +1,635 @@
|
||||
# Plan-B-Base — NAS Redis 컨테이너 + Windows WSL2/Docker/Tailscale/SMB Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 분산 아키텍처 base 인프라 셋업 — NAS에 24/7 Redis 컨테이너 신설 + Windows AI 머신에 WSL2 + Docker Engine + Tailscale + NAS SMB 마운트 구성. 후속 Plan-B-Insta/Music/Video/Infra 트랙의 prerequisite.
|
||||
|
||||
**Architecture:** SP-1 (NAS Redis) = docker-compose service 추가 + deployer auto-rebuild. SP-2 (Windows) = 박재오 머신 192.168.45.59에서 직접 셋업 (WSL2 Ubuntu 22.04 + Docker Engine + Tailscale + cifs-utils로 NAS SMB 마운트). 두 SP가 모두 끝나야 후속 트랙의 worker가 NAS ↔ Windows 양방향 통신 가능.
|
||||
|
||||
**Tech Stack:** Redis 7-alpine, WSL2, Ubuntu 22.04, Docker Engine 24+, Tailscale, cifs-utils (SMB 3.0). PowerShell (관리자) + bash (WSL2 내부).
|
||||
|
||||
**Spec:** `web-backend/docs/superpowers/specs/2026-05-18-nas-windows-distributed-architecture-design.md` §4 SP-1·SP-2, §10 SP-1·SP-2 상세
|
||||
|
||||
---
|
||||
|
||||
## 사전 확인 사항
|
||||
|
||||
- **박재오 자격증명 필요**: NAS SMB 마운트용 user/password (Synology DSM 사용자, SMB 권한 보유)
|
||||
- **Windows AI 머신 직접 접근 필요**: WSL2 설치는 관리자 PowerShell + 재부팅 1회. Claude는 별도 머신이라 명령 직접 실행 불가 — **Task 4~7은 박재오가 콘솔에서 직접 수행**. 명령어와 검증 방법 명시.
|
||||
- **NAS deployer 사용자**: Gitea webhook으로 docker compose up -d 자동 실행. 새 redis 서비스도 추가 시 자동 startup.
|
||||
|
||||
## File Structure
|
||||
|
||||
### SP-1 — NAS 측 (Modify)
|
||||
|
||||
| 파일 | 변경 | 책임 |
|
||||
|------|------|------|
|
||||
| `web-backend/docker-compose.yml` | `redis:` 서비스 블록 추가 | 컨테이너 정의 (image, volume, healthcheck) |
|
||||
|
||||
### SP-2 — Windows 측 (Create, 박재오 머신 로컬)
|
||||
|
||||
| 파일/위치 | 변경 | 책임 |
|
||||
|----------|------|------|
|
||||
| (Windows AI) WSL2 Ubuntu-22.04 | install | Linux 런타임 |
|
||||
| WSL2 `/etc/apt/keyrings/docker.gpg` | install | Docker Engine apt key |
|
||||
| WSL2 `/etc/apt/sources.list.d/docker.list` | install | Docker Engine apt source |
|
||||
| (Windows AI) Tailscale | install + auth | 사설망 100.x.x.x |
|
||||
| WSL2 `/etc/nas-smb-credentials` (신규) | NAS user/password | SMB 자격증명 (chmod 600) |
|
||||
| WSL2 `/etc/fstab` (수정) | SMB 마운트 항목 추가 | 부팅 시 자동 마운트 |
|
||||
| WSL2 `/mnt/nas` | mkdir | 마운트 포인트 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: NAS docker-compose.yml에 redis 서비스 추가
|
||||
|
||||
**Files:**
|
||||
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml`
|
||||
|
||||
- [ ] **Step 1: 현재 docker-compose.yml 끝부분 확인 (deployer 위치)**
|
||||
|
||||
Run: `tail -20 C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml`
|
||||
Expected: `deployer` 서비스가 마지막. line ~277-293 영역.
|
||||
|
||||
- [ ] **Step 2: redis 서비스 블록 추가**
|
||||
|
||||
`C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml` 파일 **끝**에 (deployer 서비스 다음, volumes 블록 있다면 그 전에) 다음 블록 추가. 들여쓰기는 다른 서비스(`lotto:`, `stock:` 등)와 동일하게 services 아래 2칸 들여쓰기:
|
||||
|
||||
```yaml
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/redis-data:/data
|
||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
networks:
|
||||
- default
|
||||
```
|
||||
|
||||
**주의:**
|
||||
- 파일 끝에 추가하되, 만약 `networks:` / `volumes:` top-level 블록이 services 다음에 있다면 그 블록들 **앞에** 삽입
|
||||
- 첫 줄에 빈 줄 1개 두기 (deployer와 분리)
|
||||
- `${RUNTIME_PATH}` 환경변수는 다른 서비스에서도 사용 중. 자동 적용됨
|
||||
|
||||
- [ ] **Step 3: yaml 문법 검증**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
python -c "import yaml; yaml.safe_load(open('C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml'))" && echo "yaml OK"
|
||||
```
|
||||
Expected: `yaml OK`
|
||||
|
||||
만약 실패하면 indent 또는 trailing space 확인.
|
||||
|
||||
- [ ] **Step 4: redis 서비스가 services dict에 들어갔는지 확인**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
python -c "import yaml; d=yaml.safe_load(open('C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml')); print(sorted(d['services'].keys()))"
|
||||
```
|
||||
Expected: 리스트에 `'redis'` 포함. 다른 서비스(`lotto`, `stock`, `music-lab`, `insta-lab`, `realestate-lab`, `agent-office`, `personal`, `packs-lab`, `travel-proxy`, `frontend`, `deployer`)도 모두 그대로.
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||||
git add docker-compose.yml
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(infra): add redis container as 24/7 queue + cache base (SP-1)
|
||||
|
||||
redis:7-alpine, 256MB maxmemory, AOF appendonly ON, allkeys-lru.
|
||||
docker volume ${RUNTIME_PATH}/redis-data로 영속화.
|
||||
Plan-B 후속 트랙(insta-render/music-render/video-render Windows
|
||||
워커)의 BLPOP 큐 + NAS↔Windows pub/sub의 base.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 6: push (Gitea webhook → NAS deployer 자동 적용)**
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||||
git push origin main
|
||||
```
|
||||
|
||||
자격증명 prompt 시 입력. 1회 실패 시 1회 재시도 패턴.
|
||||
|
||||
Expected: push 성공. NAS deployer가 webhook 수신 → `git pull` → `docker compose up -d redis` 자동 실행.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: NAS Redis 컨테이너 헬스 확인
|
||||
|
||||
**Files:** 없음 (NAS 검증)
|
||||
|
||||
- [ ] **Step 1: deployer 완료까지 대기 (통상 30초~2분)**
|
||||
|
||||
Run (Windows 로컬에서):
|
||||
```bash
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" https://gahusb.synology.me/api/stock/news -m 5)
|
||||
echo "[try $i] HTTP $code"
|
||||
if [ "$code" = "200" ]; then break; fi
|
||||
sleep 15
|
||||
done
|
||||
```
|
||||
|
||||
Expected: HTTP 200 응답 — NAS 컨테이너 안정 상태. redis 컨테이너는 별도 endpoint 없으나 deployer가 build 완료했음을 시사.
|
||||
|
||||
- [ ] **Step 2: NAS에서 redis 컨테이너 확인 (박재오 SSH)**
|
||||
|
||||
NAS bash:
|
||||
```bash
|
||||
ssh -p 22 박재오@gahusb.synology.me
|
||||
cd /volume1/docker/webpage
|
||||
docker compose ps redis
|
||||
```
|
||||
|
||||
또는 한 번에:
|
||||
```bash
|
||||
ssh -p 22 박재오@gahusb.synology.me "cd /volume1/docker/webpage && docker compose ps redis && docker exec redis redis-cli PING"
|
||||
```
|
||||
|
||||
Expected:
|
||||
- `docker compose ps redis` → `redis ... healthy` 또는 `Up X seconds (health: starting)` 후 곧 healthy
|
||||
- `redis-cli PING` → `PONG`
|
||||
|
||||
만약 `docker compose ps`에 redis가 안 보이면:
|
||||
```bash
|
||||
cd /volume1/docker/webpage && docker compose up -d redis
|
||||
```
|
||||
|
||||
수동 실행해서 startup 확인.
|
||||
|
||||
- [ ] **Step 3: redis-data 볼륨 생성 확인 (Z: drive로)**
|
||||
|
||||
Run (Windows):
|
||||
```powershell
|
||||
Test-Path "Z:\webpage\redis-data"
|
||||
```
|
||||
|
||||
또는 NAS bash:
|
||||
```bash
|
||||
ls -la /volume1/docker/webpage/redis-data/
|
||||
```
|
||||
|
||||
Expected: 디렉토리 존재. 그 안에 `appendonlydir/` 또는 `dump.rdb` 등의 redis 데이터 파일.
|
||||
|
||||
- [ ] **Step 4: AOF append-only 작동 확인 (선택, 데이터 영속성 검증)**
|
||||
|
||||
```bash
|
||||
ssh -p 22 박재오@gahusb.synology.me 'docker exec redis redis-cli SET test_key "hello"'
|
||||
ssh -p 22 박재오@gahusb.synology.me 'docker exec redis redis-cli RESTART' # 또는 docker restart
|
||||
# 잠시 대기
|
||||
ssh -p 22 박재오@gahusb.synology.me 'docker exec redis redis-cli GET test_key'
|
||||
```
|
||||
|
||||
Expected: `"hello"` — 재시작 후에도 값 유지 (AOF 영속화 작동).
|
||||
|
||||
테스트 후 정리: `docker exec redis redis-cli DEL test_key`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Windows AI에 WSL2 + Ubuntu 22.04 설치
|
||||
|
||||
**Files:** 없음 (Windows AI 머신 192.168.45.59에서 박재오 직접 실행)
|
||||
|
||||
**전제:** Windows 10 build 19041+ 또는 Windows 11. 박재오 9800X3D 머신 충족.
|
||||
|
||||
- [ ] **Step 1: 관리자 PowerShell 실행**
|
||||
|
||||
박재오 Windows AI 머신에서 시작 메뉴 → "PowerShell" 우클릭 → "관리자 권한으로 실행".
|
||||
|
||||
- [ ] **Step 2: WSL2 + Ubuntu 22.04 설치**
|
||||
|
||||
```powershell
|
||||
wsl --install -d Ubuntu-22.04
|
||||
```
|
||||
|
||||
Expected: 다운로드 progress + "Ubuntu-22.04 has been installed". **재부팅 필요할 수 있음.**
|
||||
|
||||
- [ ] **Step 3: 재부팅 (필요 시)**
|
||||
|
||||
설치 완료 메시지에 "재시작이 필요합니다"가 보이면 재부팅. 자동 재부팅 안 됨.
|
||||
|
||||
- [ ] **Step 4: Ubuntu 초기 설정 (재부팅 후 자동 실행 또는 시작 메뉴에서 "Ubuntu" 클릭)**
|
||||
|
||||
새 콘솔이 열리고 다음 입력 요청됨:
|
||||
- 새 UNIX username: `jaeoh` 또는 박재오 선호 username (이후 모든 sudo에 사용)
|
||||
- 비밀번호: 박재오가 정하는 값. 잘 기억할 것.
|
||||
|
||||
Expected: `jaeoh@<hostname>:~$` 프롬프트 표시 → WSL2 진입 성공.
|
||||
|
||||
- [ ] **Step 5: WSL 버전 확인**
|
||||
|
||||
WSL2 내부에서 PowerShell로 잠시 돌아와서:
|
||||
```powershell
|
||||
wsl -l -v
|
||||
```
|
||||
|
||||
Expected:
|
||||
```
|
||||
NAME STATE VERSION
|
||||
* Ubuntu-22.04 Running 2
|
||||
```
|
||||
|
||||
VERSION=2 확인. 만약 1이면:
|
||||
```powershell
|
||||
wsl --set-version Ubuntu-22.04 2
|
||||
```
|
||||
|
||||
- [ ] **Step 6: WSL2 안 진입 (이후 작업)**
|
||||
|
||||
```powershell
|
||||
wsl -d Ubuntu-22.04
|
||||
```
|
||||
|
||||
이후 Task 4~7은 모두 WSL2 안 bash에서 실행.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: WSL2 안 Docker Engine 설치 (Docker Desktop 사용 X)
|
||||
|
||||
**Files:** (WSL2 내부) `/etc/apt/keyrings/docker.gpg`, `/etc/apt/sources.list.d/docker.list`
|
||||
|
||||
**위치:** WSL2 Ubuntu-22.04 bash 프롬프트.
|
||||
|
||||
- [ ] **Step 1: 패키지 인덱스 + 기본 의존성 설치**
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y ca-certificates curl gnupg lsb-release
|
||||
```
|
||||
|
||||
Expected: 에러 없이 완료.
|
||||
|
||||
- [ ] **Step 2: Docker apt key 등록**
|
||||
|
||||
```bash
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
sudo chmod a+r /etc/apt/keyrings/docker.gpg
|
||||
```
|
||||
|
||||
Expected: 에러 없이 완료. `/etc/apt/keyrings/docker.gpg` 파일 생성.
|
||||
|
||||
- [ ] **Step 3: Docker repository 추가**
|
||||
|
||||
```bash
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | \
|
||||
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
sudo apt update
|
||||
```
|
||||
|
||||
Expected: `Hit:N https://download.docker.com/linux/ubuntu jammy InRelease` 라인 보임.
|
||||
|
||||
- [ ] **Step 4: Docker Engine + Compose 설치**
|
||||
|
||||
```bash
|
||||
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
```
|
||||
|
||||
Expected: 설치 완료. 용량 ~400MB.
|
||||
|
||||
- [ ] **Step 5: 현재 사용자를 docker 그룹에 추가**
|
||||
|
||||
```bash
|
||||
sudo usermod -aG docker $USER
|
||||
```
|
||||
|
||||
Expected: 출력 없음 (정상). **새 셸 열어야 적용됨.**
|
||||
|
||||
- [ ] **Step 6: Docker 서비스 시작 + 자동 시작 설정**
|
||||
|
||||
```bash
|
||||
sudo systemctl enable docker
|
||||
sudo systemctl start docker
|
||||
sudo systemctl status docker | head -5
|
||||
```
|
||||
|
||||
Expected: `Active: active (running)`.
|
||||
|
||||
만약 `systemctl: command not found` 또는 systemd 미지원 시:
|
||||
```bash
|
||||
sudo service docker start
|
||||
```
|
||||
|
||||
WSL2 systemd 활성화는 `/etc/wsl.conf`에 `[boot]\nsystemd=true` 추가 후 PowerShell에서 `wsl --shutdown` 후 재진입. (Ubuntu-22.04는 보통 기본 활성)
|
||||
|
||||
- [ ] **Step 7: docker 명령 동작 확인**
|
||||
|
||||
새 셸로 (PowerShell에서 다시 `wsl -d Ubuntu-22.04` 또는 현재 셸 종료 후 재진입):
|
||||
|
||||
```bash
|
||||
docker version
|
||||
docker run --rm hello-world
|
||||
```
|
||||
|
||||
Expected:
|
||||
- `docker version`: Client + Server 둘 다 표시 (Server에 Engine version)
|
||||
- `hello-world`: "Hello from Docker!" 출력
|
||||
|
||||
---
|
||||
|
||||
## Task 5: WSL2 안 Tailscale 설치 + 가입
|
||||
|
||||
**Files:** Tailscale은 systemd service 등록 (별도 path 신경 안 써도 됨)
|
||||
|
||||
- [ ] **Step 1: Tailscale 설치**
|
||||
|
||||
WSL2 bash:
|
||||
```bash
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
```
|
||||
|
||||
Expected: 패키지 install 후 "Installation complete!" 출력.
|
||||
|
||||
- [ ] **Step 2: Tailscale 가입 (브라우저 OAuth)**
|
||||
|
||||
```bash
|
||||
sudo tailscale up
|
||||
```
|
||||
|
||||
Expected: `To authenticate, visit: https://login.tailscale.com/a/...` URL 표시.
|
||||
|
||||
브라우저에서 그 URL 열기 → Google/Microsoft/GitHub 등으로 로그인 → 박재오 Tailscale 네트워크에 가입 (기존 계정 없으면 생성).
|
||||
|
||||
- [ ] **Step 3: 가입 완료 확인**
|
||||
|
||||
```bash
|
||||
tailscale status
|
||||
```
|
||||
|
||||
Expected:
|
||||
- 첫 줄에 Windows AI 머신의 100.x.x.x IP 표시
|
||||
- (이미 가입된) NAS도 같은 네트워크에 있다면 NAS의 100.x.x.x IP도 표시
|
||||
|
||||
- [ ] **Step 4: NAS와 Tailscale ping (양방향 사설망 확인)**
|
||||
|
||||
NAS의 Tailscale IP를 `tailscale status` 출력에서 찾아 (예: `100.64.0.10`):
|
||||
```bash
|
||||
tailscale ping 100.64.0.10
|
||||
```
|
||||
|
||||
Expected: `pong from <NAS hostname>` (직접 LAN 또는 DERP 중계). 만약 NAS가 Tailscale 미가입이면 별도로 NAS DSM Tailscale 패키지 셋업 필요 — 이는 박재오 결정 사항이라 plan 외.
|
||||
|
||||
> **참고:** Tailscale은 spec §3 sense의 사설망 layer 보조. LAN(192.168.45.0/24) 안에서만 작업한다면 Tailscale 없이도 작동. 외부 출장 등에서 NAS↔Windows 통신을 위해 권장.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: WSL2 안 NAS SMB 자격증명 파일 + 마운트 포인트 준비
|
||||
|
||||
**Files:** `/etc/nas-smb-credentials`, `/mnt/nas`
|
||||
|
||||
- [ ] **Step 1: cifs-utils 설치 (SMB 마운트 패키지)**
|
||||
|
||||
```bash
|
||||
sudo apt install -y cifs-utils
|
||||
```
|
||||
|
||||
Expected: 설치 완료.
|
||||
|
||||
- [ ] **Step 2: SMB 자격증명 파일 생성**
|
||||
|
||||
박재오 NAS 계정의 username과 password를 사용. 파일 위치는 system-wide `/etc/`.
|
||||
|
||||
```bash
|
||||
sudo bash -c 'cat > /etc/nas-smb-credentials <<EOF
|
||||
username=박재오NAS사용자명
|
||||
password=박재오NAS비밀번호
|
||||
domain=
|
||||
EOF'
|
||||
```
|
||||
|
||||
**위 명령 실행 전 `박재오NAS사용자명` / `박재오NAS비밀번호`를 실제 값으로 교체.** Synology DSM Control Panel → User & Group 에서 SMB 접근 권한 있는 계정 사용. 비밀번호에 특수문자 있을 시 escape 필요 (특히 `!`, `$`, `\`).
|
||||
|
||||
- [ ] **Step 3: 자격증명 파일 권한 보호**
|
||||
|
||||
```bash
|
||||
sudo chmod 600 /etc/nas-smb-credentials
|
||||
sudo chown root:root /etc/nas-smb-credentials
|
||||
```
|
||||
|
||||
Expected: 출력 없음.
|
||||
|
||||
```bash
|
||||
ls -la /etc/nas-smb-credentials
|
||||
```
|
||||
|
||||
Expected: `-rw------- 1 root root ... /etc/nas-smb-credentials`
|
||||
|
||||
- [ ] **Step 4: 마운트 포인트 생성**
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /mnt/nas
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: NAS SMB 마운트 (수동 마운트 + fstab 자동화)
|
||||
|
||||
**Files:** `/etc/fstab` (수정)
|
||||
|
||||
- [ ] **Step 1: 수동 마운트 시도 (자격증명·경로 검증)**
|
||||
|
||||
```bash
|
||||
sudo mount -t cifs //gahusb.synology.me/docker /mnt/nas \
|
||||
-o credentials=/etc/nas-smb-credentials,vers=3.0,uid=1000,gid=1000,_netdev
|
||||
```
|
||||
|
||||
Expected: 출력 없음 (성공). 만약 `mount error(13)` (permission) → 자격증명 오류. `mount error(2)` (no such file) → share name `docker` 확인.
|
||||
|
||||
> **share name 변형:** 박재오 NAS는 메모리(`feedback_nas_deploy_paths.md`)에 따르면 SMB 매핑이 `/volume1/docker/`를 share `docker`로 노출. 만약 다른 share name(예: `webpage`)이라면 그것으로 교체.
|
||||
|
||||
- [ ] **Step 2: 마운트 결과 확인**
|
||||
|
||||
```bash
|
||||
ls /mnt/nas/
|
||||
```
|
||||
|
||||
Expected: `webpage/` 디렉토리 + 다른 share 내 디렉토리 보임.
|
||||
|
||||
```bash
|
||||
ls /mnt/nas/webpage/data/
|
||||
```
|
||||
|
||||
Expected: `insta/`, `music/` 등 후속 트랙에서 사용할 디렉토리. 없으면 후속 트랙에서 생성됨.
|
||||
|
||||
- [ ] **Step 3: 마운트 해제 후 fstab으로 자동화**
|
||||
|
||||
```bash
|
||||
sudo umount /mnt/nas
|
||||
```
|
||||
|
||||
Expected: 출력 없음.
|
||||
|
||||
`/etc/fstab` 끝에 다음 라인 추가:
|
||||
```bash
|
||||
sudo bash -c 'cat >> /etc/fstab <<EOF
|
||||
|
||||
# NAS Synology SMB mount for web-ai-services workers (2026-05-18)
|
||||
//gahusb.synology.me/docker /mnt/nas cifs credentials=/etc/nas-smb-credentials,vers=3.0,uid=1000,gid=1000,_netdev,nofail 0 0
|
||||
EOF'
|
||||
```
|
||||
|
||||
`nofail` 옵션은 부팅 시 NAS 미접속이어도 boot 진행 (production 안전).
|
||||
|
||||
- [ ] **Step 4: fstab 적용 + 검증**
|
||||
|
||||
```bash
|
||||
sudo mount -a
|
||||
ls /mnt/nas/webpage/data/ 2>&1 | head -5
|
||||
mount | grep cifs
|
||||
```
|
||||
|
||||
Expected:
|
||||
- `mount -a` 출력 없음 (성공)
|
||||
- `ls /mnt/nas/webpage/data/` 디렉토리 내용 표시
|
||||
- `mount | grep cifs` 라인에 마운트 정보 보임
|
||||
|
||||
- [ ] **Step 5: WSL2 재시작 시 자동 마운트 확인**
|
||||
|
||||
PowerShell에서 (관리자 권한 불필요):
|
||||
```powershell
|
||||
wsl --shutdown
|
||||
wsl -d Ubuntu-22.04
|
||||
```
|
||||
|
||||
WSL2 다시 진입 후:
|
||||
```bash
|
||||
ls /mnt/nas/webpage/data/
|
||||
```
|
||||
|
||||
Expected: 정상 디렉토리 목록. 자동 마운트 성공.
|
||||
|
||||
만약 마운트 안 됨:
|
||||
- `dmesg | grep cifs` 확인
|
||||
- `nofail` 때문에 boot은 통과했으나 마운트 실패 가능. 수동 `sudo mount -a` 후 동작 확인 → fstab syntax 재검토
|
||||
|
||||
---
|
||||
|
||||
## Task 8: 통합 검증 — base 인프라 동작 확인
|
||||
|
||||
**Files:** 없음 (검증)
|
||||
|
||||
- [ ] **Step 1: NAS Redis 외부 ping (Windows 로컬에서)**
|
||||
|
||||
```powershell
|
||||
# Windows AI 또는 박재오 PC에서
|
||||
Test-NetConnection -ComputerName 192.168.45.54 -Port 6379
|
||||
```
|
||||
|
||||
Expected: `TcpTestSucceeded : True`
|
||||
|
||||
> 외부 6379 노출은 LAN 한정. 가능하면 NAS firewall (DSM Control Panel)에서 6379 LAN-only allowed로 한정 권장. (이번 plan에 포함 안 됨, 별도 사용자 작업)
|
||||
|
||||
- [ ] **Step 2: WSL2에서 NAS Redis 접속**
|
||||
|
||||
WSL2 bash:
|
||||
```bash
|
||||
docker run --rm redis:7-alpine redis-cli -h 192.168.45.54 PING
|
||||
```
|
||||
|
||||
또는 Tailscale 사용 시:
|
||||
```bash
|
||||
docker run --rm redis:7-alpine redis-cli -h <NAS_TAILSCALE_IP> PING
|
||||
```
|
||||
|
||||
Expected: `PONG`
|
||||
|
||||
- [ ] **Step 3: NAS volume 쓰기 테스트 (Windows→NAS 양방향)**
|
||||
|
||||
WSL2 bash:
|
||||
```bash
|
||||
echo "Plan-B-Base test $(date)" | sudo tee /mnt/nas/webpage/data/.plan-b-test.txt
|
||||
cat /mnt/nas/webpage/data/.plan-b-test.txt
|
||||
sudo rm /mnt/nas/webpage/data/.plan-b-test.txt
|
||||
```
|
||||
|
||||
Expected:
|
||||
- `tee` 출력에 같은 내용 + 파일 생성됨
|
||||
- `cat` 으로 확인 성공
|
||||
- 파일 삭제 성공
|
||||
|
||||
`sudo` 필요 시 chmod로 uid 1000 쓰기 권한 확인. 또는 mount option `uid=1000,gid=1000` 적용 후 일반 사용자도 쓰기 가능. 만약 안 되면 NAS DSM에서 SMB user의 write 권한 확인.
|
||||
|
||||
- [ ] **Step 4: WSL2 Docker로 hello-world 한 번 더 (재진입 후 상태 확인)**
|
||||
|
||||
```bash
|
||||
docker run --rm hello-world
|
||||
```
|
||||
|
||||
Expected: "Hello from Docker!"
|
||||
|
||||
- [ ] **Step 5: 모든 검증 완료 후 보고 — 후속 트랙으로 진입 가능 상태**
|
||||
|
||||
다음 plan(Plan-B-Insta 등)이 가정하는 상태:
|
||||
- ✅ NAS `redis:6379` PING/PONG 성공
|
||||
- ✅ Windows WSL2 Ubuntu-22.04 작동 + Docker Engine 실행
|
||||
- ✅ `/mnt/nas/webpage/data/` 양방향 read·write 성공
|
||||
- ✅ Tailscale 가입 (선택, 외부 출장 시 필요)
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
### Spec 커버리지
|
||||
|
||||
| Spec 요구사항 | 구현 Task |
|
||||
|---------------|-----------|
|
||||
| §4 SP-1: NAS Redis 컨테이너 | Task 1 (compose 추가) + Task 2 (헬스 검증) |
|
||||
| §10 SP-1: redis:7-alpine + 256MB + AOF + healthcheck | Task 1 Step 2 |
|
||||
| §4 SP-2: Windows WSL2 + Docker Engine | Task 3 (WSL2) + Task 4 (Docker) |
|
||||
| §10 SP-2: Tailscale | Task 5 |
|
||||
| §10 SP-2: NAS SMB mount `/mnt/nas` | Task 6 (자격증명·포인트) + Task 7 (마운트+fstab) |
|
||||
| §10 SP-2: 검증 (docker ps, tailscale status, ls /mnt/nas) | Task 8 |
|
||||
| §6 Redis 키 컨벤션 사용 가능 | Task 2 Step 2 (PING) — 컨벤션 자체는 후속 트랙에서 RPUSH로 시작 |
|
||||
|
||||
### Placeholder 스캔
|
||||
|
||||
- TBD/TODO 없음 ✓
|
||||
- 모든 명령어가 그대로 실행 가능한 형태 ✓
|
||||
- 한 가지 예외: Task 6 Step 2 — `박재오NAS사용자명/박재오NAS비밀번호`는 사용자 자격증명이라 placeholder가 의도된 것. 실행 전 교체 명시 ✓
|
||||
- Task 5 Step 4 — `<NAS 의 Tailscale IP>`는 `tailscale status` 출력에서 박재오가 보고 입력. 사용자 환경에서만 결정 가능, plan에 명시 ✓
|
||||
|
||||
### Type/이름 consistency
|
||||
|
||||
- `redis` 서비스명 (Task 1, 2, 8 모두 동일) ✓
|
||||
- `/mnt/nas` 마운트 포인트 (Task 6, 7, 8 모두 동일) ✓
|
||||
- `/etc/nas-smb-credentials` 자격증명 파일 (Task 6, 7 동일) ✓
|
||||
- share name `docker` (Task 7 Step 1, fstab 동일) ✓
|
||||
- Ubuntu-22.04 (Task 3, 4 동일) ✓
|
||||
|
||||
### 위험·주의
|
||||
|
||||
| 위험 | 완화 |
|
||||
|------|------|
|
||||
| Windows 재부팅 시 WSL2 자동 시작 안 함 | 향후 Plan-B-Infra(SP-9)에서 NSSM으로 자동 시작 |
|
||||
| WSL2 systemd 미지원 시 docker service 자동 시작 안 함 | Task 4 Step 6의 fallback `sudo service docker start` 또는 `/etc/wsl.conf` 수정 |
|
||||
| SMB 마운트 자격증명 노출 | `/etc/nas-smb-credentials` chmod 600 + root:root |
|
||||
| NAS firewall에서 6379 외부 노출 | 권장: LAN(192.168.45.0/24) only allow. 본 plan 외 (DSM 수동) |
|
||||
| Tailscale 미가입 시 NAS↔Windows 외부 통신 불가 | LAN 내에선 작동. 외부 출장 시 필요할 때만 가입 |
|
||||
| /mnt/nas 쓰기 권한 부족 | uid=1000 mount option + NAS DSM에서 SMB user의 share write 권한 확인 |
|
||||
|
||||
---
|
||||
|
||||
## 완료 후 다음 단계
|
||||
|
||||
Plan-B-Base 완료 후 spec §14 권장 순서대로:
|
||||
|
||||
1. **Plan-B-Insta** — SP-3 (insta-render Windows worker) + SP-4 (NAS insta-lab 분할)
|
||||
2. **Plan-B-Music** — SP-5 + SP-6
|
||||
3. **Plan-B-Video** — SP-7 + SP-8
|
||||
4. **Plan-B-Infra** — SP-9 (NSSM 자동 시작) + SP-10 (task-watcher)
|
||||
|
||||
각 후속 plan은 본 plan이 제공한 base 인프라(Redis + WSL2/Docker + /mnt/nas)에 의존.
|
||||
656
docs/superpowers/plans/2026-05-18-track-a-cache-hardening.md
Normal file
656
docs/superpowers/plans/2026-05-18-track-a-cache-hardening.md
Normal file
@@ -0,0 +1,656 @@
|
||||
# Track A — NAS↔Windows API 부하 캐시 강화 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** web-ai → NAS stock 호출량을 분당 12회 → 분당 3~4회로 축소하여, V2 재시작 시점부터 즉시 NAS CPU 부담 70% 감소.
|
||||
|
||||
**Architecture:** 2-layer cache. (1) web-ai client side: 3개 endpoint TTL 60/300/60 → 180/600/300으로 증가. (2) NAS stock server side: 동일 endpoint에 in-memory TTLCache 추가하여 web-ai 캐시 miss 시에도 KIS·LLM 재호출 차단. 두 layer가 cumulative하게 작동.
|
||||
|
||||
**Tech Stack:** Python 3.12 / FastAPI / pytest / `cachetools.TTLCache`. **two repos**: `web-ai` (signal_v2/) + `web-backend` (stock/).
|
||||
|
||||
**Spec:** `web-backend/docs/superpowers/specs/2026-05-18-nas-windows-distributed-architecture-design.md` §4 SP-A1·A2, §10 상세
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### SP-A1 — web-ai 캐시 TTL (Modify)
|
||||
|
||||
| 파일 | 변경 | 책임 |
|
||||
|------|------|------|
|
||||
| `web-ai/signal_v2/stock_client.py:13-17` | `_TTL` dict 3개 값 변경 | endpoint별 client-side cache TTL |
|
||||
| `web-ai/signal_v2/tests/test_stock_client_ttl.py` (Create) | TTL 값 회귀 테스트 | 미래 변경 시 의도하지 않은 회귀 방지 |
|
||||
|
||||
### SP-A2 — NAS stock TTLCache (Modify + Create)
|
||||
|
||||
| 파일 | 변경 | 책임 |
|
||||
|------|------|------|
|
||||
| `web-backend/stock/requirements.txt` | `cachetools>=5.3` 추가 | 의존성 |
|
||||
| `web-backend/stock/app/webai_cache.py` (Create) | 3개 TTLCache + helper 함수 | server-side cache 중앙화 |
|
||||
| `web-backend/stock/app/main.py:419-422` | `get_webai_portfolio()` cache 적용 | NAS portfolio 캐시 |
|
||||
| `web-backend/stock/app/main.py:467-470` | `get_webai_news_sentiment(date)` cache 적용 | date별 캐시 |
|
||||
| `web-backend/stock/app/screener/router.py:173` | `post_run()` cache 적용 (mode=preview만) | screener preview 캐시 |
|
||||
| `web-backend/stock/app/test_webai_cache.py` (Create) | cache 동작 + TTL + key 분기 | 캐시 hit/miss 검증 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: web-ai SP-A1 — `_TTL` dict 회귀 테스트 작성
|
||||
|
||||
**Files:**
|
||||
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/signal_v2/tests/test_stock_client_ttl.py`
|
||||
|
||||
- [ ] **Step 1: 실패하는 테스트 작성**
|
||||
|
||||
```python
|
||||
# tests/test_stock_client_ttl.py
|
||||
"""SP-A1 회귀 — _TTL이 NAS 부담 완화를 위한 값으로 설정되어 있어야 함."""
|
||||
from signal_v2.stock_client import _TTL
|
||||
|
||||
|
||||
def test_portfolio_ttl_is_180s():
|
||||
"""portfolio TTL은 180초 이상 (3분 폴링에서 1회 fetch가 3 폴링 커버)."""
|
||||
assert _TTL["portfolio"] >= 180.0
|
||||
|
||||
|
||||
def test_news_sentiment_ttl_is_600s():
|
||||
"""news-sentiment TTL은 600초 이상 (10분, 뉴스 sentiment는 자주 안 바뀜)."""
|
||||
assert _TTL["news-sentiment"] >= 600.0
|
||||
|
||||
|
||||
def test_screener_preview_ttl_is_300s():
|
||||
"""screener-preview TTL은 300초 이상 (5분, Top-20은 분 단위로 거의 안 바뀜)."""
|
||||
assert _TTL["screener-preview"] >= 300.0
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실패 확인**
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai && python -m pytest signal_v2/tests/test_stock_client_ttl.py -v`
|
||||
Expected: FAIL — 현재 _TTL 값은 60/300/60. portfolio·screener-preview 모두 < 180/300.
|
||||
|
||||
- [ ] **Step 3: `_TTL` 값 변경**
|
||||
|
||||
`C:/Users/jaeoh/Desktop/workspace/web-ai/signal_v2/stock_client.py` line 13-17:
|
||||
|
||||
변경 전:
|
||||
```python
|
||||
_TTL = {
|
||||
"portfolio": 60.0,
|
||||
"news-sentiment": 300.0,
|
||||
"screener-preview": 60.0,
|
||||
}
|
||||
```
|
||||
|
||||
변경 후:
|
||||
```python
|
||||
# Cache TTL by endpoint (seconds).
|
||||
# 2026-05-18 — NAS 인바운드 호출 부담 완화 (Plan-A SP-A1).
|
||||
_TTL = {
|
||||
"portfolio": 180.0, # 3분 (1분 폴링 시 3 폴링당 1회 실제 fetch)
|
||||
"news-sentiment": 600.0, # 10분 (뉴스 sentiment는 자주 안 바뀜)
|
||||
"screener-preview": 300.0, # 5분 (Top-20은 분 단위로 거의 안 바뀜)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 테스트 통과 확인**
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai && python -m pytest signal_v2/tests/test_stock_client_ttl.py -v`
|
||||
Expected: PASS — 3개 모두 통과.
|
||||
|
||||
- [ ] **Step 5: 전체 회귀 확인 (기존 56 tests + 신규 3 tests)**
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai && python -m pytest signal_v2/tests/ -v 2>&1 | tail -5`
|
||||
Expected: 59 tests 모두 PASS (기존 56 + 신규 3).
|
||||
|
||||
- [ ] **Step 6: 커밋**
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
||||
git add signal_v2/stock_client.py signal_v2/tests/test_stock_client_ttl.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
perf(signal_v2): raise stock_client TTL for NAS load relief (SP-A1)
|
||||
|
||||
portfolio 60s → 180s (3분 폴링 → 3회당 1회 fetch)
|
||||
news-sent 300s → 600s (sentiment는 자주 안 바뀜)
|
||||
screener 60s → 300s (Top-20 분 단위 변화 미미)
|
||||
|
||||
V2 재시작 시점부터 NAS stock에 대한 인바운드 호출이
|
||||
분당 12 → 분당 3~4 로 감소 예상. 캐시 hit ratio 0~50% → 66~80%.
|
||||
회귀 테스트 3건 추가로 미래 의도치 않은 TTL 변경 차단.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: NAS SP-A2 — `cachetools` 의존성 추가
|
||||
|
||||
**Files:**
|
||||
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/requirements.txt`
|
||||
|
||||
- [ ] **Step 1: 현재 requirements.txt 확인**
|
||||
|
||||
Run: `cat C:/Users/jaeoh/Desktop/workspace/web-backend/stock/requirements.txt`
|
||||
파일 끝 확인 — 마지막 줄 newline 여부 확인 (sed/append 안전).
|
||||
|
||||
- [ ] **Step 2: cachetools 추가**
|
||||
|
||||
`C:/Users/jaeoh/Desktop/workspace/web-backend/stock/requirements.txt` 끝에 한 줄 추가:
|
||||
|
||||
```
|
||||
cachetools>=5.3
|
||||
```
|
||||
|
||||
(파일 마지막에 newline 없으면 newline 먼저, 그 다음 cachetools 줄.)
|
||||
|
||||
- [ ] **Step 3: 로컬 import 가능 여부 확인 (선택, NAS rebuild가 정본)**
|
||||
|
||||
Run (Windows 로컬에서 docker 외부 검증용, 선택):
|
||||
```bash
|
||||
python -c "import cachetools; print(cachetools.__version__)" 2>&1
|
||||
```
|
||||
|
||||
로컬 미설치라면 skip — NAS deployer가 rebuild 시 install. 이 plan은 코드 정합성만 보장.
|
||||
|
||||
- [ ] **Step 4: 커밋 (단독 커밋, deps만)**
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||||
git add stock/requirements.txt
|
||||
git commit -m "$(cat <<'EOF'
|
||||
chore(stock): add cachetools for server-side TTLCache (SP-A2 prep)
|
||||
|
||||
다음 커밋에서 /api/webai/portfolio·news-sentiment·screener/run에
|
||||
in-memory TTLCache 적용 예정.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: NAS SP-A2 — `webai_cache.py` 모듈 + 단위 테스트
|
||||
|
||||
**Files:**
|
||||
- Create: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/webai_cache.py`
|
||||
- Create: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/test_webai_cache.py`
|
||||
|
||||
- [ ] **Step 1: 실패하는 테스트 작성**
|
||||
|
||||
`C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/test_webai_cache.py`:
|
||||
|
||||
```python
|
||||
"""SP-A2 — webai_cache module의 cache hit/miss + key 분기 검증."""
|
||||
import time
|
||||
import pytest
|
||||
from app.webai_cache import (
|
||||
PORTFOLIO_CACHE, NEWS_CACHE, SCREENER_CACHE,
|
||||
cache_get_portfolio, cache_set_portfolio,
|
||||
cache_get_news, cache_set_news,
|
||||
cache_get_screener, cache_set_screener,
|
||||
_screener_key,
|
||||
)
|
||||
|
||||
|
||||
def _clear_all():
|
||||
PORTFOLIO_CACHE.clear()
|
||||
NEWS_CACHE.clear()
|
||||
SCREENER_CACHE.clear()
|
||||
|
||||
|
||||
def test_portfolio_cache_miss_then_hit():
|
||||
_clear_all()
|
||||
assert cache_get_portfolio() is None
|
||||
cache_set_portfolio({"holdings": [], "cash": 0})
|
||||
assert cache_get_portfolio() == {"holdings": [], "cash": 0}
|
||||
|
||||
|
||||
def test_news_cache_key_by_date():
|
||||
"""date가 다르면 별도 캐시 슬롯."""
|
||||
_clear_all()
|
||||
cache_set_news("2026-05-18", {"count": 5})
|
||||
cache_set_news("2026-05-17", {"count": 3})
|
||||
assert cache_get_news("2026-05-18") == {"count": 5}
|
||||
assert cache_get_news("2026-05-17") == {"count": 3}
|
||||
assert cache_get_news("2026-05-16") is None # not cached
|
||||
|
||||
|
||||
def test_news_cache_latest_key_normalized():
|
||||
"""date=None은 'latest' 키로 정규화되어 동일 슬롯."""
|
||||
_clear_all()
|
||||
cache_set_news(None, {"count": 9})
|
||||
assert cache_get_news(None) == {"count": 9}
|
||||
|
||||
|
||||
def test_screener_key_includes_mode_and_top_n():
|
||||
"""screener key는 mode + top_n + weights hash로 분기."""
|
||||
k_preview = _screener_key("preview", 20, None)
|
||||
k_preview_w = _screener_key("preview", 20, {"news": 0.3})
|
||||
k_auto = _screener_key("auto", 20, None)
|
||||
assert k_preview != k_preview_w
|
||||
assert k_preview != k_auto
|
||||
|
||||
|
||||
def test_screener_cache_roundtrip():
|
||||
_clear_all()
|
||||
payload = {"asof": "2026-05-18", "survivors_count": 17}
|
||||
cache_set_screener("preview", 20, None, payload)
|
||||
assert cache_get_screener("preview", 20, None) == payload
|
||||
assert cache_get_screener("preview", 20, {"news": 0.3}) is None
|
||||
|
||||
|
||||
def test_ttl_expiry_portfolio():
|
||||
"""짧은 ttl로 만료 확인 — 직접 시간 조작 대신 TTLCache 내부 동작 신뢰."""
|
||||
from cachetools import TTLCache
|
||||
short = TTLCache(maxsize=1, ttl=0.1) # 0.1초
|
||||
short["result"] = "x"
|
||||
assert short.get("result") == "x"
|
||||
time.sleep(0.2)
|
||||
assert short.get("result") is None
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실패 확인**
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -m pytest app/test_webai_cache.py -v`
|
||||
Expected: FAIL — `app.webai_cache` 모듈 존재 안 함.
|
||||
|
||||
- [ ] **Step 3: `webai_cache.py` 작성**
|
||||
|
||||
`C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/webai_cache.py`:
|
||||
|
||||
```python
|
||||
"""SP-A2 — NAS stock의 /api/webai/* 엔드포인트 in-memory TTLCache.
|
||||
|
||||
web-ai 측 캐시(stock_client._TTL)가 miss됐을 때도 NAS에서 같은 데이터를
|
||||
KIS·LLM 재호출 없이 즉시 반환하기 위한 2-layer 캐시의 server 측.
|
||||
V1+V2가 동시 호출해도 NAS는 1회만 계산.
|
||||
|
||||
TTL 정책 (spec §10 SP-A2):
|
||||
- portfolio: 120s (web-ai TTL 180s 보다 짧게 — 변경 감지 가능)
|
||||
- news: 600s (sentiment는 일 단위)
|
||||
- screener: 180s
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from cachetools import TTLCache
|
||||
|
||||
|
||||
PORTFOLIO_CACHE: TTLCache = TTLCache(maxsize=1, ttl=120.0)
|
||||
NEWS_CACHE: TTLCache = TTLCache(maxsize=10, ttl=600.0)
|
||||
SCREENER_CACHE: TTLCache = TTLCache(maxsize=10, ttl=180.0)
|
||||
|
||||
|
||||
# ----- portfolio -----
|
||||
|
||||
def cache_get_portfolio() -> Optional[Any]:
|
||||
return PORTFOLIO_CACHE.get("result")
|
||||
|
||||
|
||||
def cache_set_portfolio(value: Any) -> None:
|
||||
PORTFOLIO_CACHE["result"] = value
|
||||
|
||||
|
||||
# ----- news-sentiment -----
|
||||
|
||||
def _news_key(date: Optional[str]) -> str:
|
||||
return date if date else "latest"
|
||||
|
||||
|
||||
def cache_get_news(date: Optional[str]) -> Optional[Any]:
|
||||
return NEWS_CACHE.get(_news_key(date))
|
||||
|
||||
|
||||
def cache_set_news(date: Optional[str], value: Any) -> None:
|
||||
NEWS_CACHE[_news_key(date)] = value
|
||||
|
||||
|
||||
# ----- screener -----
|
||||
|
||||
def _screener_key(mode: str, top_n: int, weights: Optional[dict]) -> str:
|
||||
"""mode + top_n + weights canonical hash. weights 객체 동등성을 키로."""
|
||||
if weights is None:
|
||||
w_repr = "none"
|
||||
else:
|
||||
# canonical: sorted keys → md5 hex (긴 weights도 짧은 키로)
|
||||
canon = json.dumps(weights, sort_keys=True, ensure_ascii=False)
|
||||
w_repr = hashlib.md5(canon.encode("utf-8")).hexdigest()[:12]
|
||||
return f"{mode}:{top_n}:{w_repr}"
|
||||
|
||||
|
||||
def cache_get_screener(mode: str, top_n: int, weights: Optional[dict]) -> Optional[Any]:
|
||||
return SCREENER_CACHE.get(_screener_key(mode, top_n, weights))
|
||||
|
||||
|
||||
def cache_set_screener(mode: str, top_n: int, weights: Optional[dict], value: Any) -> None:
|
||||
SCREENER_CACHE[_screener_key(mode, top_n, weights)] = value
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 테스트 통과 확인**
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -m pytest app/test_webai_cache.py -v`
|
||||
Expected: PASS — 6개 모두 통과.
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||||
git add stock/app/webai_cache.py stock/app/test_webai_cache.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(stock): webai_cache module (TTLCache for SP-A2)
|
||||
|
||||
3개의 TTLCache (portfolio 120s · news 600s · screener 180s) +
|
||||
헬퍼 함수. screener key는 mode + top_n + weights canonical hash로
|
||||
분기. 다음 커밋에서 /api/webai/portfolio·news-sentiment·screener/run
|
||||
3 endpoint에 적용.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: NAS SP-A2 — `/api/webai/portfolio` 캐시 적용
|
||||
|
||||
**Files:**
|
||||
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/main.py:419-422`
|
||||
|
||||
- [ ] **Step 1: 현재 endpoint 코드 확인**
|
||||
|
||||
`web-backend/stock/app/main.py` 419-422 line은 spec §10 SP-A2와 일치:
|
||||
```python
|
||||
@app.get("/api/webai/portfolio", dependencies=[Depends(verify_webai_key)])
|
||||
def get_webai_portfolio():
|
||||
"""web-ai 전용 portfolio (인증 필수, pnl_pct 비율 필드 추가)."""
|
||||
return _augment_portfolio_with_pnl_pct(get_portfolio())
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 캐시 적용으로 교체**
|
||||
|
||||
`web-backend/stock/app/main.py` 419-422 line을 다음으로 교체:
|
||||
|
||||
```python
|
||||
@app.get("/api/webai/portfolio", dependencies=[Depends(verify_webai_key)])
|
||||
def get_webai_portfolio():
|
||||
"""web-ai 전용 portfolio (인증 필수, pnl_pct 비율 필드 추가).
|
||||
|
||||
SP-A2 server-side TTLCache 적용. V1+V2 동시 호출도 NAS에서 1회 계산.
|
||||
"""
|
||||
cached = webai_cache.cache_get_portfolio()
|
||||
if cached is not None:
|
||||
return cached
|
||||
result = _augment_portfolio_with_pnl_pct(get_portfolio())
|
||||
webai_cache.cache_set_portfolio(result)
|
||||
return result
|
||||
```
|
||||
|
||||
- [ ] **Step 3: import 추가 (파일 상단)**
|
||||
|
||||
`web-backend/stock/app/main.py` 파일 상단 import 블록 (다른 `from .xxx import` 들과 같은 위치)에 추가:
|
||||
|
||||
```python
|
||||
from . import webai_cache
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 빠른 import sanity 체크**
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -c "from app import main; print('OK')"` 2>&1 | tail -3
|
||||
|
||||
(`cachetools` 미설치 환경에선 ImportError 가능 → 그 경우 `pip install cachetools` 후 재시도. 실제 검증은 NAS rebuild 후.)
|
||||
Expected: `OK` 또는 cachetools 누락 메시지 (의도된 상태).
|
||||
|
||||
---
|
||||
|
||||
## Task 5: NAS SP-A2 — `/api/webai/news-sentiment` 캐시 적용
|
||||
|
||||
**Files:**
|
||||
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/main.py:467-470`
|
||||
|
||||
- [ ] **Step 1: 캐시 적용**
|
||||
|
||||
`web-backend/stock/app/main.py` 467-470 line을 다음으로 교체:
|
||||
|
||||
```python
|
||||
@app.get("/api/webai/news-sentiment", dependencies=[Depends(verify_webai_key)])
|
||||
def get_webai_news_sentiment(date: str | None = None):
|
||||
"""web-ai 전용 news sentiment 일별 dump.
|
||||
|
||||
SP-A2 server-side TTLCache 적용. date 파라미터별로 별도 슬롯.
|
||||
"""
|
||||
cached = webai_cache.cache_get_news(date)
|
||||
if cached is not None:
|
||||
return cached
|
||||
result = _fetch_news_sentiment_dump(date)
|
||||
webai_cache.cache_set_news(date, result)
|
||||
return result
|
||||
```
|
||||
|
||||
- [ ] **Step 2: import sanity 체크**
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -c "from app import main; print('OK')" 2>&1 | tail -3`
|
||||
Expected: `OK`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: NAS SP-A2 — `/api/stock/screener/run` 캐시 적용 (preview 모드만)
|
||||
|
||||
**Files:**
|
||||
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/screener/router.py:173-...`
|
||||
|
||||
- [ ] **Step 1: 현재 함수 확인 (참고)**
|
||||
|
||||
`web-backend/stock/app/screener/router.py:173` 시작 `def post_run(body: schemas.RunRequest):` — 함수 본체는 mode 분기 후 _conn() + KIS 호출 등. 단, `mode == "auto"` 는 휴장일/실 운영 트리거이므로 캐시하지 않음 (매 호출이 다른 의미). `mode == "preview"` 는 frontend·web-ai 폴링용 → 캐시 적용.
|
||||
|
||||
- [ ] **Step 2: 함수 진입부에 cache 분기 추가**
|
||||
|
||||
`web-backend/stock/app/screener/router.py:173` `@router.post("/run", ...)` 의 `def post_run(...)` 본체 **첫 줄들에** 다음 캐시 분기 추가:
|
||||
|
||||
변경 전 (line 173-179 근처):
|
||||
```python
|
||||
@router.post("/run", response_model=schemas.RunResponse)
|
||||
def post_run(body: schemas.RunRequest):
|
||||
from .registry import NODE_REGISTRY as _NR, GATE_REGISTRY as _GR
|
||||
started_at = dt.datetime.utcnow().isoformat()
|
||||
with _conn() as c:
|
||||
asof = _resolve_asof(body.asof, c)
|
||||
```
|
||||
|
||||
변경 후:
|
||||
```python
|
||||
@router.post("/run", response_model=schemas.RunResponse)
|
||||
def post_run(body: schemas.RunRequest):
|
||||
from .registry import NODE_REGISTRY as _NR, GATE_REGISTRY as _GR
|
||||
# SP-A2 — preview 모드는 web-ai/frontend 폴링이라 캐시 적용.
|
||||
# auto 모드는 실제 운영 트리거(휴장일 게이트 등)라 캐시 미적용.
|
||||
if body.mode == "preview":
|
||||
cached = webai_cache.cache_get_screener(body.mode, body.top_n, body.weights)
|
||||
if cached is not None:
|
||||
return cached
|
||||
started_at = dt.datetime.utcnow().isoformat()
|
||||
with _conn() as c:
|
||||
asof = _resolve_asof(body.asof, c)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 함수 끝 부분 — preview 결과를 캐시에 저장**
|
||||
|
||||
`post_run`의 반환부 직전에 (preview 모드일 때만) 캐시 저장. `post_run` 함수는 결과를 `schemas.RunResponse(...)` 로 만들어 return하는 구조일 것. 정확한 return 위치 확인 후, return 직전에:
|
||||
|
||||
`web-backend/stock/app/screener/router.py` `post_run` 함수의 마지막 return 직전에:
|
||||
|
||||
```python
|
||||
# SP-A2 — preview 모드 결과 캐시 저장.
|
||||
if body.mode == "preview":
|
||||
webai_cache.cache_set_screener(body.mode, body.top_n, body.weights, response)
|
||||
return response
|
||||
```
|
||||
|
||||
(`response` 라는 변수가 없으면, 기존 return 표현식을 `response = ...` 로 binding 후 위 코드 추가.)
|
||||
|
||||
> **주의:** post_run의 정확한 return 라인을 먼저 확인. `grep -n "return " app/screener/router.py | head` 로 위치 파악 후 적용.
|
||||
|
||||
- [ ] **Step 4: import 추가 (router.py 상단)**
|
||||
|
||||
`web-backend/stock/app/screener/router.py` 상단 import 블록에 추가:
|
||||
|
||||
```python
|
||||
from .. import webai_cache
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 빠른 import sanity 체크**
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -c "from app.screener import router; print('OK')" 2>&1 | tail -3`
|
||||
Expected: `OK`
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 통합 검증 — 기존 테스트 회귀 + SP-A2 신규 테스트
|
||||
|
||||
**Files:** (조회만)
|
||||
|
||||
- [ ] **Step 1: stock 전체 pytest 실행**
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -m pytest -v 2>&1 | tail -30`
|
||||
Expected: 기존 모든 테스트 + SP-A2 신규 6 tests 모두 PASS. **0 failed**.
|
||||
|
||||
- [ ] **Step 2: 회귀 발견 시 처리**
|
||||
|
||||
회귀가 발견되면:
|
||||
- import 누락 → `from . import webai_cache` 또는 `from .. import webai_cache` 위치 재확인
|
||||
- screener test가 cache hit으로 fail → test가 `_clear_all()` 또는 cache fixture 통해 격리되어 있는지 확인. 필요 시 conftest에 `autouse=True` cache reset fixture 추가:
|
||||
|
||||
```python
|
||||
# conftest.py에 추가 (선택)
|
||||
import pytest
|
||||
from app import webai_cache
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_webai_cache():
|
||||
webai_cache.PORTFOLIO_CACHE.clear()
|
||||
webai_cache.NEWS_CACHE.clear()
|
||||
webai_cache.SCREENER_CACHE.clear()
|
||||
yield
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 커밋 (SP-A2 endpoint 통합 + 회귀 확인)**
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||||
git add stock/app/main.py stock/app/screener/router.py
|
||||
# (필요 시) git add stock/app/conftest.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(stock): apply webai_cache to portfolio/news/screener-preview (SP-A2)
|
||||
|
||||
3 endpoint cache 적용 — /api/webai/portfolio, /api/webai/news-sentiment,
|
||||
/api/stock/screener/run (preview 모드만, auto는 캐시 미적용).
|
||||
V1+V2 동시 호출도 NAS에서 1회 계산. web-ai 측 SP-A1 캐시와 2-layer로
|
||||
작동하여 NAS 인바운드 부담 70% 감소 예상.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: 양쪽 push + NAS deploy 트리거
|
||||
|
||||
**Files:** 없음 (git 작업)
|
||||
|
||||
- [ ] **Step 1: web-ai push**
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
||||
git push origin main
|
||||
```
|
||||
|
||||
Expected: success. 인증 prompt 뜨면 자격증명 입력. 1회 실패 시 1회 재시도 (캐시 패턴).
|
||||
|
||||
> **참고:** web-ai는 NAS deployer가 별도 webhook 없음 (Windows 머신 코드). push는 백업/이력 동기화 목적. 실제 적용은 V2 재시작 시점.
|
||||
|
||||
- [ ] **Step 2: web-backend push (NAS deployer 트리거)**
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||||
git push origin main
|
||||
```
|
||||
|
||||
Expected: success. NAS deployer가 webhook 수신 → `git pull` → `docker compose build stock --no-cache` (cachetools 신규 설치) → `docker compose up -d stock`. 통상 2~3분 소요.
|
||||
|
||||
- [ ] **Step 3: NAS stock 컨테이너 헬스 확인**
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "HTTP %{http_code}\n" https://gahusb.synology.me/api/stock/news -m 10
|
||||
```
|
||||
|
||||
Expected: `HTTP 200`. (NAS deploy 완료 후 통상 30초 ~ 2분 대기 필요.)
|
||||
|
||||
- [ ] **Step 4: webai 캐시 효과 확인 (선택)**
|
||||
|
||||
연속 2회 호출 시 두 번째가 즉시 응답하는지 (cached):
|
||||
|
||||
```bash
|
||||
# 인증키 필요. .env의 WEBAI_API_KEY 사용 또는 NAS에서 직접 호출.
|
||||
# Windows 로컬에서:
|
||||
# 첫 호출
|
||||
time curl -s -H "X-WebAI-Key: $WEBAI_API_KEY" https://gahusb.synology.me/api/webai/portfolio -o /dev/null
|
||||
# 즉시 두번째 (캐시 hit 기대, 첫 호출 < 1s + DB / 두번째 < 100ms)
|
||||
time curl -s -H "X-WebAI-Key: $WEBAI_API_KEY" https://gahusb.synology.me/api/webai/portfolio -o /dev/null
|
||||
```
|
||||
|
||||
Expected: 두 번째 호출이 첫 번째보다 명확히 빠름 (DB·계산 skip).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
### Spec 커버리지
|
||||
|
||||
| Spec 요구사항 | 구현 Task |
|
||||
|---------------|-----------|
|
||||
| §4 SP-A1: web-ai 캐시 TTL 증가 (180/600/300) | Task 1 |
|
||||
| §4 SP-A2: NAS stock TTLCache | Task 2~7 |
|
||||
| §10 SP-A2: 3 endpoint (portfolio/news/screener) 적용 | Task 4 (portfolio), Task 5 (news), Task 6 (screener preview) |
|
||||
| §10 SP-A2: cachetools 의존성 | Task 2 |
|
||||
| §8: X-WebAI-Key 인증 (기존 verify_webai_key 유지) | 기존 dependency 그대로, 변경 없음 |
|
||||
| §6: server cache 별개 (Redis 캐시 옵션) | in-memory TTLCache 선택 (Redis는 SP-1 이후 도입 검토) |
|
||||
|
||||
§4의 SP-A2는 `/api/webai/portfolio`, `/api/webai/news-sentiment`, `/api/stock/screener/run` 3건만 명시. 추가 endpoint 캐시는 out of scope (별도 plan에서).
|
||||
|
||||
### Placeholder 스캔
|
||||
|
||||
- TBD/TODO/"implement later" 패턴 없음 ✓
|
||||
- 모든 code step에 완전 코드 포함 ✓
|
||||
- Task 6에 한 가지 conditional ("`post_run`의 정확한 return 라인을 먼저 확인") — 이건 plan 실행 시 grep 명령으로 즉시 해결 가능한 단순 lookup이라 placeholder가 아님. 그러나 안전성 위해 helper note 그대로 유지.
|
||||
|
||||
### Type consistency
|
||||
|
||||
- `webai_cache.cache_get_portfolio()` / `cache_set_portfolio(value)` — Task 3에서 정의, Task 4에서 사용. 시그니처 일치 ✓
|
||||
- `cache_get_news(date)` — Task 3·5 일치 ✓
|
||||
- `cache_get_screener(mode, top_n, weights)` / `cache_set_screener(mode, top_n, weights, value)` — Task 3·6 일치 ✓
|
||||
- 변수명 `cached`, `result`, `payload` — 각 함수 안에서만 사용, 충돌 없음 ✓
|
||||
|
||||
### 위험·주의
|
||||
|
||||
- **NAS deployer rebuild**: `requirements.txt` 변경은 docker image rebuild 필요. deployer가 변경 감지 시 rebuild 트리거. 만약 deployer가 변경 미감지(예: requirements.txt만 변경 시 rebuild 안 함)라면 NAS에서 `docker compose build stock --no-cache && docker compose up -d stock` 수동 실행 필요.
|
||||
- **Cache stale**: TTL이 충분히 짧아 stale 문제 미미. portfolio 120s = web-ai 폴링 주기(1분) 2배. 변경 감지에 최대 2분 지연.
|
||||
- **Cache miss thunder herd**: V1+V2가 정확히 동시에 캐시 miss 시 KIS 동시 호출 가능. 현재 V1/V2 둘 다 정지 상태라 risk 0. 향후 재시작 시 KIS rate limit 모니터링 필요 (별도 plan 항목).
|
||||
|
||||
---
|
||||
|
||||
## 완료 후 다음 단계
|
||||
|
||||
Plan-A 완료 후 spec §14 "차후 plan 작성 순서 권장"대로:
|
||||
|
||||
1. **Plan-B-Base** — SP-1 (Redis) + SP-2 (WSL2)
|
||||
2. **Plan-B-Insta** — SP-3 + SP-4
|
||||
3. **Plan-B-Music** — SP-5 + SP-6
|
||||
4. **Plan-B-Video** — SP-7 + SP-8
|
||||
5. **Plan-B-Infra** — SP-9 + SP-10
|
||||
|
||||
각각은 별도 brainstorm 없이 spec §10에서 직접 plan 작성 가능 (이미 명세 충분).
|
||||
1887
docs/superpowers/plans/2026-05-19-plan-b-insta-render.md
Normal file
1887
docs/superpowers/plans/2026-05-19-plan-b-insta-render.md
Normal file
File diff suppressed because it is too large
Load Diff
3241
docs/superpowers/plans/2026-05-19-plan-b-music-render.md
Normal file
3241
docs/superpowers/plans/2026-05-19-plan-b-music-render.md
Normal file
File diff suppressed because it is too large
Load Diff
2573
docs/superpowers/plans/2026-05-19-plan-b-video-render.md
Normal file
2573
docs/superpowers/plans/2026-05-19-plan-b-video-render.md
Normal file
File diff suppressed because it is too large
Load Diff
1651
docs/superpowers/plans/2026-05-20-lotto-active-agent.md
Normal file
1651
docs/superpowers/plans/2026-05-20-lotto-active-agent.md
Normal file
File diff suppressed because it is too large
Load Diff
1587
docs/superpowers/plans/2026-05-22-lotto-weight-evolver.md
Normal file
1587
docs/superpowers/plans/2026-05-22-lotto-weight-evolver.md
Normal file
File diff suppressed because it is too large
Load Diff
929
docs/superpowers/plans/2026-05-22-plan-b-infra.md
Normal file
929
docs/superpowers/plans/2026-05-22-plan-b-infra.md
Normal file
@@ -0,0 +1,929 @@
|
||||
# Plan-B-Infra — NSSM 자동 시작 + task-watcher (시간대 큐 토글) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Windows AI 머신의 서비스(ai_trade + WSL2 Docker)를 NSSM으로 부팅 시 자동 시작 + 우선순위 설정(SP-9), 그리고 시간대 기반으로 `queue:paused`를 토글하는 task-watcher 컨테이너 신설(SP-10). 트레이딩 시간대(비휴장 평일 07:00–16:30)에 무거운 render 작업을 일시정지하여 KIS 트레이딩 우선순위 보장.
|
||||
|
||||
**Architecture:** task-watcher는 WSL2 Docker 컨테이너로 30초마다 `current_mode()` 판정(KST 시각 + NAS `/api/stock/holidays` 조회) → 트레이딩 시간대면 `SET queue:paused 1 EX 600`, 그 외엔 `DEL queue:paused`. 모든 render worker(insta/music/video)가 BLPOP 전 `queue:paused`를 확인하므로 단일 키로 전체 일시정지. NSSM(SP-9)은 박재오 Windows 머신에서 수동 설치 — plan은 정확한 명령 + 안내 문서 제공.
|
||||
|
||||
**Tech Stack:** Python 3.12 / `redis>=5.0` / `httpx` (holidays fetch) / `zoneinfo` (KST) / Docker Engine in WSL2 / NSSM (Windows service manager) / FastAPI (NAS stock holidays endpoint)
|
||||
|
||||
**Spec:** `web-backend/docs/superpowers/specs/2026-05-18-nas-windows-distributed-architecture-design.md` §3 시간대별 우선순위 모드, §10 SP-9·SP-10. **박재오 결정 (2026-05-22): idle/게임 감지 생략 — 시간대만으로 토글** (spec §3의 "박재오 활동 감지 시 SET" → "트레이딩 시간대면 무조건 SET"). idle 감지가 없으므로 WSL2 컨테이너로 구현 가능 (Win32 input API 불필요).
|
||||
|
||||
**Spec 갱신 사항 (현 상태 반영):**
|
||||
- `signal_v2` → **`ai_trade`** (rename 완료, web-ai/ai_trade/)
|
||||
- `Ubuntu-22.04` → **`Ubuntu-24.04`** (Plan-B-Base에서 변경)
|
||||
- `web-ai-services` → **`web-ai/services`** (실제 경로)
|
||||
- `/api/stock/holidays` endpoint **미존재 → 신설** (Task 1)
|
||||
|
||||
**Prerequisites (✅ 모두 완료):**
|
||||
- Plan-A / Plan-B-Base / Plan-B-Insta / Plan-B-Music / Plan-B-Video 모두 완료
|
||||
- WSL2 mirror mode + Redis chown 999:999 영구 적용
|
||||
- services/.env 분기 패턴 정착 (NAS_BASE_URL service-local default)
|
||||
|
||||
---
|
||||
|
||||
## Phase 구조
|
||||
|
||||
| Phase | 내용 | Task |
|
||||
|-------|------|------|
|
||||
| **1. NAS stock holidays endpoint** | `/api/stock/holidays` GET 신설 (task-watcher가 조회) | 1 |
|
||||
| **2. Windows task-watcher** | mode 판정 + Redis 토글 loop + Dockerfile + compose | 2~6 |
|
||||
| **3. NSSM 안내 + 검증** | SP-9 NSSM 안내 문서 + 박재오 빌드 + end-to-end | 7~8 |
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Phase 1 — NAS web-backend
|
||||
|
||||
| 파일 | 변경 | 책임 |
|
||||
|------|------|------|
|
||||
| `web-backend/stock/app/main.py` | `GET /api/stock/holidays` endpoint 추가 | holidays.json + 주말 노출 |
|
||||
| `web-backend/stock/app/test_holidays_endpoint.py` (Create) | 2 tests | TDD |
|
||||
|
||||
### Phase 2 — Windows web-ai/services/task-watcher
|
||||
|
||||
| 파일 | 변경 | 책임 |
|
||||
|------|------|------|
|
||||
| `web-ai/services/task-watcher/mode.py` (Create) | `current_mode(now, holidays)` 순수 함수 + `fetch_holidays()` | 시간대 판정 |
|
||||
| `web-ai/services/task-watcher/watcher.py` (Create) | 30초 loop + Redis 토글 | dispatcher |
|
||||
| `web-ai/services/task-watcher/main.py` (Create) | FastAPI + lifespan(watcher spawn) + /health | entry |
|
||||
| `web-ai/services/task-watcher/Dockerfile` (Create) | python:3.12-slim | image |
|
||||
| `web-ai/services/task-watcher/requirements.txt` (Create) | fastapi, redis, httpx, pytest | deps |
|
||||
| `web-ai/services/task-watcher/.env.example` (Create) | REDIS_URL, STOCK_BASE_URL, TRADING_START, TRADING_END | secrets |
|
||||
| `web-ai/services/task-watcher/tests/test_mode.py` (Create) | current_mode 6 cases | TDD |
|
||||
| `web-ai/services/task-watcher/tests/__init__.py` (Create) | 빈 marker | pkg |
|
||||
| `web-ai/services/docker-compose.yml` | task-watcher service 추가 (port 18713) | compose |
|
||||
|
||||
### Phase 3 — 안내 문서
|
||||
|
||||
| 파일 | 변경 | 책임 |
|
||||
|------|------|------|
|
||||
| `web-ai/services/task-watcher/NSSM_SETUP.md` (Create) | SP-9 NSSM 설치 안내 (ai_trade + wsl_docker + task-watcher) | 박재오 수동 가이드 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: NAS stock — `/api/stock/holidays` endpoint + tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/main.py`
|
||||
- Create: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/test_holidays_endpoint.py`
|
||||
|
||||
### Step 1: 실패 테스트 작성
|
||||
|
||||
`C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/test_holidays_endpoint.py`:
|
||||
|
||||
```python
|
||||
"""GET /api/stock/holidays — task-watcher 휴장일 조회용."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_holidays_returns_list():
|
||||
r = client.get("/api/stock/holidays")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert "holidays" in data
|
||||
assert isinstance(data["holidays"], list)
|
||||
|
||||
|
||||
def test_holidays_entries_are_iso_dates():
|
||||
r = client.get("/api/stock/holidays")
|
||||
holidays = r.json()["holidays"]
|
||||
# 비어 있지 않다면 ISO date 형식 (YYYY-MM-DD)
|
||||
if holidays:
|
||||
import datetime as dt
|
||||
for h in holidays[:5]:
|
||||
dt.date.fromisoformat(h) # raise 안 하면 통과
|
||||
```
|
||||
|
||||
### Step 2: 테스트 실패 확인
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -m pytest app/test_holidays_endpoint.py -v`
|
||||
Expected: FAIL — endpoint 404.
|
||||
|
||||
### Step 3: `main.py`에 endpoint 추가
|
||||
|
||||
`C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/main.py`에서 `_HOLIDAYS_PATH` (현재 line 82 부근) 정의를 활용. 적절한 위치(다른 `@app.get` 근처)에 추가:
|
||||
|
||||
```python
|
||||
@app.get("/api/stock/holidays")
|
||||
def get_holidays():
|
||||
"""task-watcher가 조회하는 휴장일 목록. holidays.json 그대로 노출 (인증 불필요)."""
|
||||
import json
|
||||
try:
|
||||
with open(_HOLIDAYS_PATH, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
# holidays.json 구조가 list이거나 {"holidays": [...]} 또는 {year: [...]} 형태일 수 있음
|
||||
if isinstance(data, list):
|
||||
holidays = data
|
||||
elif isinstance(data, dict) and "holidays" in data:
|
||||
holidays = data["holidays"]
|
||||
elif isinstance(data, dict):
|
||||
# {year: [dates]} → flatten
|
||||
holidays = [d for v in data.values() if isinstance(v, list) for d in v]
|
||||
else:
|
||||
holidays = []
|
||||
except (OSError, ValueError):
|
||||
holidays = []
|
||||
return {"holidays": holidays}
|
||||
```
|
||||
|
||||
**주의:** 작성 전 `holidays.json` 실제 구조를 확인할 것 (`Read web-backend/stock/app/holidays.json`). 위 코드는 list / `{"holidays":[]}` / `{year:[]}` 3가지 형태를 모두 처리하지만, 실제 구조에 맞게 단순화 가능.
|
||||
|
||||
### Step 4: 테스트 통과
|
||||
|
||||
Run: `python -m pytest app/test_holidays_endpoint.py -v`
|
||||
Expected: 2 PASS.
|
||||
|
||||
### Step 5: 회귀 확인
|
||||
|
||||
Run: `python -m pytest app/ -v 2>&1 | tail -5`
|
||||
Expected: 기존 stock 테스트 모두 통과 + 새 2개.
|
||||
|
||||
### Step 6: 커밋
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||||
git add stock/app/main.py stock/app/test_holidays_endpoint.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(stock): GET /api/stock/holidays endpoint (SP-10 task-watcher용)
|
||||
|
||||
holidays.json 노출. task-watcher가 휴장일 판정에 조회.
|
||||
인증 불필요 (민감 정보 아님). 주말은 task-watcher가 weekday로 별도 판정.
|
||||
Plan-B-Infra Phase 1.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- spec §3: "휴장일 단일 소스 — web-backend/stock/app/holidays.json 정본. NAS stock이 GET /api/stock/holidays로 노출."
|
||||
- 현재 holidays.json은 `_is_holiday()` 내부 함수에서만 사용, HTTP endpoint 없음 → 신설.
|
||||
- stock 컨테이너는 이미 deploy.sh BUILD_TARGETS에 등재됨 (신규 lab 아님 — deploy scripts 추가 불필요).
|
||||
- 작업 디렉토리: `C:/Users/jaeoh/Desktop/workspace/web-backend`
|
||||
|
||||
## Report
|
||||
|
||||
- Status: DONE | DONE_WITH_CONCERNS | BLOCKED
|
||||
- holidays.json 실제 구조 (확인 결과)
|
||||
- 2 PASS + 회귀
|
||||
- 커밋 SHA
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Windows task-watcher — mode.py (current_mode + fetch_holidays) + tests
|
||||
|
||||
**Files:**
|
||||
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/mode.py`
|
||||
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/tests/__init__.py`
|
||||
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/tests/test_mode.py`
|
||||
|
||||
### Step 1: 실패 테스트 작성
|
||||
|
||||
`tests/__init__.py`: (빈 파일)
|
||||
|
||||
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/tests/test_mode.py`:
|
||||
|
||||
```python
|
||||
"""current_mode — 시간대 + 휴장일 판정 (순수 함수)."""
|
||||
import datetime as dt
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from mode import current_mode
|
||||
|
||||
KST = ZoneInfo("Asia/Seoul")
|
||||
HOLIDAYS = {"2026-05-25"} # 가상 휴장일 (월요일)
|
||||
|
||||
|
||||
def _kst(y, m, d, hh, mm):
|
||||
return dt.datetime(y, m, d, hh, mm, tzinfo=KST)
|
||||
|
||||
|
||||
def test_weekday_trading_hours_is_trading():
|
||||
# 2026-05-22 금요일 10:00 — 트레이딩 시간대
|
||||
assert current_mode(_kst(2026, 5, 22, 10, 0), HOLIDAYS) == "trading"
|
||||
|
||||
|
||||
def test_weekday_before_open_is_free():
|
||||
# 평일 06:00 — 장 전
|
||||
assert current_mode(_kst(2026, 5, 22, 6, 0), HOLIDAYS) == "free"
|
||||
|
||||
|
||||
def test_weekday_after_close_is_free():
|
||||
# 평일 17:00 — 장 마감 후
|
||||
assert current_mode(_kst(2026, 5, 22, 17, 0), HOLIDAYS) == "free"
|
||||
|
||||
|
||||
def test_weekend_is_free():
|
||||
# 2026-05-23 토요일 10:00
|
||||
assert current_mode(_kst(2026, 5, 23, 10, 0), HOLIDAYS) == "free"
|
||||
|
||||
|
||||
def test_holiday_weekday_is_free():
|
||||
# 2026-05-25 월요일이지만 휴장일 → 트레이딩 시간대라도 free
|
||||
assert current_mode(_kst(2026, 5, 25, 10, 0), HOLIDAYS) == "free"
|
||||
|
||||
|
||||
def test_trading_boundary_inclusive_start_exclusive_end():
|
||||
# 07:00 정각 = 트레이딩 시작, 16:30 정각 = 마감 (16:30은 free)
|
||||
assert current_mode(_kst(2026, 5, 22, 7, 0), HOLIDAYS) == "trading"
|
||||
assert current_mode(_kst(2026, 5, 22, 16, 29), HOLIDAYS) == "trading"
|
||||
assert current_mode(_kst(2026, 5, 22, 16, 30), HOLIDAYS) == "free"
|
||||
```
|
||||
|
||||
### Step 2: 테스트 실패 확인
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher && python -m pytest tests/test_mode.py -v`
|
||||
Expected: FAIL — `mode` 모듈 미존재.
|
||||
|
||||
### Step 3: `mode.py` 작성
|
||||
|
||||
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/mode.py`:
|
||||
|
||||
```python
|
||||
"""시간대 + 휴장일 기반 모드 판정 (idle 감지 생략 — 박재오 결정 2026-05-22).
|
||||
|
||||
trading: 비휴장 평일 07:00–16:30 (장중) → queue:paused SET
|
||||
free: 그 외 (장 전/후, 주말, 휴장) → queue:paused DEL
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import logging
|
||||
import os
|
||||
from typing import Set
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KST = ZoneInfo("Asia/Seoul")
|
||||
STOCK_BASE_URL = os.getenv("STOCK_BASE_URL", "http://192.168.45.54:18500")
|
||||
|
||||
# 트레이딩 윈도우 (HH:MM, KST). .env로 조정 가능.
|
||||
TRADING_START = os.getenv("TRADING_START", "07:00")
|
||||
TRADING_END = os.getenv("TRADING_END", "16:30")
|
||||
|
||||
|
||||
def _parse_hhmm(s: str) -> dt.time:
|
||||
hh, mm = s.split(":")
|
||||
return dt.time(int(hh), int(mm))
|
||||
|
||||
|
||||
def current_mode(now: dt.datetime, holidays: Set[str]) -> str:
|
||||
"""now(KST aware) + holidays(ISO date set) → 'trading' | 'free'."""
|
||||
# 주말 (토=5, 일=6)
|
||||
if now.weekday() >= 5:
|
||||
return "free"
|
||||
# 휴장일
|
||||
if now.date().isoformat() in holidays:
|
||||
return "free"
|
||||
# 트레이딩 윈도우 [start, end)
|
||||
start = _parse_hhmm(TRADING_START)
|
||||
end = _parse_hhmm(TRADING_END)
|
||||
t = now.timetz().replace(tzinfo=None)
|
||||
if start <= t < end:
|
||||
return "trading"
|
||||
return "free"
|
||||
|
||||
|
||||
def fetch_holidays() -> Set[str]:
|
||||
"""NAS stock /api/stock/holidays 조회. 실패 시 빈 set (안전 — free로 판정)."""
|
||||
try:
|
||||
r = httpx.get(f"{STOCK_BASE_URL}/api/stock/holidays", timeout=10.0)
|
||||
if r.status_code == 200:
|
||||
return set(r.json().get("holidays", []))
|
||||
logger.warning("holidays fetch returned %d", r.status_code)
|
||||
except Exception:
|
||||
logger.exception("holidays fetch 실패")
|
||||
return set()
|
||||
```
|
||||
|
||||
### Step 4: 테스트 통과
|
||||
|
||||
Run: `python -m pytest tests/test_mode.py -v`
|
||||
Expected: 6 PASS.
|
||||
|
||||
### Step 5: 커밋
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
||||
git add services/task-watcher/mode.py services/task-watcher/tests/__init__.py services/task-watcher/tests/test_mode.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(task-watcher): mode.py — 시간대+휴장일 판정 (SP-10)
|
||||
|
||||
current_mode(now, holidays): 비휴장 평일 07:00–16:30 → trading, 그 외 free.
|
||||
fetch_holidays(): NAS /api/stock/holidays 조회 (실패 시 빈 set = free 안전).
|
||||
TRADING_START/END env로 윈도우 조정. idle 감지 생략 (박재오 결정).
|
||||
6 tests (평일 장중/장전/장후, 주말, 휴장, 경계).
|
||||
Plan-B-Infra Phase 2.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- KST 시각 + holidays set → trading/free 순수 함수. 테스트 용이 (now를 인자로).
|
||||
- holidays는 fetch_holidays()로 NAS 조회. 매 loop마다 호출하면 부하 — watcher.py에서 캐싱 (Task 3).
|
||||
- 작업 디렉토리: `C:/Users/jaeoh/Desktop/workspace/web-ai`
|
||||
|
||||
## Report
|
||||
- Status / 6 PASS / 커밋 SHA
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Windows task-watcher — watcher.py (Redis 토글 loop)
|
||||
|
||||
**Files:**
|
||||
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/watcher.py`
|
||||
|
||||
### Step 1: `watcher.py` 작성
|
||||
|
||||
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/watcher.py`:
|
||||
|
||||
```python
|
||||
"""30초마다 current_mode 판정 → queue:paused 토글.
|
||||
|
||||
trading → SET queue:paused 1 EX 600 (10분 TTL — watcher 죽어도 자동 해제)
|
||||
free → DEL queue:paused
|
||||
holidays는 1시간마다 refresh (매 loop fetch 부하 회피).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime as dt
|
||||
import logging
|
||||
import os
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from mode import current_mode, fetch_holidays, KST
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REDIS_URL = os.getenv("REDIS_URL", "redis://192.168.45.54:6379")
|
||||
PAUSED_KEY = "queue:paused"
|
||||
LOOP_INTERVAL = 30 # 초
|
||||
HOLIDAYS_REFRESH = 3600 # 1시간
|
||||
PAUSED_TTL = 600 # 10분 (watcher 죽어도 자동 해제)
|
||||
|
||||
|
||||
async def watcher_loop():
|
||||
redis = aioredis.from_url(REDIS_URL, decode_responses=False)
|
||||
holidays = fetch_holidays()
|
||||
last_holiday_refresh = dt.datetime.now(KST)
|
||||
last_mode = None
|
||||
logger.info("task-watcher started (trading window 토글)")
|
||||
|
||||
while True:
|
||||
try:
|
||||
now = dt.datetime.now(KST)
|
||||
# holidays 주기적 refresh
|
||||
if (now - last_holiday_refresh).total_seconds() >= HOLIDAYS_REFRESH:
|
||||
holidays = fetch_holidays()
|
||||
last_holiday_refresh = now
|
||||
|
||||
mode = current_mode(now, holidays)
|
||||
if mode == "trading":
|
||||
await redis.set(PAUSED_KEY, b"1", ex=PAUSED_TTL)
|
||||
else:
|
||||
await redis.delete(PAUSED_KEY)
|
||||
|
||||
if mode != last_mode:
|
||||
logger.info("mode 전환: %s → %s (paused=%s)", last_mode, mode, mode == "trading")
|
||||
last_mode = mode
|
||||
|
||||
await asyncio.sleep(LOOP_INTERVAL)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("watcher_loop cancelled")
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("watcher_loop iteration 실패, 30초 후 재시도")
|
||||
await asyncio.sleep(LOOP_INTERVAL)
|
||||
```
|
||||
|
||||
### Step 2: 임포트 smoke
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher && python -c "from watcher import watcher_loop; print('OK')"`
|
||||
Expected: `OK`.
|
||||
|
||||
### Step 3: 커밋
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
||||
git add services/task-watcher/watcher.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(task-watcher): watcher.py — 30초 loop + queue:paused 토글 (SP-10)
|
||||
|
||||
trading → SET queue:paused 1 EX 600 / free → DEL.
|
||||
holidays 1시간마다 refresh. PAUSED_TTL 600s (watcher 죽어도 자동 해제 — 안전).
|
||||
mode 전환 시에만 로그.
|
||||
Plan-B-Infra Phase 2.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- `PAUSED_TTL=600`이 핵심 안전장치: task-watcher가 죽어도 10분 후 자동으로 paused 해제 → 큐 영구 정지 방지.
|
||||
- holidays는 1시간 캐싱 (매 30초 fetch 안 함).
|
||||
- render worker들(insta/music/video)이 이미 `queue:paused` 체크 로직 보유 (Plan-B-Insta/Music/Video).
|
||||
|
||||
## Report
|
||||
- Status / smoke 결과 / 커밋 SHA
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Windows task-watcher — main.py + Dockerfile + requirements + .env.example
|
||||
|
||||
**Files:**
|
||||
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/main.py`
|
||||
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/Dockerfile`
|
||||
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/requirements.txt`
|
||||
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/.env.example`
|
||||
|
||||
### Step 1: `requirements.txt`
|
||||
|
||||
```
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
redis>=5.0
|
||||
httpx>=0.27
|
||||
pytest>=8.0
|
||||
```
|
||||
|
||||
### Step 2: `Dockerfile`
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.12-slim-bookworm
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
```
|
||||
|
||||
(tzdata 추가 — zoneinfo Asia/Seoul 사용.)
|
||||
|
||||
### Step 3: `.env.example`
|
||||
|
||||
```
|
||||
# Plan-B-Infra — task-watcher
|
||||
|
||||
# NAS Redis
|
||||
REDIS_URL=redis://192.168.45.54:6379
|
||||
|
||||
# NAS stock holidays endpoint
|
||||
STOCK_BASE_URL=http://192.168.45.54:18500
|
||||
|
||||
# 트레이딩 윈도우 (KST, HH:MM) — 이 시간대에만 queue:paused
|
||||
TRADING_START=07:00
|
||||
TRADING_END=16:30
|
||||
```
|
||||
|
||||
### Step 4: `main.py`
|
||||
|
||||
```python
|
||||
"""task-watcher FastAPI entry — health + lifespan (watcher loop spawn)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
import watcher
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
watcher_task = asyncio.create_task(watcher.watcher_loop())
|
||||
logger.info("task-watcher lifespan 시작")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
watcher_task.cancel()
|
||||
try:
|
||||
await watcher_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.info("task-watcher lifespan 종료")
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True, "service": "task-watcher"}
|
||||
```
|
||||
|
||||
### Step 5: smoke + 회귀
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher
|
||||
python -c "from main import app; print(len(app.routes))"
|
||||
python -m pytest tests/ -v 2>&1 | tail -5
|
||||
```
|
||||
Expected: 숫자 출력 + 6 PASS (test_mode).
|
||||
|
||||
### Step 6: 커밋
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
||||
git add services/task-watcher/main.py services/task-watcher/Dockerfile services/task-watcher/requirements.txt services/task-watcher/.env.example
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(task-watcher): main.py + Dockerfile + requirements + env (SP-10)
|
||||
|
||||
FastAPI lifespan에서 watcher_loop 스폰. /health. tzdata(zoneinfo Asia/Seoul).
|
||||
.env: REDIS_URL, STOCK_BASE_URL, TRADING_START/END.
|
||||
Plan-B-Infra Phase 2.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
## Report
|
||||
- Status / routes 개수 / 6 PASS / 커밋 SHA
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Windows services/docker-compose — task-watcher entry
|
||||
|
||||
**Files:**
|
||||
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/docker-compose.yml`
|
||||
|
||||
### Step 1: video-render service 다음에 task-watcher 추가
|
||||
|
||||
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/docker-compose.yml`에 추가:
|
||||
|
||||
```yaml
|
||||
|
||||
task-watcher:
|
||||
build:
|
||||
context: ./task-watcher
|
||||
container_name: task-watcher
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18713:8000"
|
||||
environment:
|
||||
- TZ=Asia/Seoul
|
||||
- REDIS_URL=${REDIS_URL:-redis://192.168.45.54:6379}
|
||||
- STOCK_BASE_URL=${STOCK_BASE_URL:-http://192.168.45.54:18500}
|
||||
- TRADING_START=${TRADING_START:-07:00}
|
||||
- TRADING_END=${TRADING_END:-16:30}
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
```
|
||||
|
||||
### Step 2: YAML 검증
|
||||
|
||||
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai/services && python -c "import yaml; yaml.safe_load(open('docker-compose.yml')); print('valid YAML')"`
|
||||
Expected: `valid YAML`.
|
||||
|
||||
### Step 3: 커밋 + push
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
||||
git add services/docker-compose.yml
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(task-watcher): services/docker-compose entry (SP-10)
|
||||
|
||||
port 18713, REDIS_URL/STOCK_BASE_URL/TRADING_START/END env.
|
||||
insta/music/video-render와 같은 services 묶음. outbound only.
|
||||
Plan-B-Infra Phase 2 완료 — 박재오 빌드 대기.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
git push 2>&1 # 자격증명 실패 시 박재오 수동 push
|
||||
```
|
||||
|
||||
## Report
|
||||
- Status / YAML 검증 / 커밋 SHA / push 결과
|
||||
|
||||
---
|
||||
|
||||
## Task 6: NSSM 안내 문서 (SP-9)
|
||||
|
||||
**Files:**
|
||||
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/NSSM_SETUP.md`
|
||||
|
||||
SP-9는 박재오 Windows 머신에서 NSSM 수동 설치. controller는 정확한 명령 + 안내 문서 작성. (코드 아님 — 안내 문서.)
|
||||
|
||||
### Step 1: `NSSM_SETUP.md` 작성
|
||||
|
||||
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/NSSM_SETUP.md`:
|
||||
|
||||
```markdown
|
||||
# NSSM 자동 시작 설정 (SP-9)
|
||||
|
||||
Windows AI 머신 부팅 시 ai_trade(트레이딩) + WSL2 Docker(render workers + task-watcher) 자동 시작.
|
||||
|
||||
## 1. NSSM 다운로드
|
||||
|
||||
https://nssm.cc/download → nssm-2.24.zip → `C:\nssm\nssm.exe` 배치 (또는 PATH 등록).
|
||||
|
||||
## 2. ai_trade (Native Python, HIGH priority)
|
||||
|
||||
⚠️ spec의 signal_v2는 ai_trade로 rename됨. 경로/포트 확인.
|
||||
|
||||
```powershell
|
||||
# 관리자 PowerShell
|
||||
C:\nssm\nssm.exe install ai_trade "C:\Python312\python.exe" "-m uvicorn main:app --host 0.0.0.0 --port 8001"
|
||||
C:\nssm\nssm.exe set ai_trade AppDirectory "C:\Users\jaeoh\Desktop\workspace\web-ai\ai_trade"
|
||||
C:\nssm\nssm.exe set ai_trade Priority HIGH_PRIORITY_CLASS
|
||||
C:\nssm\nssm.exe set ai_trade Start SERVICE_AUTO_START
|
||||
C:\nssm\nssm.exe set ai_trade AppStdout "C:\Users\jaeoh\nssm-logs\ai_trade.log"
|
||||
C:\nssm\nssm.exe set ai_trade AppStderr "C:\Users\jaeoh\nssm-logs\ai_trade.log"
|
||||
```
|
||||
|
||||
(ai_trade의 실제 진입점이 main:app + port 8001인지 확인. 다르면 조정.)
|
||||
|
||||
## 3. WSL2 Docker (NORMAL priority — render workers + task-watcher)
|
||||
|
||||
```powershell
|
||||
C:\nssm\nssm.exe install wsl_docker "C:\Windows\System32\wsl.exe" "-d Ubuntu-24.04 -- sh -c 'sudo service docker start && cd /workspace/web-ai/services && docker compose up -d'"
|
||||
C:\nssm\nssm.exe set wsl_docker Priority NORMAL_PRIORITY_CLASS
|
||||
C:\nssm\nssm.exe set wsl_docker Start SERVICE_AUTO_START
|
||||
C:\nssm\nssm.exe set wsl_docker AppStdout "C:\Users\jaeoh\nssm-logs\wsl_docker.log"
|
||||
```
|
||||
|
||||
⚠️ 변경점: Ubuntu-22.04 → **Ubuntu-24.04**, web-ai-services → **web-ai/services**. WSL 경로는 `/mnt/c/...` 또는 박재오 WSL 마운트 기준 (`/workspace`가 web-ai에 매핑되어 있으면 그대로).
|
||||
|
||||
`sudo service docker start`가 비밀번호 요구하면 sudoers에 NOPASSWD 추가:
|
||||
```bash
|
||||
# WSL2 안
|
||||
echo "$USER ALL=(ALL) NOPASSWD: /usr/sbin/service docker start" | sudo tee /etc/sudoers.d/docker-start
|
||||
```
|
||||
|
||||
## 4. 서비스 시작 + 확인
|
||||
|
||||
```powershell
|
||||
C:\nssm\nssm.exe start ai_trade
|
||||
C:\nssm\nssm.exe start wsl_docker
|
||||
|
||||
# 상태 확인
|
||||
C:\nssm\nssm.exe status ai_trade
|
||||
C:\nssm\nssm.exe status wsl_docker
|
||||
sc query ai_trade
|
||||
```
|
||||
|
||||
## 5. 검증
|
||||
|
||||
```powershell
|
||||
# ai_trade
|
||||
curl http://localhost:8001/health # 또는 ai_trade의 실제 health endpoint
|
||||
|
||||
# WSL2 docker 컨테이너 (재부팅 후 자동 시작 확인)
|
||||
wsl -d Ubuntu-24.04 -- docker ps
|
||||
# insta-render, music-render, video-render, task-watcher 4개 Up 확인
|
||||
```
|
||||
|
||||
## 6. 재부팅 테스트
|
||||
|
||||
Windows 재부팅 → 로그인 → 수동 조작 없이:
|
||||
- ai_trade 서비스 자동 시작 (HIGH priority)
|
||||
- WSL2 + Docker + 4 컨테이너 자동 시작 (NORMAL priority)
|
||||
- task-watcher가 trading window에 queue:paused 토글 시작
|
||||
|
||||
## task-watcher 동작 확인
|
||||
|
||||
```bash
|
||||
# WSL2
|
||||
docker logs task-watcher --tail 20
|
||||
# 기대: "task-watcher started" + mode 전환 로그 (trading/free)
|
||||
|
||||
# Redis 큐 상태 (NAS 또는 LAN)
|
||||
docker exec redis redis-cli GET queue:paused
|
||||
# 트레이딩 시간대(평일 07:00-16:30): "1"
|
||||
# 그 외: (nil)
|
||||
```
|
||||
```
|
||||
|
||||
### Step 2: 커밋 + push
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
||||
git add services/task-watcher/NSSM_SETUP.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
docs(task-watcher): NSSM_SETUP.md — SP-9 자동 시작 안내
|
||||
|
||||
ai_trade(HIGH, native python :8001) + wsl_docker(NORMAL, WSL2 Ubuntu-24.04
|
||||
docker compose up). spec의 signal_v2→ai_trade, 22.04→24.04, web-ai-services
|
||||
→web-ai/services 정정. sudoers NOPASSWD + 재부팅 검증 절차.
|
||||
Plan-B-Infra Phase 3.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
git push 2>&1
|
||||
```
|
||||
|
||||
## Report
|
||||
- Status / 커밋 SHA / push 결과
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 박재오 빌드 + task-watcher 검증
|
||||
|
||||
**Files:** (변경 없음 — 박재오 측 작업 + 검증)
|
||||
|
||||
### Step 1: web-backend push (Task 1 holidays endpoint)
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-backend && git push
|
||||
```
|
||||
→ NAS deployer가 stock 컨테이너 rebuild. `/api/stock/holidays` 활성화.
|
||||
|
||||
### Step 2: 박재오 NAS 측 holidays endpoint 확인
|
||||
|
||||
```bash
|
||||
curl https://gahusb.synology.me/api/stock/holidays
|
||||
# → {"holidays": ["2026-01-01", ...]}
|
||||
```
|
||||
|
||||
### Step 3: 박재오 Windows 측 task-watcher 빌드
|
||||
|
||||
```bash
|
||||
cd /workspace/web-ai && git pull
|
||||
cd /workspace/web-ai/services
|
||||
docker compose build task-watcher
|
||||
docker compose up -d task-watcher
|
||||
docker logs task-watcher --tail 20
|
||||
# 기대: "task-watcher lifespan 시작" + "task-watcher started" + mode 로그
|
||||
curl -m 3 http://localhost:18713/health
|
||||
```
|
||||
|
||||
### Step 4: 시간대 토글 검증
|
||||
|
||||
현재 KST 시각 기준:
|
||||
```bash
|
||||
# 트레이딩 시간대(평일 07:00-16:30)면 paused=1, 아니면 nil
|
||||
docker exec task-watcher python -c "import datetime as dt; from zoneinfo import ZoneInfo; from mode import current_mode, fetch_holidays; print('now mode:', current_mode(dt.datetime.now(ZoneInfo('Asia/Seoul')), fetch_holidays()))"
|
||||
|
||||
# Redis 확인 (NAS 또는 LAN)
|
||||
ssh nas
|
||||
docker exec redis redis-cli GET queue:paused
|
||||
```
|
||||
|
||||
기대:
|
||||
- 평일 07:00-16:30 (비휴장): `current_mode` = "trading", `queue:paused` = "1"
|
||||
- 그 외: "free", (nil)
|
||||
|
||||
### Step 5: render worker가 paused 존중하는지 (선택)
|
||||
|
||||
트레이딩 시간대에 video 생성 요청 → worker가 BLPOP 전 paused 확인 → 10초 대기 반복 (처리 보류). free 시간대 되면 자동 처리. (이미 Plan-B-Insta/Music/Video worker에 `queue:paused` 체크 로직 있음.)
|
||||
|
||||
### Step 6: 메모리 기록
|
||||
|
||||
`reference_plan_b_infra_complete.md` 작성 + MEMORY.md 인덱스 추가 (Task 8에서).
|
||||
|
||||
## Report
|
||||
- holidays endpoint 응답
|
||||
- task-watcher health + mode
|
||||
- queue:paused 토글 확인
|
||||
|
||||
---
|
||||
|
||||
## Task 8: 메모리 기록 + 최종 정리
|
||||
|
||||
**Files:**
|
||||
- Create: `C:/Users/jaeoh/.claude/projects/C--Users-jaeoh-Desktop-workspace-web-ui/memory/reference_plan_b_infra_complete.md`
|
||||
- Modify: `C:/Users/jaeoh/.claude/projects/C--Users-jaeoh-Desktop-workspace-web-ui/memory/MEMORY.md`
|
||||
|
||||
### Step 1: `reference_plan_b_infra_complete.md`
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: plan-b-infra-complete
|
||||
description: 2026-05-22 Plan-B-Infra — NSSM 자동 시작(SP-9) + task-watcher 시간대 큐 토글(SP-10). spec 12 SP 전부 완료
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
Plan-B-Infra 2026-05-22 완료. spec §10 SP-9 + SP-10. 이로써 NAS↔Windows 분산 아키텍처 spec의 12 SP 전부 완료.
|
||||
|
||||
## SP-10 task-watcher (구현)
|
||||
- web-ai/services/task-watcher/ WSL2 컨테이너 (port 18713)
|
||||
- 30초 loop: current_mode(KST + holidays) → queue:paused 토글
|
||||
- trading(비휴장 평일 07:00-16:30) → SET queue:paused 1 EX 600 / free → DEL
|
||||
- **idle/게임 감지 생략** (박재오 결정 2026-05-22) — WSL2 컨테이너는 Win32 input API 접근 불가. 시간대만으로 판정.
|
||||
- PAUSED_TTL 600s = watcher 죽어도 10분 후 자동 해제 (큐 영구정지 방지 안전장치)
|
||||
- holidays는 NAS GET /api/stock/holidays (신설) 1시간 캐싱
|
||||
- TRADING_START/END env로 윈도우 조정
|
||||
|
||||
## SP-9 NSSM (박재오 수동)
|
||||
- NSSM_SETUP.md 안내 문서. ai_trade(HIGH, native :8001) + wsl_docker(NORMAL, WSL2 docker compose up)
|
||||
- spec 정정: signal_v2→ai_trade, Ubuntu-22.04→24.04, web-ai-services→web-ai/services
|
||||
|
||||
## NAS holidays endpoint (신설)
|
||||
- GET /api/stock/holidays — holidays.json 노출. 기존엔 _is_holiday() 내부 함수만 있었음.
|
||||
|
||||
## 다음
|
||||
- frontend video/music/insta UI (backend gateway만 완료, UI 별도)
|
||||
- FOLLOW-UP B: -lab suffix 제거
|
||||
```
|
||||
|
||||
### Step 2: MEMORY.md 인덱스 추가
|
||||
|
||||
`reference_plan_b_video_complete.md` 항목 뒤:
|
||||
```markdown
|
||||
- [Plan-B-Infra 완료](reference_plan_b_infra_complete.md) — 2026-05-22 NSSM 자동 시작(SP-9) + task-watcher 시간대 큐 토글(SP-10). idle 감지 생략. spec 12 SP 전부 완료
|
||||
```
|
||||
|
||||
### Step 3: 양쪽 push 확인
|
||||
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-backend && git status && git log --oneline -3
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-ai && git status && git log --oneline -5
|
||||
```
|
||||
|
||||
### Step 4: 박재오 보고
|
||||
- spec 12 SP 전부 완료
|
||||
- task-watcher 시간대 토글 동작
|
||||
- NSSM은 박재오 수동 (NSSM_SETUP.md 참고)
|
||||
|
||||
## Report
|
||||
- 메모리 파일 생성
|
||||
- push 상태
|
||||
- 최종 보고
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**1. Spec coverage**
|
||||
|
||||
| Spec 요구사항 | 구현 위치 | 상태 |
|
||||
|--------------|-----------|------|
|
||||
| SP-9 §10: NSSM ai_trade(HIGH) + wsl_docker(NORMAL) 자동 시작 | Task 6 NSSM_SETUP.md | ✓ (박재오 수동 + 안내) |
|
||||
| SP-10 §10: task-watcher 컨테이너 30초 loop | Task 3 watcher.py | ✓ |
|
||||
| SP-10 §10: current_mode (시간대 + holidays + KST) | Task 2 mode.py | ✓ |
|
||||
| SP-10 §10: queue:paused 토글 (free→DEL, trading→SET) | Task 3 | ✓ |
|
||||
| §3 휴장일 단일 소스 GET /api/stock/holidays | Task 1 | ✓ (신설) |
|
||||
| 박재오 결정: idle 감지 생략 — 시간대만 | Task 2 (is_user_active 제거) | ✓ |
|
||||
| §3 트레이딩 모드 = 평일 비휴장 07:00-16:30 | Task 2 TRADING_START/END | ✓ |
|
||||
|
||||
**spec 대비 의도적 변경 (박재오 승인):**
|
||||
- idle/게임 감지 생략 — spec §10 SP-10의 `is_user_active()` 제거. trading 시간대면 무조건 paused.
|
||||
- spec §3의 🟡 일반(16:30-23:30) 모드 → free로 통합 (트레이딩 시간대만 paused).
|
||||
|
||||
**2. Placeholder scan:** 통과. NSSM_SETUP.md의 "(확인)" 표기는 박재오 환경 검증 안내 (placeholder 아님).
|
||||
|
||||
**3. Type consistency:**
|
||||
- `current_mode(now: dt.datetime, holidays: Set[str]) -> str` — Task 2 정의, Task 3 watcher_loop + Task 7 검증 호출 일관
|
||||
- `fetch_holidays() -> Set[str]` — Task 2 정의, Task 3 호출
|
||||
- mode 값 `"trading"` | `"free"` 2개 — Task 2/3/7 일관
|
||||
- `PAUSED_KEY = "queue:paused"` — Task 3, render workers의 PAUSED_KEY와 동일 문자열 (Plan-B-Insta/Music/Video)
|
||||
|
||||
**4. 함정 사전 인지:**
|
||||
- task-watcher는 services/ 컨테이너 (NAS lab 아님) → deploy.sh 6위치 등재 불필요
|
||||
- holidays endpoint(stock)는 기존 컨테이너 수정 → deploy.sh 등재 이미 됨
|
||||
- services/.env: TRADING_START/END는 task-watcher 전용 → 다른 서비스와 충돌 없음 (compose default로 분기)
|
||||
- PAUSED_TTL로 watcher 장애 시 큐 영구정지 방지
|
||||
|
||||
플랜 완성. 모든 검토 통과.
|
||||
|
||||
---
|
||||
|
||||
## 부록 — 알려진 결정 + follow-up
|
||||
|
||||
**박재오 결정 (2026-05-22):** idle/게임 감지 생략. 시간대만으로 큐 토글. 박재오 7결정 #1의 "Windows 작업 감지 큐 정지"는 부분 포기 (시간대 기반만). 향후 idle 감지 필요 시 Windows native idle-reporter(GetLastInputInfo) → Redis user:last_input_ts 기록 → task-watcher가 읽는 hybrid로 확장 가능.
|
||||
|
||||
**spec 12 SP 완료 후 follow-up:**
|
||||
- frontend `/video` `/music` UI (backend gateway만 완료)
|
||||
- FOLLOW-UP B: `-lab` suffix 일괄 제거
|
||||
- GCS lifecycle (Veo Vertex 미사용으로 무관 — Gemini API는 GCS 안 씀)
|
||||
- Sora 2 alternative (2026-09-24 deprecated 대비)
|
||||
1618
docs/superpowers/plans/2026-05-23-lotto-evolver-ui.md
Normal file
1618
docs/superpowers/plans/2026-05-23-lotto-evolver-ui.md
Normal file
File diff suppressed because it is too large
Load Diff
3547
docs/superpowers/plans/2026-05-23-tarot-lab.md
Normal file
3547
docs/superpowers/plans/2026-05-23-tarot-lab.md
Normal file
File diff suppressed because it is too large
Load Diff
1373
docs/superpowers/plans/2026-05-23-video-studio-backend.md
Normal file
1373
docs/superpowers/plans/2026-05-23-video-studio-backend.md
Normal file
File diff suppressed because it is too large
Load Diff
3274
docs/superpowers/plans/2026-05-25-saju-tarot-lab-migration.md
Normal file
3274
docs/superpowers/plans/2026-05-25-saju-tarot-lab-migration.md
Normal file
File diff suppressed because it is too large
Load Diff
2913
docs/superpowers/plans/2026-05-26-saju-ui-v1.md
Normal file
2913
docs/superpowers/plans/2026-05-26-saju-ui-v1.md
Normal file
File diff suppressed because it is too large
Load Diff
2970
docs/superpowers/plans/2026-05-26-saju-ui-v2-redesign.md
Normal file
2970
docs/superpowers/plans/2026-05-26-saju-ui-v2-redesign.md
Normal file
File diff suppressed because it is too large
Load Diff
1616
docs/superpowers/plans/2026-05-28-agent-office-docker-logs.md
Normal file
1616
docs/superpowers/plans/2026-05-28-agent-office-docker-logs.md
Normal file
File diff suppressed because it is too large
Load Diff
1328
docs/superpowers/plans/2026-05-31-lotto-self-learning-backtest.md
Normal file
1328
docs/superpowers/plans/2026-05-31-lotto-self-learning-backtest.md
Normal file
File diff suppressed because it is too large
Load Diff
1102
docs/superpowers/plans/2026-05-31-stock-holdings-intelligence.md
Normal file
1102
docs/superpowers/plans/2026-05-31-stock-holdings-intelligence.md
Normal file
File diff suppressed because it is too large
Load Diff
408
docs/superpowers/plans/2026-06-02-insta-cardnews-upgrade.md
Normal file
408
docs/superpowers/plans/2026-06-02-insta-cardnews-upgrade.md
Normal file
@@ -0,0 +1,408 @@
|
||||
# 인스타 카드뉴스 품질 고도화 + 업로드 친화 패키지 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 인스타 카드를 모던 미니멀 디자인 시스템으로 격상하고(렌더 견고화로 known-issue 해결), 완성 패키지를 zip으로 받아 인스타에 쉽게 업로드(반자동)할 수 있게 한다.
|
||||
|
||||
**Architecture:** 디자인 시스템 Jinja 템플릿(페이지 타입별 레이아웃)을 web-ai insta-render 워커(authoritative)와 insta-lab(참조 복사본)에 작성. 워커 `card_renderer.py`에 `document.fonts.ready` 대기 + PNG 검증 추가. card_writer 프롬프트에 글자수 가이드. insta-lab에 zip 패키지 API + web-ui 다운로드 버튼. Graph API 미사용(반자동).
|
||||
|
||||
**Tech Stack:** Jinja2 + HTML/CSS, Playwright(Chromium), FastAPI, pytest / React+Vite(web-ui).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-02-insta-cardnews-upgrade-design.md`
|
||||
|
||||
**⚠️ 3 repo 작업** (커밋·배포 경로 다름):
|
||||
- `web-backend/insta-lab` — git push → Gitea webhook 자동배포 (NAS)
|
||||
- `web-ai/services/insta-render` — **별도 repo(ai-trade.git), Windows 머신 구동** — 워커가 실제 렌더하는 authoritative 템플릿 위치
|
||||
- `web-ui` — **별도 repo**, `npm run release:nas` 수동 배포
|
||||
|
||||
---
|
||||
|
||||
## 검증된 컨텍스트
|
||||
- 워커 렌더: `web-ai/services/insta-render/card_renderer.py` — `_build_pages(slate)`가 10 spec 생성(cover page_no=1 / body page_no=2~9 / cta page_no=10, 각 `page_type`/`headline`/`body`/`accent_color`/`cta`/`page_no`/`total_pages`). `CARD_TEMPLATE_DIR`(기본 `/app/templates`)에서 `{theme}/card.html.j2` 로드 → `page.goto(file://, networkidle)` → `screenshot(full_page=False)` @viewport 1080×1350.
|
||||
- 워커 템플릿 실제 위치: `web-ai/services/insta-render/templates/default/card.html.j2` (현재 insta-lab과 동일한 55줄 기본형). **이게 렌더에 쓰이는 authoritative 파일.**
|
||||
- 카피: `insta-lab/app/card_writer.py` `DEFAULT_PROMPT`(DB `slate_writer` 오버라이드 가능). 산출: cover_copy{headline,body,accent_color}/body_copies[8]{headline,body}/cta_copy{headline,body,cta}/suggested_caption/hashtags[].
|
||||
- 슬레이트 PNG: 워커가 `INSTA_MEDIA_ROOT/{slate_id}/{page_no:02d}.png` 저장. NAS에서 `card_assets` 테이블 + `db.list_card_assets(slate_id)`(page_index + 파일경로)로 추적. `GET /api/insta/slates/{id}/assets/{page}`가 단일 PNG 서빙(파일경로 읽어 반환).
|
||||
- 슬레이트 데이터: `db.get_card_slate(slate_id)` + `db.list_card_assets(slate_id)`. `GET /api/insta/slates/{id}`가 slate + assets 반환.
|
||||
|
||||
---
|
||||
|
||||
# Phase 1 — 모던 미니멀 디자인 시스템 템플릿 (web-ai authoritative + insta-lab 복사본)
|
||||
|
||||
## Task 1.1: 디자인 시스템 card.html.j2 작성
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-ai/services/insta-render/templates/default/card.html.j2` (**렌더 authoritative**)
|
||||
- Modify: `web-backend/insta-lab/app/templates/default/card.html.j2` (참조 복사본 — 동일 내용 유지)
|
||||
|
||||
> 두 파일을 **동일 내용**으로 작성한다. 워커가 web-ai 쪽을 렌더하지만 insta-lab 복사본도 일관성 위해 갱신.
|
||||
|
||||
- [ ] **Step 1: 디자인 시스템 템플릿 작성** — 아래 전체 내용으로 두 파일을 교체:
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css');
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 1080px; height: 1350px; }
|
||||
body {
|
||||
font-family: 'Pretendard', 'Noto Sans KR', sans-serif;
|
||||
background: #F7F7FA; color: #14171A;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.card {
|
||||
position: relative; width: 1080px; height: 1350px; overflow: hidden;
|
||||
padding: 96px 84px 72px;
|
||||
display: flex; flex-direction: column;
|
||||
background: #FFFFFF;
|
||||
}
|
||||
.accent-bar { position: absolute; top: 0; left: 0; width: 100%; height: 14px; background: {{ accent_color }}; }
|
||||
.badge {
|
||||
align-self: flex-start; padding: 10px 24px; border-radius: 999px;
|
||||
background: {{ accent_color }}; color: #fff;
|
||||
font-size: 30px; font-weight: 700; letter-spacing: -0.02em;
|
||||
}
|
||||
.idx { font-size: 120px; font-weight: 800; line-height: 1; color: {{ accent_color }}; letter-spacing: -0.04em; }
|
||||
.content { flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 36px; }
|
||||
.headline {
|
||||
font-weight: 800; line-height: 1.18; letter-spacing: -0.04em; color: #14171A;
|
||||
display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.cover .headline { font-size: 104px; -webkit-line-clamp: 4; }
|
||||
.body-page .headline { font-size: 76px; -webkit-line-clamp: 3; }
|
||||
.cta .headline { font-size: 88px; -webkit-line-clamp: 3; }
|
||||
.sub {
|
||||
font-size: 42px; font-weight: 400; line-height: 1.5; color: #3A4047;
|
||||
display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; -webkit-line-clamp: 8;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.footer {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 28px; color: #8A9099; font-weight: 600; margin-top: 40px;
|
||||
}
|
||||
.cta-pill {
|
||||
align-self: flex-start; margin-top: 8px; padding: 18px 40px; border-radius: 16px;
|
||||
background: {{ accent_color }}; color: #fff; font-size: 40px; font-weight: 700;
|
||||
}
|
||||
.progress { display: flex; gap: 10px; }
|
||||
.progress i { width: 14px; height: 14px; border-radius: 50%; background: #D8DCE0; display: inline-block; }
|
||||
.progress i.on { background: {{ accent_color }}; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card {{ 'cover' if page_type=='cover' else ('cta' if page_type=='cta' else 'body-page') }}">
|
||||
<div class="accent-bar"></div>
|
||||
|
||||
{% if page_type == 'cover' %}
|
||||
<span class="badge">{{ category_label|default(headline[:0]) }}{{ '오늘의 이슈' if not category_label }}</span>
|
||||
<div class="content">
|
||||
<h1 class="headline">{{ headline }}</h1>
|
||||
<p class="sub">{{ body }}</p>
|
||||
</div>
|
||||
{% elif page_type == 'cta' %}
|
||||
<div class="content">
|
||||
<h1 class="headline">{{ headline }}</h1>
|
||||
<p class="sub">{{ body }}</p>
|
||||
{% if cta %}<div class="cta-pill">{{ cta }}</div>{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="idx">{{ '%02d'|format(page_no - 1) }}</span>
|
||||
<div class="content">
|
||||
<h1 class="headline">{{ headline }}</h1>
|
||||
<p class="sub">{{ body }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="footer">
|
||||
{% if page_type == 'cover' or page_type == 'cta' %}
|
||||
<span>{{ brand_handle|default('') }}</span><span>{{ page_no }} / {{ total_pages }}</span>
|
||||
{% else %}
|
||||
<div class="progress">{% for n in range(2, total_pages) %}<i class="{{ 'on' if n <= page_no }}"></i>{% endfor %}</div>
|
||||
<span>{{ page_no }} / {{ total_pages }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
> 디자인 노트: 페이지 타입별 분기(cover 대형 헤드라인+서브+배지 / body 좌상단 인덱스 `01~08`(page_no-1)+헤드라인+본문+진행 점 / cta 요약+CTA pill). `-webkit-line-clamp`로 오버플로우 2차 방어(글자수 가이드가 1차). `accent_color`는 기존 데이터. `brand_handle`은 미설정 시 빈칸(추후 핸들 주입 가능). Pretendard CDN(@import) — Phase 2의 fonts.ready 대기와 짝.
|
||||
|
||||
- [ ] **Step 2: 렌더 스모크 확인 (web-ai)** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render && python -c "from jinja2 import Environment, FileSystemLoader; e=Environment(loader=FileSystemLoader('templates')); t=e.get_template('default/card.html.j2'); [print(pt, len(t.render(page_type=pt, page_no=n, total_pages=10, headline='테스트 헤드라인', body='본문 테스트입니다.', accent_color='#0F62FE', cta='팔로우')) > 0) for pt,n in [('cover',1),('body',3),('cta',10)]]"`
|
||||
Expected: `True` 3줄 (3 페이지 타입 모두 렌더 예외 없음).
|
||||
|
||||
- [ ] **Step 3: Commit (2 repo 각각)**
|
||||
```bash
|
||||
# web-ai repo
|
||||
cd /c/Users/jaeoh/Desktop/workspace/web-ai && git add services/insta-render/templates/default/card.html.j2 && git commit -m "feat(insta-render): 모던 미니멀 디자인 시스템 템플릿"
|
||||
# insta-lab repo (참조 복사본)
|
||||
cd /c/Users/jaeoh/Desktop/workspace/web-backend && git add insta-lab/app/templates/default/card.html.j2 && git commit -m "feat(insta-lab): default 템플릿 디자인 시스템 동기화(참조용)"
|
||||
```
|
||||
> 커밋 메시지 trailer 각각에 `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` 추가.
|
||||
|
||||
---
|
||||
|
||||
# Phase 2 — 렌더 견고화 (web-ai 워커, known-issue 해결)
|
||||
|
||||
## Task 2.1: fonts.ready 대기 + PNG 비어있음 검증
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-ai/services/insta-render/card_renderer.py` (`_render_slate_locked`)
|
||||
- Test: `web-ai/services/insta-render/tests/test_worker.py` (또는 기존 테스트 파일에 추가)
|
||||
|
||||
- [ ] **Step 1: 실패 테스트** — `tests/test_worker.py`에 추가 (실제 Chromium 렌더 + 검증). 워커 테스트 관례 확인 후 맞출 것; pytest-asyncio 사용 가정:
|
||||
```python
|
||||
import os
|
||||
import pytest
|
||||
from card_renderer import render_slate, init_browser, shutdown_browser
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_produces_nonempty_1080x1350(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("card_renderer.INSTA_MEDIA_ROOT", str(tmp_path))
|
||||
await init_browser()
|
||||
try:
|
||||
slate = {
|
||||
"cover_copy": {"headline": "헤드라인", "body": "서브", "accent_color": "#0F62FE"},
|
||||
"body_copies": [{"headline": f"포인트{i}", "body": "본문"} for i in range(8)],
|
||||
"cta_copy": {"headline": "요약", "body": "마무리", "cta": "팔로우"},
|
||||
}
|
||||
paths = await render_slate(slate, slate_id=99999)
|
||||
assert len(paths) == 10
|
||||
for p in paths:
|
||||
assert os.path.getsize(p) > 1000 # 비어있지 않음
|
||||
finally:
|
||||
await shutdown_browser()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실패/현황 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render && python -m pytest tests/test_worker.py::test_render_produces_nonempty_1080x1350 -v`
|
||||
Expected: 현재 코드로도 통과할 수 있으나(렌더 자체는 동작), 폰트/검증 보강 전이므로 FAIL이 아니면 다음 Step에서 검증 로직 추가가 의미를 갖도록 진행. (Playwright/Chromium 미설치 환경이면 `playwright install chromium` 필요 — 안 되면 DONE_WITH_CONCERNS로 보고)
|
||||
|
||||
- [ ] **Step 3: card_renderer 보강** — `_render_slate_locked`의 페이지 루프에서 `page.goto` 직후·`screenshot` 직전에 폰트 대기 추가, screenshot 후 비어있음 검증:
|
||||
```python
|
||||
try:
|
||||
await page.goto(f"file://{html_path}", wait_until="networkidle")
|
||||
await page.evaluate("document.fonts.ready") # 웹폰트 로딩 완료까지 대기
|
||||
out_path = os.path.join(out_dir, f"{spec['page_no']:02d}.png")
|
||||
await page.screenshot(path=out_path, full_page=False, omit_background=False)
|
||||
if os.path.getsize(out_path) < 1000: # 빈/깨진 PNG 방어
|
||||
raise RuntimeError(f"rendered PNG too small: {out_path}")
|
||||
paths.append(out_path)
|
||||
finally:
|
||||
...
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 통과 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render && python -m pytest tests/test_worker.py -v` Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit (web-ai repo)**
|
||||
```bash
|
||||
cd /c/Users/jaeoh/Desktop/workspace/web-ai && git add services/insta-render/card_renderer.py services/insta-render/tests/test_worker.py && git commit -m "fix(insta-render): fonts.ready 대기 + PNG 비어있음 검증 (렌더 known-issue 해결)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Phase 3 — 카피 글자수 가이드 (insta-lab)
|
||||
|
||||
## Task 3.1: card_writer 프롬프트에 글자수 상한 추가
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-backend/insta-lab/app/card_writer.py` (`DEFAULT_PROMPT`)
|
||||
- Test: `web-backend/insta-lab/app/test_card_writer_prompt.py` (NEW)
|
||||
|
||||
- [ ] **Step 1: 실패 테스트**
|
||||
|
||||
`insta-lab/app/test_card_writer_prompt.py`:
|
||||
```python
|
||||
from app import card_writer
|
||||
|
||||
def test_default_prompt_has_length_guidance():
|
||||
p = card_writer.DEFAULT_PROMPT
|
||||
# 글자수 가이드가 프롬프트에 포함됐는지
|
||||
assert "22자" in p and "120자" in p
|
||||
# 포맷 placeholder는 유지
|
||||
assert "{category}" in p and "{keyword}" in p and "{articles}" in p
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실패 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -m pytest app/test_card_writer_prompt.py -v` Expected: FAIL
|
||||
|
||||
- [ ] **Step 3: DEFAULT_PROMPT에 가이드 추가** — `DEFAULT_PROMPT` 문자열의 JSON 스키마 안내 뒤(닫는 `}}` 다음)에 글자수 가이드 문단 추가:
|
||||
```python
|
||||
DEFAULT_PROMPT = """너는 인스타그램 카드 뉴스 카피라이터다.
|
||||
카테고리: {category}
|
||||
키워드: {keyword}
|
||||
참고 기사:
|
||||
{articles}
|
||||
|
||||
10페이지 인스타 카드용 카피를 다음 JSON 한 객체로만 출력해라 (코드펜스 금지):
|
||||
{{
|
||||
"cover_copy": {{"headline": "<훅 한 줄>", "body": "<서브카피 1~2줄>", "accent_color": "#hex"}},
|
||||
"body_copies": [
|
||||
{{"headline": "<포인트 헤드라인>", "body": "<2~4문장 본문>"}},
|
||||
... (총 8개)
|
||||
],
|
||||
"cta_copy": {{"headline": "<요약 한 줄>", "body": "<마무리 1~2줄>", "cta": "팔로우/저장 등"}},
|
||||
"suggested_caption": "<인스타 캡션 본문>",
|
||||
"hashtags": ["#태그1", "#태그2", ...]
|
||||
}}
|
||||
|
||||
[글자수 제약 — 카드 디자인 박스에 맞게 반드시 준수]
|
||||
- cover_copy.headline: 22자 이내
|
||||
- body_copies[].headline: 26자 이내
|
||||
- body_copies[].body: 120자 이내 (2~4문장)
|
||||
- cta_copy.headline: 22자 이내
|
||||
초과하면 잘리므로 간결하고 임팩트 있게 작성한다.
|
||||
"""
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 통과 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -m pytest app/test_card_writer_prompt.py -v` Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit (insta-lab)**
|
||||
```bash
|
||||
cd /c/Users/jaeoh/Desktop/workspace/web-backend && git add insta-lab/app/card_writer.py insta-lab/app/test_card_writer_prompt.py && git commit -m "feat(insta-lab): card_writer 프롬프트에 글자수 가이드(오버플로우 예방)"
|
||||
```
|
||||
> 주의: 운영 DB에 `slate_writer` prompt_template 오버라이드가 있으면 DEFAULT_PROMPT 대신 그게 쓰임 → 배포 후 필요 시 `PUT /api/insta/templates/prompts/slate_writer`로 동일 가이드 반영(plan §검증에서 안내).
|
||||
|
||||
---
|
||||
|
||||
# Phase 4 — zip 패키지 다운로드 API (insta-lab)
|
||||
|
||||
## Task 4.1: GET /api/insta/slates/{id}/package
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-backend/insta-lab/app/main.py` (엔드포인트 추가)
|
||||
- Test: `web-backend/insta-lab/app/test_package_api.py` (NEW)
|
||||
|
||||
- [ ] **Step 1: (확인됨) asset 스키마** — `card_assets(slate_id, page_index, file_path, file_hash)`. `db.list_card_assets(slate_id)` → 각 row에 `file_path`·`page_index`. `db.add_card_asset(slate_id, page_index, file_path, file_hash="")`. `db.add_card_slate(row: dict)`. 기존 `/assets/{page}`는 `FileResponse(match["file_path"], media_type="image/png")`. zip 엔드포인트는 동일하게 `a["file_path"]`를 읽는다.
|
||||
|
||||
- [ ] **Step 2: 실패 테스트**
|
||||
|
||||
`insta-lab/app/test_package_api.py`:
|
||||
```python
|
||||
import io, os, tempfile, zipfile, sys
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
def _client(monkeypatch):
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
from app import config, db
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setattr(config, "INSTA_DATA_PATH", tmp, raising=False)
|
||||
monkeypatch.setattr(db, "DB_PATH", os.path.join(tmp, "insta.db"), raising=False)
|
||||
db.init_db()
|
||||
from app.main import app
|
||||
return TestClient(app), db, tmp
|
||||
|
||||
def test_package_zip_contains_pngs_and_caption(monkeypatch):
|
||||
client, db, tmp = _client(monkeypatch)
|
||||
# 슬레이트 + 2개 asset(실제 PNG 파일) 시드
|
||||
sid = db.add_card_slate({"keyword":"k","category":"economy","status":"rendered",
|
||||
"cover_copy":{"headline":"h"}, "body_copies":[{"headline":"b","body":"x"}]*8,
|
||||
"cta_copy":{}, "suggested_caption":"캡션입니다", "hashtags":["#a","#b"]})
|
||||
cards_dir = os.path.join(tmp, "insta_cards", str(sid)); os.makedirs(cards_dir, exist_ok=True)
|
||||
for pg in (1,2):
|
||||
fp = os.path.join(cards_dir, f"{pg:02d}.png")
|
||||
with open(fp, "wb") as f: f.write(b"\x89PNG\r\n" + b"0"*2000)
|
||||
db.add_card_asset(slate_id=sid, page_index=pg, file_path=fp)
|
||||
r = client.get(f"/api/insta/slates/{sid}/package")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"] == "application/zip"
|
||||
z = zipfile.ZipFile(io.BytesIO(r.content))
|
||||
names = z.namelist()
|
||||
assert any(n.endswith(".png") for n in names)
|
||||
assert "caption.txt" in names
|
||||
cap = z.read("caption.txt").decode("utf-8")
|
||||
assert "캡션입니다" in cap and "#a" in cap
|
||||
```
|
||||
> `db.add_card_slate`/`add_card_asset`/`list_card_assets`의 실제 시그니처·컬럼명은 db.py 확인 후 맞출 것. asset 경로 컬럼이 `path`가 아니면 테스트·구현 모두 조정.
|
||||
|
||||
- [ ] **Step 3: 실패 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -m pytest app/test_package_api.py -v` Expected: FAIL (404)
|
||||
|
||||
- [ ] **Step 4: 엔드포인트 구현** — `insta-lab/app/main.py`에 추가 (`/assets/{page}` 엔드포인트 근처, 동일한 asset 파일경로 접근 방식 사용. `import io, zipfile`은 상단에 추가):
|
||||
```python
|
||||
@app.get("/api/insta/slates/{slate_id}/package")
|
||||
def download_package(slate_id: int):
|
||||
slate = db.get_card_slate(slate_id)
|
||||
if not slate:
|
||||
raise HTTPException(404, "slate not found")
|
||||
assets = sorted(db.list_card_assets(slate_id), key=lambda a: a["page_index"])
|
||||
if not assets:
|
||||
raise HTTPException(409, "아직 렌더된 카드가 없습니다")
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for a in assets:
|
||||
fp = a["file_path"]
|
||||
if os.path.exists(fp):
|
||||
z.write(fp, arcname=f"{a['page_index']:02d}.png")
|
||||
caption = (slate.get("suggested_caption") or "").strip()
|
||||
tags = slate.get("hashtags") or []
|
||||
if isinstance(tags, str):
|
||||
import json as _json
|
||||
try: tags = _json.loads(tags)
|
||||
except Exception: tags = []
|
||||
caption_full = caption + ("\n\n" + " ".join(tags) if tags else "")
|
||||
z.writestr("caption.txt", caption_full)
|
||||
buf.seek(0)
|
||||
from fastapi.responses import StreamingResponse
|
||||
return StreamingResponse(buf, media_type="application/zip", headers={
|
||||
"Content-Disposition": f'attachment; filename="insta_slate_{slate_id}.zip"'})
|
||||
```
|
||||
> `HTTPException`/`os`는 main.py에 이미 import됨. `slate.get("hashtags")`가 JSON 문자열일 수 있어 방어 파싱.
|
||||
|
||||
- [ ] **Step 5: 통과 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -m pytest app/test_package_api.py -v` Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit (insta-lab)**
|
||||
```bash
|
||||
cd /c/Users/jaeoh/Desktop/workspace/web-backend && git add insta-lab/app/main.py insta-lab/app/test_package_api.py && git commit -m "feat(insta-lab): 슬레이트 zip 패키지 다운로드 API (10 PNG + caption.txt)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Phase 5 — web-ui 패키지 다운로드 버튼 (별도 repo: web-ui)
|
||||
|
||||
## Task 5.1: 슬레이트 상세에 다운로드 버튼
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-ui/src/api.js` (헬퍼)
|
||||
- Modify: insta 카드 페이지 (`web-ui/src/pages/insta/InstaCards.jsx` 또는 슬레이트 상세 컴포넌트)
|
||||
|
||||
- [ ] **Step 1: 구조 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && git checkout -b feat/insta-package-download && grep -rln "insta\|슬레이트\|slate" src/pages/insta/ src/api.js 2>/dev/null | head` 로 슬레이트 상세 UI + apiGet 패턴 확인.
|
||||
|
||||
- [ ] **Step 2: api.js 헬퍼 + 다운로드** — `src/api.js`에 패키지 URL 헬퍼 추가(파일 다운로드는 새 탭/anchor로):
|
||||
```javascript
|
||||
export const instaPackageUrl = (slateId) => `/api/insta/slates/${slateId}/package`;
|
||||
```
|
||||
슬레이트 상세 컴포넌트에 버튼 추가 (기존 버튼 스타일 맞춤):
|
||||
```jsx
|
||||
<a className="insta-pkg-btn" href={instaPackageUrl(slate.id)} download>
|
||||
📦 패키지 다운로드 (10장 + 캡션)
|
||||
</a>
|
||||
```
|
||||
> import에 `instaPackageUrl` 추가. 실제 슬레이트 객체의 id 필드명·버튼 클래스는 Step 1 확인 결과에 맞출 것.
|
||||
|
||||
- [ ] **Step 3: 빌드 확인** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && npm run build` Expected: exit 0
|
||||
|
||||
- [ ] **Step 4: Commit (web-ui repo)**
|
||||
```bash
|
||||
cd /c/Users/jaeoh/Desktop/workspace/web-ui && git add src/ && git commit -m "feat: 인스타 슬레이트 패키지 다운로드 버튼"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Phase 6 — 통합 검증
|
||||
|
||||
## Task 6.1: 회귀 + 배포 안내
|
||||
|
||||
- [ ] **Step 1: insta-lab 테스트** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -m pytest app/ -q` (Playwright 의존 테스트는 web-ai에만 있음). 신규 통과 + 회귀 없음. (`_shared` import로 main 로드 시 PYTHONPATH 필요하면 test에 sys.path.insert 적용 — Phase 4 test가 이미 처리)
|
||||
- [ ] **Step 2: web-ai 테스트** — Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render && python -m pytest -q` (Chromium 필요; 미설치 시 `playwright install chromium`).
|
||||
- [ ] **Step 3: 배포 안내** — 3 repo 각각 push/배포:
|
||||
- insta-lab: `git push origin main` → webhook 자동배포(NAS).
|
||||
- web-ai: Windows 머신에서 워커 repo pull + 재시작 (insta-render 서비스). **신규 템플릿이 워커 CARD_TEMPLATE_DIR에 반영돼야 효과 발생.**
|
||||
- web-ui: `npm run release:nas`.
|
||||
- 배포 후 슬레이트 1건 생성 → 카드 PNG 육안 확인(디자인 시스템 적용·폰트 정상) → `/package` zip 다운로드 확인. DB `slate_writer` 오버라이드 존재 시 글자수 가이드 반영.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review 체크리스트 결과
|
||||
- **Spec 커버리지**: 디자인 시스템 템플릿(Task 1.1) / 렌더 견고화 fonts.ready+검증(2.1) / 카피 글자수 가이드(3.1) / zip 패키지(4.1) / web-ui 버튼(5.1) / 검증(6.1). known-issue(폰트·오버플로우)=2.1+템플릿 clamp. 모두 매핑.
|
||||
- **Placeholder**: 모든 코드 step에 실제 코드. db asset 컬럼명·web-ui 슬레이트 필드·워커 테스트 관례는 "Step에서 확인 후 맞춤" 명시(코드베이스 의존, 합리적). brand_handle 기본 빈칸(미설정 허용).
|
||||
- **타입 일관성**: 템플릿이 쓰는 spec 키(page_type/page_no/total_pages/headline/body/accent_color/cta)가 워커 `_build_pages` 산출과 일치. zip 엔드포인트가 쓰는 `list_card_assets`/`get_card_slate`/`suggested_caption`/`hashtags`는 기존 db/슬레이트 스키마와 일치(Step 1에서 asset 경로 컬럼명만 확인).
|
||||
- **3 repo 경로**: 각 Task에 repo별 cd + 커밋 분리 명시.
|
||||
@@ -0,0 +1,584 @@
|
||||
# NAS ↔ Windows 분산 아키텍처 — Design Spec
|
||||
|
||||
**Date:** 2026-05-18
|
||||
**Author:** CEO (with Claude)
|
||||
**Scope:** `web-backend` + `web-ai` + 신규 `web-ai-services` (Windows WSL2 컨테이너 모음)
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경 & 목적
|
||||
|
||||
NAS Synology J4025 (Celeron 2C/2.0GHz, 18GB)에서 11개 docker 컨테이너가 CPU를 과점유. 진단 결과 가장 큰 원인은 **외부 인바운드 API 호출 빈도** (web-ai signal_v1/v2가 분당 12회 NAS stock 호출) + **insta-lab Playwright Chromium의 동시 launch 비용**이었다.
|
||||
|
||||
박재오 통찰: *"NAS = 24/7 표출·게이트웨이 / Windows = 트레이딩 메인 + 트리거 기반 컴퓨팅"*. 박재오가 이미 7건의 의사결정을 마쳤고 1주 셋업 가이드도 정리되어 있다 (`Obsidian Vault/raw/2026-05-18-Windows-NAS-아키텍처-7결정-통합.md`).
|
||||
|
||||
본 spec은 그 위에 **실행 단위 분할(SP) + 의존성 그래프 + 통합 패턴 + 데이터 플로우**를 정리해서 실제 구현 plan으로 진입 가능한 형태로 만든다.
|
||||
|
||||
### 박재오 7결정 (수용된 결정 사항)
|
||||
|
||||
1. **d+b 조합** — Windows 작업 감지 큐 정지 + 트레이딩 우선순위 High
|
||||
2. **insta-lab Playwright 1순위** 이전 (NAS → Windows)
|
||||
3. **트리거 B(비동기) + C(예약)** — 즉시 응답 X, task_id 발급 + 폴링
|
||||
4. **외부 영상 생성 도구** (Runway·Sora·Veo·Pika·Kling·Luma)
|
||||
5. **Redis NAS 컨테이너** — 24/7 안정 큐
|
||||
6. **옵션 4 하이브리드** — 트레이딩 Native Python / 신규 WSL2 Docker Engine
|
||||
7. **NSSM** — Windows Service 도구 (자동 시작·우선순위)
|
||||
|
||||
---
|
||||
|
||||
## 2. 전체 아키텍처
|
||||
|
||||
```
|
||||
[사용자 브라우저]
|
||||
↓ HTTPS
|
||||
[NAS Synology J4025] ─── 24/7 안정 · 표출 · 게이트웨이 · 상태(state)
|
||||
├─ frontend (nginx :8080) React SPA
|
||||
├─ redis (:6379) ⭐ NEW 24/7 큐 + 캐시
|
||||
├─ stock (:18500) +TTLCache 메타 + KIS data + WebAI gateway
|
||||
├─ insta-lab (:18700) 분할 후 카피 생성 + DB + Redis push
|
||||
├─ music-lab (:18600) 분할 후 메타 + Redis push (Suno/MusicGen 미실행)
|
||||
├─ video-lab (:18XXX) ⭐ NEW 영상 게이트웨이 + Redis push
|
||||
├─ agent-office (:18900) 텔레그램 라우팅 + scheduler
|
||||
├─ lotto / realestate-lab / personal / packs-lab / travel-proxy
|
||||
└─ deployer (:19010)
|
||||
↓ Redis BLPOP / 직접 HTTP webhook
|
||||
[Windows AI Server 192.168.45.59] ─── 트레이딩 최우선 · 트리거 컴퓨팅
|
||||
├─ 🔵 Native Python (NSSM HIGH priority)
|
||||
│ ├─ signal_v2 (:8001) ⭐ 트레이딩 절대 우선
|
||||
│ ├─ Ollama qwen3:14b (:11435)
|
||||
│ └─ MusicGen (:8765)
|
||||
└─ 🟢 WSL2 + Docker Engine (NORMAL priority)
|
||||
├─ insta-render (:18710) ⭐ NEW Playwright Chromium pool
|
||||
├─ music-render (:18711) ⭐ NEW Suno API + MusicGen orchestration
|
||||
├─ video-render (:18712) ⭐ NEW 외부 영상 API gateway (6 provider)
|
||||
└─ task-watcher 박재오 작업 감지 + 시간대 분기
|
||||
```
|
||||
|
||||
### 핵심 원칙
|
||||
|
||||
1. **NAS = state(DB) + view(nginx 미디어 서빙)**, **Windows = stateless compute**
|
||||
2. **트레이딩 절대 우선** — 시간대 조건부 (아래 §3 참조)
|
||||
3. **무거운 작업 시간대 분리** — 데드존 23:30–04:30 + 주말·휴장일 = 골든타임
|
||||
|
||||
---
|
||||
|
||||
## 3. 시간대별 우선순위 모드
|
||||
|
||||
| 모드 | 조건 | signal_v2 | task-watcher 정책 |
|
||||
|------|------|-----------|------------------|
|
||||
| 🔴 트레이딩 | 평일 비휴장일 07:00–16:30 | NSSM HIGH, polling 활성 | 박재오 활동 감지 시 `queue:paused` SET |
|
||||
| 🟡 일반 | 평일 16:30–23:30 (NXT) | NSSM HIGH 유지 (5분 폴링 가벼움) | 박재오 활동 감지 시 SET |
|
||||
| 🟢 자유 | 주말·휴장일 + 평일 23:30–04:30 | 자동 idle (휴장일 polling 미실행) | `queue:paused` DEL 유지 — 큐 항상 활성 |
|
||||
|
||||
### 구현 위치
|
||||
- **signal_v2의 휴장일 인식**: `web-ai` CHECK_POINT #7 `holidays.json` 자동 동기화 항목. 휴장일·주말에 polling 자체 미실행.
|
||||
- **휴장일 단일 소스**: `web-backend/stock/app/holidays.json` 정본. NAS stock이 `GET /api/stock/holidays`로 노출. signal_v2 + task-watcher가 매일 00:00 갱신.
|
||||
- **task-watcher 시간대 분기**: `current_mode()` 함수가 30초 폴링마다 모드 판정 → `queue:paused` 토글.
|
||||
|
||||
---
|
||||
|
||||
## 4. Sub-project 카탈로그 (12개)
|
||||
|
||||
| SP | 명칭 | 트랙 | 위치 | 소요 |
|
||||
|----|------|------|------|------|
|
||||
| **SP-A1** | web-ai 캐시 TTL 증가 | A | `web-ai/signal_v2/stock_client.py` | 10분 |
|
||||
| **SP-A2** | NAS stock TTLCache | A | `web-backend/stock/app/*` | 30분 |
|
||||
| **SP-1** | NAS Redis 컨테이너 | B (Base) | `web-backend/docker-compose.yml` | 30분 |
|
||||
| **SP-2** | Windows WSL2 + Docker Engine | B (Base) | (Windows AI) | 2h |
|
||||
| **SP-3** | insta-render Windows 서비스 | B | `web-ai-services/insta-render/` (신규) | 4h |
|
||||
| **SP-4** | NAS insta-lab 분할 | B | `web-backend/insta-lab` | 2h |
|
||||
| **SP-5** | music-render Windows 서비스 | B | `web-ai-services/music-render/` (신규) | 3h |
|
||||
| **SP-6** | NAS music-lab 분할 | B | `web-backend/music-lab` | 2h |
|
||||
| **SP-7** | video-render Windows 서비스 | B | `web-ai-services/video-render/` (신규) | 3h |
|
||||
| **SP-8** | NAS video-lab 신설 | B | `web-backend/video-lab/` (신규 컨테이너) | 2h |
|
||||
| **SP-9** | NSSM 자동 시작 + 우선순위 | B | (Windows) | 1h |
|
||||
| **SP-10** | task-watcher (시간대 + 활동 감지) | B | `web-ai-services/task-watcher/` (신규) | 2h |
|
||||
|
||||
**총 작업시간**: ~22.5h (1주 일정에 부합)
|
||||
|
||||
### 의존성 그래프
|
||||
|
||||
```
|
||||
A 트랙 (병행, ~40분)
|
||||
SP-A1 ─╮
|
||||
├── V2 재시작 시 효과
|
||||
SP-A2 ─╯
|
||||
|
||||
B 트랙 Base (Day 1~2)
|
||||
SP-1 (Redis) ─┐
|
||||
├── 인스타·음악·영상 3 트랙 모두 의존
|
||||
SP-2 (WSL2) ──┘
|
||||
|
||||
인스타 트랙 (Day 3~4)
|
||||
SP-3 (insta-render) ──→ SP-4 (NAS insta-lab 분할)
|
||||
|
||||
음악 트랙 (Day 4~5)
|
||||
SP-5 (music-render) ──→ SP-6 (NAS music-lab 분할)
|
||||
|
||||
영상 트랙 (Day 5~6)
|
||||
SP-7 (video-render) ──→ SP-8 (NAS video-lab 신설)
|
||||
|
||||
인프라 마무리 (Day 6~7)
|
||||
SP-9 (NSSM) ──→ SP-10 (task-watcher)
|
||||
```
|
||||
|
||||
### Critical Path
|
||||
`SP-1 ∥ SP-2` → `SP-3` → `SP-4` → `SP-9` → `SP-10` (최단 약 11.5h)
|
||||
|
||||
병렬화: SP-1(NAS)·SP-2(Windows)는 다른 머신이라 동시 진행. 인스타·음악·영상 트랙은 패턴이 같아 한 번 정착 후 빠르게 복제.
|
||||
|
||||
---
|
||||
|
||||
## 5. 통합 패턴 — "Windows Render Worker"
|
||||
|
||||
인스타·음악·영상 3 트랙이 **완전히 같은 패턴**. 한 번만 정의하고 3번 재사용한다.
|
||||
|
||||
### 시퀀스
|
||||
|
||||
```
|
||||
사용자 ─POST /api/{kind}/generate ...──→ NAS {kind}-lab
|
||||
│
|
||||
├─ DB.create_task() → task_id
|
||||
├─ Redis RPUSH queue:{kind}-render {task_id, params, ...}
|
||||
└─ 200 {task_id} ─→ 사용자
|
||||
|
||||
[Windows {kind}-render]
|
||||
│ (queue:paused 체크 후 BLPOP queue:{kind}-render)
|
||||
│
|
||||
├─ POST /api/internal/{kind}/update
|
||||
│ {status: "processing", progress: 30} ─→ NAS DB update
|
||||
│
|
||||
├─ 무거운 작업 (Playwright / Suno / Runway 등)
|
||||
│ 결과 파일 → /mnt/nas/data/{kind}/{id}/{file} (SMB direct write)
|
||||
│
|
||||
└─ POST /api/internal/{kind}/update
|
||||
{status: "succeeded", progress: 100,
|
||||
result_path: "/media/{kind}/{id}/{file}"} ─→ NAS DB update
|
||||
|
||||
사용자 ─GET /api/{kind}/tasks/{task_id}──→ NAS {kind}-lab
|
||||
└─ DB.get_task() → {status, progress, result_path}
|
||||
─→ 사용자 (폴링)
|
||||
```
|
||||
|
||||
### 4가지 미세 개선 (반영됨)
|
||||
|
||||
1. **결과물 저장**: SMB direct write (`/mnt/nas/data/`) — 별도 HTTP upload 단계 제거
|
||||
2. **NAS 알림**: Windows → NAS internal webhook (`POST /api/internal/{kind}/update`) — NAS polling 부담 0
|
||||
3. **사용자 응답**: 폴링 유지 (YAGNI, 미래 SSE 검토)
|
||||
4. **인증 키 분리**: `X-WebAI-Key`(read, web-ai→NAS) vs `X-Internal-Key`(write, Windows→NAS)
|
||||
|
||||
---
|
||||
|
||||
## 6. Redis 키 컨벤션
|
||||
|
||||
| 키 | 종류 | TTL | 용도 |
|
||||
|----|------|-----|------|
|
||||
| `queue:insta-render` | list | (없음) | 인스타 카드 렌더 작업 큐 |
|
||||
| `queue:music-render` | list | (없음) | 음악 생성 작업 큐 |
|
||||
| `queue:video-render` | list | (없음) | 영상 생성 작업 큐 |
|
||||
| `queue:paused` | string `"1"` | 600s | task-watcher가 set/del. worker가 BLPOP 전 확인 |
|
||||
| (옵션) `cache:stock:*` | string (json) | 120~600s | NAS stock Redis 캐시 (SP-A2와 별개 옵션) |
|
||||
|
||||
### 큐 payload 표준 (JSON)
|
||||
|
||||
```json
|
||||
{
|
||||
"task_id": "uuid-...",
|
||||
"kind": "insta|music|video",
|
||||
"params": { ... },
|
||||
"submitted_at": "2026-05-18T08:30:00+09:00"
|
||||
}
|
||||
```
|
||||
|
||||
Worker는 `BLPOP queue:{kind}-render` (1초 timeout) → `queue:paused` 체크 → 처리.
|
||||
|
||||
---
|
||||
|
||||
## 7. NAS 볼륨 레이아웃 + nginx 서빙
|
||||
|
||||
### 실 파일 시스템
|
||||
```
|
||||
/volume1/docker/webpage/data/
|
||||
├── insta/{slate_id}/01.png ~ 10.png
|
||||
├── music/{track_id}/{file}.mp3
|
||||
└── video/{video_id}/{file}.mp4
|
||||
```
|
||||
|
||||
### WSL2 마운트
|
||||
```bash
|
||||
# WSL2 /etc/fstab
|
||||
//gahusb.synology.me/docker/webpage/data /mnt/nas cifs username=...,vers=3.0,uid=1000,_netdev 0 0
|
||||
```
|
||||
|
||||
### nginx 서빙
|
||||
```
|
||||
https://gahusb.synology.me/media/insta/{id}/01.png
|
||||
/music/{id}/...
|
||||
/video/{id}/...
|
||||
```
|
||||
|
||||
→ nginx `location /media/` 블록은 `/volume1/docker/webpage/data/`를 alias로 서빙 (기존 패턴).
|
||||
|
||||
---
|
||||
|
||||
## 8. NAS internal webhook 명세
|
||||
|
||||
### Endpoint
|
||||
`POST /api/internal/{kind}/update` (kind ∈ `insta`|`music`|`video`)
|
||||
|
||||
### 인증 — 3-layer 차단
|
||||
1. **nginx IP 화이트리스트** (Layer 1·2):
|
||||
```nginx
|
||||
location /api/internal/ {
|
||||
allow 192.168.45.0/24; # LAN 화이트리스트
|
||||
allow 100.64.0.0/10; # Tailscale CGNAT 대역
|
||||
deny all;
|
||||
...
|
||||
}
|
||||
```
|
||||
2. **`X-Internal-Key` 헤더 검증** (Layer 3): `verify_internal_key` dependency
|
||||
|
||||
### Payload
|
||||
```json
|
||||
{
|
||||
"task_id": "uuid-...",
|
||||
"status": "processing|succeeded|failed",
|
||||
"progress": 0-100,
|
||||
"result_path": "/media/insta/123/01.png", // succeeded일 때만, nginx 경로
|
||||
"error": "exception message" // failed일 때만
|
||||
}
|
||||
```
|
||||
|
||||
### NAS 측 처리
|
||||
1. `tasks` 테이블 row update (status, progress, result_path, error)
|
||||
2. (옵션) Redis PUBLISH `task:{id}` — 미래 SSE 통합 시 활용
|
||||
3. 200 응답 (또는 401 if invalid key)
|
||||
|
||||
### 인증 키 정책
|
||||
|
||||
| 키 | 방향 | 권한 | 위치 |
|
||||
|----|------|------|------|
|
||||
| `X-WebAI-Key` | web-ai → NAS | read-only (`GET /api/webai/*`) | NAS `.env` + web-ai `.env` |
|
||||
| `X-Internal-Key` | Windows worker → NAS | write-only (`POST /api/internal/*`) | NAS `.env` + Windows `.env` |
|
||||
|
||||
분리 사유: Principle of Least Privilege, 독립 로테이션, 감사 로그 명확성.
|
||||
|
||||
### 인증 helper (NAS 공통 모듈, `web-backend/_shared/auth.py` 또는 각 컨테이너 복제)
|
||||
|
||||
```python
|
||||
from fastapi import Header, HTTPException
|
||||
import os
|
||||
|
||||
async def verify_internal_key(x_internal_key: str = Header(...)):
|
||||
expected = os.getenv("INTERNAL_API_KEY")
|
||||
if not expected or x_internal_key != expected:
|
||||
raise HTTPException(401, "Invalid X-Internal-Key")
|
||||
|
||||
# 라우터 사용
|
||||
@app.post("/api/internal/insta/update", dependencies=[Depends(verify_internal_key)])
|
||||
async def insta_update(payload: InternalUpdate): ...
|
||||
```
|
||||
|
||||
기존 `verify_webai_key` 패턴(메모리 `reference_webai_auth_pattern.md`)을 복제.
|
||||
|
||||
---
|
||||
|
||||
## 9. Suno + 외부 영상 API 키 이전
|
||||
|
||||
NAS `.env`에서 다음 키들을 **제거** → Windows `.env`로 이전:
|
||||
|
||||
| 키 | NAS 이전 | Windows 이후 |
|
||||
|-----|---------|-------------|
|
||||
| `SUNO_API_KEY` | music-lab | music-render |
|
||||
| `RUNWAY_API_KEY` | (없음) | video-render |
|
||||
| `OPENAI_API_KEY` (Sora) | (있을 수도) | video-render |
|
||||
| `GEMINI_API_KEY` (Veo) | (없음) | video-render |
|
||||
| `PIKA_API_KEY` / `KLING_API_KEY` / `LUMA_API_KEY` | (없음) | video-render |
|
||||
|
||||
→ NAS music-lab + video-lab은 외부 API 호출 코드를 가지지 않음. Redis push만.
|
||||
|
||||
---
|
||||
|
||||
## 10. SP 상세 명세
|
||||
|
||||
### SP-A1 — web-ai 캐시 TTL 증가 (10분)
|
||||
|
||||
**파일**: `web-ai/signal_v2/stock_client.py`
|
||||
|
||||
변경:
|
||||
```python
|
||||
# 변경 전
|
||||
PORTFOLIO_TTL = 60
|
||||
NEWS_TTL = 300
|
||||
SCREENER_TTL = 60
|
||||
|
||||
# 변경 후
|
||||
PORTFOLIO_TTL = 180 # 3분
|
||||
NEWS_TTL = 600 # 10분
|
||||
SCREENER_TTL = 300 # 5분
|
||||
```
|
||||
|
||||
**효과**: 분당 12 → 3~4 호출 (~70% 감소), 캐시 hit ratio 0~50% → 66~80%
|
||||
|
||||
### SP-A2 — NAS stock TTLCache (30분)
|
||||
|
||||
**파일**: `web-backend/stock/app/*` (webai endpoint 위치 확인 후)
|
||||
|
||||
```python
|
||||
from cachetools import TTLCache
|
||||
|
||||
_PORTFOLIO_CACHE = TTLCache(maxsize=1, ttl=120)
|
||||
_NEWS_CACHE = TTLCache(maxsize=10, ttl=600)
|
||||
_SCREENER_CACHE = TTLCache(maxsize=10, ttl=180)
|
||||
|
||||
@app.get("/api/webai/portfolio", dependencies=[Depends(verify_webai_key)])
|
||||
async def portfolio():
|
||||
if "result" in _PORTFOLIO_CACHE:
|
||||
return _PORTFOLIO_CACHE["result"]
|
||||
result = await compute_portfolio()
|
||||
_PORTFOLIO_CACHE["result"] = result
|
||||
return result
|
||||
```
|
||||
|
||||
3 endpoint 적용: `/api/webai/portfolio` · `/api/webai/news-sentiment` · `/api/stock/screener/run`. `cachetools` 의존성 requirements.txt 확인.
|
||||
|
||||
**효과**: V1+V2 동시 호출도 NAS에서 1회 계산. KIS·LLM 재호출 방지.
|
||||
|
||||
### SP-1 — NAS Redis 컨테이너 (30분)
|
||||
|
||||
**파일**: `web-backend/docker-compose.yml`에 추가
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
검증: `docker exec redis redis-cli PING` → `PONG`
|
||||
|
||||
### SP-2 — Windows WSL2 + Docker Engine + Tailscale (2h)
|
||||
|
||||
박재오 Windows AI Server에서 (관리자 PowerShell):
|
||||
|
||||
```powershell
|
||||
wsl --install -d Ubuntu-22.04
|
||||
# 재부팅 후
|
||||
wsl -d Ubuntu-22.04
|
||||
```
|
||||
|
||||
WSL2 안:
|
||||
```bash
|
||||
# Docker Engine
|
||||
sudo apt update && sudo apt install -y ca-certificates curl gnupg
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
echo "deb [arch=amd64 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
|
||||
sudo apt update && sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
sudo usermod -aG docker $USER
|
||||
|
||||
# Tailscale
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
sudo tailscale up
|
||||
|
||||
# NAS SMB mount
|
||||
sudo mkdir -p /mnt/nas
|
||||
echo "//gahusb.synology.me/docker/webpage/data /mnt/nas cifs username=...,vers=3.0,uid=1000,_netdev 0 0" | sudo tee -a /etc/fstab
|
||||
sudo mount -a
|
||||
```
|
||||
|
||||
검증: `docker ps`, `tailscale status`, `ls /mnt/nas`
|
||||
|
||||
### SP-3 — insta-render Windows 서비스 (4h)
|
||||
|
||||
**디렉토리**: `C:\Users\jaeoh\Desktop\workspace\web-ai-services\insta-render\`
|
||||
|
||||
```
|
||||
insta-render/
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── requirements.txt
|
||||
├── .env
|
||||
├── main.py
|
||||
├── worker.py
|
||||
└── card_renderer.py # 기존 NAS insta-lab/app/card_renderer.py 이식
|
||||
```
|
||||
|
||||
핵심 로직:
|
||||
- `worker.py`: Redis BLPOP `queue:insta-render` (paused 체크)
|
||||
- `card_renderer.py`: Browser pool (`init_browser`/`shutdown_browser`) + `render_slate`
|
||||
- `main.py`: 시작 시 browser init + worker async task spawn
|
||||
- 완료 시 `/mnt/nas/data/insta/{slate_id}/` 저장 + NAS webhook `POST /api/internal/insta/update`
|
||||
|
||||
### SP-4 — NAS insta-lab 분할 (2h)
|
||||
|
||||
**파일**: `web-backend/insta-lab/app/main.py` + `app/card_renderer.py`
|
||||
|
||||
변경:
|
||||
```python
|
||||
# 변경 전 — NAS에서 직접 렌더
|
||||
async def _bg_render(task_id: str, slate_id: int):
|
||||
async with RENDER_SEMAPHORE:
|
||||
await card_renderer.render_slate(slate_id, ...)
|
||||
|
||||
# 변경 후 — Redis 큐에 push만
|
||||
import redis.asyncio as aioredis
|
||||
redis_client = aioredis.from_url(os.getenv("REDIS_URL", "redis://redis:6379"))
|
||||
|
||||
async def _bg_render(task_id: str, slate_id: int):
|
||||
payload = {"task_id": task_id, "kind": "insta",
|
||||
"params": {"slate_id": slate_id, "theme": "hedgy75"},
|
||||
"submitted_at": datetime.now(KST).isoformat()}
|
||||
await redis_client.rpush("queue:insta-render", json.dumps(payload))
|
||||
```
|
||||
|
||||
추가: `POST /api/internal/insta/update` endpoint (Windows webhook 수신).
|
||||
삭제: `card_renderer.py` Playwright 코드 (Browser pool, Semaphore 등), `requirements.txt`에서 `playwright` 제거, Dockerfile에서 Chromium install 제거.
|
||||
|
||||
### SP-5 — music-render Windows 서비스 (3h)
|
||||
|
||||
**디렉토리**: `web-ai-services/music-render/`
|
||||
|
||||
- Suno API client (외부 SaaS, polling 1~5분)
|
||||
- MusicGen local call (Windows localhost:8765)
|
||||
- Redis BLPOP `queue:music-render`
|
||||
- 결과 mp3 → `/mnt/nas/data/music/{track_id}/{file}.mp3`
|
||||
- NAS webhook `POST /api/internal/music/update`
|
||||
|
||||
`SUNO_API_KEY` Windows `.env`에 단독 보관.
|
||||
|
||||
### SP-6 — NAS music-lab 분할 (2h)
|
||||
|
||||
Suno 호출 코드 + MusicGen 호출 코드 삭제. `_bg_generate` 함수를 Redis push로 변경. `POST /api/internal/music/update` endpoint 추가.
|
||||
|
||||
### SP-7 — video-render Windows 서비스 (3h)
|
||||
|
||||
**디렉토리**: `web-ai-services/video-render/`
|
||||
|
||||
6 provider gateway (Runway·Sora·Veo·Pika·Kling·Luma) — provider 선택은 payload에서. 각 외부 API 호출 + 결과 mp4 다운로드 → `/mnt/nas/data/video/{id}/`. NAS webhook.
|
||||
|
||||
### SP-8 — NAS video-lab 신설 (2h)
|
||||
|
||||
새 docker 컨테이너. `web-backend/video-lab/`:
|
||||
- `app/main.py`: 2 endpoint
|
||||
- `POST /api/video/generate` → Redis push `queue:video-render` + task_id 반환
|
||||
- `GET /api/video/tasks/{id}` → DB 조회
|
||||
- `app/db.py`: video_tasks 테이블 (sqlite)
|
||||
- `POST /api/internal/video/update` (Windows webhook)
|
||||
- Dockerfile, requirements, docker-compose.yml entry
|
||||
|
||||
매우 가벼움 (NAS CPU 부담 미미).
|
||||
|
||||
### SP-9 — NSSM 자동 시작 + 우선순위 (1h)
|
||||
|
||||
Windows AI에서 NSSM 다운로드 후:
|
||||
|
||||
```powershell
|
||||
# 트레이딩 (Native, HIGH)
|
||||
nssm install signal_v2 "C:\Python312\python.exe" "-m uvicorn main:app --host 0.0.0.0 --port 8001"
|
||||
nssm set signal_v2 AppDirectory "C:\Users\jaeoh\Desktop\workspace\web-ai\signal_v2"
|
||||
nssm set signal_v2 Priority HIGH_PRIORITY_CLASS
|
||||
nssm set signal_v2 AppStartup AUTO
|
||||
|
||||
# WSL2 Docker (NORMAL)
|
||||
nssm install wsl_docker "wsl" "-d Ubuntu-22.04 -- sudo service docker start && cd /workspace/web-ai-services && docker compose up -d"
|
||||
nssm set wsl_docker Priority NORMAL_PRIORITY_CLASS
|
||||
nssm set wsl_docker AppStartup AUTO
|
||||
|
||||
nssm start signal_v2
|
||||
nssm start wsl_docker
|
||||
```
|
||||
|
||||
### SP-10 — task-watcher (2h)
|
||||
|
||||
**디렉토리**: `web-ai-services/task-watcher/`
|
||||
|
||||
WSL2 Docker 컨테이너. 30초마다:
|
||||
1. `current_mode()` 판정 (시간대 + holidays.json 체크 + KST 시각)
|
||||
2. `is_user_active()` 판정 (마우스/키보드 idle < 5분 또는 게임 process 감지)
|
||||
3. 모드 + 활동 → `queue:paused` 토글
|
||||
- `mode == "free"` → `DEL queue:paused`
|
||||
- `mode != "free" and active` → `SET queue:paused 1 EX 600`
|
||||
- `mode != "free" and idle` → `DEL queue:paused`
|
||||
|
||||
---
|
||||
|
||||
## 11. 데이터 플로우 검증 — 인스타 사례 end-to-end
|
||||
|
||||
```
|
||||
1. 사용자 클릭 "카드 생성"
|
||||
POST /api/insta/slates/123/render
|
||||
↓ NAS insta-lab
|
||||
2. NAS insta-lab
|
||||
- db.create_task("slate_render", {slate_id: 123}) → task_id="t-abc"
|
||||
- redis.rpush("queue:insta-render", {task_id: "t-abc", kind: "insta", params: {slate_id: 123, theme: "hedgy75"}})
|
||||
- 응답 {task_id: "t-abc"}
|
||||
↓ 즉시 사용자
|
||||
3. Windows insta-render worker
|
||||
- redis.blpop("queue:insta-render", 1)
|
||||
- paused 체크 → 통과
|
||||
- webhook(processing, 10%) → NAS DB update
|
||||
- Playwright 카드 10장 렌더 → /mnt/nas/data/insta/123/01.png..10.png
|
||||
- webhook(processing, 90%) 진행률 보고
|
||||
- webhook(succeeded, 100, result_path="/media/insta/123/01.png") → NAS DB update
|
||||
4. 사용자 폴링
|
||||
GET /api/insta/tasks/t-abc → {status: "succeeded", result_path: "/media/insta/123/01.png"}
|
||||
브라우저에서 <img src="/media/insta/123/01.png" /> 렌더
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Out of Scope
|
||||
|
||||
- V1/V2 재시작 결정 (사용자 보류, 두 process 정지 유지)
|
||||
- NAS 하드웨어 업그레이드 (#12 보류)
|
||||
- 컨테이너 리소스 제한 cpus 0.5 (#11 박재오 진행 금지)
|
||||
- SSE/WS push 모델 (YAGNI, 폴링 유지)
|
||||
- Grafana 모니터링 (NAS 자산 활용 옵션, 향후)
|
||||
|
||||
## 13. 위험 요소
|
||||
|
||||
| 위험 | 완화 |
|
||||
|------|------|
|
||||
| Windows 재부팅 시 worker 중단 | NSSM AppStartup AUTO + WSL2 자동 시작 (SP-9) |
|
||||
| Windows ↔ NAS 네트워크 단절 | task가 큐에 남음, NAS 측 timeout 처리 (예: 30분 timeout → failed) |
|
||||
| 박재오 게임·작업 중 worker 충돌 | task-watcher queue:paused (SP-10) + NORMAL priority |
|
||||
| Suno API rate limit | music-render 내부에서 retry + 큐 직렬 처리 |
|
||||
| SMB 마운트 실패 | WSL2 부팅 시 `mount -a`, 실패 시 alarm (로그) |
|
||||
| Redis 다운 | docker restart unless-stopped + healthcheck. 다운 시 모든 worker idle (NAS는 응답 계속) |
|
||||
| 키 노출 | 3-layer 차단 (IP 화이트리스트 + nginx + X-Internal-Key) |
|
||||
|
||||
## 14. 첫 plan 작성 대상
|
||||
|
||||
**옵션 A — Track A만 (사용자 선택 확정)**:
|
||||
- SP-A1: web-ai 캐시 TTL 증가 (10분)
|
||||
- SP-A2: NAS stock TTLCache (30분)
|
||||
|
||||
이 plan은 즉시 NAS CPU 70% 감소 효과 (V2 재시작 시). Track B는 별도 spec/plan으로 차후 진행.
|
||||
|
||||
차후 plan 작성 순서 권장:
|
||||
1. **Plan-A (이번)** — SP-A1 + SP-A2
|
||||
2. **Plan-B-Base** — SP-1 + SP-2
|
||||
3. **Plan-B-Insta** — SP-3 + SP-4 (1순위 패턴 정착)
|
||||
4. **Plan-B-Music** — SP-5 + SP-6
|
||||
5. **Plan-B-Video** — SP-7 + SP-8
|
||||
6. **Plan-B-Infra** — SP-9 + SP-10
|
||||
|
||||
## 15. 참고
|
||||
|
||||
- 박재오 7결정 통합: `Obsidian Vault/raw/2026-05-18-Windows-NAS-아키텍처-7결정-통합.md`
|
||||
- API 부하 해결: `Obsidian Vault/raw/2026-05-18-NAS-Window-AI-API-부하-해결방안.md`
|
||||
- 역할 분담 최적화: `Obsidian Vault/raw/2026-05-18-NAS-Windows-역할-분담-최적화.md`
|
||||
- web-backend CHECK_POINT.md (즉시·중기·장기 + 7결정 매핑)
|
||||
- web-ai CHECK_POINT.md (Phase 진행도)
|
||||
- 기존 인증 패턴: 메모리 `reference_webai_auth_pattern.md`
|
||||
301
docs/superpowers/specs/2026-05-20-lotto-active-agent-design.md
Normal file
301
docs/superpowers/specs/2026-05-20-lotto-active-agent-design.md
Normal file
@@ -0,0 +1,301 @@
|
||||
# LottoAgent 능동성 확장 설계
|
||||
|
||||
- **상태**: Draft (사용자 리뷰 대기)
|
||||
- **작성일**: 2026-05-20
|
||||
- **대상 컨테이너**: agent-office
|
||||
- **영향 외부 도메인**: lotto-lab (read-only API 소비만)
|
||||
|
||||
---
|
||||
|
||||
## 1. 문제 정의
|
||||
|
||||
현재 LottoAgent는 매주 월요일 09:05 cron으로 무조건 큐레이션을 1회 실행하고 헤드라인을 텔레그램으로 푸시한다. "결과가 좋지 않은 회차"도 동일하게 발화되며, **정량적 시그널이 평소보다 강할 때 별도로 알리는 능동성**이 없다.
|
||||
|
||||
사용자 의도: 통계·시뮬레이션·전략 가중치를 에이전트가 스스로 모니터링하다가 "좋은 수치"가 나오면 능동적으로 보고하는 패턴.
|
||||
|
||||
## 2. 의사결정 요약
|
||||
|
||||
| 결정 사항 | 선택 | 비고 |
|
||||
|---|---|---|
|
||||
| 분석 주기 | 다중 트리거 혼합 | 매일 정기 + 시뮬레이션 후 + 회차 후 |
|
||||
| 시그널 종류 | 3종 — Sim Consensus / Strategy Drift / Confidence | Hot/Cold 변화는 제외 (노이즈) |
|
||||
| 알림 정책 | 일일 요약 + 긴급 즉시 | 2개 동시 발화 OR 단일 z≥2.5 → 긴급 |
|
||||
| 임계치 전략 | 적응형 (최근 8회 μ + σ) | warmup·보수적 단계 포함 |
|
||||
| 시뮬 강도 조절 (Layer B) | v1 미포함 | 운영 검증 후 v2에서 도입 검토 |
|
||||
|
||||
## 3. 아키텍처
|
||||
|
||||
### 3.1 컴포넌트 다이어그램
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ agent-office │
|
||||
│ │
|
||||
│ cron (scheduler.py) │
|
||||
│ ├─ lotto_light_check 매일 09:15 │
|
||||
│ ├─ lotto_sim_check 4시간마다 :15 │
|
||||
│ ├─ lotto_deep_check 일/수 21:15 │
|
||||
│ ├─ lotto_daily_digest 매일 09:25 │
|
||||
│ └─ lotto_curate 월요일 09:05 (기존 유지) │
|
||||
│ ↓ │
|
||||
│ curator/signals.py (신규) │
|
||||
│ ├─ evaluate_sim_consensus() ← lotto_best API │
|
||||
│ ├─ evaluate_strategy_drift() ← strategy/weights API │
|
||||
│ ├─ evaluate_confidence() ← deep_check 시 큐레이션 결과 │
|
||||
│ └─ adaptive_baseline() ← μ, σ 갱신 │
|
||||
│ ↓ │
|
||||
│ agent_office.db │
|
||||
│ ├─ lotto_signals (이벤트 이력) │
|
||||
│ └─ lotto_baselines (롤링 8회 윈도우) │
|
||||
│ ↓ │
|
||||
│ notifiers/telegram_lotto.py │
|
||||
│ ├─ send_urgent_signal() ← 긴급 │
|
||||
│ └─ send_signal_summary() ← 일일 요약 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↑ (HTTP GET, 기존 lotto-lab API 재사용, 변경 없음)
|
||||
│
|
||||
lotto:8000
|
||||
├─ /api/lotto/best
|
||||
├─ /api/lotto/strategy/weights
|
||||
└─ /api/lotto/curator/*
|
||||
```
|
||||
|
||||
### 3.2 책임 경계
|
||||
|
||||
- **lotto-lab**: 변경 없음. 기존 GET API만 소비.
|
||||
- **agent-office**: 능동 모니터링 layer 전부 담당. DB도 `agent_office.db` 안에 분리해서 lotto.db와 결합 없음.
|
||||
- **프론트엔드**: Phase 4 별도 (web-ui repo). 본 spec 범위 밖.
|
||||
|
||||
## 4. 시그널 평가 로직
|
||||
|
||||
### 4.1 Sim Consensus Score
|
||||
|
||||
```
|
||||
best_picks 20개의 점수 5종 (s1..s5) 사용
|
||||
|
||||
normalize(s_k) = (s_k - min_k) / (max_k - min_k) per metric across 20 picks
|
||||
consensus_i = geomean( normalize(s1_i), ..., normalize(s5_i) )
|
||||
sim_signal = mean( sorted(consensus_i, desc)[:10] )
|
||||
```
|
||||
|
||||
- 기하평균: 5종 점수가 **동시에** 높을 때만 강한 시그널. 단일 폭주는 감쇠.
|
||||
- top-10 평균: 전체 20개 분포에서 강한 후보군의 농도 측정.
|
||||
|
||||
### 4.2 Strategy Drift Score
|
||||
|
||||
```
|
||||
drift_t = Σ | w_strategy_t - w_strategy_{t-1} | for each strategy in strategy_weights
|
||||
```
|
||||
|
||||
- 회차 단위로 비교. 한 전략이 EMA로 큰 폭 이동했을 때 누적값이 큼.
|
||||
- 시스템이 "지난 회차에서 의미 있게 학습한" 시그널.
|
||||
|
||||
### 4.3 Confidence Score
|
||||
|
||||
`curator.pipeline.curate_weekly()` 반환의 `validated.confidence` (0~1) 그대로.
|
||||
- light_check / sim_check: N/A (LLM 호출 없음)
|
||||
- deep_check: 직전 큐레이션 confidence를 baseline 윈도우에 push
|
||||
|
||||
### 4.4 Adaptive Baseline
|
||||
|
||||
```
|
||||
lotto_baselines.window_values = [v_{t-7}, v_{t-6}, ..., v_t] (FIFO 8)
|
||||
mu = mean(window_values)
|
||||
sigma = stddev(window_values, ddof=1)
|
||||
z_now = (v_now - mu) / sigma
|
||||
```
|
||||
|
||||
- **Cold start**: window 크기 < 4 → fire_level='warmup', 발화 X
|
||||
- **준비 단계**: window 4~7 → 임계치 z=2.0 (false positive 줄임)
|
||||
- **정상 운영**: window 8 풀 → z_normal=1.5, z_urgent=2.5
|
||||
|
||||
### 4.5 Trigger × Metric 매트릭스
|
||||
|
||||
| Trigger | Sim Consensus | Strategy Drift | Confidence |
|
||||
|---|---|---|---|
|
||||
| `light_check` (매일 09:15) | ✓ 평가 | ✓ 회차 변경 시만 | — |
|
||||
| `sim_check` (4h마다) | ✓ 평가 | ✓ 회차 변경 시만 | — |
|
||||
| `deep_check` (일/수 21:15) | ✓ 평가 | ✓ 회차 변경 시만 | ✓ (큐레이션 후) |
|
||||
| `lotto_curate` (월 09:05) | — | — | ✓ 큐레이션 결과 직접 push |
|
||||
|
||||
**회차 변경 가드**: Strategy Drift / Confidence는 **회차 단위 메트릭**. baseline 윈도우에 push할 때 `last_pushed_draw_no`를 비교, 동일 회차면 skip. 같은 회차 내에서 값 비교는 가능하지만 baseline 갱신은 회차당 1회만.
|
||||
|
||||
```
|
||||
if metric in ('drift', 'confidence'):
|
||||
if current_draw_no == baselines[metric].last_pushed_draw_no:
|
||||
# baseline 윈도우는 그대로, z-score만 현재값으로 비교
|
||||
skip_window_update = True
|
||||
```
|
||||
|
||||
Sim Consensus는 회차 무관 (4시간마다 시뮬 자체가 갱신) → 매 평가 시 window push.
|
||||
|
||||
### 4.6 Fire 결정
|
||||
|
||||
```
|
||||
fires = [m for m in [sim, drift, conf] if m.z >= LOTTO_Z_NORMAL]
|
||||
if len(fires) >= 2 or any(m.z >= LOTTO_Z_URGENT for m in fires):
|
||||
fire_level = 'urgent'
|
||||
elif len(fires) == 1:
|
||||
fire_level = 'normal'
|
||||
else:
|
||||
fire_level = 'noop'
|
||||
```
|
||||
|
||||
## 5. 알림 흐름
|
||||
|
||||
### 5.1 트리거→발송 다이어그램
|
||||
|
||||
```
|
||||
cron / signal_check
|
||||
↓
|
||||
signals.evaluate_all()
|
||||
↓
|
||||
lotto_signals INSERT (all results)
|
||||
↓
|
||||
fire_level == 'urgent' → send_urgent_signal() → 텔레그램 즉시
|
||||
fire_level == 'normal' → 09:25 digest 합류
|
||||
fire_level == 'noop' → 기록만
|
||||
```
|
||||
|
||||
### 5.2 텔레그램 메시지 폼
|
||||
|
||||
**Urgent**:
|
||||
```
|
||||
🚨 로또 능동 신호
|
||||
|
||||
[2026-05-20 16:18]
|
||||
강한 시그널 2종 동시 발화:
|
||||
• Sim Consensus 1.84 (μ=1.02, σ=0.21) z=3.9
|
||||
• Strategy Drift 0.18 (μ=0.06, σ=0.04) z=3.0
|
||||
|
||||
요인: gap_focus 전략이 지난 3회차 EMA +22%p
|
||||
다음 시뮬: 20:05
|
||||
|
||||
[자세히 보기] (→ /lotto/agent)
|
||||
```
|
||||
|
||||
**Daily digest** (09:25):
|
||||
```
|
||||
📊 로또 일일 요약 (지난 24h)
|
||||
|
||||
평가 6회 / 발화 2회
|
||||
• Sim Consensus normal z=1.7 (16:18)
|
||||
• Confidence normal z=1.6 (월 09:05)
|
||||
|
||||
전략 가중치 추세 (최근 8회 baseline):
|
||||
gap_focus ↑ +12%
|
||||
hot_focus → -2%
|
||||
pair_bias ↓ -8%
|
||||
```
|
||||
|
||||
- 24h 내 발화 0건이면 digest 자체 skip (조용한 날 강제 알림 없음).
|
||||
|
||||
### 5.3 Throttle 규칙
|
||||
|
||||
| 규칙 | 동작 |
|
||||
|---|---|
|
||||
| 같은 metric + 같은 fire_level이 6시간 이내 재발화 | 두 번째는 DB 기록만, 텔레그램 skip |
|
||||
| urgent 누적 ≥ 3통/day | 4번째부터 normal로 강등 → digest 합류 |
|
||||
| digest 24h 발화 0건 | digest skip |
|
||||
| Anthropic / 텔레그램 실패 | 평가는 success로 기록, 메시지만 60초 후 1회 retry |
|
||||
|
||||
## 6. 데이터 모델
|
||||
|
||||
### 6.1 lotto_signals
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS lotto_signals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
triggered_at TEXT NOT NULL, -- ISO8601 UTC
|
||||
source TEXT NOT NULL, -- 'light' | 'sim' | 'deep'
|
||||
metric TEXT NOT NULL, -- 'sim_signal' | 'drift' | 'confidence'
|
||||
value REAL NOT NULL,
|
||||
baseline_mu REAL,
|
||||
baseline_sigma REAL,
|
||||
z_score REAL,
|
||||
fire_level TEXT NOT NULL, -- 'noop' | 'warmup' | 'normal' | 'urgent'
|
||||
notified_at TEXT, -- 텔레그램 발송 시각 (NULL=미발송)
|
||||
payload TEXT -- JSON 부가 정보
|
||||
);
|
||||
CREATE INDEX idx_ls_triggered ON lotto_signals(triggered_at DESC);
|
||||
CREATE INDEX idx_ls_fire ON lotto_signals(fire_level, notified_at);
|
||||
```
|
||||
|
||||
### 6.2 lotto_baselines
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS lotto_baselines (
|
||||
metric TEXT PRIMARY KEY,
|
||||
window_values TEXT NOT NULL, -- JSON: [v1..v8]
|
||||
mu REAL NOT NULL,
|
||||
sigma REAL NOT NULL,
|
||||
last_pushed_draw_no INTEGER, -- 회차 단위 메트릭의 중복 push 방지 (drift, confidence)
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
마이그레이션: `agent-office/app/db.py`의 `init_db()`에 `CREATE TABLE IF NOT EXISTS` 추가만으로 idempotent. 기존 테이블 영향 없음.
|
||||
|
||||
## 7. API 추가
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|---|---|---|
|
||||
| GET | `/api/agent-office/lotto/signals?days=7` | 시그널 이력 (timeline, 차트용) |
|
||||
| GET | `/api/agent-office/lotto/baselines` | 현재 baseline μ/σ 조회 |
|
||||
| POST | `/api/agent-office/lotto/signal-check` | 수동 트리거 (디버깅·테스트용) |
|
||||
|
||||
## 8. 환경 변수
|
||||
|
||||
```bash
|
||||
LOTTO_SIGNAL_WINDOW=8 # baseline 윈도우 크기
|
||||
LOTTO_Z_NORMAL=1.5 # normal fire 임계치
|
||||
LOTTO_Z_URGENT=2.5 # urgent fire 임계치
|
||||
LOTTO_DIGEST_HOUR=9 # digest cron hour (KST)
|
||||
LOTTO_DIGEST_MIN=25
|
||||
LOTTO_THROTTLE_HOURS=6 # 같은 메트릭 재발화 throttle
|
||||
LOTTO_URGENT_DAILY_MAX=3 # urgent 하루 cap
|
||||
```
|
||||
|
||||
모두 default 있음. `.env` 미설정 시 default로 동작.
|
||||
|
||||
## 9. 스케줄러 cron
|
||||
|
||||
```python
|
||||
scheduler.add_job(lotto_light_check, "cron", hour=9, minute=15, id="lotto_light_check")
|
||||
scheduler.add_job(lotto_sim_check, "cron", minute=15, hour="0,4,8,12,16,20", id="lotto_sim_check")
|
||||
scheduler.add_job(lotto_deep_check, "cron", day_of_week="sun,wed", hour=21, minute=15, id="lotto_deep_check")
|
||||
scheduler.add_job(lotto_daily_digest, "cron", hour=9, minute=25, id="lotto_digest")
|
||||
# 기존: lotto_curate (월 09:05) 유지
|
||||
```
|
||||
|
||||
## 10. 구현 Phase
|
||||
|
||||
| Phase | 범위 | 검증 |
|
||||
|---|---|---|
|
||||
| 1 | DB 마이그레이션 + `signals.py` (순수 함수, LLM X) | `POST /lotto/signal-check`로 수동 호출 → z-score 계산 확인 |
|
||||
| 2 | cron 4개 + `lotto_signals` INSERT (텔레그램 X) | 24h 가동 → DB에 시그널 누적 |
|
||||
| 3 | 텔레그램 urgent / digest + throttle | dry-run env로 메시지 검증 후 실제 발송 |
|
||||
| 4 | 프론트 (web-ui) — `/lotto/agent` 시그널 timeline UI | 별도 PR (본 spec 범위 외) |
|
||||
|
||||
Phase 1~3이 백엔드 능동성 완성. 각 Phase 끝에 commit + 자동 배포.
|
||||
|
||||
## 11. 비기능 요구
|
||||
|
||||
- **백워드 호환**: 기존 월요일 큐레이션 cron 변경 없음
|
||||
- **장애 격리**: signals 평가 실패해도 LottoAgent.state는 idle 유지 (try/except + add_log warning)
|
||||
- **테스트**: `signals.py`의 메트릭 함수는 input/output 순수형 → 단위 테스트 작성 가능
|
||||
- **관측**: `agent_logs` 테이블 그대로 활용 (별도 로깅 추가 없음)
|
||||
|
||||
## 12. 비목표 (Out of scope)
|
||||
|
||||
- 자동 구매·자동 픽 갱신 (보고만)
|
||||
- 시뮬레이션 강도 자동 조절 (Layer B는 v2)
|
||||
- 텔레그램 인라인 키보드 (v2에서 자동 액션 도입 시 함께)
|
||||
- 핫넘버/콜드넘버 시그널 (노이즈 위험, v1 제외)
|
||||
- 프론트 UI (별도 PR)
|
||||
|
||||
## 13. v2 후속 검토
|
||||
|
||||
- Layer B 시뮬 강도 조절 (모호 시그널 시 deep_simulate)
|
||||
- 사용자 피드백 루프 (텔레그램 [좋아요/별로] 버튼 → 임계치 가중 조정)
|
||||
- 회차 retrospective 자동 분석 (당첨번호 vs 추천번호 패턴 학습)
|
||||
419
docs/superpowers/specs/2026-05-22-lotto-weight-evolver-design.md
Normal file
419
docs/superpowers/specs/2026-05-22-lotto-weight-evolver-design.md
Normal file
@@ -0,0 +1,419 @@
|
||||
# Lotto Weight Evolver — 자율 학습 루프 설계 (v2)
|
||||
|
||||
- **상태**: Draft (사용자 리뷰 대기)
|
||||
- **작성일**: 2026-05-22
|
||||
- **대상 컨테이너**: lotto (lotto-lab) + agent-office (텔레그램 보고)
|
||||
- **선행 작업**: v1 LottoAgent 능동성 확장 (2026-05-20 배포)
|
||||
- **목표**: 5종 시뮬 점수 가중치를 매주 6가지로 변형 시도 → 토요일 회고 → winner 가중치를 다음 주 base로 적용 → 무한 반복 자가 학습 루프
|
||||
|
||||
---
|
||||
|
||||
## 1. 문제 정의
|
||||
|
||||
현재 `analyzer.score_combination()`은 5종 점수(`score_frequency`, `score_fingerprint`, `score_gap`, `score_cooccur`, `score_diversity`)를 **균등 합산**으로 `score_total`을 계산한다. 어떤 메트릭이 실제 추첨 결과와 더 잘 상관되는지에 대한 학습 없이 가중치가 고정.
|
||||
|
||||
또한 `purchase_history` 기반 `strategy_evolver`는 **사용자가 실제 구매한 번호만** 학습 시그널로 사용. 사람이 안 사면 학습 안 됨.
|
||||
|
||||
사용자 요구: 에이전트가 사람 없이도 **매일 다른 가중치로 시뮬레이션 → 번호 시도 → 토요일 추첨 후 best 가중치 식별 → 다음주 base 갱신**의 무한 학습 루프.
|
||||
|
||||
## 2. 의사결정 요약
|
||||
|
||||
| 결정 사항 | 선택 | 비고 |
|
||||
|---|---|---|
|
||||
| 학습 대상 | 시뮬 점수 5종 가중치 (`W = [w_freq, w_finger, w_gap, w_cooccur, w_diversity]`) | 메타 전략 가중치는 strategy_evolver가 별도 학습 (v2에서 손대지 않음) |
|
||||
| 탐험 전략 | 현재 base 주변 4개 perturbation + Dirichlet 무작위 2개 | 매주 월요일 6개 후보 |
|
||||
| 일일 시도량 | N = 5 세트/일 × 6일 = 30 세트/주 | 통계적 의미 + 비용 균형 |
|
||||
| 평가 시그널 | strategy_evolver의 `RANK_BONUS` + `correct/6` | 기존 패턴 재사용으로 일관성 |
|
||||
| Base 적용 강도 | Hybrid — winner_max_correct ≥ 4면 교체, =3이면 EMA blend (0.3), ≤2면 유지 | 노이즈에 base가 헤매지 않도록 보호 |
|
||||
| v1과의 결합 | W가 `analyzer.score_combination`에 반영 → best_picks 점수 자동 영향 → v1 시그널 자동 cascade | 별도 통합 코드 없음 |
|
||||
| strategy_evolver와의 상호작용 | strategy_evolver는 `score_total`을 그대로 입력으로 사용 → W 변경 시 입력 분포가 함께 변함. **의도된 간접 영향** | v3에서 메타 가중치도 함께 학습할 때 명시적으로 분리 검토 |
|
||||
| 자동 구매 | v2 비포함 | 사람 결정 영역 — purchase_history는 사람이 등록 |
|
||||
|
||||
## 3. 아키텍처
|
||||
|
||||
### 3.1 컴포넌트 다이어그램
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ lotto-lab (자율 학습 루프 추가) │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ weight_evolver.py (신규) │ │
|
||||
│ │ • generate_weekly_candidates() ← 월 09:00 │ │
|
||||
│ │ • apply_today_weight() ← 매일 09:00 │ │
|
||||
│ │ • evaluate_weekly() ← 토 22:00 │ │
|
||||
│ │ • update_base() ← evaluate 안에서 │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ ↓ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ analyzer.score_combination(numbers, cache, │ │
|
||||
│ │ weights=None) 확장 │ │
|
||||
│ │ • weights=None → 균등 합산 (기존 호환) │ │
|
||||
│ │ • weights=[..] → 가중 합산 │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ ↓ │
|
||||
│ lotto.db 신규 테이블 3개 │
|
||||
│ • weight_trials (주별 6일치 후보 가중치) │
|
||||
│ • auto_picks (매일 N=5 시도 번호 + 채점 결과) │
|
||||
│ • weight_base_history (base 변경 이력) │
|
||||
│ │
|
||||
│ 기존 시뮬 cron (00/04/08/12/16/20:05) — 변경 없음. │
|
||||
│ 단 best_picks 재계산 시 활성 W를 읽어 적용. │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓ (HTTP)
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ agent-office │
|
||||
│ │
|
||||
│ cron 신규 1종: lotto_evolution_weekly (토 22:15) │
|
||||
│ LottoAgent.run_weekly_evolution_report() (신규) │
|
||||
│ notifiers/telegram_lotto.send_evolution_report() (신규) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 책임 경계
|
||||
|
||||
- **lotto-lab**: 가중치 생성·적용·평가·base 갱신 + DB CRUD + API. 시그널/알림 책임 없음.
|
||||
- **agent-office**: 토요일 22:15 lotto-lab API 폴링 → 텔레그램 보고 1통.
|
||||
- **v1 (signals layer)**: 변경 없음. W 변경의 효과는 best_picks 분포 변화로 자동 흡수.
|
||||
- **strategy_evolver (메타 가중치 5종)**: 그대로 둠.
|
||||
|
||||
## 4. 가중치 진화 알고리즘
|
||||
|
||||
### 4.1 Weight Vector
|
||||
|
||||
```
|
||||
W = [w_freq, w_finger, w_gap, w_cooccur, w_diversity]
|
||||
제약: w_i ≥ 0.05, sum(W) = 1.0
|
||||
```
|
||||
|
||||
(MIN_WEIGHT=0.05는 한 메트릭이 죽지 않도록 보호. strategy_evolver의 MIN_WEIGHT 패턴.)
|
||||
|
||||
### 4.2 주간 6개 후보 생성
|
||||
|
||||
`generate_weekly_candidates()` — 매주 월요일 09:00 KST.
|
||||
|
||||
```python
|
||||
W_base = get_current_base() # weight_base_history 최신 row, 없으면 [0.2]*5
|
||||
|
||||
# 4개 Local Perturbation
|
||||
for i in range(4):
|
||||
noise = np.random.normal(0, 0.05, size=5)
|
||||
W_i = W_base + noise
|
||||
W_i = clamp(W_i, min=0.05)
|
||||
W_i = W_i / W_i.sum()
|
||||
save_trial(week_start, day=i, W_i, source='perturb', base=W_base)
|
||||
|
||||
# 2개 Dirichlet 탐험
|
||||
for i in range(4, 6):
|
||||
W_i = np.random.dirichlet([2.0]*5)
|
||||
W_i = clamp(W_i, min=0.05)
|
||||
W_i = W_i / W_i.sum()
|
||||
save_trial(week_start, day=i, W_i, source='dirichlet', base=W_base)
|
||||
```
|
||||
|
||||
- `σ=0.05` 정규분포: 각 메트릭 ±10%p 안쪽 변동
|
||||
- `α=2.0` Dirichlet: 균등 분포에 약간 치우치게, 극단 가중치도 포함
|
||||
|
||||
### 4.3 일일 W 적용
|
||||
|
||||
`apply_today_weight()` — 매일 09:00 KST.
|
||||
|
||||
```python
|
||||
W_today = get_trial(week_start, day_of_week=today)
|
||||
set_active_weight(W_today) # 메모리 캐시 or DB row (W_active 테이블 또는 file)
|
||||
generate_n_picks(N=5, weight=W_today) # auto_picks에 5세트 저장
|
||||
```
|
||||
|
||||
같은 W로 그날 기존 시뮬 cron (4시간마다 6회) best_picks 재계산.
|
||||
|
||||
### 4.4 토요일 회고
|
||||
|
||||
`evaluate_weekly()` — 매주 토요일 22:00 KST (추첨 20:35 KST + sync 21:10 → 22:00 안전).
|
||||
|
||||
```python
|
||||
winning_numbers = get_latest_draw().numbers # 1224, 1225, ...
|
||||
trials = get_trials(week_start) # 6 trials
|
||||
|
||||
scores_per_day = []
|
||||
for trial in trials:
|
||||
picks = get_auto_picks(trial.id) # N=5
|
||||
day_score = mean(
|
||||
calc_pick_score(p.numbers, winning_numbers) for p in picks
|
||||
)
|
||||
max_correct = max(
|
||||
count_match(p.numbers, winning_numbers) for p in picks
|
||||
)
|
||||
update_pick_grades(picks, winning_numbers) # auto_picks 채점 결과 저장
|
||||
scores_per_day.append({
|
||||
"trial_id": trial.id,
|
||||
"day": trial.day_of_week,
|
||||
"weight": trial.weight,
|
||||
"score": day_score,
|
||||
"max_correct": max_correct,
|
||||
})
|
||||
|
||||
winner = max(scores_per_day, key=lambda s: s.score)
|
||||
update_base(winner)
|
||||
```
|
||||
|
||||
**점수 함수** (strategy_evolver `calc_draw_score` 패턴, 단순화):
|
||||
|
||||
v2에서는 보너스 번호를 평가에 포함하지 않음 → 5개 일치를 2등/3등으로 구분 불가. 따라서 보너스 무시한 단순 매핑:
|
||||
|
||||
```python
|
||||
# correct → rank 매핑 (보너스 제외)
|
||||
RANK_BY_CORRECT = {
|
||||
6: 1, # 1등
|
||||
5: 3, # 3등 (보너스 평가 안 함 → 2등 표시 X)
|
||||
4: 4,
|
||||
3: 5,
|
||||
}
|
||||
RANK_BONUS = {1: 1.0, 2: 0.8, 3: 0.6, 4: 0.3, 5: 0.1}
|
||||
|
||||
def calc_pick_score(pick_numbers, winning_numbers):
|
||||
correct = count_match(pick_numbers, winning_numbers[:6])
|
||||
base = correct / 6.0
|
||||
rank = RANK_BY_CORRECT.get(correct)
|
||||
bonus = RANK_BONUS.get(rank, 0)
|
||||
return base + bonus
|
||||
```
|
||||
|
||||
(rank=2의 보너스 0.8은 매핑되지 않으므로 v2 점수에 등장하지 않음. v3에서 보너스 번호 평가 도입 시 활성화.)
|
||||
|
||||
### 4.5 Base 갱신 규칙 (Hybrid)
|
||||
|
||||
```python
|
||||
if winner.max_correct >= 4:
|
||||
W_base_next = winner.weight
|
||||
reason = "winner_4plus"
|
||||
elif winner.max_correct == 3:
|
||||
W_base_next = 0.3 * winner.weight + 0.7 * W_base_current
|
||||
reason = "ema_blend"
|
||||
else:
|
||||
W_base_next = W_base_current
|
||||
reason = "unchanged"
|
||||
|
||||
save_to_weight_base_history(W_base_next, reason, winner)
|
||||
```
|
||||
|
||||
성과가 약할 때 base를 그대로 두는 게 핵심 — base가 노이즈에 따라 헤매지 않음.
|
||||
|
||||
### 4.6 Cold start (첫 주)
|
||||
|
||||
`weight_base_history`가 비어있으면 `W_base = [0.2]*5` (균등) 가정. 첫 주는 4 perturbation이 모두 균등 주변, 2 Dirichlet 탐험.
|
||||
|
||||
## 5. 데이터 모델
|
||||
|
||||
### 5.1 weight_trials
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS weight_trials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
week_start TEXT NOT NULL, -- 'YYYY-MM-DD' (해당 주 월요일)
|
||||
day_of_week INTEGER NOT NULL, -- 0=월 .. 5=토
|
||||
weight_json TEXT NOT NULL, -- '[0.18, 0.22, ...]'
|
||||
source TEXT NOT NULL, -- 'perturb' | 'dirichlet'
|
||||
base_at_gen TEXT, -- 생성 시점 W_base (참조용)
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
UNIQUE(week_start, day_of_week)
|
||||
);
|
||||
CREATE INDEX idx_wt_week ON weight_trials(week_start, day_of_week);
|
||||
```
|
||||
|
||||
### 5.2 auto_picks
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS auto_picks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trial_id INTEGER NOT NULL REFERENCES weight_trials(id) ON DELETE CASCADE,
|
||||
pick_no INTEGER NOT NULL, -- 1..5
|
||||
numbers TEXT NOT NULL, -- JSON 정렬 6개
|
||||
meta_score REAL, -- 활성 W로 계산한 score_total
|
||||
correct INTEGER, -- 채점 후 채워짐
|
||||
rank INTEGER, -- 1..5 또는 NULL
|
||||
graded_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
UNIQUE(trial_id, pick_no)
|
||||
);
|
||||
CREATE INDEX idx_ap_trial ON auto_picks(trial_id);
|
||||
CREATE INDEX idx_ap_graded ON auto_picks(graded_at);
|
||||
```
|
||||
|
||||
### 5.3 weight_base_history
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS weight_base_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
effective_from TEXT NOT NULL, -- 'YYYY-MM-DD' (적용 시작 월요일)
|
||||
weight_json TEXT NOT NULL,
|
||||
source_trial_id INTEGER REFERENCES weight_trials(id), -- NULL=cold start
|
||||
update_reason TEXT, -- 'winner_4plus' | 'ema_blend' | 'unchanged' | 'cold_start'
|
||||
winner_score REAL,
|
||||
winner_max_correct INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
```
|
||||
|
||||
마이그레이션: `lotto/app/db.py`의 `init_db()`에 `CREATE TABLE IF NOT EXISTS` 추가만으로 idempotent. 기존 테이블 영향 없음.
|
||||
|
||||
## 6. analyzer.score_combination 시그니처 확장
|
||||
|
||||
```python
|
||||
# 기존
|
||||
def score_combination(numbers, cache) -> Dict[str, float]:
|
||||
...
|
||||
return {
|
||||
"score_frequency": ...,
|
||||
"score_fingerprint": ...,
|
||||
"score_gap": ...,
|
||||
"score_cooccur": ...,
|
||||
"score_diversity": ...,
|
||||
"score_total": sum(5 scores) # 균등 합산
|
||||
}
|
||||
|
||||
# 변경
|
||||
def score_combination(numbers, cache, weights: Optional[List[float]] = None) -> Dict[str, float]:
|
||||
...
|
||||
scores = [s_freq, s_finger, s_gap, s_cooccur, s_diversity]
|
||||
if weights is None:
|
||||
total = sum(scores)
|
||||
else:
|
||||
total = sum(s * w for s, w in zip(scores, weights))
|
||||
return {
|
||||
"score_frequency": ...,
|
||||
...
|
||||
"score_total": total
|
||||
}
|
||||
```
|
||||
|
||||
- 기본값 None → 기존 호출 호환 (변경 없는 효과)
|
||||
- 시뮬 cron / smart_recommendation 등은 `get_active_weight()` 결과 전달
|
||||
- 활성 W가 없으면 (cold start 이전) None 그대로 → 균등 합산 폴백
|
||||
|
||||
### 6.1 활성 W 조회 (`get_active_weight()`)
|
||||
|
||||
별도 캐시 테이블 없이 `weight_trials`에서 오늘 요일 row 직접 조회:
|
||||
|
||||
```python
|
||||
def get_active_weight() -> Optional[List[float]]:
|
||||
today = datetime.now(KST).date()
|
||||
week_start = today - timedelta(days=today.weekday()) # 이번주 월요일
|
||||
day_of_week = today.weekday() # 0=월, 6=일
|
||||
if day_of_week == 6: # 일요일은 trial 없음 → 직전 토요일 W 유지
|
||||
day_of_week = 5
|
||||
row = db.get_weight_trial(week_start.isoformat(), day_of_week)
|
||||
return json.loads(row["weight_json"]) if row else None
|
||||
```
|
||||
|
||||
- 컨테이너 재시작·timezone 변화에 영향 없음 (DB 진실 기준)
|
||||
- 일요일(6)은 토요일 W를 그대로 사용 (회고 cron 22:00 전까지)
|
||||
- 첫 주 월요일 generate가 안 끝났을 때만 None 반환 → 균등 폴백
|
||||
|
||||
## 7. API 추가 (lotto-lab)
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|---|---|---|
|
||||
| GET | `/api/lotto/evolver/status` | 현재 base + 이번주 6 trials + 진행 상황 |
|
||||
| GET | `/api/lotto/evolver/history?weeks=12` | 주별 winner + base 변경 이력 |
|
||||
| GET | `/api/lotto/evolver/trials/{week_start}` | 특정 주 trials + 채점 결과 |
|
||||
| POST | `/api/lotto/evolver/generate-now` | 수동 트리거 (다음 월요일 후보 생성) |
|
||||
| POST | `/api/lotto/evolver/evaluate-now` | 수동 채점 (디버그) |
|
||||
|
||||
## 8. 스케줄러 cron (lotto-lab)
|
||||
|
||||
```python
|
||||
scheduler.add_job(generate_weekly_candidates, "cron", day_of_week="mon", hour=9, minute=0, id="weight_evolver_weekly")
|
||||
scheduler.add_job(apply_today_weight, "cron", hour=9, minute=0, id="weight_evolver_daily")
|
||||
scheduler.add_job(evaluate_weekly, "cron", day_of_week="sat", hour=22, minute=0, id="weight_evolver_eval")
|
||||
```
|
||||
|
||||
순서 보장: 월요일 09:00에 generate가 먼저 row 저장 후 commit, 그 다음 같은 시각 apply가 그 row 읽음. APScheduler가 동일 시간 job 직렬 실행 보장하지 않으므로 **월요일에 generate 함수 마지막에 inline으로 apply_today_weight 호출** — race 제거.
|
||||
|
||||
## 9. agent-office 통합 (텔레그램 주간 보고)
|
||||
|
||||
### 9.1 cron 추가
|
||||
|
||||
```python
|
||||
scheduler.add_job(_run_lotto_weekly_evolution_report, "cron", day_of_week="sat", hour=22, minute=15, id="lotto_evolution_weekly")
|
||||
```
|
||||
|
||||
### 9.2 LottoAgent.run_weekly_evolution_report (신규)
|
||||
|
||||
```python
|
||||
async def run_weekly_evolution_report(self) -> dict:
|
||||
from ..service_proxy import lotto_evolver_status
|
||||
from ..notifiers.telegram_lotto import send_evolution_report
|
||||
status = await lotto_evolver_status()
|
||||
await send_evolution_report(status)
|
||||
return {"ok": True, **status}
|
||||
```
|
||||
|
||||
### 9.3 텔레그램 메시지 폼
|
||||
|
||||
```
|
||||
🧬 로또 학습 주간 리포트 (1225회차)
|
||||
|
||||
이번주 시도: 6일 × 5세트 = 30번
|
||||
|
||||
🏆 Winner: 목요일 (W_4)
|
||||
W = [freq 0.18, finger 0.32, gap 0.20, cooccur 0.22, divers 0.08]
|
||||
최고 적중: 4개 일치 (1세트)
|
||||
평균 점수: 0.42 (vs 다른 요일 0.18~0.30)
|
||||
|
||||
📊 다음주 base 변경:
|
||||
freq 0.20 → 0.18 (-)
|
||||
finger 0.20 → 0.32 (+)
|
||||
gap 0.20 → 0.20 (=)
|
||||
cooccur 0.20 → 0.22 (+)
|
||||
divers 0.20 → 0.08 (--)
|
||||
reason: winner_4plus (4개 이상 일치 → base 교체)
|
||||
|
||||
[웹에서 차트 보기] (/lotto/evolver)
|
||||
```
|
||||
|
||||
## 10. v1 시그널과의 연동 (자동 cascade)
|
||||
|
||||
별도 코드 추가 없음. 활성 W가 `analyzer.score_combination`에 반영되면:
|
||||
1. 매 4시간 시뮬 cron이 새 W로 best_picks 갱신
|
||||
2. score 분포 자체가 변하므로 v1의 `sim_consensus_score`가 자동으로 새 분포 평가
|
||||
3. W 변경 직후 outlier 패턴이 나오면 자연스럽게 sim_signal urgent fire
|
||||
|
||||
→ 사용자는 두 종류 텔레그램 받음:
|
||||
- **🧬 토 22:15 weekly evolution report** (정해진 리듬)
|
||||
- **🚨 평시 v1 urgent / 📊 v1 digest** (시그널 기반)
|
||||
|
||||
## 11. 구현 Phase
|
||||
|
||||
| Phase | 범위 | 검증 |
|
||||
|---|---|---|
|
||||
| 1 | DB 마이그레이션 + `weight_evolver.py` (순수 함수: generate/evaluate + 점수 함수) + 단위 테스트 | pytest로 perturbation·Dirichlet·점수·base 갱신 룰 검증 |
|
||||
| 2 | analyzer.score_combination 시그니처 확장 + active weight 캐시 | 기존 시뮬 cron이 새 시그니처로 정상 동작 (regression X) |
|
||||
| 3 | cron 3종 등록 + API 5종 | 수동 트리거로 generate→apply→evaluate 전체 흐름 확인 |
|
||||
| 4 | agent-office 통합 (cron + 텔레그램 폼 + 테스트) | 토요일 22:15 자동 발송 확인 |
|
||||
|
||||
각 Phase 끝 commit + 자동 배포.
|
||||
|
||||
## 12. 비기능 요구
|
||||
|
||||
- **백워드 호환**: `analyzer.score_combination` 기본값 None → 기존 호출 그대로 작동
|
||||
- **장애 격리**: 가중치 적용 실패 시 균등 합산 폴백, evaluate 실패해도 다음 주 base는 직전 값 유지
|
||||
- **테스트**:
|
||||
- `weight_evolver` 순수 함수 (clamp, normalize, perturbation, base update rule) — 단위 테스트
|
||||
- `analyzer.score_combination(weights=...)` — 가중 합산 정확성 테스트
|
||||
- `evaluate_weekly` mock 추첨번호 시나리오 — base 갱신 분기 3가지 (winner_4plus / ema_blend / unchanged)
|
||||
- **관측**: `weight_base_history` 테이블로 모든 base 변경 추적 가능 (rollback도 가능)
|
||||
|
||||
## 13. 비목표 (Out of scope)
|
||||
|
||||
- 메타 전략(combined/simulation/heatmap/manual/custom) 가중치 학습 — strategy_evolver 영역, v3 후속
|
||||
- 6일 trials의 day-transition에서 이전 W로 계산된 best_picks를 새 W로 재계산하는 처리 — 다음 시뮬 cron에서 자동 덮어씀
|
||||
- Multi-objective 학습 (적중 + 분포 균등 등 복합 점수)
|
||||
- 자동 구매 (purchase_history 자동 채움)
|
||||
- 프론트 `/lotto/evolver` UI — v2 백엔드 완성 후 별도 PR (web-ui repo)
|
||||
|
||||
## 14. v3 후속 검토
|
||||
|
||||
- Multi-armed bandit (UCB1) — 탐험·활용 균형 더 정교
|
||||
- 메타 전략 가중치도 함께 학습 (2-layer Bayesian Optimization)
|
||||
- 가중치 공간을 RL agent로 학습 (policy gradient)
|
||||
- 자동 구매 후보 픽 (winner W로 1주 N장 자동 발주, 사람 승인 후)
|
||||
368
docs/superpowers/specs/2026-05-23-lotto-evolver-ui-design.md
Normal file
368
docs/superpowers/specs/2026-05-23-lotto-evolver-ui-design.md
Normal file
@@ -0,0 +1,368 @@
|
||||
# Lotto Evolver UI + 에이전트 활동 가시화 설계 (v2.1)
|
||||
|
||||
- **상태**: Draft (사용자 리뷰 대기)
|
||||
- **작성일**: 2026-05-23
|
||||
- **대상 저장소**:
|
||||
- `web-ui` (프론트엔드) — `/lotto/evolver` 페이지 신설 + 공용 활동 컴포넌트
|
||||
- `web-backend` agent-office — LottoAgent task_id 도입 + sync_evolver_activity cron
|
||||
- **선행 작업**: v2 Lotto Weight Evolver (2026-05-22 배포, 운영 중)
|
||||
- **목표**: 토요일 22:15 텔레그램 리포트의 "[웹에서 차트 보기]" 링크가 가리키는 페이지 구축 + 로또 에이전트의 모든 활동(시그널·digest·큐레이션·evolver)을 한 곳에서 추적 가능하게.
|
||||
|
||||
---
|
||||
|
||||
## 1. 문제 정의
|
||||
|
||||
v2 텔레그램 메시지가 `https://gahusb.synology.me/lotto/evolver` 링크를 포함하지만 web-ui repo에 해당 라우트가 없음 → React Router catch-all 404. spec section 13에서 "프론트 UI는 별도 PR"로 명시했지만 링크는 미리 박혀있음 → UX 깨짐.
|
||||
|
||||
또한 LottoAgent의 활동(signals / digest / weekly_evolution_report / curate)이 agent_office.db의 `agent_logs`에는 기록되지만 `agent_tasks` 테이블에는 **`curate_weekly`만** 들어감 → agent-office UI에서 "Tasks" 섹션 봤을 때 활동 이력이 누락. lotto-lab의 weight_evolver cron(매일 apply / 월 generate / 토 evaluate)은 lotto.db에만 기록 → agent_office에서 완전히 안 보임.
|
||||
|
||||
사용자 의도: "로또 에이전트가 무엇을 했는지" 한 곳에서 확인 가능하게.
|
||||
|
||||
## 2. 의사결정 요약
|
||||
|
||||
| 결정 사항 | 선택 | 비고 |
|
||||
|---|---|---|
|
||||
| 라우트 위치 | 별도 `/lotto/evolver` (텔레그램 링크와 일치) | `/stock/trade`, `/stock/screener` 패턴 따름 |
|
||||
| 사용 시나리오 | 토 22:15 텔레그램 직후 주간 요약 대시보드 | 평일 운영·장기 분석은 부차 |
|
||||
| 페이지 구조 | 단일 스크롤, 5개 카드 (Header / Winner / TrialsGrid / BaseDiff / BaseHistory / Actions) | sub-tab 불필요 |
|
||||
| 차트 | Recharts (이미 dep) — Radar / Bar / Line + 인라인 metric-card | small multiples 대신 텍스트 강조 |
|
||||
| 활동 노출 위치 | `/lotto/evolver` + `/agent-office` 양쪽 (공용 컴포넌트) | DRY |
|
||||
| 백엔드 보강 | 기존 add_log만 있던 LottoAgent 메서드에 task_id 도입 + 신규 sync_evolver_activity cron | 멱등 guard 포함 |
|
||||
|
||||
## 3. 아키텍처
|
||||
|
||||
### 3.1 컴포넌트 다이어그램
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ web-ui (신규 컴포넌트) │
|
||||
│ │
|
||||
│ src/pages/lotto/ │
|
||||
│ Evolver.jsx ← /lotto/evolver 진입점 │
|
||||
│ Evolver.css │
|
||||
│ evolver/ │
|
||||
│ WinnerCard.jsx ← Radar (5축) + 메타 │
|
||||
│ TrialsGrid.jsx ← 6일 Bar 비교 + 펼치기 │
|
||||
│ BaseDiff.jsx ← 5 metric-card (텍스트+arrow)│
|
||||
│ BaseHistory.jsx ← LineChart 12주 시계열 │
|
||||
│ EvolverActions.jsx ← 수동 트리거 (dev) │
|
||||
│ useEvolverApi.js ← status+history+activity hook│
|
||||
│ │
|
||||
│ src/components/lotto/ │
|
||||
│ LottoActivityTimeline.jsx ← 공용 활동 timeline │
|
||||
│ /lotto/evolver + /agent-office│
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓ (HTTP)
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ web-backend (보강) │
|
||||
│ │
|
||||
│ agent-office/app/agents/lotto.py │
|
||||
│ • run_signal_check → task_id 도입 (신규) │
|
||||
│ • run_daily_digest → task_id 도입 (신규) │
|
||||
│ • run_weekly_evolution_report → task_id 도입 (신규) │
|
||||
│ • sync_evolver_activity → 신규 메서드 │
|
||||
│ │
|
||||
│ agent-office/app/scheduler.py │
|
||||
│ • lotto_evolver_activity_sync — 매일 09:30 cron 신규 │
|
||||
│ │
|
||||
│ agent-office/app/db.py │
|
||||
│ • get_tasks_by_agent_date_kind — 멱등 guard helper 신규 │
|
||||
│ │
|
||||
│ agent-office/app/main.py │
|
||||
│ • GET /agents/{id}/tasks에 task_type 필터 추가 (확장) │
|
||||
│ │
|
||||
│ lotto-lab: 변경 없음 (web-ui가 evolver API 직접 소비) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 책임 경계
|
||||
|
||||
- **web-ui Evolver 페이지**: 데이터 시각화 전담. 비즈니스 로직 없음. fetch는 useEvolverApi에 집중.
|
||||
- **LottoActivityTimeline**: 시간순 timeline 표현만. logs/tasks/evolverEvents 3종 입력 받아 merge sort + 렌더.
|
||||
- **LottoAgent**: 모든 자율 작업 시 task row 생성 (다른 에이전트와 동일 패턴).
|
||||
- **sync_evolver_activity**: lotto-lab의 결과를 agent_office.db에 거울 비추기. 백엔드 polling 패턴. 멱등.
|
||||
- **lotto-lab**: 변경 없음. 모든 evolver API는 web-ui가 직접 호출.
|
||||
|
||||
## 4. 페이지 정보 layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ HEADER │
|
||||
│ Lotto · Weight Evolver │
|
||||
│ "스스로 가중치를 조절하는 자율 학습 루프" │
|
||||
│ 마지막 회고: 1225회 (2026-05-21 22:00) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ① WinnerCard (대형, 메인) │
|
||||
│ 🏆 목요일 · W_4 · max=4개 일치 │
|
||||
│ ┌─ Radar Chart (5축) ──┐ │
|
||||
│ │ freq, finger, gap, │ │
|
||||
│ │ cooccur, divers │ │
|
||||
│ └──────────────────────┘ │
|
||||
│ avg_score · n_picks graded · update reason │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ② TrialsGrid │
|
||||
│ 월 화 수 목⭐ 금 토 (가로 6개 Bar) │
|
||||
│ ░░ ▓▓ ░░ ██ ▒▒ ░░ │
|
||||
│ max=2 1 3 4 2 1 │
|
||||
│ 클릭 → 그날 5세트 numbers + scores 펼침 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ③ BaseDiff │
|
||||
│ 5개 metric-card 가로 정렬 │
|
||||
│ freq 0.20 → 0.18 ↓ -10% │
|
||||
│ finger 0.20 → 0.32 ↑↑ +60% │
|
||||
│ gap 0.20 → 0.20 = (변화 없음) │
|
||||
│ cooccur 0.20 → 0.22 ↑ +10% │
|
||||
│ divers 0.20 → 0.08 ↓↓ -60% │
|
||||
│ → reason: winner_4plus │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ④ BaseHistory (12주) │
|
||||
│ LineChart 5 라인 (freq/finger/gap/cooccur/divers) │
|
||||
│ X축: effective_from, Y축: weight 0~1 │
|
||||
│ dot click → reason tooltip + 회차 표시 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ⑤ LottoActivityTimeline (compact=false) │
|
||||
│ 최근 7일 — task + log + lotto-lab evolver 이벤트 merge │
|
||||
│ 2026-05-23 22:15 🧬 weekly_evolution_report succeeded │
|
||||
│ 2026-05-23 22:00 ⚖️ weight_evolver_eval (lotto-lab) │
|
||||
│ 2026-05-23 21:15 🔍 deep_check succeeded │
|
||||
│ ... │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ⑥ EvolverActions (개발자 모드) │
|
||||
│ [수동 generate-now] [수동 evaluate-now] │
|
||||
│ 응답 JSON 콘솔에 표시 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.1 모바일 반응형
|
||||
|
||||
- ≤640px: 1 컬럼, 차트는 가로폭 100%
|
||||
- 641-1024px: WinnerCard·TrialsGrid 가로 분할 (50/50)
|
||||
- ≥1025px: 위 layout 그대로
|
||||
|
||||
## 5. 데이터 흐름
|
||||
|
||||
### 5.1 useEvolverApi hook
|
||||
|
||||
```js
|
||||
function useEvolverApi({ days = 7, weeks = 12 } = {}) {
|
||||
// 4개 fetch 동시 — Promise.all
|
||||
// 1. GET /api/lotto/evolver/status → status
|
||||
// 2. GET /api/lotto/evolver/history?weeks=12 → history
|
||||
// 3. GET /api/agent-office/agents/lotto/logs?days=7 → logs
|
||||
// 4. GET /api/agent-office/agents/lotto/tasks?days=7 → tasks
|
||||
//
|
||||
// activity = merge(logs, tasks, evolverEventsFromHistory) sorted by timestamp DESC
|
||||
return { status, history, activity, loading, error, refetch };
|
||||
}
|
||||
```
|
||||
|
||||
`activity` 합성 규칙:
|
||||
- agent_logs의 created_at + level + message + task_id
|
||||
- agent_tasks의 created_at + task_type + status + result_data
|
||||
- history.items의 created_at + update_reason + weight (evolver eval 자체 이벤트로 별도 표시)
|
||||
- 클라이언트에서 timestamp DESC sort → React에서 렌더링
|
||||
|
||||
### 5.2 Recharts 매핑
|
||||
|
||||
| 컴포넌트 | 차트 | data prop |
|
||||
|---|---|---|
|
||||
| WinnerCard | `RadarChart` | `[{metric, value, previous}]` 5점 (overlay: previous_base) |
|
||||
| TrialsGrid | `BarChart` 수평 6개 | `[{day_name, avg_score, max_correct, is_winner}]` |
|
||||
| BaseHistory | `LineChart` | `[{effective_from, freq, finger, gap, cooccur, divers}, ...]` |
|
||||
|
||||
### 5.3 LottoActivityTimeline
|
||||
|
||||
```jsx
|
||||
<LottoActivityTimeline
|
||||
logs={agentLogs}
|
||||
tasks={agentTasks}
|
||||
evolverEvents={evolverEventsFromHistory}
|
||||
days={7}
|
||||
compact={false}
|
||||
/>
|
||||
```
|
||||
|
||||
merge & sort:
|
||||
```js
|
||||
const stream = [
|
||||
...logs.map(l => ({ ts: l.created_at, kind: 'log', payload: l })),
|
||||
...tasks.map(t => ({ ts: t.created_at, kind: 'task', payload: t })),
|
||||
...evolverEvents.map(e => ({ ts: e.created_at, kind: 'evolver', payload: e })),
|
||||
].sort((a, b) => b.ts.localeCompare(a.ts));
|
||||
```
|
||||
|
||||
각 stream item:
|
||||
- kind='task': 아이콘 + task_type label + status badge + (completed_at - created_at) 소요시간
|
||||
- kind='log': 아이콘(level) + message
|
||||
- kind='evolver': ⚖️ + update_reason + winner_score
|
||||
|
||||
icon · color mapping (task_type 기준):
|
||||
```
|
||||
curate_weekly 📋 blue
|
||||
signal_check 🔍 green / fired면 amber
|
||||
daily_digest 📊 cyan
|
||||
weekly_evolution_report 🧬 purple
|
||||
evolver_generate 🌱 teal
|
||||
evolver_apply 🎲 gray
|
||||
```
|
||||
|
||||
### 5.4 cold start / empty state
|
||||
|
||||
- `weight_base_history` empty → 큰 빈 카드: "아직 학습 시작 전. 다음 월요일 09:00 자동 시작" + `[수동 generate-now 트리거]` 버튼
|
||||
- `trials` empty (월 09:00 전) → 안내 카드
|
||||
- `activity` empty → 회색 "최근 활동 없음"
|
||||
|
||||
## 6. 백엔드 보강
|
||||
|
||||
### 6.1 LottoAgent 메서드 — task_id 도입
|
||||
|
||||
3개 메서드에 `_run` 패턴(`create_task` + try/except + `update_task_status` + `add_log(..., task_id=...)`) 적용:
|
||||
|
||||
| 메서드 | 새 task_type | result_data 핵심 |
|
||||
|---|---|---|
|
||||
| `run_signal_check(source)` | `signal_check` | source, overall_fire, n_results, fired_metrics |
|
||||
| `run_daily_digest()` | `daily_digest` | evaluated, fired, signals_count |
|
||||
| `run_weekly_evolution_report()` | `weekly_evolution_report` | draw_no, update_reason, winner_day |
|
||||
|
||||
기존 `_run`(`curate_weekly`)은 그대로.
|
||||
|
||||
### 6.2 sync_evolver_activity — 신규 메서드
|
||||
|
||||
매일 09:30 cron. lotto-lab의 today_trial 가져와 agent_office.db에 task+log 기록. 멱등 guard.
|
||||
|
||||
```python
|
||||
async def sync_evolver_activity(self):
|
||||
"""lotto-lab evolver 상태 polling → agent_office.db에 거울. 멱등."""
|
||||
today_iso = _today_kst_iso()
|
||||
dow = _today_dow()
|
||||
|
||||
status = await service_proxy.lotto_evolver_status()
|
||||
|
||||
# 오늘 trial + picks → evolver_apply task
|
||||
today_trial = next((t for t in status["trials"] if t["day_of_week"] == dow), None)
|
||||
if today_trial and today_trial.get("picks") and not db.get_tasks_by_agent_date_kind("lotto", today_iso, "evolver_apply"):
|
||||
tid = db.create_task("lotto", "evolver_apply", {
|
||||
"date": today_iso, "trial_id": today_trial["id"],
|
||||
"day_of_week": dow, "weight": today_trial["weight"],
|
||||
})
|
||||
db.update_task_status(tid, "succeeded", result_data={
|
||||
"n_picks": len(today_trial["picks"]),
|
||||
"meta_scores": [p["meta_score"] for p in today_trial["picks"]],
|
||||
})
|
||||
db.add_log("lotto", f"evolver_apply: 오늘 W로 {len(today_trial['picks'])}세트 추출", task_id=tid)
|
||||
|
||||
# 월요일 + 6 trials 완성 → evolver_generate task
|
||||
if dow == 0 and len(status["trials"]) == 6 and not db.get_tasks_by_agent_date_kind("lotto", today_iso, "evolver_generate"):
|
||||
tid = db.create_task("lotto", "evolver_generate", {"week_start": status["week_start"]})
|
||||
db.update_task_status(tid, "succeeded", result_data={"trials_count": 6})
|
||||
db.add_log("lotto", f"evolver_generate: {status['week_start']} 주의 6 trials 생성", task_id=tid)
|
||||
```
|
||||
|
||||
토요일 22:15 evaluate는 `run_weekly_evolution_report`가 이미 task 기록 → sync 불필요.
|
||||
|
||||
### 6.3 db.py — 신규 helper
|
||||
|
||||
```python
|
||||
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 [dict(r) for r in rows]
|
||||
```
|
||||
|
||||
### 6.4 scheduler.py — cron 추가
|
||||
|
||||
```python
|
||||
async def _run_lotto_sync_evolver_activity():
|
||||
agent = AGENT_REGISTRY.get("lotto")
|
||||
if agent:
|
||||
await agent.sync_evolver_activity()
|
||||
|
||||
scheduler.add_job(
|
||||
_run_lotto_sync_evolver_activity,
|
||||
"cron", hour=9, minute=30,
|
||||
id="lotto_evolver_activity_sync",
|
||||
)
|
||||
```
|
||||
|
||||
### 6.5 main.py — API 확장
|
||||
|
||||
`GET /api/agent-office/agents/{id}/tasks`에 query param 추가:
|
||||
```python
|
||||
@app.get("/api/agent-office/agents/{agent_id}/tasks")
|
||||
async def get_agent_tasks(agent_id: str, days: int = 7, task_type: Optional[str] = None):
|
||||
return {"items": db.get_agent_tasks(agent_id, days=days, task_type=task_type)}
|
||||
```
|
||||
|
||||
`db.get_agent_tasks`도 task_type 필터 추가 (기존 함수 보강).
|
||||
|
||||
### 6.6 task_type 명세 (참조)
|
||||
|
||||
| task_type | 트리거 | 어디서 생성 |
|
||||
|---|---|---|
|
||||
| `curate_weekly` | 월 09:05 또는 deep_check | LottoAgent._run (기존) |
|
||||
| `signal_check` | light / sim / deep cron | LottoAgent.run_signal_check (신규 wrap) |
|
||||
| `daily_digest` | 매일 09:25 | LottoAgent.run_daily_digest (신규 wrap) |
|
||||
| `weekly_evolution_report` | 토 22:15 | LottoAgent.run_weekly_evolution_report (신규 wrap) |
|
||||
| `evolver_generate` | 월 09:30 sync | LottoAgent.sync_evolver_activity (신규) |
|
||||
| `evolver_apply` | 매일 09:30 sync | LottoAgent.sync_evolver_activity (신규) |
|
||||
|
||||
## 7. 라우터 등록
|
||||
|
||||
`web-ui/src/routes.jsx`에 추가:
|
||||
|
||||
```jsx
|
||||
const Evolver = lazy(() => import('./pages/lotto/Evolver'));
|
||||
|
||||
// appRoutes 배열에 추가:
|
||||
{
|
||||
path: 'lotto/evolver',
|
||||
element: <Evolver />,
|
||||
},
|
||||
```
|
||||
|
||||
## 8. 구현 Phase
|
||||
|
||||
| Phase | 범위 | 검증 |
|
||||
|---|---|---|
|
||||
| 1 | agent-office 백엔드 보강 (LottoAgent task_id wrap + sync cron + db helper) + 단위 테스트 | task row 생성 확인, 멱등 가드 동작 |
|
||||
| 2 | agent-office API 확장 (task_type 필터) | curl로 필터링 동작 확인 |
|
||||
| 3 | web-ui Evolver 페이지 — useEvolverApi + WinnerCard + TrialsGrid + BaseDiff + BaseHistory + EvolverActions | 로컬 dev 브라우저에서 모든 카드 정상 렌더, 모바일 반응형 |
|
||||
| 4 | LottoActivityTimeline 공용 컴포넌트 — /lotto/evolver에 통합 + /agent-office LottoAgent 카드에 compact 모드 통합 | 두 페이지에서 동일 데이터 보임 |
|
||||
| 5 | 라우터 등록 + 텔레그램 링크 404 해결 확인 | `release:nas` → 텔레그램 [차트 보기] 클릭 → 정상 페이지 |
|
||||
|
||||
Phase 1-2: web-backend repo, Phase 3-5: web-ui repo. 각 repo는 별도 git, 별도 배포 (web-backend git push → Gitea webhook auto, web-ui `npm run release:nas`).
|
||||
|
||||
## 9. 비기능 요구
|
||||
|
||||
- **백워드 호환**: 기존 LottoAgent 호출자 (cron 등) 시그니처 변경 없음. 내부 task_id wrap만 추가.
|
||||
- **장애 격리**: sync_evolver_activity 실패해도 lotto-lab 영향 없음. task_id wrap 실패 시 try/except로 메서드 자체는 계속 동작.
|
||||
- **멱등성**: sync_evolver_activity는 멱등 guard로 cron 재실행·재시작 안전.
|
||||
- **테스트**:
|
||||
- LottoAgent task_id wrap — mock task_id 받아 update 호출 확인
|
||||
- sync_evolver_activity 멱등 — 같은 날 2번 호출 시 1 row만
|
||||
- LottoActivityTimeline merge sort — unit test로 stream 순서·아이콘 매핑
|
||||
- **관측**: 모든 LottoAgent 메서드의 result_data 표준화 (Section 6.1 표 참조)
|
||||
|
||||
## 10. 비목표 (Out of scope)
|
||||
|
||||
- TrialsGrid에서 과거 주 deep dive 조회 (`GET /trials/{week_start}` 사용) — v2.2 후속, 별도 UI
|
||||
- 차트 export / CSV 다운로드
|
||||
- 가중치 수동 편집 UI — v3에서 사용자 개입 모드 도입 검토
|
||||
- 다른 에이전트(stock / music / realestate)의 활동 통합 timeline — 현재 spec은 lotto만
|
||||
- 실시간 WebSocket 푸시 (agent-office에 ws 있지만 evolver 활동은 polling으로 충분)
|
||||
|
||||
## 11. v3 후속 검토
|
||||
|
||||
- 다른 에이전트 활동도 같은 패턴(LottoActivityTimeline 제너릭화 → AgentActivityTimeline)으로 노출
|
||||
- /lotto/evolver 페이지에 사용자 의견 입력 (이번 winner가 마음에 듦/싫음) → 학습 시그널로 활용
|
||||
- BaseHistory에 brush 도입 (긴 history 시계열 zoom)
|
||||
- TrialsGrid에 picks 채점 결과 통계 (몇 개 trial에서 4개 일치 났는지 등)
|
||||
559
docs/superpowers/specs/2026-05-23-tarot-lab-design.md
Normal file
559
docs/superpowers/specs/2026-05-23-tarot-lab-design.md
Normal file
@@ -0,0 +1,559 @@
|
||||
# Tarot Lab v1 — Design Spec
|
||||
|
||||
**작성일:** 2026-05-23
|
||||
**상태:** 디자인 승인 완료, 구현 계획 작성 대기
|
||||
**관련 자산:**
|
||||
- `source/images/tarot_page/tarot_main_landing_page.png` (랜딩 시안)
|
||||
- `source/images/tarot_page/tarot_card_select_page.png` (카드 선택 시안)
|
||||
- `source/images/tarot_page/tarot_background.png` (정적 배경 폴백)
|
||||
- `source/images/tarot_page/tarot_cards.png` (카드 콜라주 참고)
|
||||
- `source/videos/tarot_main_background.mp4` (히어로 영상)
|
||||
|
||||
---
|
||||
|
||||
## 1. 목표와 배경
|
||||
|
||||
개인 웹 플랫폼에 라이더-웨이트(RWS) 기반 타로 리딩 기능을 추가한다. v1은 **오늘의 카드 / 3장 스프레드 / 리딩 히스토리·마이페이지** 3개 핵심 흐름을 한 번에 배포하고, AI 해석은 Claude Sonnet 4.6을 통해 **근거 기반(evidence)** 으로 생성한다. 켈틱 크로스 10장 스프레드와 카드 78장 정식 이미지 자산은 v2 분리.
|
||||
|
||||
### 비목표 (v2 이후)
|
||||
- 켈틱 크로스 10장 스프레드
|
||||
- 사용자가 제공할 카드 78장 정식 이미지 자산의 정식 매핑 (v1은 placeholder/CSS)
|
||||
- 78장 의미 텍스트 완성본 (v1은 메이저 22 + 마이너 키워드만)
|
||||
- 텔레그램 자동 push ("매일 오늘의 카드")
|
||||
- 카드 78장 도감 화면
|
||||
- 즐겨찾기 메모 편집 UI (백엔드 endpoint는 v1에 포함, UI는 v2)
|
||||
- **카드 시각 효과 보강** — 카드 이미지 자산 도착 이후 보강:
|
||||
- 카드 hover·focus 시 보더 주변 황금 글로우·sparkle particles
|
||||
- 카드 뒤집기 애니메이션 (3D rotateY transform, 0.6~0.8s ease-out, 뒷면→앞면 전환)
|
||||
- 우주 입자 floating · 별 깜빡임 등 분위기 효과
|
||||
- v1은 hover lift + 단순 fade-in 정도의 미니멀 모션만
|
||||
|
||||
---
|
||||
|
||||
## 2. 아키텍처
|
||||
|
||||
```
|
||||
web-ui (React + Vite)
|
||||
/tarot 랜딩 (히어로 영상 + 3-tier)
|
||||
/tarot/today 오늘의 카드 (원카드)
|
||||
/tarot/reading 3장 스프레드 (메인 인터랙션)
|
||||
/tarot/history 마이페이지 (리딩 이력)
|
||||
│
|
||||
│ /api/agent-office/tarot/*
|
||||
▼
|
||||
agent-office (FastAPI 확장)
|
||||
app/routes/tarot.py 4 endpoint
|
||||
app/agents/tarot.py TarotAgent (Claude Sonnet 호출 + 응답 검증)
|
||||
app/db.py tarot_readings 테이블 추가
|
||||
│
|
||||
▼ Anthropic API
|
||||
Claude Sonnet 4.6
|
||||
```
|
||||
|
||||
### 경계 결정 이유
|
||||
- **카드 78장 메타데이터는 프론트 정적 JSON** — 자주 안 변하고 셔플·선택에 백엔드 호출 불필요. 라운드트립 절약.
|
||||
- **AI 해석만 백엔드** — API key 보호 + 호출 로깅·검증·reroll 가능.
|
||||
- **히스토리도 백엔드** — localStorage는 기기 의존, 사용자가 영속화 요구.
|
||||
- **신규 컨테이너 없음** — agent-office 확장. nginx·docker-compose 변경 0건.
|
||||
|
||||
### Why agent-office인가
|
||||
1. `ANTHROPIC_API_KEY` 이미 환경변수로 연결됨
|
||||
2. Claude SDK + httpx 클라이언트 set up 완료
|
||||
3. Agent FSM 패턴(idle→working→reporting)에 자연스럽게 맞음 — TarotAgent도 "리딩 수행" 작업으로 모델링
|
||||
4. 텔레그램 봇 연결되어 있어 v2에서 "매일 오늘의 카드" push 확장 여지
|
||||
|
||||
---
|
||||
|
||||
## 3. 프론트 데이터 모델
|
||||
|
||||
### 정적 카드 데이터 (`web-ui/src/pages/tarot/data/cards.js`)
|
||||
|
||||
```js
|
||||
export const TAROT_DECK = [
|
||||
// Major Arcana 22장
|
||||
{
|
||||
id: 0,
|
||||
slug: "the-fool",
|
||||
name: "바보",
|
||||
nameEn: "The Fool",
|
||||
arcana: "major",
|
||||
element: "air",
|
||||
keywords: ["새로운 시작", "도약", "순수", "자유"],
|
||||
reversedKeywords: ["무모함", "경솔함", "위험", "방향 상실"],
|
||||
meaningUpright: "미지의 세계로 내딛는 첫걸음. 계산보다 직관과 신뢰로 시작하는 시기.",
|
||||
meaningReversed: "준비 없이 뛰어들어 위험을 자초하거나, 두려움으로 첫걸음을 미루는 상태.",
|
||||
image: null, // 사용자가 /images/tarot/cards/the-fool.png 추가 시 자동 매핑
|
||||
},
|
||||
// ... Major 21장 더
|
||||
|
||||
// Minor Arcana 56장
|
||||
{
|
||||
id: 22,
|
||||
slug: "ace-of-wands",
|
||||
name: "지팡이 에이스",
|
||||
arcana: "minor",
|
||||
suit: "wands",
|
||||
rank: 1,
|
||||
element: "fire",
|
||||
keywords: ["창조의 불씨", "영감", "새로운 시작"],
|
||||
reversedKeywords: ["지연", "동기 부족", "방향 상실"],
|
||||
meaningUpright: "...",
|
||||
meaningReversed: "...",
|
||||
image: null,
|
||||
},
|
||||
// ... Minor 55장 더
|
||||
];
|
||||
|
||||
export const SPREADS = {
|
||||
one_card: {
|
||||
id: "one_card",
|
||||
name: "오늘의 카드",
|
||||
positions: [{ idx: 0, label: "오늘" }],
|
||||
},
|
||||
three_card: {
|
||||
id: "three_card",
|
||||
name: "3장 스프레드",
|
||||
positions: [
|
||||
{ idx: 0, label: "과거" },
|
||||
{ idx: 1, label: "현재" },
|
||||
{ idx: 2, label: "미래" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const CATEGORIES = ["연애", "일·커리어", "관계", "재물", "건강", "일반"];
|
||||
```
|
||||
|
||||
**v1 시드 데이터 작업량:**
|
||||
- 메이저 22장: 정·역 키워드 + 정·역 의미 텍스트 완성 (필수)
|
||||
- 마이너 56장: 정·역 키워드만 (필수) + 의미 텍스트는 짧은 요약 1문장씩 (v2에서 보강)
|
||||
|
||||
### 카드 이미지 자동 매핑 규칙
|
||||
- 사용자가 `web-ui/public/images/tarot/cards/<slug>.png` 추가 시 자동 표시
|
||||
- `cards.js`에서 `image: \`/images/tarot/cards/${slug}.png\`` 일관 패턴
|
||||
- `onError` → CSS 카드 디자인 폴백 (그라데이션 보더 + 카드명 + 심볼)
|
||||
|
||||
---
|
||||
|
||||
## 4. 백엔드 데이터 모델
|
||||
|
||||
### tarot_readings 테이블 (`agent_office.db`)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS tarot_readings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL, -- UTC ISO8601
|
||||
spread_type TEXT NOT NULL, -- 'one_card' | 'three_card'
|
||||
category TEXT, -- '연애' | '일·커리어' | …
|
||||
question TEXT, -- 사용자 입력 (NULL 가능)
|
||||
cards TEXT NOT NULL, -- JSON: [{position, card_id, reversed}]
|
||||
interpretation_json TEXT, -- Claude 응답 파싱 결과 전체
|
||||
summary TEXT, -- interpretation_json.summary 빠른 조회용
|
||||
model TEXT, -- 'claude-sonnet-4-6'
|
||||
tokens_in INTEGER,
|
||||
tokens_out INTEGER,
|
||||
cost_usd REAL,
|
||||
confidence TEXT, -- 'high' | 'medium' | 'low'
|
||||
favorite INTEGER DEFAULT 0,
|
||||
note TEXT
|
||||
);
|
||||
CREATE INDEX idx_tarot_created ON tarot_readings(created_at DESC);
|
||||
CREATE INDEX idx_tarot_favorite ON tarot_readings(favorite, created_at DESC);
|
||||
```
|
||||
|
||||
**저장 정책:**
|
||||
- 모든 리딩은 자동 저장 (사용자가 "저장" 누르지 않아도). 사용자가 별도 액션 없이도 히스토리에서 확인 가능.
|
||||
- `favorite` 토글 + `note` 편집은 별도 PATCH 호출
|
||||
- 카드는 `card_id`(slug)만 저장 — 실제 이름·의미는 항상 프론트 데이터에서 조회 → 카드 데이터 수정이 과거 이력에 자동 반영
|
||||
|
||||
### interpretation_json 구조
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": "전체 흐름 한 단락 (3~4문장)",
|
||||
"cards": [
|
||||
{
|
||||
"position": "과거",
|
||||
"card": "the-fool",
|
||||
"reversed": false,
|
||||
"interpretation": "이 위치에서 이 카드가 의미하는 바 (3~4문장)",
|
||||
"evidence": {
|
||||
"card_meaning_used": "참고 카드 정보에서 인용한 키워드·상징",
|
||||
"position_logic": "왜 이 의미가 이 위치에 그렇게 적용되는지 (1~2문장)",
|
||||
"category_lens": "카테고리 관점에서 부각되는 면 (1문장)"
|
||||
},
|
||||
"advice": "이 카드가 주는 짧고 구체적인 조언 (1문장)"
|
||||
}
|
||||
],
|
||||
"interactions": [
|
||||
{
|
||||
"type": "synergy" | "conflict" | "transition",
|
||||
"between": ["the-fool", "the-lovers"],
|
||||
"explanation": "두 카드의 슈트·원소·정역방향 흐름 근거 (1~2문장)"
|
||||
}
|
||||
],
|
||||
"advice": "3장(또는 1장) 종합 조언 (2문장)",
|
||||
"warning": null,
|
||||
"confidence": "high" | "medium" | "low"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. API 명세
|
||||
|
||||
### 5.1 `POST /api/agent-office/tarot/interpret`
|
||||
AI 해석만 수행 (저장과 분리). 응답 받은 후 사용자가 별도 액션 없으면 자동 저장 호출.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"spread_type": "three_card",
|
||||
"category": "연애",
|
||||
"question": "다음 달 그 사람과의 관계는?",
|
||||
"cards": [
|
||||
{ "position": "과거", "card_id": "the-fool", "reversed": false },
|
||||
{ "position": "현재", "card_id": "the-lovers", "reversed": true },
|
||||
{ "position": "미래", "card_id": "ten-of-cups", "reversed": false }
|
||||
],
|
||||
"cards_reference": "## 1. 위치: 과거 | 카드: The Fool ...",
|
||||
"context_meta": {
|
||||
"major_minor_ratio": "2:1",
|
||||
"element_distribution": { "air": 2, "water": 1, "fire": 0, "earth": 0 },
|
||||
"orientation_flow": "upright→reversed→upright"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`cards_reference`와 `context_meta`는 프론트가 `cards.js`를 기반으로 빌드해서 전송. 백엔드가 카드 데이터를 따로 가지고 있을 필요 없음 (DRY).
|
||||
|
||||
**Response:** `interpretation_json` 구조 + 호출 메타.
|
||||
```json
|
||||
{
|
||||
"interpretation_json": { /* 위 4절 구조 */ },
|
||||
"model": "claude-sonnet-4-6",
|
||||
"tokens_in": 712,
|
||||
"tokens_out": 942,
|
||||
"cost_usd": 0.0163,
|
||||
"latency_ms": 5240,
|
||||
"reroll_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
**에러:**
|
||||
- 400 — spread_type 미지원 / cards 길이 불일치 / cards_reference 빈 문자열
|
||||
- 429 — Anthropic API rate limit
|
||||
- 500 — Claude 호출 실패 (Retry-After 헤더 포함) 또는 reroll 2회 모두 실패
|
||||
|
||||
### 5.2 `POST /api/agent-office/tarot/readings`
|
||||
리딩 저장. interpret 결과를 그대로 + 사용자 컨텍스트.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"spread_type": "three_card",
|
||||
"category": "연애",
|
||||
"question": "...",
|
||||
"cards": [...],
|
||||
"interpretation_json": { ... },
|
||||
"model": "claude-sonnet-4-6",
|
||||
"tokens_in": 712, "tokens_out": 942, "cost_usd": 0.0163,
|
||||
"confidence": "medium"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `{ "id": 123, "created_at": "2026-05-23T07:42:11Z" }`
|
||||
|
||||
### 5.3 `GET /api/agent-office/tarot/readings`
|
||||
페이지네이션 + 필터.
|
||||
|
||||
**Query:** `?page=1&size=20&favorite=true&spread_type=three_card&category=연애`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{ "id": 123, "created_at": "...", "spread_type": "three_card",
|
||||
"category": "연애", "question": "...", "cards": [...],
|
||||
"summary": "한 줄 요약", "confidence": "medium", "favorite": 1 }
|
||||
],
|
||||
"page": 1, "size": 20, "total": 47
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 `PATCH /api/agent-office/tarot/readings/{id}`
|
||||
즐겨찾기 토글·메모.
|
||||
|
||||
**Request:** `{ "favorite": true }` 또는 `{ "note": "메모" }`
|
||||
|
||||
### 5.5 `DELETE /api/agent-office/tarot/readings/{id}`
|
||||
이력 삭제.
|
||||
|
||||
### Nginx 라우팅
|
||||
변경 없음. 기존 `/api/agent-office/` 매칭에 흡수됨.
|
||||
|
||||
---
|
||||
|
||||
## 6. AI 프롬프트 설계
|
||||
|
||||
### SYSTEM_PROMPT
|
||||
|
||||
```text
|
||||
당신은 라이더-웨이트(RWS) 타로 덱의 전통 상징체계에 정통한 타로 리더입니다.
|
||||
사용자의 질문, 카테고리, 뽑힌 카드 각각의 정·역방향과 위치를 받아 근거 기반으로 해석합니다.
|
||||
|
||||
# 해석 원칙
|
||||
1. 데이터 우선: "참고 카드 정보" 블록의 키워드·기본의미·상징만을 1차 근거로 사용.
|
||||
외부 변형 의미·다른 덱 해석은 사용하지 않음.
|
||||
2. 위치 의미 결합: 카드의 의미와 위치(과거/현재/미래 또는 오늘)를 명시적으로 결합해서 해석. evidence에 근거 기록.
|
||||
3. 카드 간 상호작용 분석 (3장 스프레드):
|
||||
- 시너지: 같은 슈트, 같은 원소, 메이저 비율, 정·역 흐름
|
||||
- 충돌·전환: 슈트 충돌(컵-소드, 완드-펜타클), 정→역 전환, 메이저↔마이너 전환
|
||||
4. 자기 성찰 톤: 운명론 단정 금지. "…할 가능성이 있어 보입니다" 같은 표현.
|
||||
5. 카테고리 컨텍스트: 동일 카드라도 카테고리에 따라 강조점이 달라야 함.
|
||||
6. 질문 직접 응답: 사용자 질문을 evidence·advice에서 인용·반영.
|
||||
|
||||
# 응답 형식 (strict JSON only — 코드블록 없이 raw JSON)
|
||||
{
|
||||
"summary": "전체 흐름 한 단락 (3~4문장)",
|
||||
"cards": [
|
||||
{
|
||||
"position": "<위치 라벨>",
|
||||
"card": "<card_id>",
|
||||
"reversed": <bool>,
|
||||
"interpretation": "3~4문장",
|
||||
"evidence": {
|
||||
"card_meaning_used": "참고 카드 정보에서 인용한 키워드·상징",
|
||||
"position_logic": "왜 이 위치에 이렇게 적용되는지 (1~2문장)",
|
||||
"category_lens": "카테고리 관점에서 부각되는 면 (1문장)"
|
||||
},
|
||||
"advice": "1문장"
|
||||
}
|
||||
],
|
||||
"interactions": [
|
||||
{ "type": "synergy"|"conflict"|"transition",
|
||||
"between": ["<card_id>", "<card_id>"],
|
||||
"explanation": "1~2문장" }
|
||||
],
|
||||
"advice": "2문장. interactions를 1개 이상 참조할 것.",
|
||||
"warning": "역방향·충돌 경계 (없으면 null)",
|
||||
"confidence": "high"|"medium"|"low"
|
||||
}
|
||||
|
||||
# confidence 판정 기준
|
||||
- high: 3장 모두 한 방향 서사 또는 명확한 전환
|
||||
- medium: 2장 일관, 1장 별도 신호
|
||||
- low: 카드 간 의미 충돌이 커서 명확한 흐름 잡기 어려움
|
||||
|
||||
# 금지사항
|
||||
- 참고 카드 정보에 없는 상징 도입 금지
|
||||
- 역방향 카드를 정방향처럼 다루지 말 것
|
||||
- "신비롭게 들리는" 문구로 채우지 말 것 — evidence에 인용·근거 명시
|
||||
- JSON 외 텍스트 금지
|
||||
```
|
||||
|
||||
### USER_PROMPT_TEMPLATE
|
||||
|
||||
```text
|
||||
# 질문
|
||||
{question}
|
||||
|
||||
# 카테고리
|
||||
{category}
|
||||
|
||||
# 스프레드
|
||||
{spread_name} ({spread_count}장)
|
||||
|
||||
# 뽑힌 카드와 참고 카드 정보
|
||||
{cards_with_reference_block}
|
||||
|
||||
# 작업
|
||||
위 정보만을 근거로 사용해, 시스템 지침의 JSON 형식으로 응답하세요.
|
||||
- 각 카드의 evidence.card_meaning_used에는 위 "참고 카드 정보"에서 발췌한 키워드·의미를 그대로 인용.
|
||||
- interactions는 3장 간 슈트·원소·정역방향 패턴을 분석해 최소 1개 이상 도출.
|
||||
- confidence는 카드 흐름의 일관성에 따라 정직하게 판정.
|
||||
```
|
||||
|
||||
### cards_with_reference_block 예시
|
||||
|
||||
```
|
||||
## 1. 위치: 과거 | 카드: The Fool (정방향)
|
||||
- 아르카나: Major (0)
|
||||
- 원소: 공기 (Air)
|
||||
- 정방향 키워드: 새로운 시작, 도약, 순수, 자유
|
||||
- 정방향 의미: 미지의 세계로 내딛는 첫걸음. 계산보다 직관과 신뢰로 시작하는 시기.
|
||||
|
||||
## 2. 위치: 현재 | 카드: The Lovers (역방향)
|
||||
- 아르카나: Major (6)
|
||||
- 원소: 공기 (Air)
|
||||
- 역방향 키워드: 관계 갈등, 선택의 어려움
|
||||
- 역방향 의미: 두 길 사이에서 머뭇거리거나, 이미 내린 선택의 의구심이 커지는 시기.
|
||||
|
||||
## 3. 위치: 미래 | 카드: Ten of Cups (정방향)
|
||||
- 아르카나: Minor (Cups, 10)
|
||||
- 원소: 물 (Water)
|
||||
- 정방향 키워드: 정서적 충만, 가족·공동체의 행복
|
||||
- 정방향 의미: 컵 슈트의 완성 단계. 감정적 만족이 안정된 형태로 자리잡는 시기.
|
||||
|
||||
## 추가 컨텍스트
|
||||
- 메이저:마이너 비율: 2:1 (메이저 우세 → 큰 인생 주제)
|
||||
- 원소 분포: 공기 2, 물 1
|
||||
- 정역 흐름: 정→역→정 (일시적 정체 후 회복 가능성)
|
||||
```
|
||||
|
||||
### 응답 검증 (백엔드)
|
||||
- `cards[].evidence.card_meaning_used`가 비어있으면 → reroll 1회 (max 1 retry, 총 2회 호출)
|
||||
- `interactions`가 비어있고 spread_type == "three_card"이면 → reroll 1회
|
||||
- reroll 2회 모두 실패 → 받은 응답 그대로 저장 + log warning + 500 응답
|
||||
- JSON 파싱 실패 → codeblock 추출 시도 → raw 추출 시도 → 텍스트 그대로 summary에 박고 cards=[]
|
||||
|
||||
### 비용
|
||||
- Sonnet 4.6 입력 $3/1M, 출력 $15/1M
|
||||
- 회당 입력 ~700, 출력 ~900 토큰
|
||||
- 회당 비용 ~$0.015~0.022
|
||||
- 환경변수로 가격 오버라이드: `TAROT_COST_INPUT_PER_M`, `TAROT_COST_OUTPUT_PER_M`
|
||||
|
||||
---
|
||||
|
||||
## 7. UI 흐름
|
||||
|
||||
### 7.1 Route 구조
|
||||
| Path | 화면 | 컴포넌트 |
|
||||
|---|---|---|
|
||||
| `/tarot` | 랜딩 | `Tarot.jsx` |
|
||||
| `/tarot/today` | 오늘의 카드 | `TodayCard.jsx` |
|
||||
| `/tarot/reading` | 3장 스프레드 메인 | `Reading.jsx` |
|
||||
| `/tarot/history` | 마이페이지 | `History.jsx` |
|
||||
|
||||
### 7.2 랜딩 (`/tarot`)
|
||||
- 영상 배경 (`tarot_main_background.mp4` autoplay muted loop, `prefers-reduced-motion` 시 정지 이미지)
|
||||
- Overlay: `linear-gradient(rgba(15,4,40,.5) → rgba(15,4,40,.85))`
|
||||
- 헤더 sticky nav: 오늘의 카드 / 타로 리딩 / 가이드 / 히스토리
|
||||
- Hero: h1 "당신의 오늘을 비추는 타로" + sub + 2 CTA (지금 시작하기 / 오늘의 카드)
|
||||
- 3-tier 카드: 🌙 오늘의 운세 / 🃏 3장 스프레드 / ✨ AI 해석 (hover lift)
|
||||
|
||||
### 7.3 3장 스프레드 (`/tarot/reading`)
|
||||
3-step 진행, 한 화면 안에서 step 전환.
|
||||
|
||||
**Step 1 — 질문 입력 (좌측 panel)**
|
||||
- 질문 textarea
|
||||
- 카테고리 chip 선택 (`CATEGORIES` 중 1개)
|
||||
- 스프레드 라디오 (3장 / 1장)
|
||||
- [⊃ 카드 셔플하기] 버튼
|
||||
|
||||
**Step 2 — 카드 선택 (중앙)**
|
||||
- 셔플된 카드 16장 그리드 (4×4, 카드 뒷면)
|
||||
- 카드 hover 시 lift + glow
|
||||
- 카드 click 시 자리(과거→현재→미래)로 날아가며 flip + 위치 라벨 표시
|
||||
- 3장 모두 채워지면 [AI 해석 시작] 버튼 활성
|
||||
|
||||
**Step 3 — AI 해석 (우측 panel)**
|
||||
- 좌측: 3장 카드 자리 (카드 click으로 우측 panel 전환)
|
||||
- 우측 panel: 선택된 카드명 + 키워드 chip + 기본 의미 + AI interpretation + AI evidence(접을 수 있음) + advice
|
||||
- 하단: 종합 summary + advice + warning(있을 때) + confidence 배지
|
||||
- 액션: [⭐ 즐겨찾기 토글] / [다시 뽑기]
|
||||
|
||||
### 7.4 오늘의 카드 (`/tarot/today`)
|
||||
- 단일 큰 카드 슬롯 + "운명을 묻다" 버튼
|
||||
- 카테고리·질문 옵션 (default = "일반 / 없음")
|
||||
- 클릭 → 1장 추출 + flip 애니메이션 + Claude 호출 → 우측 텍스트로 해석 표시
|
||||
- 하루 1회 제한은 v1에 없음 (소비 자유)
|
||||
|
||||
### 7.5 히스토리 (`/tarot/history`)
|
||||
- 카드 리스트형: 날짜 · 스프레드 종류 · 질문 · 카드 미니 · 요약 한 줄 · confidence 배지 · ⭐ 토글
|
||||
- 클릭 → 디테일 모달 (원본 해석 전체)
|
||||
- 필터: 즐겨찾기만 / 스프레드 종류 / 카테고리
|
||||
- 페이지네이션 20개씩
|
||||
|
||||
### 7.6 공용 컴포넌트
|
||||
- `TarotCard.jsx` — 단일 카드 (앞·뒷면 토글, props: cardId / reversed / size / clickable)
|
||||
- `CardGrid.jsx` — 셔플 16장 그리드 (props: deckSlice / onPick)
|
||||
- `SpreadSlots.jsx` — 위치별 슬롯 (props: spread / cards)
|
||||
- `InterpretationPanel.jsx` — 우측 패널 (카드 의미 + AI 텍스트 + evidence 접기)
|
||||
- `useTarotShuffle.js` — Fisher–Yates + 16장 슬라이스 hook
|
||||
- `useTarotReading.js` — 카드 선택 상태 + reference 블록 빌더 + AI 호출 + 저장 hook
|
||||
|
||||
### 7.7 디자인 토큰
|
||||
- 배경 그라데이션: `#0a0420 → #1a0d2e → #2a1648`
|
||||
- 금색 액센트: `#d4af37`
|
||||
- 카드 보더 글로우: `0 0 24px rgba(212, 175, 55, .35)`
|
||||
- 폰트: 본문 기존 / 타이틀 세리프 (Cormorant Garamond + Noto Serif KR 폴백)
|
||||
- 네임스페이스: `.tarot-*`
|
||||
|
||||
### 7.8 navLinks 추가
|
||||
- id: `tarot`, label: `Tarot`, path: `/tarot`, subtitle: `ARCANA`,
|
||||
description: "라이더-웨이트 카드로 오늘과 내일을 비추는 리딩 랩",
|
||||
icon: sparkle 아이콘, accent: `#a78bfa`
|
||||
|
||||
---
|
||||
|
||||
## 8. 미디어 자산
|
||||
|
||||
### 히어로 영상
|
||||
- 원본: `source/videos/tarot_main_background.mp4`
|
||||
- 배포 위치: `web-ui/public/videos/tarot_hero.mp4` (Vite public/ 직접 서빙)
|
||||
- 권장 압축: 1920×1080 H.264 ≤4Mbps, ≤15초 loop
|
||||
- 폴백: `prefers-reduced-motion` 또는 `navigator.connection.saveData` 시 `tarot_background.png` 정지 이미지
|
||||
|
||||
### 배경 이미지
|
||||
- 원본: `source/images/tarot_page/tarot_background.png`
|
||||
- 배포 위치: `web-ui/public/images/tarot_background.png`
|
||||
- 사용: 영상 fallback + 카드 선택 페이지 배경 layer
|
||||
|
||||
### 카드 자산
|
||||
- v1: `web-ui/public/images/tarot/card_back.svg` — 단일 카드 뒷면 SVG (보라+금 + ARCANA TAROT 모노그램)
|
||||
- v1 카드 앞면: 78장 모두 CSS 카드 디자인 (그라데이션 보더 + 카드명 세리프 + 심볼 이모지)
|
||||
- 사용자 자산 추가 시: `web-ui/public/images/tarot/cards/<slug>.png` 자동 매핑, 누락 시 `onError` → CSS 폴백
|
||||
- 정적 파일이므로 이미지 추가 후 별도 빌드 불필요. NAS의 `frontend/images/tarot/cards/`에 robocopy 또는 직접 업로드 → 페이지 reload만으로 즉시 반영
|
||||
- 사용자가 78장을 한 번에 추가하지 않아도 됨 — 매핑된 것은 이미지로, 안 된 것은 CSS 폴백으로 자연스럽게 혼용
|
||||
|
||||
---
|
||||
|
||||
## 9. 테스트 전략
|
||||
|
||||
### 프론트 (Vitest)
|
||||
- `data/cards.js` 검증: 78장 총수, slug 중복 없음, 메이저 22 + 마이너 56, 모든 카드 keywords·meaningUpright·meaningReversed 존재
|
||||
- `useTarotShuffle.js`: Fisher–Yates 정확성 (중복 없음, 분포)
|
||||
- `useTarotReading.js`: 카드 선택 상태 전환, reference 블록 빌더 단위 테스트
|
||||
- `TarotCard.jsx`: 정·역 토글, flip 상태, 이미지 onError 폴백
|
||||
- `Reading.jsx`: step 1→2→3 전환
|
||||
|
||||
### 백엔드 (pytest)
|
||||
- `tarot.py::interpret`: 응답 파싱 (raw JSON / codeblock 감싸진 JSON / 깨진 JSON 폴백)
|
||||
- `tarot.py::interpret`: evidence·interactions 누락 시 reroll 1회 → 실패 시 그대로 저장
|
||||
- `db.py`: tarot_readings CRUD 정확성, favorite 필터, 페이지네이션
|
||||
- Anthropic 호출은 mock — 실제 호출은 통합 테스트 1건만
|
||||
|
||||
### 제외
|
||||
- AI 응답 품질 자체는 자동 테스트 불가 — manual QA로 검수
|
||||
|
||||
---
|
||||
|
||||
## 10. 배포
|
||||
|
||||
1. **백엔드 (agent-office 수정만)**: `git push` → Gitea Webhook → agent-office 재빌드 + 자동 마이그레이션 (`CREATE TABLE IF NOT EXISTS`)
|
||||
2. **프론트**: 로컬 빌드 → `npm run release:nas` → robocopy (영상·이미지 포함)
|
||||
3. **docker-compose 변경 없음**
|
||||
4. **nginx 변경 없음**
|
||||
5. **`scripts/deploy*.sh` 변경 없음** — 컨테이너 리스트 그대로
|
||||
|
||||
---
|
||||
|
||||
## 11. 위험·완화
|
||||
|
||||
| 위험 | 완화 |
|
||||
|---|---|
|
||||
| Claude 응답 JSON 깨짐 | 파싱 폴백 3단(codeblock→raw→텍스트) + reroll 1회 |
|
||||
| 영상 파일 NAS 트래픽↑ | 압축 후 사이즈 체크 — 5MB 초과 시 사용자 노티 |
|
||||
| 카드 이미지 미준비로 임팩트↓ | CSS 카드 디자인을 시안 톤(보라+금)에 맞춰 정교화 |
|
||||
| AI 비용 폭주 | 회당 ~$0.02, 일 50회 가정 시 월 ~$30 — 개인 사용 OK |
|
||||
| 78장 의미 텍스트 작성 부담 | v1 plan에 별도 "데이터 시드 task" 분리, 메이저 22 우선 + 마이너 키워드만 |
|
||||
| reference 블록을 프론트가 빌드 → 백엔드 검증 누락 | reference 블록 빈 문자열·길이 단순 검증만 추가 (carot 검증은 v2) |
|
||||
|
||||
---
|
||||
|
||||
## 12. v1 작업량 추산
|
||||
- 백엔드: agent-office 추가 ~300 LOC (`agents/tarot.py` + `routes/tarot.py` + `db.py` 마이그레이션 + 테스트)
|
||||
- 프론트: ~1500~2000 LOC (4 페이지 + 5~7 컴포넌트 + 데이터 + CSS)
|
||||
- 카드 시드 데이터: 메이저 22장 완성 + 마이너 56장 키워드만 + 짧은 의미 1문장
|
||||
- 예상 plan task: 15~18개
|
||||
@@ -0,0 +1,670 @@
|
||||
# saju-lab 신설 + tarot-lab 분리 — 마이그레이션 설계
|
||||
|
||||
**작성일**: 2026-05-25
|
||||
**상태**: Spec (구현 plan 작성 전)
|
||||
|
||||
---
|
||||
|
||||
## 1. 목표
|
||||
|
||||
1. **saju-lab 신설**: 별도 디렉토리에 있던 `saju-web` (Next.js + Supabase + OpenAI) 프로젝트를 web-backend 모노레포의 한 lab 서비스로 마이그레이션. Python FastAPI + Claude + SQLite 패턴으로 단순화.
|
||||
2. **tarot-lab 분리**: 현재 `agent-office` 컨테이너 내부 모듈로 들어 있는 tarot 기능을 독립 컨테이너로 분리. agent-office가 가벼워지고 tarot은 자체 라이프사이클을 가짐.
|
||||
|
||||
두 작업이 같은 패턴(독립 lab 컨테이너 신설)을 공유하므로 하나의 spec에 담아 순차 구현.
|
||||
|
||||
---
|
||||
|
||||
## 2. 배경
|
||||
|
||||
### 2-1. saju-web 현황
|
||||
- 위치: `C:\Users\jaeoh\Desktop\workspace\saju-web`
|
||||
- 스택: Next.js 16, TypeScript, Supabase(OAuth+DB), OpenAI gpt-4o, PortOne 결제, Kakao 공유
|
||||
- 기능 4종: 사주분석(10토큰), 궁합(15토큰), 토정비결(5토큰), 오늘의 운세
|
||||
- 핵심 자산: `lib/saju-calculator.ts`, `lib/ai-interpretation.ts`, `lib/daeun-calculator.ts`, `lib/solar-terms.ts` (계산 엔진 ~1500줄)
|
||||
- 현재 사용 중이 아님. 자산 보존 + 패턴 일치화를 위한 마이그레이션
|
||||
|
||||
### 2-2. tarot-lab 현황
|
||||
- 위치: `agent-office/app/tarot/` (모듈), `agent-office/app/routers/tarot.py`
|
||||
- DB: `agent_office.db`의 `tarot_readings` 테이블
|
||||
- API: `/api/agent-office/tarot/*` 6개 endpoint (interpret, save, list, get, patch, delete)
|
||||
- 21개 단위 테스트 존재
|
||||
- 문제: agent-office가 점점 비대해짐 (텔레그램·로또·주식·청약·유튜브·타로 모두 한 컨테이너에). tarot은 독립 도메인이라 분리가 자연스러움
|
||||
|
||||
### 2-3. 다른 lab 패턴 (참조 기준)
|
||||
`insta-lab`, `music-lab`, `realestate-lab`은 모두 동일 패턴:
|
||||
```
|
||||
<lab>/
|
||||
├── Dockerfile (python:3.12-slim)
|
||||
├── requirements.txt
|
||||
├── pytest.ini
|
||||
├── tests/
|
||||
└── app/
|
||||
├── main.py (FastAPI)
|
||||
├── config.py
|
||||
├── db.py (SQLite)
|
||||
└── <도메인 모듈들>
|
||||
```
|
||||
- 인증 없음 (개인 NAS 서비스)
|
||||
- nginx가 `/api/<name>/`로 라우팅
|
||||
- docker-compose의 한 항목으로 등록
|
||||
- Gitea Webhook → deployer가 rsync + docker compose up -d --build
|
||||
|
||||
---
|
||||
|
||||
## 3. 핵심 결정 사항
|
||||
|
||||
| 항목 | 결정 |
|
||||
|------|------|
|
||||
| 백엔드 언어 | Python FastAPI (saju 계산 엔진은 TypeScript → Python 포팅) |
|
||||
| AI 모델 | Claude Sonnet 4.6 (`claude-sonnet-4-6`) + prompt-caching beta. tarot과 일관 |
|
||||
| DB | SQLite 로컬 (saju-lab은 `saju.db`, tarot-lab은 `tarot.db`) |
|
||||
| 인증 | 없음 (다른 lab 패턴 일치). saju-web의 Supabase/PortOne/Kakao 제거 |
|
||||
| saju-lab v1 기능 | 사주 분석 + 궁합 + 사주 결과 내 세운(歲運) (오늘의 운세는 세운으로 통합). 토정비결은 v2 |
|
||||
| tarot DB 마이그레이션 | 1회성 복사 스크립트 (agent_office.db → tarot.db), cutover 후 agent-office tarot 모듈 완전 제거 |
|
||||
| saju-lab UI | 시안 기반 신규 (시안 추후 제공, Phase 2 마지막 단계) |
|
||||
| API prefix | `/api/saju/*`, `/api/tarot/*` (완전 이전) — `/api/agent-office/tarot/*`는 제거 |
|
||||
| 포트 (내부) | tarot-lab 18250, saju-lab 18300 |
|
||||
| 진행 순서 | Phase 1 tarot 분리 → Phase 2 saju 신설 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 디렉토리 구조
|
||||
|
||||
```
|
||||
web-backend/
|
||||
├── tarot-lab/ # [신설]
|
||||
│ ├── Dockerfile
|
||||
│ ├── requirements.txt
|
||||
│ ├── pytest.ini
|
||||
│ ├── tests/
|
||||
│ │ ├── test_db.py # agent-office/tests/test_tarot_db.py 이관
|
||||
│ │ ├── test_schema.py
|
||||
│ │ ├── test_pipeline.py
|
||||
│ │ └── test_routes.py # 6 endpoint (interpret + readings CRUD 5)
|
||||
│ └── app/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # FastAPI app + /api/tarot/* 라우터 6개
|
||||
│ ├── config.py # TAROT_MODEL, TAROT_COST_*, ANTHROPIC_API_KEY, TAROT_TIMEOUT_SEC
|
||||
│ ├── db.py # tarot.db: 5 CRUD + _tarot_row_to_dict
|
||||
│ ├── models.py # Pydantic 모델 5개 (TarotCardDraw, TarotInterpretRequest, TarotInterpretResponse, TarotSaveRequest, TarotPatchRequest)
|
||||
│ ├── pipeline.py # Claude 호출 + reroll 1회
|
||||
│ ├── prompt.py # SYSTEM_PROMPT + build_user_message
|
||||
│ └── schema.py # validate_interpretation
|
||||
│
|
||||
├── saju-lab/ # [신설]
|
||||
│ ├── Dockerfile
|
||||
│ ├── requirements.txt # fastapi, httpx, anthropic, pydantic, sxtwl(절기/음력)
|
||||
│ ├── pytest.ini
|
||||
│ ├── tests/
|
||||
│ │ ├── fixtures/
|
||||
│ │ │ └── reference_saju.json # Node.js 원본에서 추출한 입력→출력 쌍
|
||||
│ │ ├── test_core.py # 천간/지지/십성/십이운성/calculate_saju
|
||||
│ │ ├── test_solar_terms.py # 24절기
|
||||
│ │ ├── test_lunar.py # 음력 변환
|
||||
│ │ ├── test_analysis.py # 오행/신강신약/용신/세운
|
||||
│ │ ├── test_daeun.py
|
||||
│ │ ├── test_shinsal.py # 신살/공망/지장간
|
||||
│ │ ├── test_compatibility.py # 궁합 점수
|
||||
│ │ ├── test_pipeline.py # Claude mock + reroll
|
||||
│ │ ├── test_compat_pipeline.py
|
||||
│ │ ├── test_schema.py
|
||||
│ │ ├── test_db.py
|
||||
│ │ └── test_routes.py
|
||||
│ └── app/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # FastAPI app
|
||||
│ ├── config.py # SAJU_MODEL, SAJU_COST_*, ANTHROPIC_API_KEY, SAJU_TIMEOUT_SEC
|
||||
│ ├── db.py # saju.db: saju_records, compat_records 테이블 + CRUD
|
||||
│ ├── models.py # SajuRequest, CompatRequest, etc.
|
||||
│ ├── calculator/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── constants.py # HEAVENLY_STEMS, EARTHLY_BRANCHES, FIVE_ELEMENTS, HIDDEN_STEMS, TEN_GODS, TWELVE_FORTUNES
|
||||
│ │ ├── core.py # get_year_ganzi, get_month_ganzi, get_day_ganzi, get_hour_ganzi, get_ten_god, get_twelve_fortune, calculate_saju
|
||||
│ │ ├── solar_terms.py # get_solar_term_date, get_current_solar_term, get_solar_term_month_branch, get_days_to_next_solar_term — sxtwl 사용
|
||||
│ │ ├── lunar.py # solar_to_lunar, lunar_to_solar
|
||||
│ │ ├── shinsal.py # get_hidden_stems, get_all_hidden_stems, analyze_branch_interactions, calculate_shinsal, calculate_gongmang
|
||||
│ │ ├── analysis.py # calculate_detailed_element_balance, calculate_element_score, analyze_day_master_strength, estimate_yongshin, calculate_seun, perform_full_analysis
|
||||
│ │ ├── daeun.py # calculate_daeun, get_current_daeun, get_daeun_description
|
||||
│ │ └── compatibility.py # calculate_compatibility (오행 상생/상극 + 지지 합/충 점수화)
|
||||
│ ├── interpret/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── pipeline.py # Claude 호출 + reroll (tarot 패턴)
|
||||
│ │ ├── compat_pipeline.py
|
||||
│ │ ├── prompt.py # 사주 12항목 SYSTEM_PROMPT (Claude용 재작성, evidence-based)
|
||||
│ │ ├── compat_prompt.py # 궁합 SYSTEM_PROMPT
|
||||
│ │ └── schema.py # validate_saju_interpretation, validate_compat_interpretation
|
||||
│ └── routers/
|
||||
│ ├── __init__.py
|
||||
│ ├── saju.py # POST /api/saju/interpret, /readings CRUD, /current-fortune
|
||||
│ └── compat.py # POST /api/saju/compat/interpret, /readings CRUD
|
||||
│
|
||||
├── agent-office/ # [수정]
|
||||
│ ├── app/
|
||||
│ │ ├── tarot/ # [제거]
|
||||
│ │ ├── routers/tarot.py # [제거]
|
||||
│ │ ├── models.py # Tarot* 5개 제거
|
||||
│ │ ├── db.py # tarot_readings 관련 CRUD 5개 + _tarot_row_to_dict + CREATE TABLE 제거
|
||||
│ │ └── main.py # include_router(tarot_router.router) 줄 제거
|
||||
│ ├── tests/ # test_tarot_*.py 4개 제거
|
||||
│ └── scripts/
|
||||
│ └── migrate_tarot_to_lab.py # [신설] 1회성 마이그레이션
|
||||
│
|
||||
├── docker-compose.yml # [수정] tarot-lab, saju-lab 추가
|
||||
├── nginx/default.conf # [수정] /api/tarot/ → tarot-lab, /api/saju/ → saju-lab, /api/agent-office/tarot/ 제거
|
||||
├── scripts/
|
||||
│ ├── deploy-nas.sh # [수정] CONTAINERS 배열에 saju-lab, tarot-lab 추가
|
||||
│ └── deploy.sh # [수정] 5위치 (CLAUDE.md memory의 "배포 스크립트 동기화" 항목 참조)
|
||||
└── docs/superpowers/specs/
|
||||
└── 2026-05-25-saju-tarot-lab-migration-design.md # 본 문서
|
||||
```
|
||||
|
||||
**프론트엔드 (`web-ui/`)** — Phase 1·2 양쪽 변경:
|
||||
```
|
||||
web-ui/
|
||||
├── src/
|
||||
│ ├── api.js # [Phase 1 수정] tarot helpers 6개 URL prefix 변경 + [Phase 2 추가] saju/compat helpers
|
||||
│ ├── routes.jsx # [Phase 2 수정] /saju, /saju/result, /saju/compatibility, /saju/compatibility/result 라우트
|
||||
│ ├── components/Icons.jsx # [Phase 2 수정] IconSaju 추가
|
||||
│ └── pages/
|
||||
│ ├── tarot/ # [Phase 1] URL prefix만 변경, 그 외 변경 없음
|
||||
│ └── saju/ # [Phase 2 신설, 시안 받은 후]
|
||||
│ ├── Saju.jsx
|
||||
│ ├── SajuForm.jsx
|
||||
│ ├── SajuResult.jsx
|
||||
│ ├── Compatibility.jsx
|
||||
│ ├── CompatibilityForm.jsx
|
||||
│ ├── CompatibilityResult.jsx
|
||||
│ ├── data/
|
||||
│ │ ├── constants.js # 천간/지지/오행 상수 (UI 표시용)
|
||||
│ │ └── interpretations.js
|
||||
│ ├── hooks/
|
||||
│ │ ├── useSajuForm.js
|
||||
│ │ └── useSajuInterpretation.js
|
||||
│ └── components/
|
||||
│ ├── SajuBoard.jsx # 4기둥 시각화
|
||||
│ ├── ElementChart.jsx# 오행 차트
|
||||
│ ├── DaeunTimeline.jsx
|
||||
│ └── InterpretationPanel.jsx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Phase 1: tarot-lab 분리
|
||||
|
||||
### 5-1. 신규 tarot-lab 컨테이너 생성
|
||||
|
||||
**파일 단순 복사 + 모듈 평탄화:**
|
||||
- `agent-office/app/tarot/__init__.py` → `tarot-lab/app/__init__.py` (간단화)
|
||||
- `agent-office/app/tarot/prompt.py` → `tarot-lab/app/prompt.py`
|
||||
- `agent-office/app/tarot/pipeline.py` → `tarot-lab/app/pipeline.py` (import 경로 수정: `..config` → `.config`, `..models` → `.models`)
|
||||
- `agent-office/app/tarot/schema.py` → `tarot-lab/app/schema.py`
|
||||
|
||||
**추출 파일:**
|
||||
- `tarot-lab/app/config.py`: agent-office의 config.py에서 TAROT_* 환경변수만 추출
|
||||
```python
|
||||
import os
|
||||
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
||||
TAROT_MODEL = os.getenv("TAROT_MODEL", "claude-sonnet-4-6")
|
||||
TAROT_COST_INPUT_PER_M = float(os.getenv("TAROT_COST_INPUT_PER_M", "3.0"))
|
||||
TAROT_COST_OUTPUT_PER_M = float(os.getenv("TAROT_COST_OUTPUT_PER_M", "15.0"))
|
||||
TAROT_TIMEOUT_SEC = int(os.getenv("TAROT_TIMEOUT_SEC", "180"))
|
||||
TAROT_DATA_PATH = os.getenv("TAROT_DATA_PATH", "/app/data")
|
||||
TAROT_DB_PATH = os.path.join(TAROT_DATA_PATH, "tarot.db")
|
||||
```
|
||||
- `tarot-lab/app/models.py`: agent-office models.py에서 Tarot* 5개만 추출
|
||||
- `tarot-lab/app/db.py`:
|
||||
- tarot_readings CREATE TABLE + WAL 활성화
|
||||
- 5 CRUD (save/get/list/update/delete) + `_tarot_row_to_dict`
|
||||
- DB 경로는 `TAROT_DB_PATH` (volume mount된 `/app/data/tarot.db`)
|
||||
- `tarot-lab/app/main.py`:
|
||||
```python
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from .models import (...)
|
||||
from . import pipeline, db as db_module
|
||||
|
||||
app = FastAPI(title="tarot-lab")
|
||||
|
||||
@app.on_event("startup")
|
||||
def _init_db():
|
||||
db_module.init_db()
|
||||
|
||||
# /api/tarot/* 5 endpoints (routers/tarot.py 코드 그대로)
|
||||
```
|
||||
|
||||
**Dockerfile (insta-lab 패턴):**
|
||||
```dockerfile
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app/ ./app/
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
**requirements.txt:**
|
||||
```
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.32.0
|
||||
httpx==0.27.2
|
||||
pydantic==2.9.2
|
||||
```
|
||||
|
||||
### 5-2. 테스트 이관
|
||||
- `agent-office/tests/test_tarot_*.py` 4개 파일 → `tarot-lab/tests/test_*.py`
|
||||
- import 경로 수정 (`from app.tarot.pipeline` → `from app.pipeline`)
|
||||
- pytest.ini 추가 (`testpaths = tests`, `pythonpath = .`)
|
||||
- 모두 통과 확인 (21 tests)
|
||||
|
||||
### 5-3. DB 마이그레이션 스크립트
|
||||
|
||||
`agent-office/scripts/migrate_tarot_to_lab.py`:
|
||||
```python
|
||||
"""1회성 — agent_office.db의 tarot_readings를 tarot.db로 복사.
|
||||
멱등성: 이미 존재하는 id는 SKIP.
|
||||
실행: docker exec agent-office python /app/scripts/migrate_tarot_to_lab.py
|
||||
"""
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
SRC = os.getenv("AGENT_OFFICE_DB", "/app/data/agent_office.db")
|
||||
DST = os.getenv("TAROT_DB", "/app/data/tarot.db")
|
||||
|
||||
def migrate():
|
||||
src = sqlite3.connect(SRC)
|
||||
dst = sqlite3.connect(DST)
|
||||
dst.execute("""
|
||||
CREATE TABLE IF NOT EXISTS tarot_readings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
... (agent-office 스키마 그대로)
|
||||
)
|
||||
""")
|
||||
rows = src.execute("SELECT * FROM tarot_readings").fetchall()
|
||||
cols = [c[0] for c in src.execute("SELECT * FROM tarot_readings LIMIT 1").description]
|
||||
placeholders = ",".join("?" * len(cols))
|
||||
cols_str = ",".join(cols)
|
||||
moved = 0
|
||||
for r in rows:
|
||||
cur = dst.execute(f"SELECT 1 FROM tarot_readings WHERE id = ?", (r[0],))
|
||||
if cur.fetchone() is None:
|
||||
dst.execute(f"INSERT INTO tarot_readings ({cols_str}) VALUES ({placeholders})", r)
|
||||
moved += 1
|
||||
dst.commit()
|
||||
print(f"migrated {moved} / {len(rows)} rows")
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
```
|
||||
|
||||
**볼륨 공유 전략**: tarot-lab의 `/app/data`를 agent-office의 `/app/data`와 같은 NAS 호스트 디렉토리에 마운트. tarot.db는 신규 파일이라 별도 마운트 가능.
|
||||
|
||||
### 5-4. docker-compose / nginx / deploy 갱신
|
||||
|
||||
**docker-compose.yml**에 추가:
|
||||
```yaml
|
||||
tarot-lab:
|
||||
build: ./tarot-lab
|
||||
container_name: tarot-lab
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18250:8000"
|
||||
environment:
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
- TAROT_MODEL=${TAROT_MODEL:-claude-sonnet-4-6}
|
||||
- TAROT_DATA_PATH=/app/data
|
||||
volumes:
|
||||
- ${RUNTIME_PATH:-.}/data:/app/data
|
||||
```
|
||||
|
||||
**nginx/default.conf**에 추가, 기존 `/api/agent-office/tarot/`은 제거:
|
||||
```nginx
|
||||
location /api/tarot/ {
|
||||
proxy_pass http://tarot-lab:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_connect_timeout 60s;
|
||||
}
|
||||
```
|
||||
|
||||
**deploy 스크립트 5위치** (memory의 "배포 스크립트 동기화" 참조):
|
||||
- `scripts/deploy-nas.sh`의 CONTAINERS 배열
|
||||
- `scripts/deploy.sh`의 SERVICES, DIRS 배열
|
||||
- 컨테이너 목록 하드코딩된 모든 위치에 `tarot-lab` 추가 (Phase 1) / `saju-lab` 추가 (Phase 2)
|
||||
|
||||
### 5-5. agent-office cutover
|
||||
|
||||
마이그레이션 + 데이터 검증 후:
|
||||
- `agent-office/app/tarot/` 디렉토리 통째로 제거
|
||||
- `agent-office/app/routers/tarot.py` 제거
|
||||
- `agent-office/app/main.py`에서 tarot router import + include_router 줄 제거
|
||||
- `agent-office/app/models.py`에서 `TarotCardDraw`, `TarotInterpretRequest`, `TarotInterpretResponse`, `TarotSaveRequest`, `TarotPatchRequest` 제거
|
||||
- `agent-office/app/db.py`에서 `save_tarot_reading`, `get_tarot_reading`, `list_tarot_readings`, `update_tarot_reading`, `delete_tarot_reading`, `_tarot_row_to_dict` 제거
|
||||
- `agent-office/app/db.py`의 CREATE TABLE에서 `tarot_readings` 줄 제거 (또는 idempotent 유지: 기존 DB 호환 위해 CREATE IF NOT EXISTS는 유지하되 코드 경로 제거)
|
||||
- `agent-office/tests/test_tarot_*.py` 4개 제거
|
||||
- agent-office pytest 통과 확인
|
||||
|
||||
### 5-6. web-ui api.js URL 변경
|
||||
|
||||
`web-ui/src/api.js`의 tarot helpers 6개:
|
||||
- `tarotInterpret`: `/api/agent-office/tarot/interpret` → `/api/tarot/interpret`
|
||||
- `tarotSaveReading`: `/api/agent-office/tarot/readings` → `/api/tarot/readings`
|
||||
- `tarotListReadings`: 동일 변환
|
||||
- `tarotGetReading`: 동일 변환
|
||||
- `tarotPatchReading`: 동일 변환
|
||||
- `tarotDeleteReading`: 동일 변환
|
||||
|
||||
Phase 1 검증: `npm run dev` → http://127.0.0.1:3007/tarot → 3장 리딩 1회 e2e 동작 확인.
|
||||
|
||||
---
|
||||
|
||||
## 6. Phase 2: saju-lab 신설
|
||||
|
||||
### 6-1. 계산 엔진 포팅 (TypeScript → Python)
|
||||
|
||||
**핵심 위험**: 계산 엔진은 ~1500줄 TypeScript로 매년 검증된 코드. Python으로 옮기면서 미세한 버그가 들어가면 모든 사주 해석이 잘못됨.
|
||||
|
||||
**대응 전략 — Reference Output 비교 테스트**:
|
||||
1. saju-web의 `lib/saju-calculator.ts` 코드를 Node.js로 직접 실행 (`node -e "..."`)
|
||||
2. 알려진 입력 30~50쌍에 대해 `calculateSaju(year, month, day, hour, gender)` + `performFullAnalysis(saju, currentYear)` + `calculateDaeun(...)` 호출 결과를 JSON 파일로 저장
|
||||
3. `tests/fixtures/reference_saju.json` 형식:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"input": {"year": 1990, "month": 5, "day": 15, "hour": 14, "gender": "male"},
|
||||
"expected": {
|
||||
"saju": {...},
|
||||
"analysis": {...},
|
||||
"daeun": [...]
|
||||
}
|
||||
},
|
||||
... (50개)
|
||||
]
|
||||
```
|
||||
4. Python 포팅 후 pytest로 매 입력 → expected와 1:1 비교 (`assert deep_equal(actual, expected)`)
|
||||
|
||||
**포팅 순서** (의존성 그래프):
|
||||
1. `calculator/constants.py` — 모든 상수 (천간 10·지지 12·오행 5·십성·십이운성·지장간·신살)
|
||||
2. `calculator/solar_terms.py` — `sxtwl` Python 라이브러리 사용 (24절기 + 음력)
|
||||
3. `calculator/lunar.py` — `sxtwl` 음력↔양력 변환
|
||||
4. `calculator/core.py` — `get_year_ganzi`, `get_month_ganzi` (절기 기반), `get_day_ganzi`, `get_hour_ganzi`, `get_ten_god`, `get_twelve_fortune`, `calculate_saju`
|
||||
5. `calculator/shinsal.py` — 지장간(`get_hidden_stems`, `get_all_hidden_stems`), 지지 상호작용(`analyze_branch_interactions`), 신살(`calculate_shinsal`), 공망(`calculate_gongmang`)
|
||||
6. `calculator/analysis.py` — 오행 점수(`calculate_detailed_element_balance`, `calculate_element_score`), 신강신약(`analyze_day_master_strength`), 용신(`estimate_yongshin`), 세운(`calculate_seun`), 종합(`perform_full_analysis`)
|
||||
7. `calculator/daeun.py` — `calculate_daeun`, `get_current_daeun`, `get_daeun_description`
|
||||
8. `calculator/compatibility.py` — 두 사주의 오행 매칭 + 지지 합/충 점수화 → 0~100 점수
|
||||
|
||||
각 단계마다 reference test 통과를 게이트로.
|
||||
|
||||
### 6-2. Claude 프롬프트 (tarot 패턴 재활용)
|
||||
|
||||
**`interpret/prompt.py`** — 사주 12항목 해석:
|
||||
- 시스템 프롬프트: "당신은 한국 전통 사주명리학 전문가다. 다음 사주 + 분석 결과를 보고, JSON 스키마로 12항목 해석을 작성하라. 각 항목은 evidence 필드를 포함해 어떤 사주 요소에서 결론을 도출했는지 명시하라."
|
||||
- 12항목: 타고난 기질 / 오행 밸런스 / 지지 상호작용 / 신살 영향 / 재물운 / 직업 적성 / 애정운 / 건강운 / 현재 대운 / 올해 세운 / 인생 황금기 / 종합 조언
|
||||
- JSON 응답 스키마:
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{ "key": "기질", "title": "...", "content": "...", "evidence": {"saju_element": "...", "reasoning": "..."} },
|
||||
...
|
||||
],
|
||||
"summary": "...",
|
||||
"advice": "...",
|
||||
"warning": "...",
|
||||
"confidence": "high|medium|low"
|
||||
}
|
||||
```
|
||||
- `cache_control: ephemeral`을 system 블록에 적용
|
||||
|
||||
**`interpret/compat_prompt.py`** — 궁합 해석:
|
||||
- 두 사주 + 궁합 점수 + 오행 상생/상극 분석 → JSON 응답
|
||||
- evidence: 어떤 지지 합/충에서 점수가 나왔는지 명시
|
||||
|
||||
**`interpret/schema.py`** — validate 함수:
|
||||
- `validate_saju_interpretation(parsed)`: items 12개 존재 / 각 evidence 채워졌는지 / confidence 값 검증
|
||||
- `validate_compat_interpretation(parsed)`: 마찬가지
|
||||
|
||||
**`interpret/pipeline.py`** — Claude 호출 (tarot pipeline.py 거의 그대로 복사 + 사주용 prompt/schema 사용):
|
||||
- max_tokens 2400 (12항목 + 종합이라 더 길음)
|
||||
- reroll 1회
|
||||
- latency_ms / tokens 로깅
|
||||
|
||||
### 6-3. DB 스키마
|
||||
|
||||
`saju-lab/app/db.py`:
|
||||
```python
|
||||
SAJU_DB_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS saju_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
birth_year INTEGER NOT NULL,
|
||||
birth_month INTEGER NOT NULL,
|
||||
birth_day INTEGER NOT NULL,
|
||||
birth_hour INTEGER,
|
||||
gender TEXT NOT NULL,
|
||||
calendar_type TEXT DEFAULT 'solar',
|
||||
saju_data JSON NOT NULL,
|
||||
analysis_data JSON NOT NULL,
|
||||
daeun_data JSON NOT NULL,
|
||||
interpretation_json JSON,
|
||||
model TEXT,
|
||||
tokens_in INTEGER DEFAULT 0,
|
||||
tokens_out INTEGER DEFAULT 0,
|
||||
cost_usd REAL DEFAULT 0,
|
||||
latency_ms INTEGER DEFAULT 0,
|
||||
reroll_count INTEGER DEFAULT 0,
|
||||
favorite INTEGER DEFAULT 0,
|
||||
memo TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS compat_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
person_a JSON NOT NULL,
|
||||
person_b JSON NOT NULL,
|
||||
score INTEGER NOT NULL,
|
||||
breakdown JSON NOT NULL,
|
||||
interpretation_json JSON,
|
||||
model TEXT,
|
||||
tokens_in INTEGER DEFAULT 0,
|
||||
tokens_out INTEGER DEFAULT 0,
|
||||
cost_usd REAL DEFAULT 0,
|
||||
favorite INTEGER DEFAULT 0,
|
||||
memo TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
"""
|
||||
```
|
||||
|
||||
CRUD 함수: `save_saju_record`, `get_saju_record`, `list_saju_records`, `update_saju_record`, `delete_saju_record` + compat 5개.
|
||||
|
||||
### 6-4. API 엔드포인트
|
||||
|
||||
**`routers/saju.py`**:
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| POST | `/api/saju/interpret` | 입력 → 계산 + AI 해석 + DB 저장. 응답에 saju/analysis/daeun/interpretation/reading_id 포함 |
|
||||
| GET | `/api/saju/readings` | 페이지네이션 목록 (page, size, favorite) |
|
||||
| GET | `/api/saju/readings/{id}` | 상세 조회 |
|
||||
| PATCH | `/api/saju/readings/{id}` | favorite, memo 수정 |
|
||||
| DELETE | `/api/saju/readings/{id}` | 삭제 |
|
||||
| GET | `/api/saju/current-fortune?reading_id={id}` | 저장된 사주 기반 오늘의 세운 (실시간 계산, AI 호출 없음) |
|
||||
|
||||
**`routers/compat.py`**:
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| POST | `/api/saju/compat/interpret` | 두 사람 입력 → 두 사주 계산 + 궁합 점수 + AI 해석 + DB 저장 |
|
||||
| GET | `/api/saju/compat/readings` | 목록 |
|
||||
| GET | `/api/saju/compat/readings/{id}` | 상세 |
|
||||
| PATCH | `/api/saju/compat/readings/{id}` | favorite, memo |
|
||||
| DELETE | `/api/saju/compat/readings/{id}` | 삭제 |
|
||||
|
||||
### 6-5. docker-compose / nginx 등록
|
||||
|
||||
**docker-compose.yml**에 saju-lab 항목 추가 (tarot-lab과 동일 패턴, 포트 18300).
|
||||
|
||||
**nginx/default.conf**에 추가:
|
||||
```nginx
|
||||
location /api/saju/ {
|
||||
proxy_pass http://saju-lab:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_connect_timeout 60s;
|
||||
}
|
||||
```
|
||||
|
||||
deploy 스크립트 5위치에 `saju-lab` 추가.
|
||||
|
||||
### 6-6. web-ui /saju 페이지
|
||||
|
||||
**시안 추후 제공** (사용자 확인). 시안 받은 후 tarot 페이지 패턴 따라 구현:
|
||||
- 입력 폼: 생년월일 + 시간 + 성별 + 양력/음력 (양력 default)
|
||||
- 결과 페이지: 사주판 시각화 + 오행 차트 + 대운 타임라인 + AI 12항목 아코디언
|
||||
- 궁합: 두 사람 입력 폼 + 결과 카드
|
||||
- 인사이트 패널 (tarot의 InterpretationPanel.jsx 패턴 차용)
|
||||
|
||||
`api.js`에 helpers 추가:
|
||||
- `sajuInterpret`, `sajuListReadings`, `sajuGetReading`, `sajuPatchReading`, `sajuDeleteReading`, `sajuCurrentFortune`
|
||||
- `compatInterpret`, `compatListReadings`, `compatGetReading`, `compatPatchReading`, `compatDeleteReading`
|
||||
|
||||
`routes.jsx`에 라우트 추가:
|
||||
- `/saju` (입력), `/saju/result` (사주 결과), `/saju/compatibility` (입력), `/saju/compatibility/result` (궁합 결과)
|
||||
|
||||
`components/Icons.jsx`에 `IconSaju` 추가.
|
||||
|
||||
---
|
||||
|
||||
## 7. 데이터 흐름
|
||||
|
||||
### tarot-lab (Phase 1)
|
||||
```
|
||||
[web-ui /tarot/reading]
|
||||
↓ POST /api/tarot/interpret { cards, question, category, spread_type }
|
||||
[nginx /api/tarot/ → tarot-lab:8000]
|
||||
↓ pipeline.interpret() → Claude API
|
||||
↓ validate + reroll
|
||||
[tarot-lab]
|
||||
↓ POST /api/tarot/readings { ... save body }
|
||||
↓ db.save_tarot_reading() → tarot.db INSERT
|
||||
← { id, created_at }
|
||||
```
|
||||
|
||||
### saju-lab (Phase 2)
|
||||
```
|
||||
[web-ui /saju/result]
|
||||
↓ POST /api/saju/interpret { year, month, day, hour, gender, calendarType }
|
||||
[nginx /api/saju/ → saju-lab:8000]
|
||||
↓ calculator.calculate_saju() → SajuData
|
||||
↓ calculator.perform_full_analysis() → SajuAnalysis
|
||||
↓ calculator.calculate_daeun() → DaeunPillar[]
|
||||
↓ interpret.pipeline.interpret() → Claude API
|
||||
↓ validate + reroll
|
||||
↓ db.save_saju_record() → saju.db INSERT
|
||||
← { saju, analysis, daeun, interpretation, reading_id, cost_usd, latency_ms }
|
||||
[web-ui]
|
||||
```
|
||||
|
||||
### saju-lab 궁합
|
||||
```
|
||||
[web-ui /saju/compatibility/result]
|
||||
↓ POST /api/saju/compat/interpret { person_a: {...}, person_b: {...} }
|
||||
[saju-lab]
|
||||
↓ calculate_saju(person_a) + calculate_saju(person_b)
|
||||
↓ compatibility.calculate_compatibility(saju_a, saju_b) → { score, breakdown }
|
||||
↓ interpret.compat_pipeline.interpret() → Claude API
|
||||
↓ db.save_compat_record()
|
||||
← { saju_a, saju_b, score, breakdown, interpretation, reading_id }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 에러 처리
|
||||
|
||||
| 시나리오 | 처리 |
|
||||
|---------|------|
|
||||
| Claude API HTTP error | `TarotError` / `SajuError` raise → FastAPI 500 |
|
||||
| Claude JSON 파싱 실패 | `_extract_json` codeblock 스트립 + 첫 `{` / 마지막 `}` 추출. 실패 시 reroll |
|
||||
| validate 실패 (필수 필드 누락) | reroll 1회. 그래도 실패 시 `_Error("검증 실패")` raise → 500 |
|
||||
| 계산 엔진 입력 오류 (잘못된 날짜 등) | Pydantic validation → 422 |
|
||||
| DB 락 | sqlite WAL 모드. 짧은 retry 없이 raise (드물게 발생) |
|
||||
| 마이그레이션 스크립트 중복 실행 | `INSERT OR IGNORE` 패턴 / 멱등 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 테스트 전략
|
||||
|
||||
### tarot-lab
|
||||
- 기존 21 tests 이관 + import 경로 수정 후 100% 통과
|
||||
|
||||
### saju-lab — 계산 엔진
|
||||
- **Reference output 비교가 핵심**. 30~50개 입력 → JSON 저장 → Python 결과와 deep_equal 비교
|
||||
- 각 모듈 단위 테스트 (constants, solar_terms, lunar, core, shinsal, analysis, daeun, compatibility)
|
||||
- 회귀 방지: 추가 입력 케이스 발견 시 fixtures에 추가
|
||||
|
||||
### saju-lab — Claude 파이프라인
|
||||
- httpx mock (respx 또는 monkeypatch) 사용 (tarot 패턴 그대로)
|
||||
- validate / reroll / JSON 파싱 폴백 / cost 계산 검증
|
||||
|
||||
### saju-lab — 라우터
|
||||
- TestClient 기반 e2e (FastAPI 표준)
|
||||
- DB tmp_path fixture
|
||||
|
||||
### 통합 검증 (Phase 1, Phase 2 끝)
|
||||
- `npm run dev` + http://127.0.0.1:3007/tarot에서 리딩 1회 (Phase 1)
|
||||
- 같은 곳에서 /saju에서 사주 + 궁합 1회씩 (Phase 2, 시안 적용 후)
|
||||
|
||||
---
|
||||
|
||||
## 10. 환경변수 정리
|
||||
|
||||
**tarot-lab 신규 환경변수** (docker-compose env):
|
||||
- `ANTHROPIC_API_KEY` (필수)
|
||||
- `TAROT_MODEL` (기본 `claude-sonnet-4-6`)
|
||||
- `TAROT_COST_INPUT_PER_M` (기본 3.0)
|
||||
- `TAROT_COST_OUTPUT_PER_M` (기본 15.0)
|
||||
- `TAROT_TIMEOUT_SEC` (기본 180)
|
||||
- `TAROT_DATA_PATH` (기본 `/app/data`)
|
||||
|
||||
**saju-lab 신규 환경변수**:
|
||||
- `ANTHROPIC_API_KEY` (필수)
|
||||
- `SAJU_MODEL` (기본 `claude-sonnet-4-6`)
|
||||
- `SAJU_COST_INPUT_PER_M`, `SAJU_COST_OUTPUT_PER_M`
|
||||
- `SAJU_TIMEOUT_SEC`
|
||||
- `SAJU_DATA_PATH`
|
||||
|
||||
---
|
||||
|
||||
## 11. 마이그레이션 위험 + 완화
|
||||
|
||||
| 위험 | 영향 | 완화 |
|
||||
|------|------|------|
|
||||
| TS→Python 포팅 미세 차이 (예: 절기 일자 1일 차이) | 모든 사주 결과 변형 | Reference output 비교 테스트 50건 + sxtwl로 절기 동일 알고리즘 사용 |
|
||||
| tarot.db 마이그레이션 중 데이터 손실 | 사용자 리딩 이력 손실 | 멱등 스크립트 + 검증 후 cutover. agent-office의 원본 데이터는 cutover 후에도 30일 유지 (테이블만 DROP 안 함) |
|
||||
| 두 컨테이너 추가로 NAS 메모리 압박 | 다른 서비스 OOM | python:3.12-slim 기반 ~150MB. 18GB RAM 여유 충분 |
|
||||
| API prefix 변경 missed 위치 (web-ui에서 일부 호출만 변경) | 일부 페이지 404 | grep 검색 (`/api/agent-office/tarot`) 후 일괄 변경 |
|
||||
| nginx restart 누락 | 라우팅 안 됨 | docker compose up -d --build → nginx 컨테이너 재시작 자동 (deployer 패턴) |
|
||||
| saju-web 코드 사라짐 (참조 못 하게 됨) | 검증 어려움 | saju-web 디렉토리는 그대로 유지 (포팅 끝나도 archive로 보존) |
|
||||
|
||||
---
|
||||
|
||||
## 12. 향후 (v2, 본 spec 밖)
|
||||
|
||||
- 토정비결 (12개월 운세) — saju-lab v2에서 추가
|
||||
- 정밀 음력 + 윤달 처리 검증
|
||||
- 자동 마이그레이션 스크립트의 ON DELETE CASCADE 검토 (이력 정합성)
|
||||
- agent-office의 tarot 관련 텔레그램 명령이 있다면 그것도 saju-lab에 추가할지 검토
|
||||
- saju-lab UI 디자인 시안 확정 후 별도 짧은 plan으로 진행
|
||||
|
||||
---
|
||||
|
||||
## 13. 참고 자료
|
||||
|
||||
- saju-web/PROJECT_OVERVIEW.md — 마이그레이션 원본 명세
|
||||
- web-backend/CLAUDE.md — lab 서비스 패턴 참조
|
||||
- agent-office/app/tarot/, agent-office/app/routers/tarot.py — Phase 1 이관 원본
|
||||
- web-backend/insta-lab/, music-lab/, realestate-lab/ — Dockerfile + 디렉토리 구조 참조 패턴
|
||||
- sxtwl (Python 만세력 라이브러리) — solarlunar 대체
|
||||
- docs/superpowers/specs/2026-05-23-tarot-lab-design.md — 본 작업의 직전 spec (tarot-lab 원본 설계)
|
||||
387
docs/superpowers/specs/2026-05-26-saju-ui-design.md
Normal file
387
docs/superpowers/specs/2026-05-26-saju-ui-design.md
Normal file
@@ -0,0 +1,387 @@
|
||||
# saju-lab UI v1 — 호령 사주 페이지 설계
|
||||
|
||||
**작성일**: 2026-05-26
|
||||
**상태**: Spec (구현 plan 작성 전)
|
||||
**전제**: saju-lab 백엔드 완성 (474 tests, SHA 8123f75) + web-ui Task 28 (api helpers + placeholder pages)
|
||||
|
||||
---
|
||||
|
||||
## 1. 목표
|
||||
|
||||
사용자 시안 4종(`source/images/saju_page/horyung_saju_main.png`, `_today.png`, `_gunghab.png`, `_saju.png`) + 캐릭터 시트(`source/characters/horyung.png`) + 컬러시트(`saju_color_sheet.png`) 기반으로 web-ui `/saju/*` 페이지를 호령 마스코트와 함께 구축한다.
|
||||
|
||||
v1 범위: **메인 / 오늘의 운세 / 사주풀이** 3개 페이지. 궁합은 v2 placeholder.
|
||||
|
||||
---
|
||||
|
||||
## 2. 결정된 핵심 사항
|
||||
|
||||
| 항목 | 결정 |
|
||||
|------|------|
|
||||
| 캐릭터 자산 | horyung.png + saju_color_sheet.png에서 PNG 6개 추출 |
|
||||
| 백엔드 확장 | saju-lab에 fortune_scores + lucky + monthly_flow 산출 추가 |
|
||||
| 입력 흐름 | 메인에서 사주 1회 입력 → reading_id를 다른 페이지 URL query로 공유 |
|
||||
| v1 페이지 | 메인 + 사주풀이 + 오늘운세 (궁합은 v2) |
|
||||
| 반응형 | 데스크탑(1280+) 우선 + 태블릿 그라데이션 |
|
||||
| 컬러 | 시안 추출 — 크림 베이스 + 다크 네이비 + 골드 + 살구 + 청록 |
|
||||
| 폰트 | Pretendard (본문) + Noto Serif KR (큰 제목, Google Fonts) |
|
||||
| CSS 격리 | `.saju-page` scope (다른 페이지에 새지 않음) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 백엔드 확장 (saju-lab)
|
||||
|
||||
### 3-1. 신설 모듈
|
||||
|
||||
**`saju-lab/app/calculator/fortune_scores.py`** — 4 카테고리 점수:
|
||||
|
||||
```
|
||||
calculate_fortune_scores(saju, analysis, current_year) → {
|
||||
wealth: 0-100 (재물운)
|
||||
romance: 0-100 (연애운)
|
||||
social: 0-100 (인간관계)
|
||||
career: 0-100 (직장운)
|
||||
overall: 0-100 (가중평균: wealth*0.3 + career*0.3 + romance*0.2 + social*0.2)
|
||||
}
|
||||
```
|
||||
|
||||
알고리즘 (각 base 60에서 가산/감산, clamp 0-100):
|
||||
|
||||
- **wealth**: +정재 강도 / +편재 강도 / +식상→재 통로 / -비겁 강도 / +세운재성
|
||||
- **romance**: +일지 합 / +정관·정재 균형 / -일지 충 / +세운 도화살
|
||||
- **social**: +인성 / +비겁 적정 / +식상 / +격국 균형 / +천을귀인
|
||||
- **career**: +정관 강도 / +편관 제어 / +일간 신강 / +세운 관성
|
||||
|
||||
**`saju-lab/app/calculator/lucky.py`** — 럭키 데이터:
|
||||
|
||||
```
|
||||
calculate_lucky(saju, analysis, target_date) → {
|
||||
color: [str, str] # 용신 오행 컬러 1~2개 (예: ["청록", "녹색"])
|
||||
number: int 1-9 # (일진 천간 idx + 시진 천간 idx) % 9 + 1
|
||||
direction: str # 용신 오행 방향 (동/남/중앙/서/북)
|
||||
good_signs: [str] # 세운 천간이 일간 재성 → "재물 기회" 등
|
||||
warnings: [str] # 세운 지지가 일지 충 → "대인 갈등 주의"
|
||||
}
|
||||
```
|
||||
|
||||
오행→컬러/방향 매핑은 정적 dict. 럭키 숫자는 일진+시진(시간 미상 시 일진만)으로 산출.
|
||||
|
||||
**`saju-lab/app/calculator/monthly_flow.py`** — 12개월 운세 흐름:
|
||||
|
||||
```
|
||||
calculate_monthly_flow(saju, year) → [
|
||||
{month: 1, stem: "壬", branch: "寅", score: 65, label: "변동"},
|
||||
{month: 2, stem: "癸", branch: "卯", score: 70, label: "성장"},
|
||||
... 12 entries
|
||||
]
|
||||
```
|
||||
|
||||
각 월: 해당 월의 60갑자(寅월부터 12월 사이클) → 일간 관계(상생/상극/충/합) → score 0-100 + label(`변동`/`성장`/`안정`/`도전`/`정체` 등).
|
||||
|
||||
### 3-2. `routers/saju.py` 응답 확장
|
||||
|
||||
`SajuInterpretResponse`에 3 필드 추가:
|
||||
|
||||
```python
|
||||
fortune_scores: dict # {wealth, romance, social, career, overall}
|
||||
lucky: dict # {color, number, direction, good_signs, warnings}
|
||||
monthly_flow: list[dict] # 12 entries
|
||||
```
|
||||
|
||||
`interpret_saju_endpoint`에서 계산 + DB 저장 + 응답 포함.
|
||||
|
||||
### 3-3. `db.py` 스키마 마이그레이션
|
||||
|
||||
`saju_records` 테이블에 ALTER TABLE로 3 컬럼 추가 (idempotent):
|
||||
- `fortune_scores_json TEXT`
|
||||
- `lucky_json TEXT`
|
||||
- `monthly_flow_json TEXT`
|
||||
|
||||
`init_db()`에 try/except OperationalError 패턴 (이미 존재하면 skip).
|
||||
|
||||
`_saju_row_to_dict`에서 3 컬럼 JSON 파싱하여 응답에 포함.
|
||||
|
||||
### 3-4. 테스트
|
||||
|
||||
- `test_fortune_scores.py` — 5-8 case (정재 강함 → wealth 80+, 일지 충 → romance 50-, clamp 검증)
|
||||
- `test_lucky.py` — 5 case (오행→컬러/방향 매핑, 럭키 숫자 1-9 범위)
|
||||
- `test_monthly_flow.py` — 3 case (12 entries 정확, 일간 충 월 score 낮음)
|
||||
|
||||
기존 30 reference fixture 비교는 영향 없음 (응답에 새 필드만 추가).
|
||||
|
||||
---
|
||||
|
||||
## 4. 프론트엔드 구조 (web-ui)
|
||||
|
||||
### 4-1. 디렉토리
|
||||
|
||||
```
|
||||
web-ui/
|
||||
├── public/images/saju/
|
||||
│ ├── horyung/
|
||||
│ │ ├── horyung-front.png # 시안 main hero용 (정면, 큰 사이즈)
|
||||
│ │ ├── horyung-bust.png # 작은 카드용 (가슴샷)
|
||||
│ │ ├── horyung-greeting.png # 인사 표정 (메인 좌상단)
|
||||
│ │ ├── horyung-thinking.png # 생각하는 표정 (사주풀이)
|
||||
│ │ ├── horyung-pointing.png # 가르치는 표정 (오늘운세)
|
||||
│ │ └── horyung-happy.png # 기쁜 표정 (점수 높을 때)
|
||||
│ ├── frame-cloud.png # 시안의 한국화 산 배경 (hero용)
|
||||
│ ├── pattern-cloud.svg # 한국 전통 구름 패턴
|
||||
│ └── icons/
|
||||
│ ├── icon-today.svg
|
||||
│ ├── icon-heart.svg
|
||||
│ └── icon-book.svg
|
||||
└── src/pages/saju/
|
||||
├── Saju.css # 모든 saju 페이지 공통 스타일 (격리)
|
||||
├── data/
|
||||
│ └── constants.js # 4 카테고리 메타, 컬러 토큰
|
||||
├── hooks/
|
||||
│ ├── useSajuForm.js
|
||||
│ └── useSajuReading.js # reading_id → fetched data + 캐시
|
||||
├── components/
|
||||
│ ├── HoryungMascot.jsx
|
||||
│ ├── SajuNav.jsx # 시안 상단 네비게이션 (호령사주 로고 + nav)
|
||||
│ ├── SajuInputForm.jsx
|
||||
│ ├── ActionCard.jsx # 3 카드 (오늘운세/궁합/사주풀이)
|
||||
│ ├── ScoreCard.jsx # 카테고리 점수 카드
|
||||
│ ├── FortuneRing.jsx # 종합점 ring SVG
|
||||
│ ├── LuckyBox.jsx # 럭키 컬러/숫자/방향
|
||||
│ ├── ElementBarChart.jsx # 오행 5색 가로 바
|
||||
│ ├── SajuPillars.jsx # 4기둥 8자 표시
|
||||
│ ├── MonthlyFlow.jsx # 12개월 운세 흐름 차트
|
||||
│ ├── InterpretAccordion.jsx # AI 12항목 아코디언
|
||||
│ └── HoryungQuote.jsx # 호령 말풍선
|
||||
├── Saju.jsx # 메인 페이지
|
||||
├── SajuResult.jsx # 사주풀이 결과
|
||||
├── Today.jsx # 오늘의 운세
|
||||
└── Compatibility.jsx # v2 placeholder
|
||||
```
|
||||
|
||||
### 4-2. 라우팅 (변경 없음, Task 28에서 등록됨)
|
||||
|
||||
| 경로 | 컴포넌트 | reading_id 필요 |
|
||||
|------|---------|----------------|
|
||||
| `/saju` | Saju.jsx (메인) | 아니오 |
|
||||
| `/saju/result?rid=N` | SajuResult.jsx | 예 |
|
||||
| `/saju/today?rid=N` | Today.jsx | 예 |
|
||||
| `/saju/compatibility` | Compatibility.jsx (placeholder) | — |
|
||||
|
||||
기존 `/saju/result` 등은 Task 28에서 placeholder로 등록 — 본 task에서 실제 컴포넌트로 교체.
|
||||
|
||||
### 4-3. 데이터 흐름
|
||||
|
||||
```
|
||||
[사용자] → /saju (메인)
|
||||
↓ 사주 입력
|
||||
↓ sajuInterpret(form)
|
||||
↓ POST /api/saju/interpret
|
||||
[saju-lab] 계산 + Claude AI + fortune_scores + lucky + monthly_flow
|
||||
↓ 응답: { reading_id, ... 풍부한 데이터 }
|
||||
[프론트] navigate(`/saju/result?rid=${reading_id}`)
|
||||
|
||||
[사주풀이 페이지] /saju/result?rid=N
|
||||
↓ useSajuReading(N) → sajuGetReading(N)
|
||||
↓ GET /api/saju/readings/N
|
||||
↓ saju_data + analysis_data + daeun_data + interpretation_json + fortune_scores + lucky + monthly_flow
|
||||
↓ 렌더
|
||||
|
||||
[오늘운세] /saju/today?rid=N — 사용자가 메인 또는 사주풀이에서 클릭
|
||||
↓ useSajuReading(N) + sajuCurrentFortune(N)
|
||||
↓ 렌더: ring + 4 score + lucky + 오늘 세운
|
||||
```
|
||||
|
||||
### 4-4. 호령 마스코트
|
||||
|
||||
`HoryungMascot.jsx` — `pose` prop으로 6개 PNG 중 선택.
|
||||
|
||||
```jsx
|
||||
<HoryungMascot pose="greeting" size="lg" /> // 메인 좌상단
|
||||
<HoryungMascot pose="thinking" size="md" /> // 사주풀이
|
||||
<HoryungMascot pose="pointing" size="md" /> // 오늘운세
|
||||
<HoryungMascot pose="happy" size="sm" /> // 점수 높을 때 (옵션)
|
||||
```
|
||||
|
||||
`onError` 핸들러로 PNG 누락 시 silent (디자인 깨짐 방지).
|
||||
|
||||
### 4-5. CSS 격리 + 컬러 시스템
|
||||
|
||||
`Saju.css`:
|
||||
|
||||
```css
|
||||
.saju-page {
|
||||
/* 베이스 */
|
||||
--saju-cream: #FAF6EE;
|
||||
--saju-paper: #F2EAD8;
|
||||
--saju-ink: #2E2D45; /* 다크 네이비 (헤더, 본문) */
|
||||
--saju-ink-deep: #1F1D38;
|
||||
|
||||
/* 액센트 */
|
||||
--saju-gold: #D4A574;
|
||||
--saju-gold-deep: #B5874E;
|
||||
--saju-apricot: #C58F76;
|
||||
--saju-rose: #D9A2A6;
|
||||
--saju-jade: #4B7065;
|
||||
--saju-violet: #6A5285;
|
||||
|
||||
/* 카테고리 (3 ActionCard) */
|
||||
--saju-today-bg: #4B7065; /* 청록 (오늘운세) */
|
||||
--saju-gunghab-bg: #A8736E; /* 살구 (궁합) */
|
||||
--saju-saju-bg: #4F4A78; /* 보라 (사주풀이) */
|
||||
|
||||
/* 점수 카테고리 (4 ScoreCard) */
|
||||
--saju-wealth: #D4A574; /* 골드 (재물) */
|
||||
--saju-romance: #D9A2A6; /* 로즈 (연애) */
|
||||
--saju-social: #4B7065; /* 청록 (인간관계) */
|
||||
--saju-career: #6A5285; /* 보라 (직장) */
|
||||
|
||||
min-height: 100vh;
|
||||
background: var(--saju-cream);
|
||||
color: var(--saju-ink);
|
||||
font-family: 'Pretendard', sans-serif;
|
||||
}
|
||||
|
||||
.saju-page .saju-h1,
|
||||
.saju-page .saju-h2 {
|
||||
font-family: 'Noto Serif KR', serif;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
```
|
||||
|
||||
모든 saju 컴포넌트의 클래스는 `saju-` prefix로 시작 (다른 페이지와 격리).
|
||||
|
||||
### 4-6. 반응형
|
||||
|
||||
- 기준: `1280px+` 데스크탑 (시안 그대로)
|
||||
- `768~1280px` 태블릿: hero 컬럼 → 세로 스택, action card 3 → 2x2 grid
|
||||
- `~768px` 모바일: 호령 작게 (size="sm"), action card 1열, 입력 폼 세로
|
||||
|
||||
`@media` 쿼리로 `Saju.css` 안에서 처리.
|
||||
|
||||
### 4-7. 폰트
|
||||
|
||||
`index.html`에 Google Fonts preconnect + Noto Serif KR 추가:
|
||||
|
||||
```html
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+KR:wght@500;700&display=swap" rel="stylesheet">
|
||||
```
|
||||
|
||||
큰 제목(h1/h2)만 Noto Serif KR, 본문은 기존 Pretendard.
|
||||
|
||||
---
|
||||
|
||||
## 5. 컴포넌트별 세부
|
||||
|
||||
### 5-1. Saju.jsx (메인)
|
||||
|
||||
레이아웃 (시안 horyung_saju_main.png):
|
||||
- 상단: SajuNav (호령사주 로고 + 4 nav + "사주풀이 시작하기" 버튼)
|
||||
- Hero: 좌측 호령(front + greeting 박스) / 우측 큰 h1 + 3 ActionCard
|
||||
- Bottom: 좌측 통계 미리보기 / 우측 SajuInputForm
|
||||
|
||||
폴백: reading_id가 query에 있으면 (`/saju?rid=N`) 통계 영역에 미리보기 점수 + 마지막 분석 결과로.
|
||||
|
||||
### 5-2. SajuResult.jsx (사주풀이)
|
||||
|
||||
레이아웃 (시안 horyung_saju_saju.png):
|
||||
- 상단: SajuNav + "사주풀이" 큰 타이틀 + 기본 정보 (이름, 생년월일) + 호령(thinking)
|
||||
- 중단 좌: 사주 4기둥 표 (SajuPillars) + 오행 바 차트 (ElementBarChart)
|
||||
- 중단 우: 호령의 비전 박스 (HoryungQuote — interpretation의 summary 발췌)
|
||||
- 하단: 성격강점 / 직업운 / 재물운 / 연애운 4 카드 (12항목 중 추출) + 12개월 운세 흐름 (MonthlyFlow)
|
||||
- 우하단: 이번 달 핵심 결정 포인트 (interpretation_json.advice)
|
||||
|
||||
데이터: `useSajuReading(rid)` → saju + analysis + daeun + interpretation_json + monthly_flow
|
||||
|
||||
### 5-3. Today.jsx (오늘의 운세)
|
||||
|
||||
레이아웃 (시안 horyung_saju_today.png):
|
||||
- 상단: SajuNav + "오늘의 운세" 큰 타이틀 + 호령(pointing) + 풍경 배경
|
||||
- 중단: FortuneRing(overall) + 4 ScoreCard(wealth/romance/social/career) + LuckyBox
|
||||
- 하단: 행운 알림 / 위험 알림 (lucky.good_signs, lucky.warnings)
|
||||
- 최하단: 다음 페이지 (사주풀이 / 궁합보기) 버튼
|
||||
|
||||
데이터: `useSajuReading(rid)` → fortune_scores + lucky + `sajuCurrentFortune(rid)` → 오늘 세운
|
||||
|
||||
### 5-4. Compatibility.jsx (v2 placeholder)
|
||||
|
||||
```jsx
|
||||
export default function Compatibility() {
|
||||
return (
|
||||
<div className="saju-page saju-page--compat-stub">
|
||||
<SajuNav />
|
||||
<div className="saju-stub">
|
||||
<HoryungMascot pose="thinking" />
|
||||
<h2>궁합보기는 곧 만나요!</h2>
|
||||
<p>두 사람의 사주를 함께 풀어보는 기능을 준비 중입니다.</p>
|
||||
<Link to="/saju">메인으로 돌아가기</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
백엔드 `/api/saju/compat/*`는 이미 동작하지만 UI는 v2에서 정식 구현.
|
||||
|
||||
---
|
||||
|
||||
## 6. 에러 처리
|
||||
|
||||
| 시나리오 | 처리 |
|
||||
|---------|------|
|
||||
| 메인 입력 폼 — 잘못된 날짜 | Pydantic 422 → 폼에서 "올바른 날짜를 입력해주세요" |
|
||||
| Claude API 504/500 | "잠시 후 다시 시도해주세요" + 사용자 입력 보존 |
|
||||
| reading_id 무효(404) | "사주 결과를 찾을 수 없습니다" + 메인으로 돌아가기 버튼 |
|
||||
| 호령 PNG 누락 | onError로 silent hide (디자인은 살짝 빈 자리, 동작은 정상) |
|
||||
| fortune_scores 산출 실패 (예외) | 기본값 60/60/60/60으로 fallback + 콘솔 warn |
|
||||
|
||||
---
|
||||
|
||||
## 7. 테스트 전략
|
||||
|
||||
### 백엔드
|
||||
- fortune_scores: 5-8 unit test (각 카테고리 high/low 케이스 + clamp)
|
||||
- lucky: 5 unit test (오행→컬러 매핑, 숫자 1-9 범위, 방향)
|
||||
- monthly_flow: 3 unit test (12 entries, 점수 범위, 충/합 영향)
|
||||
- 기존 30 reference fixture 비교: 영향 없음 (응답 추가 필드만)
|
||||
|
||||
### 프론트
|
||||
- 컴포넌트 단위 테스트는 v1 범위 밖 (수동 e2e 검증)
|
||||
- 로컬 e2e: `npm run dev` + 입력 → 사주풀이/오늘운세 1회 정상 동작
|
||||
- 호령 6 PNG 모두 존재 확인 (수동)
|
||||
- 반응형 — Chrome DevTools 1280/1024/768 3가지 확인
|
||||
|
||||
---
|
||||
|
||||
## 8. 위험 + 완화
|
||||
|
||||
| 위험 | 완화 |
|
||||
|------|------|
|
||||
| 호령 PNG crop 좌표가 부정확 | plan 단계에서 PIL로 trial-and-error + 사용자 검수. onError로 silent fallback |
|
||||
| fortune_scores 점수 산식이 명리학적 부정확 | v1은 plausible default + base 60으로 보수적. 실사용 피드백으로 튜닝 |
|
||||
| 시안 색상과 미세 차이 | 시안 PNG에서 color picker로 hex 추출 후 CSS variable로 명시 |
|
||||
| Noto Serif KR Google Fonts 로드 지연 | display=swap로 폰트 fallback (Pretendard) → 깜빡임 최소화 |
|
||||
| reading_id 만료(DB row 삭제) | 404 graceful fallback + 새 입력 유도 |
|
||||
| Claude 응답 시간 초과 | nginx timeout 300s + 폼에서 progress 표시 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 향후 (v2, 본 spec 밖)
|
||||
|
||||
- 궁합보기 페이지 정식 구현 (시안 horyung_saju_gunghab.png 기반)
|
||||
- 상담안내 페이지 (nav에 있는 메뉴)
|
||||
- 즐겨찾기/히스토리 페이지 (sajuListReadings 활용)
|
||||
- 사주풀이 PDF 내보내기
|
||||
- 호령 캐릭터 lottie 애니메이션 (정적 PNG → 동적)
|
||||
|
||||
---
|
||||
|
||||
## 10. 참고
|
||||
|
||||
- 시안: `source/images/saju_page/horyung_saju_{main,today,gunghab,saju}.png`
|
||||
- 캐릭터: `source/characters/horyung.png`
|
||||
- 컬러시트: `source/images/saju_page/saju_color_sheet.png`
|
||||
- 백엔드: web-backend/saju-lab/ (SHA 8123f75)
|
||||
- 직전 spec: `docs/superpowers/specs/2026-05-25-saju-tarot-lab-migration-design.md` (saju-lab 백엔드 설계)
|
||||
- web-ui Task 28 commit: e634cde (api.js + routes + IconSaju + placeholder pages)
|
||||
415
docs/superpowers/specs/2026-05-26-saju-ui-v2-redesign-design.md
Normal file
415
docs/superpowers/specs/2026-05-26-saju-ui-v2-redesign-design.md
Normal file
@@ -0,0 +1,415 @@
|
||||
# 호령 사주 UI v2 리디자인 — 디자인 문서
|
||||
|
||||
- **상태**: Spec 단계 (brainstorming 종료, plan 대기)
|
||||
- **작성일**: 2026-05-26
|
||||
- **대상 저장소**: `web-ui` (React + Vite, `/saju` 라우트 트리)
|
||||
- **참조 디자인 소스**: `C:\Users\jaeoh\Desktop\workspace\source\images\saju_page\사주풀이\` (백호 사주도사 프로토타입: babel/standalone JSX 11 파일 + styles.css)
|
||||
- **선행 시스템**: saju-lab UI v1 (`web-ui/src/pages/saju/`, 호령 캐릭터 7 PNG 자산 포함)
|
||||
- **백엔드 변경 없음**: saju-lab `/api/saju/*` API는 그대로 사용
|
||||
|
||||
---
|
||||
|
||||
## 1. 목적 & 성공 기준
|
||||
|
||||
### 목적
|
||||
v1의 임시 구조(컴포넌트 12개 직렬 배치, 단일 SajuNav)를 한국 전통 명리학 미학에 충실한 **풀 디자인 시스템**으로 교체. 4 라우트(`/saju`, `/saju/result`, `/saju/today`, `/saju/compatibility`)를 동시 리디자인하고 신규 `/saju/me` placeholder 추가.
|
||||
|
||||
### 성공 기준
|
||||
1. 4 라우트가 새 디자인 토큰/컴포넌트/네비로 일관되게 동작
|
||||
2. 1024px breakpoint에서 모바일(BottomNav) ↔ 데스크탑(헤더 nav) 자동 전환
|
||||
3. `useSajuReading` hook + 기존 API 호출 0개 변경, 응답 매핑만 추가
|
||||
4. 호령 PNG 7개 자산 100% 재사용 (variant API로 추상화)
|
||||
5. v1 컴포넌트 12개 + SajuNav 제거 — 두 디자인 시스템 동시 유지 X
|
||||
6. 시각 QA: 골든 패스(메인→입력→result→today→compatibility) + 1024px ± 경계 + me placeholder 모두 정상
|
||||
|
||||
---
|
||||
|
||||
## 2. 미학 방향 (Aesthetic Direction)
|
||||
|
||||
**컨셉**: *한국 전통 명리학 + 차분한 호령 캐릭터*. 디자인 프로토타입이 이미 강하게 commit한 방향을 충실히 옮긴다.
|
||||
|
||||
### 2.1 타이포
|
||||
- **Display**: Nanum Myeongjo (weight 800, `letter-spacing: -0.02em`) — 페이지 타이틀, h1, 큰 한자
|
||||
- **Body**: Nanum Gothic (weight 400/700, `letter-spacing: -0.01em`) — 본문, 버튼, 캡션
|
||||
- **Fallback serif**: Gowun Batang
|
||||
- Google Fonts CSS 로드는 `web-ui/index.html`에 link 추가 (페이지 import 대신 — preconnect로 LCP 개선)
|
||||
- Inter/Roboto/system-ui 같은 generic AI sans는 사용 금지
|
||||
|
||||
### 2.2 컬러 시스템 (CSS 토큰)
|
||||
디자인 프로토타입 `styles.css`의 `:root` 변수를 그대로 도입:
|
||||
|
||||
| 토큰 | 값 | 용도 |
|
||||
|---|---|---|
|
||||
| `--navy` | `#1F2A44` | dominant body color, dark surface |
|
||||
| `--navy-deep` | `#141B30` | night-bg gradient 하단 |
|
||||
| `--navy-soft` | `#2E3B5A` | 보조 dark |
|
||||
| `--ivory` | `#F7F2E8` | paper 배경, dark surface 위 텍스트 |
|
||||
| `--ivory-soft` | `#FBF7EF` | 카드 배경 |
|
||||
| `--ivory-warm` | `#F0E9D9` | 액센트 배경 |
|
||||
| `--gold` | `#D4AF37` | sharp accent, 보더, ornament |
|
||||
| `--gold-soft` | `#E8C76B` | 활성 상태 텍스트 |
|
||||
| `--gold-dim` | `#B89530` | 비활성 골드 |
|
||||
| `--green` / `--green-soft` / `--green-bg` | 한국 전통 녹색 | 궁합 화면 accent |
|
||||
| `--purple` / `--purple-soft` / `--purple-bg` | `#6A4C7C` 계열 | 사주풀이 accent |
|
||||
| `--pink` / `--pink-deep` / `--pink-bg` | `#F2C7CD` 계열 | 보조 |
|
||||
| `--gray` / `--gray-soft` | `#6B6B6B` / `#9A968D` | 메타 텍스트 |
|
||||
| `--gray-line` / `--gray-line-strong` | 보더 |
|
||||
| `--shadow-card` / `--shadow-pop` / `--shadow-dark` | 그림자 단계 |
|
||||
|
||||
**화면별 accent 단일 색** (팔레트 골고루 분산 안티패턴 회피):
|
||||
- 홈 (`/saju`) — navy
|
||||
- 오늘 (`/saju/today`) — gold
|
||||
- 궁합 (`/saju/compatibility`) — green
|
||||
- 사주풀이 (`/saju/result`) — purple
|
||||
- 마이 (`/saju/me`) — gray
|
||||
|
||||
### 2.3 배경 텍스처
|
||||
- `.paper-bg` — radial gold/purple wash + 페이퍼 노이즈 (사주풀이, 오늘, 궁합, 마이)
|
||||
- `.night-bg` — 밤하늘 gradient (홈 hero)
|
||||
- `.mt-wash` — 데스크탑 헤더 산수화 SVG decoration (좌하단 + 우하단 산 outline, opacity 0.35)
|
||||
- 단색 배경은 카드 내부에서만 (`--ivory-soft`)
|
||||
|
||||
### 2.4 차별화 요소 (UNFORGETTABLE)
|
||||
1. **OrnateFrame** — 한국 전통 더블 보더 + 4 코너 꺽쇠 SVG (`<path d="M0 4 L0 0 L4 0" />`)
|
||||
2. **MascotBubble** — 호령 발자국이 매 말풍선마다 `paw-bob` 2.4s ease infinite로 미세 bobbing
|
||||
3. **OrnamentBloom** — 골드 꽃봉오리 SVG가 모든 섹션 타이틀 좌우 ornament
|
||||
4. **TopRibbon** — 구름 SVG ribbon이 페이지 상단에 은은히
|
||||
5. **CharBox** — 사주명식 천간/지지 한자 Nanum Myeongjo 800 + 원소별 색 (목=green, 화=red, 토=earth, 금=gold, 수=blue)
|
||||
|
||||
### 2.5 모션
|
||||
- `screenIn` 0.3s `cubic-bezier(0.16,1,0.3,1)` translateY(6→0) — 라우트 진입 fade-up
|
||||
- `paw-bob` 2.4s ease infinite — 호령 발자국
|
||||
- BottomNav 활성 항목 배경 색 전환 0.2s
|
||||
- 과한 마이크로 인터랙션 X — "페이지당 1 hero 모션" 원칙
|
||||
|
||||
---
|
||||
|
||||
## 3. 아키텍처 & 라우팅
|
||||
|
||||
### 3.1 라우트 매핑
|
||||
|
||||
| 라우트 | 디자인 화면 | 파일 | 상태 |
|
||||
|---|---|---|---|
|
||||
| `/saju` | HomeScreen | `Saju.jsx` | **교체** (v1 메인) |
|
||||
| `/saju/result?rid=N` | SajuScreen (4탭) | `SajuResult.jsx` | **교체** (v1 결과) |
|
||||
| `/saju/today?rid=N` | TodayScreen | `Today.jsx` | **교체** (v1 오늘) |
|
||||
| `/saju/compatibility` | MatchScreen | `Compatibility.jsx` | **placeholder → 본격 구현** |
|
||||
| `/saju/compatibility/result?cid=N` | (디자인에 없음) | `CompatibilityResult.jsx` | 디자인 토큰만 라이트 리스타일 |
|
||||
| `/saju/me` | MeScreen placeholder | `Me.jsx` | **신규** |
|
||||
|
||||
라우트 수: 5 신규 진입점 + 1 sub. `routes.jsx`에 `/saju/me` lazy import 추가.
|
||||
|
||||
### 3.2 디렉토리 구조
|
||||
|
||||
```
|
||||
web-ui/src/pages/saju/
|
||||
├── _shell/ # v2 디자인 시스템 + 네비
|
||||
│ ├── tokens.css # CSS 변수 정의
|
||||
│ ├── shell.css # paper-bg, night-bg, mt-wash, OrnateFrame, screenIn
|
||||
│ ├── useViewportMode.js # 1024px breakpoint hook
|
||||
│ ├── BottomNav.jsx # 모바일 5항목 (home/today/match/saju/me)
|
||||
│ ├── DesktopHeader.jsx # 데스크탑 horizontal nav + 로고
|
||||
│ ├── Mascot.jsx # variant API: full|head|upper|greeting|thinking|pointing|happy
|
||||
│ ├── MascotBubble.jsx # tone: ivory|navy|purple|green
|
||||
│ ├── OrnateFrame.jsx
|
||||
│ ├── OrnamentBloom.jsx
|
||||
│ ├── TopRibbon.jsx
|
||||
│ ├── TitleBlock.jsx
|
||||
│ ├── PrimaryButton.jsx # gold inset shadow
|
||||
│ ├── GhostButton.jsx
|
||||
│ ├── Icons.jsx # 5 nav icon + IconPaw/IconChevron/IconSparkle/IconYinYang
|
||||
│ └── helpers/
|
||||
│ ├── daeunLabel.js # age → 성장기/학습기/...
|
||||
│ ├── deriveTraits.js # elements + sipsin → 6 성향
|
||||
│ └── hexA.js # hex → rgba(x,x,x,a)
|
||||
├── Saju.jsx # routes 진입, useViewportMode → 분기
|
||||
├── SajuResult.jsx
|
||||
├── Today.jsx
|
||||
├── Compatibility.jsx
|
||||
├── CompatibilityResult.jsx
|
||||
├── Me.jsx
|
||||
└── views/ # mobile/desktop 컴포넌트 분리
|
||||
├── home.mobile.jsx
|
||||
├── home.desktop.jsx
|
||||
├── saju.mobile.jsx # 4탭 (basic/chart/flow/traits)
|
||||
├── saju.desktop.jsx # 데스크탑은 4탭 그대로 vs 2-column 변형 — plan에서 결정
|
||||
├── today.mobile.jsx
|
||||
├── today.desktop.jsx
|
||||
├── match.mobile.jsx
|
||||
└── match.desktop.jsx
|
||||
```
|
||||
|
||||
**Me 페이지는 mobile/desktop 분리 안 함** (placeholder라 단순 — `Me.jsx` 본문에 직접 구현).
|
||||
```
|
||||
|
||||
기존 v1 파일들:
|
||||
- `components/` 디렉토리 **전체 삭제** (SajuNav, HoryungMascot, SajuInputForm, ActionCard, SajuPillars, ElementBarChart, FortuneRing, ScoreCard, LuckyBox, InterpretAccordion, MonthlyFlow, HoryungQuote)
|
||||
- `hooks/useSajuForm.js`, `hooks/useSajuReading.js` 유지 (데이터 흐름)
|
||||
- `Saju.css` 신규 `_shell/tokens.css` + `_shell/shell.css`로 교체
|
||||
|
||||
---
|
||||
|
||||
## 4. 컴포넌트 명세
|
||||
|
||||
### 4.1 `useViewportMode()`
|
||||
```js
|
||||
function useViewportMode() {
|
||||
const [mode, setMode] = useState(() =>
|
||||
typeof window !== 'undefined' && window.innerWidth >= 1024 ? 'desktop' : 'mobile'
|
||||
);
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
const next = window.innerWidth >= 1024 ? 'desktop' : 'mobile';
|
||||
setMode(prev => (prev === next ? prev : next));
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
return mode;
|
||||
}
|
||||
```
|
||||
- 디자인 프로토타입의 동일 hook 그대로 포팅
|
||||
- SSR 안전 (typeof window 체크) — Vite 기본 CSR이라 항상 window 존재하지만 방어
|
||||
- debounce 없음 — resize 빈도가 낮고 setState가 동일 값일 때 reflow 없음 (Object.is 비교)
|
||||
|
||||
### 4.2 `<Mascot variant="...">`
|
||||
| variant | 매핑 PNG (기존 v1 자산) |
|
||||
|---|---|
|
||||
| `full` | `/images/saju/horyung/horyung-main.png` |
|
||||
| `head` | `/images/saju/horyung/horyung-bust.png` (얼굴 중심 crop) |
|
||||
| `upper` | `/images/saju/horyung/horyung-front.png` |
|
||||
| `greeting` | `/images/saju/horyung/horyung-greeting.png` |
|
||||
| `thinking` | `/images/saju/horyung/horyung-thinking.png` |
|
||||
| `pointing` | `/images/saju/horyung/horyung-pointing.png` |
|
||||
| `happy` | `/images/saju/horyung/horyung-happy.png` |
|
||||
|
||||
props: `variant`, `size` (px), `style` (override). `<img loading="lazy">`.
|
||||
|
||||
### 4.3 `<BottomNav current onChange theme>`
|
||||
- `position: fixed; bottom: 0` — iPhone frame이 아닌 실제 모바일 뷰포트의 하단
|
||||
- 5 아이템: home/today/match/saju/me. NavLink 사용으로 라우트 매핑 (`useLocation`으로 current 결정)
|
||||
- theme: `'ivory'` (paper 배경) / `'navy'` (night 배경) — backdrop-filter blur 적용
|
||||
- 활성 항목: 화면별 accent 색 배경(opacity 0.10~0.18) + 라벨 weight 700
|
||||
|
||||
### 4.4 `<DesktopHeader>`
|
||||
- `position: sticky; top: 0; z-index: 30` — 스크롤 시 상단 고정
|
||||
- 좌측: 로고 (`壽` 한자 + "호령사주" Nanum Myeongjo)
|
||||
- 중앙: nav 5 링크 (BottomNav와 동일 항목, horizontal 배치)
|
||||
- 우측: 미사용 (향후 me 메뉴)
|
||||
- 배경: `--ivory-soft` + 하단 `--gray-line` 1px
|
||||
|
||||
### 4.5 `<OrnateFrame children color bg radius padding double>`
|
||||
- 디자인 프로토타입 `common.jsx`의 OrnateFrame 그대로 포팅
|
||||
- `double=true`면 inset 4px 위치에 추가 보더
|
||||
- 4 코너 꺽쇠 SVG (rotate 0/90/180/270)
|
||||
|
||||
### 4.6 `<MascotBubble text align tone tail paw>`
|
||||
- tone 팔레트 (`ivory`/`navy`/`green`/`purple`) → bg/border/text 색
|
||||
- `paw=true`면 우하단 IconPaw + `paw-bob` 애니메이션
|
||||
- `tail=true`면 풍선 꼬리 (rotate 45deg 사각형)
|
||||
|
||||
### 4.7 Buttons
|
||||
- `PrimaryButton`: gold inset shadow (`inset 0 1px 0 rgba(212,175,55,0.4)`) + 풀 너비 옵션
|
||||
- `GhostButton`: 투명 배경 + 보더만, 동일 폰트/spacing
|
||||
|
||||
### 4.8 `Me.jsx` (placeholder, mobile/desktop 공통)
|
||||
- `paper-bg` + `<TopRibbon>` + `<Mascot variant="thinking">` + `<MascotBubble tone="purple">` "곧 만나요" + 비활성 카드 4개 (이력/북마크/설정/문의 — disabled)
|
||||
- 뷰포트 분리 없이 단일 컴포넌트 (placeholder라 단순)
|
||||
|
||||
### 4.9 입력 폼 컴포넌트 (Home에서 사용)
|
||||
- `<InputRow label name type ...>` — 디자인 토큰 적용된 단일 행 (label 좌측 64px + input 우측)
|
||||
- `<DateSelect>`, `<TimeSelect>`, `<GenderToggle>`, `<CalendarToggle>` (양/음력) — `useSajuForm` state와 연결
|
||||
- Phase 2에서 신설. 기존 v1 `SajuInputForm.jsx`의 검증 로직만 이식, 시각 표현은 새 디자인
|
||||
|
||||
---
|
||||
|
||||
## 5. 데이터 흐름
|
||||
|
||||
### 5.1 hook 재사용
|
||||
- `useSajuReading(rid)` — 그대로 유지. `api.js`의 `sajuGetReading(id)` 호출 → `reading` 객체 반환
|
||||
- `useSajuForm()` — 그대로 유지. 입력 검증 + `sajuInterpret(body)` 호출 + navigate
|
||||
|
||||
### 5.2 매핑 헬퍼 (`_shell/helpers/`)
|
||||
|
||||
#### `daeunLabel(age)` → string
|
||||
- `age < 10` → "성장기"
|
||||
- `age < 20` → "학습기"
|
||||
- `age < 30` → "도전기"
|
||||
- `age < 40` → "성장기"
|
||||
- `age < 50` → "전성기"
|
||||
- `age < 60` → "안정기"
|
||||
- `age < 70` → "정리기"
|
||||
- `age >= 70` → "여유기"
|
||||
|
||||
#### `deriveTraits(elements, sipsin)` → `[{id, ko, icon, color}]` (최대 6개)
|
||||
- 강한 원소 1~2개 → 매칭 성향:
|
||||
- `fire >= 30` → `{id:'challenge', ko:'도전정신', color:'#C04A4A'}`
|
||||
- `metal >= 30` → `{id:'lead', ko:'리더십', color:'#D4AF37'}`
|
||||
- `wood >= 30` → `{id:'adapt', ko:'적응력', color:'#4E6B5C'}`
|
||||
- `water >= 30` → `{id:'wisdom', ko:'지혜', color:'#3A5A8C'}`
|
||||
- `earth >= 30` → `{id:'wealth', ko:'풍부함', color:'#A67B3F'}`
|
||||
- 일간 강도 (신강/신약) → `will` (의지)
|
||||
- 결과 6개 미만이면 다음으로 강한 원소 추가
|
||||
- 순서: 강한 원소 점수 내림차순
|
||||
|
||||
#### `hexA(hex, alpha)` → `rgba(...)` 문자열
|
||||
- 디자인 프로토타입 동일 헬퍼
|
||||
|
||||
### 5.3 SAJU_DATA mock → 실제 API 매핑 표
|
||||
|
||||
| 디자인 mock 필드 (screen-saju.jsx) | API 응답 경로 (saju-lab) | 비고 |
|
||||
|---|---|---|
|
||||
| `name`, `birth`, `gender`, `birthTime`, `birthPlace` | `reading.input.*` | 직접 매핑 |
|
||||
| `sajuLabel` | `reading.label` | "경오년 신사월 갑자일 OO시" |
|
||||
| `ilgan` | `reading.ilgan` | `{ko, ch, element, sound}` |
|
||||
| `pillars[]` | `reading.pillars` | year/month/day/hour 4기둥 |
|
||||
| `pillars[].cheongan.color` | 원소→색 매핑 (`elementColor()`) | wood=green, fire=red, earth=earth, metal=gold, water=blue |
|
||||
| `pillars[].sipsin`, `jijang` | `reading.pillars[i].sipsin`, `jijang` | |
|
||||
| `ohaeng[]` | `reading.analysis.elements` | `{wood, fire, earth, metal, water}` → `[{id, ko, ch, value, color}]` 변환 |
|
||||
| `daeun[]` | `reading.daeun` (8개) | `label`은 `daeunLabel(age)` 헬퍼, `current`는 현재 나이 기반 derive |
|
||||
| `traits[]` | `deriveTraits(elements, sipsin)` | 헬퍼로 derive (API 응답에 직접 없음) |
|
||||
| TraitsTab `title`, `desc` | 상위 3 성향 → 정적 desc 사전 매핑 | YAGNI: 백엔드에 trait description 추가는 향후 작업 |
|
||||
| Today: `fortune_scores`, `lucky`, `monthly_flow` | API 응답에 이미 존재 | 그대로 사용 |
|
||||
|
||||
### 5.4 BottomNav active state
|
||||
```jsx
|
||||
const { pathname } = useLocation();
|
||||
const current =
|
||||
pathname === '/saju' ? 'home'
|
||||
: pathname.startsWith('/saju/today') ? 'today'
|
||||
: pathname.startsWith('/saju/compatibility') ? 'match'
|
||||
: pathname.startsWith('/saju/result') ? 'saju'
|
||||
: pathname.startsWith('/saju/me') ? 'me'
|
||||
: 'home';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 반응형 & 네비게이션 전략
|
||||
|
||||
### 6.1 1024px breakpoint
|
||||
- `< 1024px` → 모바일: 페이지 컴포넌트가 `<MobileXxx>` 렌더, `<BottomNav>` 표시
|
||||
- `>= 1024px` → 데스크탑: `<DesktopXxx>` 렌더, `<DesktopHeader>` 표시
|
||||
- 페이지 진입 시 `useViewportMode()`가 결정. resize 시 동적 전환
|
||||
|
||||
### 6.2 iPhone frame 제거
|
||||
- 디자인 프로토타입은 모바일 미리보기용으로 iPhone 외곽선을 그렸으나 실제 모바일 디바이스는 OS frame이 있으므로 frame DOM 제거
|
||||
- StatusBar(`BrandStatusBar`)도 미사용 — 실제 디바이스 status bar 자연스럽게 사용
|
||||
|
||||
### 6.3 컨테이너 max-width
|
||||
- 모바일: `100%` (BottomNav만 fixed)
|
||||
- 데스크탑: 콘텐츠 max-width 1200px, `margin: 0 auto`. mt-wash 배경은 viewport 풀
|
||||
|
||||
### 6.4 transition between modes
|
||||
- 1024px 경계에서 mode 변경 시 컴포넌트가 unmount → 새 컴포넌트 mount → screenIn 0.3s 재생
|
||||
- 폼 입력 중 transition 발생 시: useSajuForm 상태는 hook이 보관하므로 데이터 유실 X
|
||||
|
||||
---
|
||||
|
||||
## 7. 점진적 구현 단계 (Phase Plan)
|
||||
|
||||
각 Phase 끝에 `npm run dev`로 `http://localhost:3007/saju` 시각 확인 + git commit. PR은 Phase 1~3, 4~5, 6 (fixup) 3개로 분할 권장.
|
||||
|
||||
| Phase | 산출물 | 검증 |
|
||||
|---|---|---|
|
||||
| **1. Shell + 토큰** | `_shell/` 전체 + `Me.jsx` + 라우트 `/saju/me` 추가 + Google Fonts link | `/saju/me` 진입 시 placeholder + BottomNav/Header 모두 정상. 기존 4 페이지 무손상 |
|
||||
| **2. Home** | `Saju.jsx` + `views/home.{mobile,desktop}.jsx` + 입력 폼 + 호령 hero | 모바일/데스크탑 모두 입력 → submit → `/saju/result?rid=N` 이동 |
|
||||
| **3. SajuResult** | `SajuResult.jsx` + `views/saju.{mobile,desktop}.jsx` 4탭 + 매핑 헬퍼 | 실제 reading 데이터로 4탭 모두 정상 표시. 일간 표시·오행 막대·대운 흐름·성향 derive 검증 |
|
||||
| **4. Today** | `Today.jsx` + `views/today.{mobile,desktop}.jsx` | fortune_scores·lucky·monthly_flow 표시. PrimaryButton "다른 운세 보기" → SajuResult 이동 |
|
||||
| **5. Compatibility** | `Compatibility.jsx` + `views/match.{mobile,desktop}.jsx` 본격 구현. `CompatibilityResult.jsx` 라이트 리스타일 | 두 사람 입력 폼 + compat API 호출 + 결과 화면 |
|
||||
| **6. QA + cleanup** | v1 `components/` 삭제, `Saju.css` 제거, 시각 QA, 1024px 경계 chrome devtools | 골든 패스 통과, dead code 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 에러 / 빈 상태
|
||||
|
||||
| 상황 | UI |
|
||||
|---|---|
|
||||
| API 실패 (네트워크/500) | `<OrnateFrame color="--purple">` + `<MascotBubble tone="purple">` "아이고, 다시 시도해주세요" + `<GhostButton>` 새로고침 |
|
||||
| `?rid=` 없이 `/saju/result` 직접 진입 | `<MascotBubble tone="ivory">` "사주를 먼저 입력해주세요" + `<PrimaryButton color="--purple">` "사주 입력하러 가기" → `/saju` |
|
||||
| `?rid=` 없이 `/saju/today` 직접 진입 | 동일 패턴, accent gold |
|
||||
| `?cid=` 없이 `/saju/compatibility/result` 진입 | 동일 패턴, accent green |
|
||||
| `/saju/me` | `<MascotBubble tone="purple">` "곧 만나요" + 비활성 placeholder 카드 4개 |
|
||||
| 백엔드 timeout (사주 해석 30~60초) | 로딩 화면: `<Mascot variant="thinking">` + `<MascotBubble>` "호령이 풀이 중이에요..." + spinner |
|
||||
|
||||
---
|
||||
|
||||
## 9. 검증 전략
|
||||
|
||||
### 9.1 자동 테스트
|
||||
- `useViewportMode.test.js` — `vi.mock` window.innerWidth + resize 이벤트 dispatch, 1023/1024 경계 변환 확인
|
||||
- `daeunLabel.test.js` — 8 구간 모두 정답 매핑
|
||||
- `deriveTraits.test.js` — 강한 원소 1~5개 입력에 대한 정렬·중복 제거 확인
|
||||
- `Mascot.test.jsx` — 7 variant 모두 올바른 src prop
|
||||
|
||||
### 9.2 시각 검증 (Phase 마다 dev server)
|
||||
1. `npm run dev` → `http://localhost:3007/saju` 진입
|
||||
2. 모바일 chrome devtools (375×667 iPhone SE, 390×844 iPhone 12)
|
||||
3. 데스크탑 (1280×720 이상)
|
||||
4. 1024px 경계 ± 1px (1023↔1024)에서 mode 전환 확인
|
||||
5. 5 라우트 모두 BottomNav active 상태 + DesktopHeader active 상태 일치
|
||||
6. 호령 PNG 7 variant 모두 로드 확인 (Network 탭)
|
||||
7. 폰트 로드 (Nanum Myeongjo, Nanum Gothic, Gowun Batang)
|
||||
|
||||
### 9.3 회귀
|
||||
- 기존 reading_id URL 호환 (`/saju/result?rid=N` 패턴 유지)
|
||||
- `useSajuReading` hook 응답 매핑이 v1과 동일 데이터 표시
|
||||
- saju-lab API 호출 0개 변경 (네트워크 탭 비교)
|
||||
|
||||
---
|
||||
|
||||
## 10. YAGNI 명시 제외
|
||||
|
||||
다음은 이번 v2에서 의도적으로 **제외**:
|
||||
- i18n / 다국어
|
||||
- 다크모드 토글 (디자인 자체가 화면별 light/dark scope 고정)
|
||||
- 호령 마스코트 드래그·물리 모션 (paw-bob bobbing만)
|
||||
- BottomNav 햅틱·진동
|
||||
- 인증/로그인 (Me는 placeholder, 향후 별도 spec)
|
||||
- PWA / 오프라인 캐시
|
||||
- 백엔드 trait description API (`deriveTraits` 프론트 헬퍼로 충분)
|
||||
- 디자인 프로토타입의 desktop-shell.jsx full conversion — DesktopHeader만 차용, shell 전체는 v2 컨테이너에 흡수
|
||||
|
||||
---
|
||||
|
||||
## 11. 마이그레이션 노트
|
||||
|
||||
### 11.1 삭제 대상 (Phase 6에서 일괄 정리)
|
||||
- `web-ui/src/pages/saju/components/` 전체 12 파일
|
||||
- `web-ui/src/pages/saju/Saju.css`
|
||||
- v1 `Compatibility.jsx`의 placeholder 본문 (본격 구현으로 교체)
|
||||
|
||||
### 11.2 보존 대상
|
||||
- `web-ui/src/pages/saju/hooks/useSajuForm.js`, `useSajuReading.js` (데이터 흐름)
|
||||
- `web-ui/public/images/saju/horyung/` 7 PNG 자산 (Mascot variant API가 매핑)
|
||||
- `web-ui/src/api.js` saju 헬퍼 함수들
|
||||
|
||||
### 11.3 routes.jsx 변경
|
||||
기존 import 라인 유지 + Me lazy import 추가:
|
||||
```diff
|
||||
+ const SajuMe = lazy(() => import('./pages/saju/Me'));
|
||||
```
|
||||
`children` 배열에 me 라우트 추가:
|
||||
```diff
|
||||
path: '/saju',
|
||||
children: [
|
||||
{ index: true, element: <Saju /> },
|
||||
{ path: 'result', element: <SajuResult /> },
|
||||
{ path: 'today', element: <SajuToday /> },
|
||||
{ path: 'compatibility', element: <Compatibility /> },
|
||||
{ path: 'compatibility/result', element: <CompatibilityResult /> },
|
||||
+ { path: 'me', element: <SajuMe /> },
|
||||
],
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Plan 단계로 넘길 결정 사항
|
||||
|
||||
다음은 plan 작성 시 구체화:
|
||||
- 각 view 파일별 line budget (현실적 500~800 라인 예상, 더 크면 sub 컴포넌트 분할)
|
||||
- 색→원소 매핑 함수 (`elementColor(elementId)`) 위치 — `_shell/helpers/` vs view 안 인라인
|
||||
- 데스크탑 `saju.desktop.jsx`의 4탭 유지 vs 2-column 변형 (디자인 프로토타입의 `desktop-saju.jsx` 상세 검토 후 결정)
|
||||
- 데스크탑 헤더의 me 메뉴 (향후 인증 위치 — 현재는 nav 5번째 링크)
|
||||
- 시각 QA 시 사용자 직접 확인 단계 (Claude가 puppeteer로 자동화하지 않음 — 시각 판단은 사람)
|
||||
- `<InputRow>` 등 입력 컴포넌트의 상세 props 시그니처
|
||||
@@ -0,0 +1,362 @@
|
||||
# Agent Office — Docker 로그 기반 통합 타임라인 설계
|
||||
|
||||
> 작성일: 2026-05-28
|
||||
> 대상: web-backend (5개 lab + agent-office) + web-ui (LogTab)
|
||||
|
||||
## 배경
|
||||
|
||||
`/agent-office` 의 각 에이전트 상세 패널에 노출되는 **로그 탭** 이 현재는 의미가 빈약하다.
|
||||
- 노출 소스는 `agent-office` 의 자체 SQLite `agent_logs` 테이블 한 곳뿐.
|
||||
- `base.py BaseAgent.transition()` 가 매번 `State: idle -> working ({detail})` 형식 자동 로그를 기록 — 사용자가 실제로 무슨 일이 일어났는지 파악하기 어려운 노이즈가 다수.
|
||||
- 각 에이전트가 실제로 호출하는 외부 서비스 컨테이너 (lotto / stock / music-lab / insta-lab / realestate-lab) 의 docker stdout 은 LogTab 에 한 줄도 흐르지 않는다.
|
||||
|
||||
따라서 LogTab 에서는 “이 에이전트가 어떤 API 를 불러서 어떤 응답을 받았는지” “외부 서비스에서 어떤 비즈니스 이벤트가 발생했는지” 가 보이지 않는다.
|
||||
|
||||
## 목표
|
||||
|
||||
1. 각 에이전트 LogTab 에 **해당 서비스 컨테이너의 의미 있는 docker 로그** 를 흘려보낸다.
|
||||
2. healthcheck / static / OPTIONS 같은 노이즈 로그는 **서버 측에서 미리 차단** 한다.
|
||||
3. API 호출 한 줄 (`POST /api/lotto/recommend → 200 142ms`) 과 비즈니스 이벤트 (`수집 완료: new=12, total=340`) 양쪽 모두 표시한다.
|
||||
4. 에이전트 내부 동작 로그 (`agent_logs` DB) 와 서비스 로그를 **한 화면에 시간순으로 통합** 한다.
|
||||
5. `State: idle -> working` 형식 자동 transition 로그는 제거한다.
|
||||
|
||||
## 비목표
|
||||
|
||||
- 실시간 WebSocket push (지금은 5초 폴링이면 충분).
|
||||
- 컨테이너 외부 (NAS 호스트, Windows AI 서버) 로그 수집.
|
||||
- 로그 검색 / 필터 UI (당장은 단순 시간순 표시).
|
||||
- 다른 lab (image-lab / tarot-lab / saju-lab / packs-lab / video-lab) 은 1차 범위에서 제외 — 5개 활성 에이전트가 가리키는 5개 컨테이너만 다룬다.
|
||||
|
||||
## 결정사항 요약
|
||||
|
||||
| 항목 | 결정 |
|
||||
|---|---|
|
||||
| 수집 방식 | 각 서비스가 `/logs/recent` 엔드포인트 노출 + agent-office 가 polling |
|
||||
| 표시 방식 | 통합 타임라인 (agent 로그 + service 로그 시간순 merge) |
|
||||
| 로그 범위 | 액세스 로그 (healthcheck 제외) + 비즈니스 이벤트 (logger.info/warning/error) |
|
||||
| ring buffer 크기 | 컨테이너당 500개, in-memory deque |
|
||||
| docker logs retention | `max-size 10m × max-file 3` = 서비스당 30MB |
|
||||
| agent_logs DB retention | **90일** (매일 03:00 cleanup) |
|
||||
| state 자동 로그 | 제거 (`base.py BaseAgent.transition()` 의 `add_log("State: ...")`) |
|
||||
| 자동 수집 메커니즘 | Python `logging.Handler` 를 BufferLogHandler 로 등록 — 기존 logger.info/warning/error 호출이 자동으로 ring buffer 에 흐름 |
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ web-ui (LogTab) │
|
||||
│ ─ GET /api/agent-office/agents/{id}/logs?limit=N │
|
||||
│ ─ 5초 폴링 (기존 refreshTrigger 흐름 재활용) │
|
||||
│ ─ source 뱃지 표시 (access | log | agent) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
▲
|
||||
│ 통합 타임라인 (시간순 merge)
|
||||
│
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ agent-office │
|
||||
│ - get_merged_logs(agent_id, limit) = │
|
||||
│ agent_logs (state 로그 제외) │
|
||||
│ + service_proxy.fetch_logs(container, path_prefix) │
|
||||
│ → ts 기준 정렬 → 최근 N개 │
|
||||
│ - 매핑: AGENT_CONTAINER_MAP │
|
||||
│ stock → ("stock", "/api/(stock|trade|portfolio)") │
|
||||
│ music → ("music-lab", "/api/music") │
|
||||
│ insta → ("insta-lab", "/api/insta") │
|
||||
│ realestate → ("realestate-lab", "/api/realestate") │
|
||||
│ lotto → ("lotto-backend", "/api/lotto") │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
▲
|
||||
│ GET http://{container}:{port}/logs/recent
|
||||
│ ?since=ISO&limit=N&path_prefix=...
|
||||
│ (내부 docker 네트워크 only, nginx public 라우팅 X)
|
||||
│
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 각 서비스 컨테이너 (5개) │
|
||||
│ 공용 모듈 _shared/access_log.py: │
|
||||
│ - LogBuffer: collections.deque(maxlen=500) │
|
||||
│ - AccessLogMiddleware: 모든 요청 후 한 줄 기록 │
|
||||
│ 제외: /health /healthz /ping /favicon /docs /redoc │
|
||||
│ /openapi.json /logs/recent OPTIONS HEAD │
|
||||
│ - BufferLogHandler: logger.info/warning/error 자동 캡처 │
|
||||
│ - /logs/recent 라우터 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 공용 모듈 — `web-backend/_shared/access_log.py`
|
||||
|
||||
```python
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
import logging
|
||||
import time
|
||||
|
||||
_BUFFER = 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
|
||||
_BUFFER.append({
|
||||
"ts": datetime.utcnow().isoformat() + "Z",
|
||||
"level": "info" if status < 400 else
|
||||
"warning" if status < 500 else "error",
|
||||
"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):
|
||||
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:
|
||||
pass
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/logs/recent")
|
||||
def logs_recent(limit: int = 200, since: str | None = None,
|
||||
path_prefix: str | None = 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, logger_root: str = ""):
|
||||
"""서비스 main.py 가 호출하는 단일 설치 함수."""
|
||||
app.add_middleware(AccessLogMiddleware)
|
||||
app.include_router(router)
|
||||
logging.getLogger(logger_root).addHandler(BufferLogHandler())
|
||||
```
|
||||
|
||||
### 각 서비스 main.py 적용
|
||||
|
||||
```python
|
||||
from _shared.access_log import install as install_access_log
|
||||
install_access_log(app)
|
||||
```
|
||||
|
||||
## docker-compose 변경
|
||||
|
||||
5개 서비스 (`lotto-backend`, `stock`, `music-lab`, `insta-lab`, `realestate-lab`) 에 동일 패턴 추가:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- PYTHONPATH=/app:/shared
|
||||
volumes:
|
||||
- ../_shared:/shared:ro
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
```
|
||||
|
||||
`/logs/recent` 는 **nginx default.conf 의 public location 블록에 추가하지 않는다**. 내부 docker 네트워크에서 `http://{container_name}:{port}/logs/recent` 로만 접근.
|
||||
|
||||
## agent-office 측 변경
|
||||
|
||||
### `app/constants.py`
|
||||
```python
|
||||
AGENT_CONTAINER_MAP = {
|
||||
"stock": ("stock", 8000, r"^/api/(stock|trade|portfolio)"),
|
||||
"music": ("music-lab", 8000, r"^/api/music"),
|
||||
"insta": ("insta-lab", 8000, r"^/api/insta"),
|
||||
"realestate": ("realestate-lab", 8000, r"^/api/realestate"),
|
||||
"lotto": ("lotto-backend", 8000, r"^/api/lotto"),
|
||||
}
|
||||
```
|
||||
|
||||
### `app/service_proxy.py`
|
||||
```python
|
||||
async def fetch_service_logs(agent_id: str, since: str | None = None,
|
||||
limit: int = 200) -> list[dict]:
|
||||
mapping = AGENT_CONTAINER_MAP.get(agent_id)
|
||||
if not mapping:
|
||||
return []
|
||||
host, port, path_re = mapping
|
||||
url = f"http://{host}:{port}/logs/recent"
|
||||
params = {"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 []
|
||||
# path_prefix 필터: access 로그만 path_re 검증
|
||||
return [x for x in data if x["source"] == "log"
|
||||
or re.match(path_re, x.get("path", ""))]
|
||||
```
|
||||
|
||||
### `app/db.py`
|
||||
```python
|
||||
def get_logs(agent_id: str, limit: int = 50) -> list[dict]:
|
||||
# 'State: ...' 자동 로그 제외 (사용자 요청)
|
||||
rows = conn.execute("""
|
||||
SELECT * FROM agent_logs
|
||||
WHERE agent_id=?
|
||||
AND message NOT LIKE 'State: %'
|
||||
ORDER BY created_at DESC LIMIT ?
|
||||
""", (agent_id, limit)).fetchall()
|
||||
return [...]
|
||||
|
||||
def delete_old_logs(days: int = 90) -> int:
|
||||
cutoff = (datetime.utcnow() - timedelta(days=days)).isoformat()
|
||||
with _conn() as conn:
|
||||
c = conn.execute("DELETE FROM agent_logs WHERE created_at < ?", (cutoff,))
|
||||
return c.rowcount
|
||||
```
|
||||
|
||||
### `app/main.py`
|
||||
```python
|
||||
@app.get("/api/agent-office/agents/{agent_id}/logs")
|
||||
async def agent_logs(agent_id: str, limit: int = 50):
|
||||
agent_items = get_logs(agent_id, limit=limit)
|
||||
service_items = await fetch_service_logs(agent_id, limit=limit)
|
||||
merged = sorted(agent_items + service_items,
|
||||
key=lambda x: x.get("ts") or x.get("created_at"),
|
||||
reverse=True)[:limit]
|
||||
return {"logs": merged}
|
||||
```
|
||||
|
||||
### `app/agents/base.py`
|
||||
```python
|
||||
async def transition(self, new_state, detail="", task_id=None):
|
||||
# add_log(... "State: ...") 호출 삭제 — 사용자 요청
|
||||
...
|
||||
# ws_manager 알림은 유지
|
||||
```
|
||||
|
||||
### `app/scheduler.py`
|
||||
```python
|
||||
scheduler.add_job(
|
||||
lambda: delete_old_logs(days=90),
|
||||
CronTrigger(hour=3, minute=0),
|
||||
id="cleanup_old_logs",
|
||||
)
|
||||
```
|
||||
|
||||
## web-ui 측 변경
|
||||
|
||||
### `src/pages/agent-office/components/LogTab.jsx`
|
||||
- log row schema 가 두 가지로 늘어남: agent_logs `{level, message, created_at}` vs service `{ts, level, source, method, path, status, ms, message}`.
|
||||
- source 뱃지를 추가로 표시: `[ACCESS]` / `[LOG]` / `[AGENT]`.
|
||||
- access 로그는 method + path + status + ms 를 보조 라인으로 표시.
|
||||
|
||||
색상 가이드:
|
||||
- `source=access` 청록 (#5eead4)
|
||||
- `source=log` 파랑 (#60a5fa)
|
||||
- `level=warning` 노랑 (#fbbf24)
|
||||
- `level=error` 빨강 (#ef4444)
|
||||
- `source=agent` (agent_logs) 회색 (#9ca3af)
|
||||
|
||||
## Phase 분리
|
||||
|
||||
대규모 변경이라 단일 PR 위험. 3단계로 나눠 진행.
|
||||
|
||||
### Phase 1 — PoC (가장 우선)
|
||||
1. `web-backend/_shared/access_log.py` 신설.
|
||||
2. `web-backend/lotto/app/main.py` 한 곳에만 `install_access_log(app)` 추가.
|
||||
3. `web-backend/docker-compose.yml` 의 `lotto-backend` 서비스에 PYTHONPATH + volume + logging 추가.
|
||||
4. `agent-office` 측 `service_proxy.fetch_service_logs()` + `AGENT_CONTAINER_MAP` (lotto 만) + `get_logs(agent_id)` merge.
|
||||
5. `LogTab.jsx` 가 source 뱃지를 표시하도록 확장.
|
||||
6. base.py `State: ...` 자동 로그 제거 + `db.get_logs()` NOT LIKE 필터 추가.
|
||||
|
||||
검증: `/agent-office` 에서 lotto 에이전트 선택 → LogTab 에 `POST /api/lotto/...` 한 줄과 기존 logger.info 출력이 같이 보이는지.
|
||||
|
||||
### Phase 2 — 4개 서비스 확장
|
||||
1. stock / music-lab / insta-lab / realestate-lab 의 `main.py` 에 `install_access_log(app)` 추가.
|
||||
2. docker-compose 4개 서비스 동일 패턴 적용.
|
||||
3. `AGENT_CONTAINER_MAP` 에 4개 매핑 추가.
|
||||
4. `delete_old_logs` cleanup job 등록.
|
||||
|
||||
검증: 5개 에이전트 모두 LogTab 에서 의미 있는 로그 노출.
|
||||
|
||||
### Phase 3 — 비즈니스 이벤트 보강
|
||||
디자인 4/5 의 "추가 권장" 표 항목들을 `logger.info(...)` 한 줄씩 추가. 약 10–15줄.
|
||||
- stock: Order 응답, AI Coach 호출, 스크리너 결과
|
||||
- music-lab: 생성 시작/완료
|
||||
- insta-lab: 키워드 추출 완료, 슬레이트 생성 완료, 발행 결과
|
||||
- lotto-backend: AI 큐레이터 호출/응답, 점수 계산 완료
|
||||
|
||||
## 알려진 위험과 완화
|
||||
|
||||
| 위험 | 완화 |
|
||||
|---|---|
|
||||
| `/logs/recent` 가 외부로 노출되면 access pattern + 내부 동작 노출 | nginx public location 에 등재하지 않음 + 내부 docker 네트워크만 |
|
||||
| 각 서비스의 logger 가 propagate 설정이 달라 BufferLogHandler 에 안 흐를 가능성 | `install()` 에서 `logging.getLogger("")` (root) 에 핸들러 등록 — 모든 child logger 가 자동 전파 |
|
||||
| BufferLogHandler 의 `emit()` 가 다른 핸들러의 포맷팅에 영향 | `Handler.emit` 만 override, formatter 사용 안 함 |
|
||||
| ring buffer 가 0.5초당 수십 건 트래픽으로 가득 차서 30초 분량밖에 안 남음 | 500개는 평소 트래픽 기준 1시간 이상 보관. 모니터링하다 부족하면 1000 으로 상향 |
|
||||
| `lotto-backend` 컨테이너의 personal/blog/todo API 가 lotto 에이전트 로그에 섞임 | `AGENT_CONTAINER_MAP` 의 path_prefix 정규식으로 `/api/lotto` 만 매칭 — 다른 prefix 는 자연스럽게 필터 |
|
||||
| docker-compose volume `../_shared:/shared:ro` 가 NAS 운영 환경에서 경로 차이로 깨질 가능성 | repo 의 상대경로 (`../_shared`) 는 NAS 의 `/volume1/docker/webpage/backend/_shared` 와 동일 구조로 git pull 됨. Gitea webhook 으로 push 되는 경로에 `_shared/` 디렉토리도 함께 포함됨을 deployer rsync 시 검증 |
|
||||
|
||||
## 변경 파일 요약
|
||||
|
||||
```
|
||||
■ 신설
|
||||
web-backend/_shared/__init__.py
|
||||
web-backend/_shared/access_log.py
|
||||
|
||||
■ web-backend
|
||||
lotto/app/main.py + install_access_log + 추가 logger.info 3–4개 (Phase 3)
|
||||
stock/app/main.py + install_access_log + 추가 logger.info 3개 (Phase 3)
|
||||
music-lab/app/main.py + install_access_log + 추가 logger.info 2개 (Phase 3)
|
||||
insta-lab/app/main.py + install_access_log + 추가 logger.info 3개 (Phase 3)
|
||||
realestate-lab/app/main.py + install_access_log (Phase 3 추가 없음)
|
||||
docker-compose.yml 5개 서비스 PYTHONPATH/volume/logging 추가
|
||||
|
||||
■ web-backend/agent-office
|
||||
app/service_proxy.py + fetch_service_logs(agent_id, ...)
|
||||
app/main.py agent_logs 엔드포인트가 merge 사용
|
||||
app/db.py + delete_old_logs + get_logs NOT LIKE 'State: %'
|
||||
app/scheduler.py + 매일 03:00 cleanup job
|
||||
app/agents/base.py transition() 의 add_log('State: ...') 제거
|
||||
app/constants.py + AGENT_CONTAINER_MAP
|
||||
|
||||
■ web-ui
|
||||
src/pages/agent-office/components/LogTab.jsx
|
||||
source 뱃지 + access 로그 method/status/ms 표시
|
||||
```
|
||||
@@ -0,0 +1,191 @@
|
||||
# 로또 자가학습 백테스트 & 캘리브레이션 — 설계 Spec
|
||||
|
||||
- **작성일**: 2026-05-31
|
||||
- **상태**: 설계 승인 (구현 plan 대기)
|
||||
- **대상 서비스**: `lotto` (lotto-lab) + `agent-office` (LottoAgent) + `web-ui` (/lotto 자율학습 탭)
|
||||
- **사이클**: 스마트 에이전트 고도화 3종(로또/주식/인스타) 중 **1번 로또**. 주식·인스타는 후속 사이클.
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경 & 목표
|
||||
|
||||
사용자(CEO)는 로또 에이전트를 "분석 번호를 계속 가상구매해 시도횟수를 늘리고, 실제 당첨조합을 역분석해 스스로 학습·디벨롭하며 일요일에 회고 브리핑하는 스마트 에이전트"로 고도화하길 원한다. 명시 목표는 "로또 1등".
|
||||
|
||||
### ⚠️ 정직성 전제 (설계의 토대)
|
||||
로또는 매 회차 균등·독립 추첨이다. C(45,6)=8,145,060 조합이 전부 동일 확률이며 회차 간 독립이다. 따라서:
|
||||
- **과거 데이터(빈도·갭·공동출현)의 미래 예측력은 수학적으로 0.** 통계 분석으로 1등 확률을 올릴 수 없다.
|
||||
- 고정 예산 N장으로 1등 확률을 최대화하는 유일한 방법은 **서로 다른(distinct) 조합 N개**를 사는 것이다.
|
||||
|
||||
이 사실을 부정하지 않고 **시스템에 내장**한다. 본 프로젝트의 가치는 "예측"이 아니라:
|
||||
1. **정직한 측정** — "내 분석 엔진이 무작위를 이기는가?"를 null-model 대조군으로 매번 엄밀히 검정.
|
||||
2. **자가학습 엔진 인프라** — 측정→학습→회고 루프 자체의 엔지니어링.
|
||||
3. **커버리지 최적화** — 1등이 목표라면 distinct 조합 커버리지 최대화가 수학적 최적.
|
||||
|
||||
→ 사용자 결정(2026-05-31): **"정직한 측정 + 커버리지 최적"** 프레이밍 채택. 패턴 학습은 계속하되 모든 백테스트에 null-model 베이스라인을 내장한다.
|
||||
|
||||
### 기존 자산 (100% 재활용, 신규 ML 없음)
|
||||
- `analyzer.build_analysis_cache(draws)` / `score_combination(numbers, cache, weights)` — 임의 조합의 5개 sub-score + 종합점수(0~1) = **"분석치"**.
|
||||
- `analyzer.build_number_weights` + `utils.weighted_sample_6` — 가중 후보 생성.
|
||||
- `generator.run_simulation` — 20k 후보를 `score_combination(·, active_weights)`로 랭킹→best_picks. **W가 선택을 바꾸는 경로가 이미 존재.**
|
||||
- `weight_evolver` — 토 22:00 주간 6 가중치 후보 채점→base 갱신.
|
||||
|
||||
### 발견된 잠재 결함 (본 작업으로 수정)
|
||||
`weight_evolver.apply_today_and_pick`은 `recommend_numbers(draws)`(W 미사용)로 픽을 뽑은 뒤 W로 점수만 매긴다. 즉 **현재 daily 픽은 W와 무관**하고, evolver가 평가하는 매칭 결과도 W-독립이라 가중치 진화가 픽 품질에 연결돼 있지 않다. → forward 가상구매를 **시뮬레이션 선택 경로(풀 생성→W 랭킹→상위 K 구매)**로 구현하면 W가 결과를 실제로 바꿔 가중치 학습이 비로소 의미를 갖고 이 결함도 해소된다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 핵심 개념 — Self-Learning Backtest Loop
|
||||
|
||||
세 축으로 구성:
|
||||
|
||||
### 축 A — Forward 가상구매 (매주, 회차당 수천 장)
|
||||
매 회차 추첨 후, 각 전략별로 대량 후보를 생성·랭킹해 상위 K장을 "구매"로 간주 → 실제 당첨번호로 채점 → **회차별 집계 1행만 영구 저장**. 개별 티켓 미저장.
|
||||
- 전략: `engine_w`(6개 trial 가중치 각각) / `random_null`(무작위 대조군) / `coverage`(distinct 최대화).
|
||||
- 이 매칭 결과가 evolver의 학습 신호가 된다.
|
||||
|
||||
### 축 B — Winner 캘리브레이션 (역대 전체 백필 + 매주 증분)
|
||||
각 회차의 **실제 당첨조합을 그 시점 이전 데이터로 만든 캐시(point-in-time)에 넣어** 5개 분석치 + 종합점수 + percentile을 기록.
|
||||
- percentile = 당첨조합 score_total이 그 시점 무작위 M개 표본 분포에서 차지하는 위치.
|
||||
- "내 엔진이 실제 당첨번호에 높은 점수를 주는가?"의 가장 정직한 신호. 당첨조합이 일관되게 낮은 percentile이면 엔진은 헛다리.
|
||||
|
||||
### 축 C — 일요일 회고 브리핑
|
||||
토 추첨(20:45)→동기화(21:10)→기존 evolver 리포트(토 22:15) 이후, **일 09:00**에 차분히 회고. 이번 회차 forward 성적 + 당첨조합 역분석 + 내 추천과 비교 + 캘리브레이션 추세 + 가중치 진화를 텔레그램 1통 + UI.
|
||||
|
||||
---
|
||||
|
||||
## 3. 데이터 모델 (lotto.db 신규)
|
||||
|
||||
집계 전용 — row 수 ≈ 회차 × 전략 (수천 규모, 무시 가능).
|
||||
|
||||
### `backtest_runs` — forward 가상구매 집계
|
||||
```
|
||||
id INTEGER PK
|
||||
draw_no INTEGER NOT NULL -- 채점 대상(당첨 확정된) 회차
|
||||
strategy TEXT NOT NULL -- 'engine_w' | 'random_null' | 'coverage'
|
||||
weight_label TEXT NOT NULL -- engine_w는 trial day_of_week('w0'..'w5'), 그 외 '-'
|
||||
weight_json TEXT -- 사용한 W (random/coverage는 NULL)
|
||||
trial_id INTEGER -- FK weight_trials (engine_w만, nullable)
|
||||
n_tickets INTEGER NOT NULL -- 구매(채점) 장수
|
||||
m3 INTEGER NOT NULL DEFAULT 0 -- 3개 일치 장수
|
||||
m4 INTEGER NOT NULL DEFAULT 0
|
||||
m5 INTEGER NOT NULL DEFAULT 0
|
||||
m6 INTEGER NOT NULL DEFAULT 0
|
||||
bonus_hits INTEGER NOT NULL DEFAULT 0 -- 5+보너스(2등) 장수
|
||||
best_match INTEGER NOT NULL DEFAULT 0
|
||||
avg_meta_score REAL -- 구매 티켓 평균 분석치
|
||||
created_at TEXT NOT NULL
|
||||
UNIQUE(draw_no, strategy, weight_label) -- 멱등
|
||||
```
|
||||
- 등수 매핑: 1등=m6, 2등=bonus_hits, 3등=m5−bonus_hits, 4등=m4, 5등=m3.
|
||||
|
||||
### `winner_calibration` — 회차별 당첨조합 역분석
|
||||
```
|
||||
draw_no INTEGER PK -- 멱등
|
||||
winning_json TEXT NOT NULL -- [n1..n6] (보너스 별도 보관 안 함)
|
||||
score_total REAL NOT NULL
|
||||
score_frequency REAL NOT NULL
|
||||
score_fingerprint REAL NOT NULL
|
||||
score_gap REAL NOT NULL
|
||||
score_cooccur REAL NOT NULL
|
||||
score_diversity REAL NOT NULL
|
||||
percentile REAL -- 0~1, 무작위 M표본 대비 당첨조합 점수 위치
|
||||
my_pick_avg REAL -- 그 회차 engine 추천 평균 분석치(있으면)
|
||||
cache_draws INTEGER NOT NULL -- point-in-time 캐시에 쓰인 회차 수
|
||||
created_at TEXT NOT NULL
|
||||
```
|
||||
|
||||
> 누적 성적표(track record)는 `backtest_runs` SUM 집계로 on-the-fly 계산 — 별도 테이블 불필요.
|
||||
|
||||
---
|
||||
|
||||
## 4. 컴포넌트
|
||||
|
||||
### 4.1 lotto-lab `app/backtest.py` (순수 연산 — FastAPI 의존성 0, Windows 이전 대비)
|
||||
- `generate_pool(cache, number_weights, n) -> list[tuple]` — `weighted_sample_6` 반복으로 distinct 후보 풀.
|
||||
- `purchase_tickets(pool, cache, W, k) -> list[dict]` — 풀을 `score_combination(·, W)`로 랭킹→상위 k장 distinct.
|
||||
- `coverage_select(pool, k) -> list` — distinct 보장 상위 커버리지(초기엔 단순 distinct, 휠링은 향후).
|
||||
- `grade_tickets(tickets, winning6, bonus) -> dict` — 매칭 히스토그램 + 등수 카운트 + best_match + avg_meta. `bonus`는 draws 레코드에서 가져옴(2등=5일치+보너스 판정용).
|
||||
- `run_forward_purchase(draw_no, k=5000, pool_n=20000) -> dict` — engine(6 W)+random_null+coverage 각각 **전략당 k=5000장(수천 장)** 구매·채점·`backtest_runs` 저장(멱등). 풀 pool_n=20000에서 랭킹.
|
||||
- `calibrate_winner(draw_no, sample_m=2000) -> dict` — `draws[:idx]`(대상 회차 제외) 캐시로 당첨조합 채점 + 무작위 sample_m 표본 percentile → `winner_calibration` 저장(멱등).
|
||||
- `backfill_calibration(batch=50) -> dict` — 미처리 회차만 청크 처리, 재개 가능.
|
||||
- `build_review_payload(draw_no) -> dict` — 회고 브리핑용 조립(당첨조합 분해 + 내 추천 비교 + forward 성적 + 캘리브레이션 추세 + 진화 결과).
|
||||
|
||||
### 4.2 lotto-lab `app/routers/backtest.py`
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/lotto/backtest/track-record` | 누적 성적표(전략별 등수 카운트, engine vs random) |
|
||||
| GET | `/api/lotto/backtest/calibration?weeks=N` | 캘리브레이션 이력 + 추세 |
|
||||
| GET | `/api/lotto/backtest/review/{draw_no}` | 회고 payload |
|
||||
| POST | `/api/lotto/backtest/run-forward?draw_no=` | forward 수동 트리거 |
|
||||
| POST | `/api/lotto/backtest/backfill` | 캘리브레이션 백필(백그라운드) |
|
||||
|
||||
### 4.3 weight_evolver 업그레이드
|
||||
- `evaluate_weekly`: 학습 신호를 N=5(W-무관)에서 **forward 가상구매(engine_w 6전략) + null-model 대비 lift**로 승격.
|
||||
- lift = engine_w 등수 점수 − random_null 등수 점수(동일 회차).
|
||||
- 승자 = lift 최대 trial. **모든 W의 lift가 노이즈 범위(±ε) 내면 base `unchanged`** → 노이즈 과적합 방지.
|
||||
- `decide_base_update` 규칙은 유지하되 입력(winner)을 backtest 기반으로 교체.
|
||||
- 기존 `auto_picks` 경로는 하위호환·일일 활동표시용으로 유지(evolver 결정에는 미사용).
|
||||
|
||||
---
|
||||
|
||||
## 5. 플로우
|
||||
|
||||
1. **캘리브레이션 백필 (1회)**: `POST /backtest/backfill` → 백그라운드 청크(50회차/배치, 멱등 재개). 이후 회차마다 증분.
|
||||
2. **주간 forward**: 당첨번호 동기화 직후 `run_forward_purchase(latest)`. 참고: 6 W × 20k 풀은 기존 시뮬이 **하루 6회** 돌리는 부하보다 가벼움 → NAS 부담 작음.
|
||||
3. **일 09:00 회고 (agent-office 신규 cron)**: `LottoAgent.run_sunday_review()` → forward+calibration 보장 → `GET /backtest/review/{latest}` → 텔레그램 1통.
|
||||
4. **evolver (토 22:00, 기존 cron)**: backtest 집계를 학습 신호로 소비.
|
||||
|
||||
### Windows 이전 경로 (NAS 부하 측정 후 필요시)
|
||||
`backtest.py`가 순수 함수라, lotto-lab은 system-of-record 유지 + 무거운 연산만 Windows WSL docker 워커에 위임(`/api/internal/lotto/*` webhook, 기존 music/video/image 워커 패턴 재활용) + agent 폴링. 코드 경계가 깨끗해 마이그레이션 비용 최소. **초기 구현은 NAS-first**, 측정 후 결정.
|
||||
|
||||
---
|
||||
|
||||
## 6. 출력
|
||||
|
||||
### 6.1 텔레그램 (일 09:00, `notifiers/telegram_lotto.py` 신규 섹션)
|
||||
이번 당첨조합 5분석치 분해 + 내 추천 평균과 비교 + 이번주 forward 성적(등수 카운트, **무작위 대비 lift**) + 캘리브레이션 percentile 추세 + 가중치 진화 결과.
|
||||
|
||||
### 6.2 web-ui `/lotto` "자율 학습" 탭 확장 (`.lotto-evolver-*` 다크 네임스페이스 재활용)
|
||||
- **TrackRecordCard**: 누적 "매주 전략당 5,000장 샀다면" 등수 — engine vs random_null 나란히 + 총지출 대비 당첨금(정직하게 적자 표시).
|
||||
- **CalibrationChart**: 당첨조합 score_total 추세 + 내 추천 평균 오버레이 + percentile 밴드 → "우위 없음"을 시각화.
|
||||
- **WinnerAnalysisCard**: 이번 회차 당첨조합 5분석치 레이더 + 내 추천 비교.
|
||||
|
||||
---
|
||||
|
||||
## 7. 에러·성능·멱등
|
||||
- **멱등성**: `winner_calibration` UNIQUE(draw_no), `backtest_runs` UNIQUE(draw_no,strategy,weight_label) → 재실행 skip.
|
||||
- **NAS 성능**: 주간 forward는 기존 시뮬보다 가벼움. 백필만 1회 무거움(≈1100 point-in-time 캐시 재구성) → 청크+백그라운드+멱등 재개. 야간/유휴 트리거 권장.
|
||||
- **텔레그램 실패**: 로그만 남기고 job은 성공 처리(기존 패턴). 회고 데이터는 이미 DB에 있어 UI는 영향 없음.
|
||||
|
||||
## 8. 테스트 전략
|
||||
- 등수 매핑(m3~m6/bonus → 1~5등) 단위 테스트.
|
||||
- null-model 기대값 + lift 계산.
|
||||
- percentile 계산 정확성.
|
||||
- **point-in-time 캐시가 대상 회차를 제외하는지** (calibrate_winner 정직성 핵심).
|
||||
- 멱등 백필(재실행 시 중복 row 없음, 중단 후 재개).
|
||||
- evolver의 lift-over-random 승자 선택 + ε-게이팅(노이즈 시 unchanged).
|
||||
- 기존 `count_match`/`calc_pick_score` 테스트 유지.
|
||||
|
||||
## 9. 리스크 & 완화
|
||||
| 리스크 | 완화 |
|
||||
|--------|------|
|
||||
| 무작위성 → 실제 우위 없음 | null-model 정직 프레이밍, 우위 없음을 데이터로 보고하는 게 목표 |
|
||||
| Celeron 백필 부하 | 청크+1회성+멱등 재개, 필요시 Windows 이전 |
|
||||
| evolver 노이즈 추종 | lift-over-random + ε-게이팅으로 unchanged 처리 |
|
||||
| DB 증가 | 집계 전용, row 수 무시 가능 |
|
||||
| forward 풀 중복으로 커버리지 손실 | distinct 강제 + coverage 전략 별도 측정 |
|
||||
|
||||
## 10. 결정 로그 (2026-05-31 brainstorming)
|
||||
1. 3종 중 **로또 먼저**, 주식·인스타는 후속 사이클.
|
||||
2. 회고 브리핑 = **토 추첨 직후 일 09:00**.
|
||||
3. 시도 규모 = **수천 장/회차 + 집계만 저장**.
|
||||
4. 자율성 = **가중치 자동튜닝 강화**(산식 구조 고정).
|
||||
5. 백테스트 범위 = **캘리브레이션 전체 백필 + 가상구매 forward**.
|
||||
6. 출력 = **텔레그램 + 기존 자율학습 탭 확장**.
|
||||
7. 프레이밍 = **정직한 측정(null-model) + 커버리지 최적**.
|
||||
8. 연산 위치 = **NAS-first, 필요시 Windows WSL 이전**.
|
||||
|
||||
## 11. 스코프 밖 / 향후
|
||||
- 주식 에이전트(보유종목 집중 분석+차트 매수/매도 시그널), 인스타 에이전트(자율 카드 발급) — 별도 사이클.
|
||||
- 휠링/커버링 디자인(하위 등수 최소 보장) — coverage 전략 고도화로 향후.
|
||||
- Windows WSL 워커 분리 — NAS 부하 측정 후.
|
||||
@@ -0,0 +1,122 @@
|
||||
# 주식 보유종목 인텔리전스 — 설계 Spec
|
||||
|
||||
- **작성일**: 2026-05-31
|
||||
- **상태**: 설계 승인 (구현 plan 대기)
|
||||
- **대상 서비스**: `stock` + `agent-office`(StockAgent) + `web-ui`(stock/포트폴리오 페이지)
|
||||
- **사이클**: 스마트 에이전트 고도화 3종 중 **2번 주식**. (1번 로또 완료, 3번 인스타 후속)
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경 & 목표
|
||||
|
||||
현재 StockAgent는 아침 뉴스 요약(07:30) · KRX 강세주 스크리너(16:30) · AI 뉴스 sentiment(08:00)를 브리핑한다. CEO는 여기서 더 나아가 **내 보유종목을 집중 분석**해 ①종목별 매수/매도 자세 ②이슈 정리 ③포트폴리오 건강을 매일 advisory로 브리핑받길 원한다.
|
||||
|
||||
### 핵심 결정 (2026-05-31 brainstorming)
|
||||
1. **실행 수준 = 브리핑 전용(advisory)**. `/api/trade/order`(KIS 실주문) 미사용. 매수/매도는 "제안"만, 실제 주문은 사용자 수동. (로또와 동일한 정직·관찰 철학)
|
||||
2. **분석 주기 = 일봉 EOD + 장중 경량 가드**. 장마감 후 일봉으로 기술분석 → 다음날 아침 브리핑. 장중엔 현재가로 손절·급변(±N%)만 경도 알림. 인트라데이 분봉 파이프라인 신설 안 함.
|
||||
3. **브리핑 범위 = 보유종목 + 포트 레벨**. 종목별 액션 + 포트폴리오 건강(집중도·비중·현금·손익).
|
||||
4. **이슈 소스 = 기존 뉴스+감성+LLM 요약 + 급변·거래량·외인수급 이벤트**. 신규 스크래핑 0 (DART·실적 일정 제외).
|
||||
|
||||
### 기존 자산 (100% 재활용, 신규 ML/데이터소스 없음)
|
||||
- `stock/app/screener/snapshot.py` → `krx_daily_prices`(일봉 OHLCV) + `krx_master`(listing) + naver 외인 flow. 스크리너 잡(평일 16:30)이 갱신.
|
||||
- `stock/app/screener/engine.py` + `nodes/`(ma_alignment·momentum·rs_rating·vcp_lite·volume_surge·foreign_buy·high52w·hygiene). **`ScreenContext.restrict(tickers)`** + `latest_close()`/`latest_high()`로 보유종목 한정 분석 가능.
|
||||
- `portfolio` 테이블(broker·ticker·name·quantity·avg_price·purchase_price) + `/api/portfolio`(현재가·손익 계산) + `broker_cash`(예수금).
|
||||
- `price_fetcher`(현재가 3분 TTL) · `news_sentiment` 테이블(종목별 감성) · `ai_summarizer`(Claude Haiku).
|
||||
|
||||
### 알려진 제약 (설계 반영)
|
||||
- **섹터 필드 없음**: `portfolio`·`krx_master`에 sector 없음 → 섹터 편중은 best-effort(FDR `StockListing`의 Sector/Industry가 있으면 사용, 없으면 생략)이고, **시장(KOSPI/KOSDAQ)·종목 비중 집중도**를 기본 지표로 사용.
|
||||
- **KRX 외 종목**(미국주 등): krx_daily_prices 밖 → 기술분석 불가, **뉴스·현재가·손익만** graceful 처리.
|
||||
- **snapshot 히스토리 의존**: MA200·52주 고점 노드는 ~1년 일봉 필요. 스크리너가 이미 이 노드들을 쓰므로 윈도우는 충족 가정(plan에서 lookback 확인 단계 포함).
|
||||
|
||||
---
|
||||
|
||||
## 2. 데이터 모델 & 컴포넌트
|
||||
|
||||
### 신규 테이블 `holdings_signals` (stock.db, 일별 종목 시그널 이력)
|
||||
```
|
||||
date TEXT NOT NULL -- KST 거래일
|
||||
ticker TEXT NOT NULL
|
||||
name TEXT
|
||||
action TEXT NOT NULL -- 'add' | 'hold' | 'trim' | 'sell'
|
||||
tech_score REAL -- 매수강도(score 노드 가중합, 0~1 정규화)
|
||||
exit_flags TEXT NOT NULL DEFAULT '{}' -- JSON {stop_loss,ma50_break,ma200_break,momentum_loss,take_profit,climax}
|
||||
issues TEXT NOT NULL DEFAULT '[]' -- JSON [{type, severity, summary}]
|
||||
close INTEGER
|
||||
pnl_rate REAL -- 평단 대비 % (스냅샷 시점)
|
||||
reasons TEXT -- 액션 근거 텍스트
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
PRIMARY KEY(date, ticker) -- 멱등 upsert
|
||||
```
|
||||
> 추세/이력은 이 테이블에서 조회. 포트 레벨 요약은 on-the-fly 계산(별도 테이블 불필요).
|
||||
|
||||
### 신규 `stock/app/holdings_intel.py` (순수연산 중심, FastAPI 의존성 최소)
|
||||
- `get_holdings() -> list[dict]` — `portfolio` 행 + 현재가(price_fetcher) + pnl_rate. KRX 여부 플래그(`is_krx`).
|
||||
- `technical_posture(ctx_restricted, tickers) -> dict[ticker, score]` — `ScreenContext.restrict(tickers)`에 score 노드 실행 → 매수강도.
|
||||
- `exit_rules(holding, prices_df, params) -> dict` — **신규**: 손절·MA이탈·모멘텀소멸·익절·클라이맥스 flag 산출 (§3).
|
||||
- `decide_action(tech_score, exit_flags, pnl) -> (action, reasons)` — **신규**: 매수강도+exit 조합 → add/hold/trim/sell + 근거.
|
||||
- `market_events(prices_df, flow, params) -> dict[ticker, list]` — 급변(±N%)·거래량 Z-score·외인 순매도.
|
||||
- `news_issues(tickers) -> dict[ticker, list]` — news+news_sentiment 필터 → Claude Haiku 악재·심각도 요약(악재 있는 종목만).
|
||||
- `portfolio_health(holdings, cash) -> dict` — 종목 비중 집중도(HHI/최대비중)·시장 mix·현금 비중·총 손익.
|
||||
- `compute_and_store(asof) -> dict` — 위를 조합해 holdings_signals upsert (멱등).
|
||||
- `build_holdings_brief(asof) -> dict` — 브리핑/UI payload 조립(종목별 action+issues + portfolio_health + 추세).
|
||||
|
||||
### API (stock)
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/stock/holdings/intel` | 최신 브리핑 payload |
|
||||
| GET | `/api/stock/holdings/intel/history?ticker=&days=` | 종목 시그널 추세 |
|
||||
| POST | `/api/stock/holdings/intel/run` | 수동 계산 트리거(BackgroundTask) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 매도/리스크 룰 & 이슈 (설정 가능 임계값 — 기본값 제시)
|
||||
|
||||
### exit_flags (각 boolean + 값)
|
||||
- **stop_loss**: `current < avg_price × (1 − STOP_PCT)` (기본 STOP_PCT=0.08, Minervini식)
|
||||
- **ma50_break / ma200_break**: 종가 < MA50 / MA200
|
||||
- **momentum_loss**: momentum/RS 노드 점수가 직전 대비 임계 하락 (or 음전환)
|
||||
- **take_profit**: `pnl_rate ≥ TAKE_PCT` (기본 25%) — 부분 익절 후보
|
||||
- **climax**: 거래량 급증(vol > avg×CLIMAX_VOL_X) + 종가 상단 꼬리 (분산 의심)
|
||||
|
||||
### decide_action 매트릭스
|
||||
- tech_score 高 + exit_flags 無 → **add**(추가매수 후보)
|
||||
- exit_flags 無 (강건) → **hold**
|
||||
- ma50_break 또는 momentum_loss 또는 take_profit → **trim**(일부 축소)
|
||||
- stop_loss 또는 ma200_break → **sell**(청산 후보)
|
||||
- 각 결정에 trigger된 flag를 근거 텍스트로 동봉. (advisory — "제안")
|
||||
|
||||
### issues
|
||||
- **시장이벤트** (기존 데이터): 일봉 ±EVENT_PCT% 급변 / 거래량 Z-score>임계 / naver flow 외인 순매도 N일 연속.
|
||||
- **뉴스이슈**: 보유종목 최근 뉴스 + news_sentiment 음수 → Claude Haiku로 `{type, severity(low/med/high), summary}` 요약. 악재 있는 종목만 호출(비용 bounded).
|
||||
|
||||
---
|
||||
|
||||
## 4. 플로우 · 에이전트 · UI
|
||||
|
||||
1. **EOD 계산 (평일 16:40)**: 기존 스크리너/뉴스 잡과 동일하게 **agent-office cron이 orchestrate** — `_run_stock_holdings_eod()` → `StockAgent.run_holdings_eod()` → stock `POST /api/stock/holdings/intel/run` → `holdings_intel.compute_and_store(today)` → holdings_signals upsert. 스크리너 snapshot 갱신(16:30) 직후라 일봉 준비됨.
|
||||
2. **아침 브리핑 (평일 08:30, agent-office StockAgent.run_holdings_brief)**: 저장된 최신 시그널 + 야간 갭(현재가) → 텔레그램 1통(종목별 액션 + 포트 건강 + 상위 이슈). AI 뉴스(08:00) 다음 슬롯.
|
||||
3. **장중 경량 가드 (평일 09:00~15:30, 30분 간격)**: 현재가로 손절선 이탈·급변(±N%)만 점검 → 발생 시 텔레그램 alert. throttle(종목·유형별 재발화 억제) + daily cap (로또 시그널 패턴 재활용).
|
||||
4. **agent-office**: `service_proxy`에 holdings intel 호출 추가 + StockAgent 메서드(run_holdings_brief / intraday_guard) + scheduler cron.
|
||||
5. **UI (web-ui)**: stock/포트폴리오 페이지에 **"보유종목 인텔리전스" 탭/섹션 통합** — 종목별 액션 카드(자세·exit flags·근거) + 포트 건강 위젯 + 이슈 피드 + 종목 시그널 추세(history).
|
||||
|
||||
---
|
||||
|
||||
## 5. 에러·성능·테스트·리스크
|
||||
|
||||
- **멱등성**: holdings_signals PRIMARY KEY(date,ticker) upsert → 재계산 안전.
|
||||
- **성능 (NAS Celeron)**: 보유종목만 restrict(소수 종목)이라 전체 스크리너 대비 매우 가벼움. LLM 이슈 요약은 악재 종목만(bounded). EOD 1회 + 장중 가드는 현재가만(경량).
|
||||
- **graceful degrade**: price_fetcher/KIS/news 실패 시 부분 데이터로 진행 + 경고 로그. KRX 외 종목은 기술분석 skip(뉴스·손익만). 텔레그램 실패는 로그만(job 성공 유지).
|
||||
- **테스트**: exit_rules 각 flag, decide_action 매트릭스 전 분기, market_events 검출, portfolio_health 계산, holdings_signals 멱등, KRX 외 종목 graceful, 뉴스 0건 경로.
|
||||
- **리스크**: ①기술적 시그널은 휴리스틱이지 보장 아님 → advisory 프레이밍·자동매매 없음 ②섹터 데이터 갭 → 시장·비중 집중도로 대체 ③snapshot 히스토리 의존 → plan에 lookback 확인 ④보유종목 출처는 portfolio 테이블(사용자/KIS 동기화) — 누락 시 빈 브리핑 graceful.
|
||||
|
||||
---
|
||||
|
||||
## 6. 결정 로그 (2026-05-31)
|
||||
1. 실행 수준 = **advisory 전용** (KIS 실주문 미사용)
|
||||
2. 주기 = **일봉 EOD + 장중 경량 가드**
|
||||
3. 범위 = **보유종목 + 포트 레벨**
|
||||
4. 이슈 소스 = **기존 뉴스+감성+LLM + 급변·거래량·외인 이벤트**
|
||||
|
||||
## 7. 스코프 밖 / 향후
|
||||
- 자동매매(승인후/완전자동), 인트라데이 분봉, DART 공시·실적 일정, 신규 매수후보 발굴(기존 16:30 스크리너가 담당), 교체(rotation) 제안 — 향후 사이클.
|
||||
- 인스타 에이전트(자율 카드 발급) — 다음 사이클.
|
||||
@@ -0,0 +1,97 @@
|
||||
# 인스타 카드뉴스 품질 고도화 + 업로드 친화 패키지 — 설계 Spec
|
||||
|
||||
- **작성일**: 2026-06-02
|
||||
- **상태**: 설계 승인 (구현 plan 대기)
|
||||
- **대상**: `insta-lab`(템플릿·카피·zip·web-ui) + `web-ai/services/insta-render`(렌더 워커, **별도 repo**)
|
||||
- **사이클**: 스마트 에이전트 고도화 3종 중 **3번 인스타**. (1 로또·2 주식 배포 완료)
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경 & 목표
|
||||
|
||||
현재 insta-lab은 뉴스→키워드→Claude 카피(cover+본문8+cta+caption+hashtags)→Redis push→**Windows insta-render 워커**가 Jinja→HTML→Playwright 스크린샷(1080×1350)→텔레그램 전달 흐름이다. 그러나 카드가 "진짜 카드뉴스" 품질에 못 미치고(메모리상 렌더 known-issue), 현재 default 템플릿은 55줄짜리 기본형(accent+headline/body/footer)이다.
|
||||
|
||||
CEO 목표: **진짜 카드뉴스 형식**으로 카드 품질을 끌어올리고, 완성 패키지를 **인스타에 업로드하기 쉽게** 만든다.
|
||||
|
||||
### 핵심 결정 (2026-06-02 brainstorming)
|
||||
1. **업로드 방식 = 반자동(현행 개선)**. Instagram Graph API/Meta 앱/IG 비즈니스 계정 미사용. 완성 카드+캡션을 사용자가 인스타 앱에서 직접 업로드하되, **마찰 없는 패키지 전달**(텔레그램 + zip 다운로드)로 개선.
|
||||
2. **카드 품질 = 디자인 시스템 템플릿 고도화**. 폴리시한 HTML/CSS 디자인 시스템 + Playwright 렌더, known-issue 해결. (AI 생성 비주얼·Vision import 수리 아님)
|
||||
3. **비주얼 = 모던 미니멀**. 넉넉한 여백·강한 산세리프 타이포·1~2 accent·깔끔한 그리드. 단일 강한 default 테마(멀티테마 X), accent만 카테고리별.
|
||||
|
||||
### 기존 자산 (재사용)
|
||||
- `insta-lab/app/card_writer.py` — Claude 카피 생성(cover_copy{headline,body,accent_color}, body_copies[8]{headline,body}, cta_copy{headline,body,cta}, suggested_caption, hashtags[]).
|
||||
- `insta-lab/app/templates/default/card.html.j2` — 격상 대상(현 55줄 기본형).
|
||||
- `web-ai/services/insta-render/`: `worker.py`(BLPOP `queue:insta-render` → `GET /api/insta/slates/{id}` → `render_slate` → webhook `/api/internal/insta/update`), `card_renderer.py`(`_build_pages`로 10페이지 spec 구성 cover/body8/cta, Jinja→HTML→`page.goto(file://, networkidle)`→`screenshot(full_page=False)` @viewport 1080×1350, `CARD_TEMPLATE_DIR`에서 템플릿 로드).
|
||||
- nginx `/media/insta/` → `/data/insta_cards/`(카드 PNG 공개 서빙) — 패키지 다운로드에 활용.
|
||||
|
||||
### known-issue 근원 (이번 작업으로 해결)
|
||||
- 웹폰트(@import Google Fonts) 로딩 전 스크린샷 → fallback 폰트 렌더.
|
||||
- `full_page=False` + 콘텐츠가 1350px 초과 → 하단 잘림.
|
||||
- (기존 minimal 테마) Vision-import 마스킹 좌표·background-image 경로 문제 → **신규 깨끗한 디자인 시스템 템플릿으로 경로 자체를 제거(우회)**.
|
||||
|
||||
---
|
||||
|
||||
## 2. 디자인 시스템 (모던 미니멀)
|
||||
|
||||
`insta-lab/app/templates/default/card.html.j2`를 페이지 타입별 레이아웃을 가진 디자인 시스템으로 재작성.
|
||||
|
||||
### 페이지 타입별 레이아웃 (`_build_pages`의 page_type 사용)
|
||||
- **cover** (page 1): 카테고리 배지 + 대형 헤드라인(96px급) + 서브카피 + 브랜드 핸들. 시선 집중.
|
||||
- **body** ×8 (page 2~9): 좌상단 번호 인덱스(02~09) + 포인트 헤드라인(72px급) + 본문(40px급, 2~4문장) + 하단 진행 인디케이터(점/바). 일관 그리드.
|
||||
- **cta** (page 10): 요약 헤드라인 + 마무리 본문 + 행동유도(팔로우/저장) + 핸들.
|
||||
|
||||
### 디자인 토큰
|
||||
- 타이포: Pretendard(우선) 또는 Noto Sans KR, weight 900/700/400, letter-spacing 음수, line-height 1.15~1.55.
|
||||
- 레이아웃: 1080×1350 고정, safe-margin(예: 좌우/상하 ~80px), 그리드 정렬.
|
||||
- 컬러: 라이트 배경(#F7F7FA 계열) + `accent_color`(카테고리별, 데이터 기존: economy #0F62FE / psychology #A66CFF / celebrity #FF5C8A 등) 포인트.
|
||||
- 푸터: `{page_no} / {total_pages}` + 브랜드 핸들. body는 진행 인디케이터.
|
||||
|
||||
### 제약
|
||||
- 각 페이지 = 정확히 1080×1350 고정 박스, `overflow:hidden`. 긴 본문 대비 본문 컨테이너 `max-height` + 줄수 clamp(말줄임 또는 폰트 축소).
|
||||
- 단일 default 테마. accent만 카테고리 차등(추가 테마 디렉토리 안 만듦).
|
||||
|
||||
---
|
||||
|
||||
## 3. 렌더 견고화 (web-ai 워커, known-issue 해결)
|
||||
|
||||
`web-ai/services/insta-render/card_renderer.py` 보강:
|
||||
- **폰트 보장**: `page.goto` 후 screenshot 전에 `await page.evaluate('document.fonts.ready')` 대기 추가. (가능하면 Pretendard를 워커에 self-host/번들해 네트워크 의존 제거 — 폴백으로 fonts.ready 대기.)
|
||||
- **정확한 1080×1350**: 템플릿이 `.card{width:1080px;height:1350px;overflow:hidden}`을 보장. `full_page=False` + viewport 1080×1350 유지. 콘텐츠 오버플로우는 템플릿 CSS(clamp/max-height)로 차단.
|
||||
- **PNG 검증**: 렌더 후 각 PNG가 1080×1350인지 + 0바이트/빈 페이지 아닌지 확인. 실패 시 webhook `failed`.
|
||||
- **템플릿 sync (open item)**: 워커의 `CARD_TEMPLATE_DIR`가 신규 디자인 템플릿을 받는 경로 확인·정립. (insta-lab 템플릿 → 워커로 어떻게 전달되는지 plan에서 확인: web-ai repo 복사본인지 별도 sync인지. 신규 템플릿이 워커에 반영돼야 효과 발생.)
|
||||
|
||||
---
|
||||
|
||||
## 4. 카피 정합 + 업로드 친화 패키지
|
||||
|
||||
- **카피 글자수 가이드**: `card_writer.py`의 프롬프트에 헤드라인/본문 글자수 상한 명시(디자인 박스에 맞게) → 오버플로우 예방. 시작 기준값(템플릿 박스 확정 시 ±조정): cover headline ≤ 22자 / body headline ≤ 26자 / body ≤ 120자 / cta headline ≤ 22자. CSS clamp가 2차 방어이므로 가이드는 근사치여도 안전.
|
||||
- **업로드 친화 패키지 (신규)**: 기존 텔레그램 미디어그룹(10장)+캡션/해시태그 유지 + **zip 다운로드** 추가:
|
||||
- 신규 API `GET /api/insta/slates/{id}/package` → 10 PNG + `caption.txt`(suggested_caption + hashtags) 묶은 zip 반환.
|
||||
- web-ui 슬레이트 상세에 "패키지 다운로드" 버튼.
|
||||
- 사용자가 zip 받아 인스타 앱에 캐러셀 업로드 + caption 붙여넣기.
|
||||
- **승인 게이트 유지**: 키워드 후보 푸시 → 사용자 선택 → 렌더 → 전달. 자동 게시 없음(반자동).
|
||||
|
||||
---
|
||||
|
||||
## 5. 에러·테스트·리스크·스코프
|
||||
|
||||
- **2 repo 배포 경로**: insta-lab = git push → Gitea webhook 자동배포. web-ai 워커 = Windows 머신에서 별도 갱신(repo: ai-trade.git). 템플릿·렌더 변경이 양쪽에 반영돼야 함.
|
||||
- **테스트**:
|
||||
- insta-lab: card_writer 글자수 제약, zip 패키지 구성(10 PNG + caption.txt), package API.
|
||||
- web-ai: 페이지 타입별 템플릿 렌더 HTML 스냅샷, PNG 1080×1350 크기 검증, fonts.ready 대기, 오버플로우 clamp (web-ai `tests/test_worker` 확장).
|
||||
- **리스크**:
|
||||
- 템플릿 sync 누락 → 워커가 구 템플릿 렌더(효과 없음). plan에서 sync 경로 확정.
|
||||
- 긴 카피 오버플로우 → 글자수 가이드 + CSS clamp 이중 방어.
|
||||
- 폰트 로딩 타이밍 → fonts.ready 대기(+self-host).
|
||||
- known-issue는 깨끗한 디자인 시스템 + 렌더 견고화로 **근본 해결**(Vision-import 경로 제거).
|
||||
|
||||
---
|
||||
|
||||
## 6. 결정 로그 (2026-06-02)
|
||||
1. 업로드 = 반자동(현행 개선, Graph API 미사용)
|
||||
2. 카드 품질 = 디자인 시스템 템플릿 고도화
|
||||
3. 비주얼 = 모던 미니멀, 단일 default 테마
|
||||
|
||||
## 7. 스코프 밖 / 향후
|
||||
- Instagram Graph API 자동 게시, 멀티 테마, AI 생성 비주얼, Vision design_importer 수리, 카테고리별 차별 테마 — 향후.
|
||||
- 9:30 자동 슬레이트(auto_select) 흐름 자체는 변경 안 함(품질·패키지만 개선).
|
||||
7
image-lab/Dockerfile
Normal file
7
image-lab/Dockerfile
Normal file
@@ -0,0 +1,7 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app ./app
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
0
image-lab/app/__init__.py
Normal file
0
image-lab/app/__init__.py
Normal file
13
image-lab/app/auth.py
Normal file
13
image-lab/app/auth.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Windows image-render worker → NAS image-lab internal webhook 인증."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from fastapi import Header, HTTPException
|
||||
|
||||
|
||||
def verify_internal_key(x_internal_key: str = Header(...)):
|
||||
expected = os.getenv("INTERNAL_API_KEY")
|
||||
if not expected:
|
||||
raise HTTPException(401, "INTERNAL_API_KEY not configured on server")
|
||||
if x_internal_key != expected:
|
||||
raise HTTPException(401, "Invalid X-Internal-Key")
|
||||
83
image-lab/app/db.py
Normal file
83
image-lab/app/db.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""SQLite persistence for image_tasks. Single table — task 단위 추적만."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
DB_PATH = os.path.join(os.getenv("IMAGE_DATA_DIR", "/app/data"), "image.db")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _conn():
|
||||
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")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS image_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider TEXT NOT NULL,
|
||||
params TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'queued',
|
||||
progress INTEGER DEFAULT 0,
|
||||
message TEXT DEFAULT '',
|
||||
image_url TEXT,
|
||||
error TEXT,
|
||||
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _row_to_dict(row) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": row["id"], "provider": row["provider"], "params": row["params"],
|
||||
"status": row["status"], "progress": row["progress"], "message": row["message"],
|
||||
"image_url": row["image_url"], "error": row["error"],
|
||||
"created_at": row["created_at"], "updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
def create_task(task_id: str, provider: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO image_tasks (id, provider, params) VALUES (?, ?, ?)",
|
||||
(task_id, provider, json.dumps(params)),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM image_tasks WHERE id = ?", (task_id,)).fetchone()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
def update_task(task_id: str, status: str, progress: int, message: str = "",
|
||||
image_url: Optional[str] = None, error: Optional[str] = None) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE image_tasks
|
||||
SET status = ?, progress = ?, message = ?, image_url = ?, error = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||
WHERE id = ?
|
||||
""",
|
||||
(status, progress, message, image_url, error, task_id),
|
||||
)
|
||||
|
||||
|
||||
def get_task(task_id: str) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM image_tasks WHERE id = ?", (task_id,)).fetchone()
|
||||
return _row_to_dict(row) if row else None
|
||||
52
image-lab/app/internal_router.py
Normal file
52
image-lab/app/internal_router.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Windows image-render → NAS image-lab internal webhook.
|
||||
|
||||
POST /api/internal/image/update
|
||||
- X-Internal-Key 인증 필수
|
||||
- image_tasks row update (status, progress, message, image_url, error)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import db
|
||||
from .auth import verify_internal_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class UpdatePayload(BaseModel):
|
||||
task_id: str
|
||||
status: str = Field(..., description="processing|succeeded|failed")
|
||||
progress: int = Field(..., ge=0, le=100)
|
||||
message: str = ""
|
||||
image_url: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/internal/image/update",
|
||||
dependencies=[Depends(verify_internal_key)],
|
||||
)
|
||||
def image_update(payload: UpdatePayload):
|
||||
task = db.get_task(payload.task_id)
|
||||
if task is None:
|
||||
raise HTTPException(404, f"task not found: {payload.task_id}")
|
||||
|
||||
db.update_task(
|
||||
payload.task_id,
|
||||
payload.status,
|
||||
payload.progress,
|
||||
message=payload.message,
|
||||
image_url=payload.image_url,
|
||||
error=payload.error,
|
||||
)
|
||||
logger.info(
|
||||
"internal/image/update task=%s status=%s progress=%d",
|
||||
payload.task_id, payload.status, payload.progress,
|
||||
)
|
||||
return {"ok": True}
|
||||
113
image-lab/app/main.py
Normal file
113
image-lab/app/main.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""FastAPI entrypoint for image-lab.
|
||||
|
||||
POST /api/image/generate — provider + prompt → Redis push → task_id
|
||||
GET /api/image/tasks/{id} — DB 조회
|
||||
GET /api/image/providers — 3 provider 메타
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import db
|
||||
from .internal_router import router as internal_router
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CORS_ALLOW_ORIGINS = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080")
|
||||
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379")
|
||||
redis_client = aioredis.from_url(REDIS_URL, decode_responses=False)
|
||||
|
||||
SUPPORTED_PROVIDERS = {"gpt_image", "nano_banana", "flux"}
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(internal_router)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[o.strip() for o in CORS_ALLOW_ORIGINS.split(",")],
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
|
||||
allow_headers=["Content-Type"],
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
db.init_db()
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True, "service": "image-lab"}
|
||||
|
||||
|
||||
@app.get("/api/image/providers")
|
||||
def list_providers():
|
||||
"""3 provider 항상 노출 (key 누락은 worker가 failed 보고)."""
|
||||
return {"providers": [
|
||||
{"id": "gpt_image", "name": "GPT Image 2.0", "models": ["gpt-image-1"],
|
||||
"sizes": ["1024x1024", "1024x1536", "1536x1024"]},
|
||||
{"id": "nano_banana", "name": "Nano Banana (Gemini)", "models": ["gemini-2.5-flash-image"],
|
||||
"sizes": ["1024x1024"]},
|
||||
{"id": "flux", "name": "FLUX (local)", "models": ["flux-schnell", "flux-dev"],
|
||||
"sizes": ["1024x1024", "832x1216", "1216x832"]},
|
||||
]}
|
||||
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
provider: str = Field(..., description="gpt_image|nano_banana|flux")
|
||||
model: Optional[str] = None
|
||||
prompt: str
|
||||
size: Optional[str] = None
|
||||
negative_prompt: Optional[str] = None
|
||||
# Provider 별 추가 키는 extra 허용
|
||||
extra: Optional[Dict[str, Any]] = None
|
||||
|
||||
class Config:
|
||||
extra = "allow"
|
||||
|
||||
|
||||
async def _push_render_job(task_id: str, job_type: str, params: dict) -> None:
|
||||
"""Redis queue:image-render에 push."""
|
||||
kst = timezone(timedelta(hours=9))
|
||||
payload = {
|
||||
"task_id": task_id,
|
||||
"kind": "image",
|
||||
"job_type": job_type,
|
||||
"params": params,
|
||||
"submitted_at": datetime.now(kst).isoformat(),
|
||||
}
|
||||
await redis_client.rpush("queue:image-render", json.dumps(payload))
|
||||
|
||||
|
||||
@app.post("/api/image/generate")
|
||||
async def generate_image(req: GenerateRequest):
|
||||
"""이미지 생성 — Redis 큐로 Windows image-render에 위임."""
|
||||
if req.provider not in SUPPORTED_PROVIDERS:
|
||||
raise HTTPException(400, f"지원하지 않는 provider: {req.provider} (supported: {sorted(SUPPORTED_PROVIDERS)})")
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
params = req.model_dump(exclude_none=True)
|
||||
db.create_task(task_id, req.provider, params)
|
||||
|
||||
job_type = f"{req.provider}_generation" # gpt_image_generation, nano_banana_generation, flux_generation
|
||||
await _push_render_job(task_id, job_type, params)
|
||||
return {"task_id": task_id, "provider": req.provider}
|
||||
|
||||
|
||||
@app.get("/api/image/tasks/{task_id}")
|
||||
def get_task_status(task_id: str):
|
||||
t = db.get_task(task_id)
|
||||
if not t:
|
||||
raise HTTPException(404, "task not found")
|
||||
return t
|
||||
4
image-lab/env.example
Normal file
4
image-lab/env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
INTERNAL_API_KEY=replace-me
|
||||
IMAGE_DATA_DIR=/app/data
|
||||
CORS_ALLOW_ORIGINS=http://localhost:3007,http://localhost:8080
|
||||
REDIS_URL=redis://redis:6379
|
||||
5
image-lab/requirements.txt
Normal file
5
image-lab/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.6
|
||||
pydantic==2.9.2
|
||||
redis==5.0.8
|
||||
httpx==0.27.2
|
||||
0
image-lab/tests/__init__.py
Normal file
0
image-lab/tests/__init__.py
Normal file
19
image-lab/tests/test_auth.py
Normal file
19
image-lab/tests/test_auth.py
Normal file
@@ -0,0 +1,19 @@
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from app.auth import verify_internal_key
|
||||
|
||||
def test_no_server_key_rejects(monkeypatch):
|
||||
monkeypatch.delenv("INTERNAL_API_KEY", raising=False)
|
||||
with pytest.raises(HTTPException) as e:
|
||||
verify_internal_key("anything")
|
||||
assert e.value.status_code == 401
|
||||
|
||||
def test_wrong_key_rejects(monkeypatch):
|
||||
monkeypatch.setenv("INTERNAL_API_KEY", "secret")
|
||||
with pytest.raises(HTTPException) as e:
|
||||
verify_internal_key("wrong")
|
||||
assert e.value.status_code == 401
|
||||
|
||||
def test_correct_key_passes(monkeypatch):
|
||||
monkeypatch.setenv("INTERNAL_API_KEY", "secret")
|
||||
assert verify_internal_key("secret") is None
|
||||
29
image-lab/tests/test_db.py
Normal file
29
image-lab/tests/test_db.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import os, tempfile, importlib
|
||||
|
||||
def _fresh_db(monkeypatch, tmp):
|
||||
monkeypatch.setenv("IMAGE_DATA_DIR", tmp)
|
||||
import app.db as db
|
||||
importlib.reload(db)
|
||||
db.init_db()
|
||||
return db
|
||||
|
||||
def test_create_and_get_task(monkeypatch):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db = _fresh_db(monkeypatch, tmp)
|
||||
row = db.create_task("t1", "gpt_image", {"prompt": "a cat"})
|
||||
assert row["id"] == "t1"
|
||||
assert row["provider"] == "gpt_image"
|
||||
assert row["status"] == "queued"
|
||||
got = db.get_task("t1")
|
||||
assert got["id"] == "t1"
|
||||
assert db.get_task("nope") is None
|
||||
|
||||
def test_update_task_sets_image_url(monkeypatch):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db = _fresh_db(monkeypatch, tmp)
|
||||
db.create_task("t2", "nano_banana", {"prompt": "x"})
|
||||
db.update_task("t2", "succeeded", 100, message="done", image_url="/media/image/t2.png")
|
||||
got = db.get_task("t2")
|
||||
assert got["status"] == "succeeded"
|
||||
assert got["image_url"] == "/media/image/t2.png"
|
||||
assert got["progress"] == 100
|
||||
38
image-lab/tests/test_internal_router.py
Normal file
38
image-lab/tests/test_internal_router.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import os, tempfile, importlib
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
def _client(monkeypatch, tmp):
|
||||
monkeypatch.setenv("IMAGE_DATA_DIR", tmp)
|
||||
monkeypatch.setenv("INTERNAL_API_KEY", "secret")
|
||||
import app.db as db; importlib.reload(db); db.init_db()
|
||||
import app.internal_router as ir; importlib.reload(ir)
|
||||
app = FastAPI(); app.include_router(ir.router)
|
||||
return TestClient(app), db
|
||||
|
||||
def test_update_requires_key(monkeypatch):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
client, db = _client(monkeypatch, tmp)
|
||||
db.create_task("t1", "gpt_image", {"prompt": "x"})
|
||||
r = client.post("/api/internal/image/update",
|
||||
json={"task_id": "t1", "status": "succeeded", "progress": 100})
|
||||
assert r.status_code == 422 or r.status_code == 401 # header 누락
|
||||
|
||||
def test_update_succeeds_with_key(monkeypatch):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
client, db = _client(monkeypatch, tmp)
|
||||
db.create_task("t1", "gpt_image", {"prompt": "x"})
|
||||
r = client.post("/api/internal/image/update",
|
||||
headers={"X-Internal-Key": "secret"},
|
||||
json={"task_id": "t1", "status": "succeeded", "progress": 100,
|
||||
"image_url": "/media/image/t1.png"})
|
||||
assert r.status_code == 200
|
||||
assert db.get_task("t1")["image_url"] == "/media/image/t1.png"
|
||||
|
||||
def test_update_unknown_task_404(monkeypatch):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
client, db = _client(monkeypatch, tmp)
|
||||
r = client.post("/api/internal/image/update",
|
||||
headers={"X-Internal-Key": "secret"},
|
||||
json={"task_id": "nope", "status": "failed", "progress": 0})
|
||||
assert r.status_code == 404
|
||||
43
image-lab/tests/test_main.py
Normal file
43
image-lab/tests/test_main.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import os, tempfile, importlib
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _client(monkeypatch, tmp):
|
||||
monkeypatch.setenv("IMAGE_DATA_DIR", tmp)
|
||||
import app.db as db
|
||||
importlib.reload(db)
|
||||
db.init_db()
|
||||
import app.main as main
|
||||
importlib.reload(main)
|
||||
pushed = []
|
||||
|
||||
async def fake_push(task_id, job_type, params):
|
||||
pushed.append((task_id, job_type, params))
|
||||
|
||||
monkeypatch.setattr(main, "_push_render_job", fake_push)
|
||||
return TestClient(main.app), db, pushed
|
||||
|
||||
|
||||
def test_providers_lists_three(monkeypatch):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
client, _, _ = _client(monkeypatch, tmp)
|
||||
r = client.get("/api/image/providers")
|
||||
ids = {p["id"] for p in r.json()["providers"]}
|
||||
assert ids == {"gpt_image", "nano_banana", "flux"}
|
||||
|
||||
|
||||
def test_generate_rejects_unknown_provider(monkeypatch):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
client, _, _ = _client(monkeypatch, tmp)
|
||||
r = client.post("/api/image/generate", json={"provider": "midjourney", "prompt": "x"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_generate_creates_task_and_pushes(monkeypatch):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
client, db, pushed = _client(monkeypatch, tmp)
|
||||
r = client.post("/api/image/generate", json={"provider": "gpt_image", "prompt": "a cat"})
|
||||
assert r.status_code == 200
|
||||
task_id = r.json()["task_id"]
|
||||
assert db.get_task(task_id)["status"] == "queued"
|
||||
assert pushed[0][1] == "gpt_image_generation"
|
||||
@@ -3,24 +3,15 @@ ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Korean fonts + Chromium runtime deps (Debian 12 / bookworm)
|
||||
# `playwright install --with-deps`를 쓰지 않는 이유: 그 명령은 Ubuntu 패키지명을
|
||||
# 사용해 Debian에서 ttf-ubuntu-font-family / ttf-unifont 등 없는 패키지를 시도
|
||||
# → apt 실패. 대신 Chromium이 실제 필요로 하는 라이브러리만 명시 설치.
|
||||
# Korean fonts (insta-lab가 자체 텍스트 처리는 안 하지만 향후 thumbnail 생성 등 위해 유지)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
fonts-noto-cjk fonts-noto-cjk-extra \
|
||||
libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \
|
||||
libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 \
|
||||
libxfixes3 libxrandr2 libgbm1 libxshmfence1 libpango-1.0-0 \
|
||||
libcairo2 libasound2 libatspi2.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
# --timeout 600 --retries 5: NAS 느린 네트워크/CPU에서 pip 다운로드 timeout 방지
|
||||
RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt
|
||||
RUN playwright install chromium
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
|
||||
17
insta-lab/app/auth.py
Normal file
17
insta-lab/app/auth.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""SP-4 — Windows worker → NAS internal webhook 인증.
|
||||
|
||||
X-Internal-Key 헤더를 .env의 INTERNAL_API_KEY와 비교.
|
||||
서버 측 키 미설정 시 401 (안전한 기본값).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from fastapi import Header, HTTPException
|
||||
|
||||
|
||||
def verify_internal_key(x_internal_key: str = Header(...)):
|
||||
expected = os.getenv("INTERNAL_API_KEY")
|
||||
if not expected:
|
||||
raise HTTPException(401, "INTERNAL_API_KEY not configured on server")
|
||||
if x_internal_key != expected:
|
||||
raise HTTPException(401, "Invalid X-Internal-Key")
|
||||
@@ -1,108 +1,7 @@
|
||||
"""Jinja → HTML → Playwright headless screenshot."""
|
||||
"""DEPRECATED 2026-05-19 — NAS에서 카드 렌더 안 함. Windows insta-render 워커로 이전됨.
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
기존 render_slate, init_browser, shutdown_browser는 모두 web-ai/services/insta-render/card_renderer.py로 이식.
|
||||
NAS insta-lab은 Redis push (queue:insta-render)만 담당.
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
from .config import CARDS_DIR, CARD_TEMPLATE_DIR
|
||||
from . import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_template_dir() -> str:
|
||||
"""Prefer config CARD_TEMPLATE_DIR if it exists; else fall back to in-repo templates/."""
|
||||
if os.path.isdir(CARD_TEMPLATE_DIR):
|
||||
return CARD_TEMPLATE_DIR
|
||||
return os.path.join(os.path.dirname(__file__), "templates")
|
||||
|
||||
|
||||
def _env() -> Environment:
|
||||
return Environment(
|
||||
loader=FileSystemLoader(_resolve_template_dir()),
|
||||
autoescape=select_autoescape(["html", "j2"]),
|
||||
)
|
||||
|
||||
|
||||
def _slate_dir(slate_id: int) -> str:
|
||||
out = os.path.join(CARDS_DIR, str(slate_id))
|
||||
os.makedirs(out, exist_ok=True)
|
||||
return out
|
||||
|
||||
|
||||
def _build_pages(slate: dict) -> List[dict]:
|
||||
cover = json.loads(slate["cover_copy"] or "{}")
|
||||
bodies = json.loads(slate["body_copies"] or "[]")
|
||||
cta = json.loads(slate["cta_copy"] or "{}")
|
||||
accent = cover.get("accent_color") or "#0F62FE"
|
||||
pages: List[dict] = []
|
||||
pages.append({
|
||||
"page_type": "cover", "page_no": 1, "total_pages": 10,
|
||||
"headline": cover.get("headline", ""), "body": cover.get("body", ""),
|
||||
"accent_color": accent, "cta": "",
|
||||
})
|
||||
for i, b in enumerate(bodies[:8]):
|
||||
pages.append({
|
||||
"page_type": "body", "page_no": i + 2, "total_pages": 10,
|
||||
"headline": b.get("headline", ""), "body": b.get("body", ""),
|
||||
"accent_color": accent, "cta": "",
|
||||
})
|
||||
pages.append({
|
||||
"page_type": "cta", "page_no": 10, "total_pages": 10,
|
||||
"headline": cta.get("headline", ""), "body": cta.get("body", ""),
|
||||
"accent_color": accent, "cta": cta.get("cta", ""),
|
||||
})
|
||||
return pages
|
||||
|
||||
|
||||
async def render_slate(slate_id: int, template: str = "default/card.html.j2") -> List[str]:
|
||||
slate = db.get_card_slate(slate_id)
|
||||
if not slate:
|
||||
raise ValueError(f"slate {slate_id} not found")
|
||||
env = _env()
|
||||
|
||||
# template 파일이 없으면 default로 폴백 (INSTA_DEFAULT_THEME가 import 안 된 theme이면 안전)
|
||||
template_full = Path(_resolve_template_dir()) / template
|
||||
if not template_full.exists():
|
||||
logger.warning("Template '%s' 없음 → 'default/card.html.j2'로 폴백", template)
|
||||
template = "default/card.html.j2"
|
||||
|
||||
tmpl = env.get_template(template)
|
||||
pages = _build_pages(slate)
|
||||
out_dir = _slate_dir(slate_id)
|
||||
paths: List[str] = []
|
||||
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch()
|
||||
try:
|
||||
ctx = await browser.new_context(viewport={"width": 1080, "height": 1350})
|
||||
page = await ctx.new_page()
|
||||
for spec in pages:
|
||||
html_str = tmpl.render(**spec)
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False, encoding="utf-8") as f:
|
||||
f.write(html_str)
|
||||
html_path = f.name
|
||||
try:
|
||||
await page.goto(f"file://{html_path}", wait_until="networkidle")
|
||||
out_path = os.path.join(out_dir, f"{spec['page_no']:02d}.png")
|
||||
await page.screenshot(path=out_path, full_page=False, omit_background=False)
|
||||
with open(out_path, "rb") as fp:
|
||||
file_hash = hashlib.md5(fp.read()).hexdigest()
|
||||
db.add_card_asset(slate_id, spec["page_no"], out_path, file_hash)
|
||||
paths.append(out_path)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(html_path)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
await browser.close()
|
||||
return paths
|
||||
이 파일은 임포트 호환성 위해서만 존재. 새 코드는 이 모듈을 import하지 말 것.
|
||||
"""
|
||||
|
||||
@@ -35,6 +35,13 @@ DEFAULT_PROMPT = """너는 인스타그램 카드 뉴스 카피라이터다.
|
||||
"suggested_caption": "<인스타 캡션 본문>",
|
||||
"hashtags": ["#태그1", "#태그2", ...]
|
||||
}}
|
||||
|
||||
[글자수 제약 — 카드 디자인 박스에 맞게 반드시 준수]
|
||||
- cover_copy.headline: 22자 이내
|
||||
- body_copies[].headline: 26자 이내
|
||||
- body_copies[].body: 120자 이내 (2~4문장)
|
||||
- cta_copy.headline: 22자 이내
|
||||
초과하면 잘리므로 간결하고 임팩트 있게 작성한다.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
"""사용자 디자인 PNG 10장 → Claude Sonnet Vision → Jinja card.html.j2 자동 생성.
|
||||
|
||||
CLI (이 phase 이후 추가): python -m app.design_importer <theme_name>
|
||||
⚠️ 실행 위치 — 로컬 권장:
|
||||
docker-compose의 insta-lab volume은 /app/data만 마운트. /app/app/templates는
|
||||
컨테이너 ephemeral이라 NAS docker exec로 돌리면 다음 rebuild에 결과물 소실됨.
|
||||
|
||||
로컬:
|
||||
cd insta-lab
|
||||
export ANTHROPIC_API_KEY=sk-ant-...
|
||||
python -m app.design_importer <theme> --templates-dir ./app/templates
|
||||
git add app/templates/<theme>/card.html.j2 && git commit + push
|
||||
|
||||
응급 hotfix만 NAS:
|
||||
docker exec insta-lab python -m app.design_importer <theme>
|
||||
docker cp insta-lab:/app/app/templates/<theme>/card.html.j2 ./<dst>
|
||||
# → 즉시 host repo에 commit + push (안 그러면 다음 rebuild에 소실)
|
||||
"""
|
||||
|
||||
import base64
|
||||
@@ -102,8 +115,16 @@ def _build_mapping(pngs: List[str]) -> Dict[str, int]:
|
||||
return mapping
|
||||
|
||||
|
||||
_EXPECTED_RATIO = 1080 / 1350 # 4:5 = 0.8
|
||||
_RATIO_TOLERANCE = 0.02 # ±2% (1122/1402 ≈ 0.80028도 통과)
|
||||
|
||||
|
||||
def _validate_images(pages_dir: Path) -> None:
|
||||
"""모든 PNG가 정확히 1080×1350인지 검증. 다르면 ValueError.
|
||||
"""모든 PNG가 4:5 종횡비(1080x1350 권장)에 가까운지 검증.
|
||||
|
||||
Vision은 base64로 원본을 분석하고 Playwright는 background-size: cover로
|
||||
1080x1350 컨테이너에 fit하므로 절대 사이즈는 유연. 단 종횡비가 어긋나면
|
||||
카드가 늘어나거나 잘리므로 ±2% 허용 범위 내에서만 통과.
|
||||
|
||||
early-exit 하지 않고 전체 파일을 검사한 뒤 한 메시지에 모아 raise.
|
||||
"""
|
||||
@@ -111,12 +132,17 @@ def _validate_images(pages_dir: Path) -> None:
|
||||
bad = []
|
||||
for png_path in sorted(pages_dir.glob("*.png")):
|
||||
with Image.open(png_path) as img:
|
||||
if img.size != _EXPECTED_SIZE:
|
||||
w, h = img.size
|
||||
if h == 0:
|
||||
bad.append((png_path.name, img.size))
|
||||
continue
|
||||
ratio = w / h
|
||||
if abs(ratio - _EXPECTED_RATIO) > _RATIO_TOLERANCE:
|
||||
bad.append((png_path.name, img.size))
|
||||
if bad:
|
||||
msg = "; ".join(f"{n}: {s[0]}x{s[1]}" for n, s in bad)
|
||||
raise ValueError(
|
||||
f"모든 카드 디자인은 1080x1350이어야 함. 잘못된 파일: {msg}"
|
||||
f"카드 디자인은 4:5 비율(1080x1350 권장)이어야 함. 잘못된 파일: {msg}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
69
insta-lab/app/internal_router.py
Normal file
69
insta-lab/app/internal_router.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""SP-4 — Windows insta-render → NAS internal webhook.
|
||||
|
||||
POST /api/internal/insta/update
|
||||
- X-Internal-Key 인증 필수
|
||||
- task DB row update (status, progress, result_path, error)
|
||||
- result_path는 nginx 서빙 경로 (예: /media/insta/{slate_id}/01.png)
|
||||
- succeeded 시 params에서 slate_id 추출 → result_id 세팅
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import db
|
||||
from .auth import verify_internal_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class UpdatePayload(BaseModel):
|
||||
task_id: str
|
||||
status: str = Field(..., description="processing|succeeded|failed")
|
||||
progress: int = Field(..., ge=0, le=100)
|
||||
result_path: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/internal/insta/update",
|
||||
dependencies=[Depends(verify_internal_key)],
|
||||
)
|
||||
def insta_update(payload: UpdatePayload):
|
||||
task = db.get_task(payload.task_id)
|
||||
if task is None:
|
||||
raise HTTPException(404, f"task not found: {payload.task_id}")
|
||||
|
||||
result_id = None
|
||||
if payload.status == "succeeded":
|
||||
try:
|
||||
# DB stores params (not input_data) from create_task
|
||||
params_data = json.loads(task.get("params") or "{}")
|
||||
result_id = params_data.get("slate_id")
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
db.update_task(
|
||||
payload.task_id,
|
||||
payload.status,
|
||||
payload.progress,
|
||||
message=payload.result_path or "",
|
||||
result_id=result_id,
|
||||
error=payload.error,
|
||||
)
|
||||
# succeeded 시 slate_status도 'rendered'로 갱신 (cutover 후 NAS가 처리)
|
||||
if payload.status == "succeeded" and result_id is not None:
|
||||
try:
|
||||
db.update_slate_status(result_id, "rendered")
|
||||
except Exception:
|
||||
logger.exception("update_slate_status %s 실패 (무시)", result_id)
|
||||
logger.info(
|
||||
"internal/insta/update task=%s status=%s progress=%d",
|
||||
payload.task_id, payload.status, payload.progress,
|
||||
)
|
||||
return {"ok": True}
|
||||
@@ -80,6 +80,7 @@ def extract_for_category(category: str, limit: int = KEYWORDS_PER_CATEGORY) -> L
|
||||
"articles_count": sum(1 for a in articles if kw["keyword"] in a["title"]),
|
||||
})
|
||||
saved.append({"id": kid, **kw, "category": category})
|
||||
logger.info(f"키워드 추출 완료: category={category!r}, count={len(saved)}")
|
||||
return saved
|
||||
|
||||
|
||||
|
||||
@@ -1,25 +1,37 @@
|
||||
"""FastAPI entrypoint for insta-lab."""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import zipfile
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, BackgroundTasks, Body, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from _shared.access_log import install as install_access_log
|
||||
|
||||
from .config import (
|
||||
CORS_ALLOW_ORIGINS, NAVER_CLIENT_ID, ANTHROPIC_API_KEY,
|
||||
INSTA_DATA_PATH, DB_PATH, DEFAULT_CATEGORY_SEEDS, KEYWORDS_PER_CATEGORY,
|
||||
INSTA_DEFAULT_THEME,
|
||||
)
|
||||
from . import db, news_collector, keyword_extractor, card_writer, card_renderer, trend_collector
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from . import db, news_collector, keyword_extractor, card_writer, trend_collector
|
||||
from .internal_router import router as internal_router
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379")
|
||||
redis_client = aioredis.from_url(REDIS_URL, decode_responses=False)
|
||||
|
||||
app = FastAPI()
|
||||
install_access_log(app)
|
||||
app.include_router(internal_router)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -31,11 +43,16 @@ app.add_middleware(
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
async def on_startup():
|
||||
os.makedirs(INSTA_DATA_PATH, exist_ok=True)
|
||||
db.init_db()
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def on_shutdown():
|
||||
pass
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True}
|
||||
@@ -146,12 +163,21 @@ async def _bg_create_slate(task_id: str, keyword: str, category: str, keyword_id
|
||||
try:
|
||||
db.update_task(task_id, "processing", 30, "카피 생성 중")
|
||||
sid = card_writer.write_slate(keyword=keyword, category=category)
|
||||
db.update_task(task_id, "processing", 70, "카드 렌더 중")
|
||||
await card_renderer.render_slate(sid, template=f"{INSTA_DEFAULT_THEME}/card.html.j2")
|
||||
db.update_slate_status(sid, "rendered")
|
||||
if keyword_id:
|
||||
db.mark_keyword_used(keyword_id)
|
||||
db.update_task(task_id, "succeeded", 100, "완료", result_id=sid)
|
||||
# Redis 큐에 push — Windows insta-render worker가 BLPOP 후 렌더
|
||||
from datetime import datetime, timezone, timedelta
|
||||
kst = timezone(timedelta(hours=9))
|
||||
payload = {
|
||||
"task_id": task_id,
|
||||
"kind": "insta",
|
||||
"params": {"slate_id": sid, "theme": INSTA_DEFAULT_THEME},
|
||||
"submitted_at": datetime.now(kst).isoformat(),
|
||||
}
|
||||
await redis_client.rpush("queue:insta-render", json.dumps(payload))
|
||||
logger.info(f"슬레이트 생성 완료: slate_id={sid}, keyword={keyword!r}, category={category!r}")
|
||||
# 사용자는 GET /api/insta/tasks/{task_id}로 폴링 — worker가 webhook으로 status update
|
||||
db.update_task(task_id, "processing", 70, "Redis 큐 푸시 → Windows worker 대기 중", result_id=sid)
|
||||
except Exception as e:
|
||||
logger.exception("create slate failed")
|
||||
db.update_task(task_id, "failed", 0, "", error=str(e))
|
||||
@@ -185,13 +211,21 @@ def get_slate(slate_id: int):
|
||||
|
||||
|
||||
async def _bg_render(task_id: str, slate_id: int):
|
||||
"""Redis 큐에 push. 실 렌더는 Windows insta-render worker."""
|
||||
try:
|
||||
db.update_task(task_id, "processing", 30, "재렌더 중")
|
||||
await card_renderer.render_slate(slate_id, template=f"{INSTA_DEFAULT_THEME}/card.html.j2")
|
||||
db.update_slate_status(slate_id, "rendered")
|
||||
db.update_task(task_id, "succeeded", 100, "완료", result_id=slate_id)
|
||||
from datetime import datetime, timezone, timedelta
|
||||
kst = timezone(timedelta(hours=9))
|
||||
payload = {
|
||||
"task_id": task_id,
|
||||
"kind": "insta",
|
||||
"params": {"slate_id": slate_id, "theme": INSTA_DEFAULT_THEME},
|
||||
"submitted_at": datetime.now(kst).isoformat(),
|
||||
}
|
||||
await redis_client.rpush("queue:insta-render", json.dumps(payload))
|
||||
logger.info(f"렌더 큐 푸시 완료: slate_id={slate_id}, task_id={task_id}")
|
||||
db.update_task(task_id, "processing", 30, "Redis 큐 푸시 → Windows worker 대기 중")
|
||||
except Exception as e:
|
||||
logger.exception("render failed")
|
||||
logger.exception("queue push failed")
|
||||
db.update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
|
||||
@@ -215,6 +249,39 @@ def get_asset(slate_id: int, page: int):
|
||||
return FileResponse(match["file_path"], media_type="image/png")
|
||||
|
||||
|
||||
@app.get("/api/insta/slates/{slate_id}/package")
|
||||
def download_package(slate_id: int):
|
||||
slate = db.get_card_slate(slate_id)
|
||||
if not slate:
|
||||
raise HTTPException(404, "slate not found")
|
||||
assets = sorted(db.list_card_assets(slate_id), key=lambda a: a["page_index"])
|
||||
if not assets:
|
||||
raise HTTPException(409, "아직 렌더된 카드가 없습니다")
|
||||
buf = io.BytesIO()
|
||||
written = 0
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for a in assets:
|
||||
fp = a["file_path"]
|
||||
if os.path.exists(fp):
|
||||
z.write(fp, arcname=f"{a['page_index']:02d}.png")
|
||||
written += 1
|
||||
caption = (slate.get("suggested_caption") or "").strip()
|
||||
tags = slate.get("hashtags") or []
|
||||
if isinstance(tags, str):
|
||||
try:
|
||||
tags = json.loads(tags)
|
||||
except Exception:
|
||||
tags = []
|
||||
caption_full = caption + ("\n\n" + " ".join(tags) if tags else "")
|
||||
z.writestr("caption.txt", caption_full)
|
||||
if written == 0:
|
||||
raise HTTPException(409, "렌더된 카드 파일이 없습니다")
|
||||
buf.seek(0)
|
||||
return StreamingResponse(buf, media_type="application/zip", headers={
|
||||
"Content-Disposition": f'attachment; filename="insta_slate_{slate_id}.zip"'
|
||||
})
|
||||
|
||||
|
||||
@app.delete("/api/insta/slates/{slate_id}")
|
||||
def delete_slate(slate_id: int):
|
||||
if not db.get_card_slate(slate_id):
|
||||
@@ -243,12 +310,40 @@ class TemplateBody(BaseModel):
|
||||
description: str = ""
|
||||
|
||||
|
||||
def _default_prompt_templates() -> dict:
|
||||
"""DB에 저장된 override가 없을 때 노출할 코드 기본값.
|
||||
생성 파이프라인이 실제로 폴백하는 값과 동일한 단일 소스를 사용."""
|
||||
return {
|
||||
"slate_writer": {
|
||||
"template": card_writer.DEFAULT_PROMPT,
|
||||
"description": "카드 10페이지 카피 생성 마스터 프롬프트 (Claude Sonnet). "
|
||||
"{category}/{keyword}/{articles} 치환자 필수.",
|
||||
},
|
||||
"category_seeds": {
|
||||
"template": json.dumps(DEFAULT_CATEGORY_SEEDS, ensure_ascii=False, indent=2),
|
||||
"description": "트렌드 수집·분류용 카테고리별 시드 키워드 (JSON). "
|
||||
"최상위 키가 분류 라벨로도 쓰임.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/insta/templates/prompts/{name}")
|
||||
def get_prompt(name: str):
|
||||
pt = db.get_prompt_template(name)
|
||||
if not pt:
|
||||
raise HTTPException(404)
|
||||
return pt
|
||||
if pt:
|
||||
return pt
|
||||
# DB override 없음 → 코드 기본값 노출 (편집 UI가 마스터 프롬프트를 보고 수정 가능)
|
||||
defaults = _default_prompt_templates()
|
||||
if name in defaults:
|
||||
d = defaults[name]
|
||||
return {
|
||||
"name": name,
|
||||
"template": d["template"],
|
||||
"description": d["description"],
|
||||
"updated_at": None,
|
||||
"is_default": True,
|
||||
}
|
||||
raise HTTPException(404)
|
||||
|
||||
|
||||
@app.put("/api/insta/templates/prompts/{name}")
|
||||
|
||||
@@ -3,52 +3,85 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700;900&display=swap');
|
||||
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css');
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
width: 1080px; height: 1350px;
|
||||
font-family: 'Noto Sans KR', sans-serif;
|
||||
html, body { width: 1080px; height: 1350px; }
|
||||
body {
|
||||
font-family: 'Pretendard', 'Noto Sans KR', sans-serif;
|
||||
background: #F7F7FA; color: #14171A;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.card {
|
||||
width: 1080px; height: 1350px;
|
||||
padding: 80px 72px;
|
||||
display: flex; flex-direction: column; justify-content: space-between;
|
||||
background: linear-gradient(180deg, #FFFFFF 0%, #F7F7FA 100%);
|
||||
border-top: 16px solid {{ accent_color }};
|
||||
position: relative; width: 1080px; height: 1350px; overflow: hidden;
|
||||
padding: 96px 84px 72px;
|
||||
display: flex; flex-direction: column;
|
||||
background: #FFFFFF;
|
||||
}
|
||||
.accent-bar { position: absolute; top: 0; left: 0; width: 100%; height: 14px; background: {{ accent_color | safe }}; }
|
||||
.badge {
|
||||
display: inline-block; padding: 8px 20px; border-radius: 999px;
|
||||
background: {{ accent_color }}; color: #fff;
|
||||
font-size: 28px; font-weight: 700; letter-spacing: -0.02em;
|
||||
align-self: flex-start; padding: 10px 24px; border-radius: 999px;
|
||||
background: {{ accent_color | safe }}; color: #fff;
|
||||
font-size: 30px; font-weight: 700; letter-spacing: -0.02em;
|
||||
}
|
||||
.idx { font-size: 120px; font-weight: 800; line-height: 1; color: {{ accent_color | safe }}; letter-spacing: -0.04em; }
|
||||
.content { flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 36px; }
|
||||
.headline {
|
||||
font-size: {{ 96 if page_type == 'cover' else 72 }}px;
|
||||
font-weight: 900; line-height: 1.15; letter-spacing: -0.04em;
|
||||
margin-top: 32px;
|
||||
font-weight: 800; line-height: 1.18; letter-spacing: -0.04em; color: #14171A;
|
||||
display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.body {
|
||||
font-size: 40px; font-weight: 400; line-height: 1.55;
|
||||
margin-top: 40px; color: #2A2F35;
|
||||
.cover .headline { font-size: 104px; -webkit-line-clamp: 4; }
|
||||
.body-page .headline { font-size: 76px; -webkit-line-clamp: 3; }
|
||||
.cta .headline { font-size: 88px; -webkit-line-clamp: 3; }
|
||||
.sub {
|
||||
font-size: 42px; font-weight: 400; line-height: 1.5; color: #3A4047;
|
||||
display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; -webkit-line-clamp: 8;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.cover .sub { -webkit-line-clamp: 5; }
|
||||
.footer {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 28px; color: #6B7280; font-weight: 500;
|
||||
font-size: 28px; color: #8A9099; font-weight: 600; margin-top: 40px;
|
||||
}
|
||||
.cta { font-weight: 700; color: {{ accent_color }}; }
|
||||
.cta-pill {
|
||||
align-self: flex-start; margin-top: 8px; padding: 18px 40px; border-radius: 16px;
|
||||
background: {{ accent_color | safe }}; color: #fff; font-size: 40px; font-weight: 700;
|
||||
}
|
||||
.progress { display: flex; gap: 10px; }
|
||||
.progress i { width: 14px; height: 14px; border-radius: 50%; background: #D8DCE0; display: inline-block; }
|
||||
.progress i.on { background: {{ accent_color | safe }}; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div>
|
||||
<span class="badge">{{ page_type|upper }}</span>
|
||||
<h1 class="headline">{{ headline }}</h1>
|
||||
<p class="body">{{ body }}</p>
|
||||
</div>
|
||||
<div class="card {{ 'cover' if page_type=='cover' else ('cta' if page_type=='cta' else 'body-page') }}">
|
||||
<div class="accent-bar"></div>
|
||||
|
||||
{% if page_type == 'cover' %}
|
||||
<span class="badge">{{ category_label|default('') or '오늘의 이슈' }}</span>
|
||||
<div class="content">
|
||||
<h1 class="headline">{{ headline }}</h1>
|
||||
<p class="sub">{{ body }}</p>
|
||||
</div>
|
||||
{% elif page_type == 'cta' %}
|
||||
<div class="content">
|
||||
<h1 class="headline">{{ headline }}</h1>
|
||||
<p class="sub">{{ body }}</p>
|
||||
{% if cta %}<div class="cta-pill">{{ cta }}</div>{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="idx">{{ '%02d'|format(page_no - 1) }}</span>
|
||||
<div class="content">
|
||||
<h1 class="headline">{{ headline }}</h1>
|
||||
<p class="sub">{{ body }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="footer">
|
||||
<span>{{ page_no }} / {{ total_pages }}</span>
|
||||
{% if cta %}<span class="cta">{{ cta }}</span>{% endif %}
|
||||
{% if page_type == 'cover' or page_type == 'cta' %}
|
||||
<span>{{ brand_handle|default('') }}</span><span>{{ page_no }} / {{ total_pages }}</span>
|
||||
{% else %}
|
||||
<div class="progress">{% for n in range(2, total_pages) %}<i class="{{ 'on' if n <= page_no }}"></i>{% endfor %}</div>
|
||||
<span>{{ page_no }} / {{ total_pages }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
788
insta-lab/app/templates/minimal/card.html.j2
Normal file
788
insta-lab/app/templates/minimal/card.html.j2
Normal file
@@ -0,0 +1,788 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Hedgy Card News – {{ page_no }}/10</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background: #d0d0d0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
font-family: 'Noto Sans KR', sans-serif;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
width: 1080px;
|
||||
height: 1350px;
|
||||
overflow: hidden;
|
||||
border-radius: 48px;
|
||||
background-size: cover;
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
/* ── shared overlay layer ── */
|
||||
.mask {
|
||||
position: absolute;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PAGE 1 insta_card_start.png
|
||||
bg: #f2f2f0 (light warm white)
|
||||
═══════════════════════════════════════════ */
|
||||
.p1-headline-mask {
|
||||
top: 222px; left: 48px;
|
||||
width: 580px; height: 150px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p1-headline-text {
|
||||
position: absolute;
|
||||
top: 222px; left: 48px;
|
||||
width: 580px; height: 150px;
|
||||
padding: 8px;
|
||||
font-size: 108px;
|
||||
font-weight: 900;
|
||||
color: #1e2235;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.p1-body-mask {
|
||||
top: 400px; left: 48px;
|
||||
width: 460px; height: 120px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p1-body-text {
|
||||
position: absolute;
|
||||
top: 400px; left: 48px;
|
||||
width: 460px; height: 120px;
|
||||
padding: 8px;
|
||||
font-size: 34px;
|
||||
font-weight: 500;
|
||||
color: #4a4e5e;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
.p1-cta-mask {
|
||||
top: 562px; left: 48px;
|
||||
width: 260px; height: 76px;
|
||||
background: #2f6ef7;
|
||||
border-radius: 38px;
|
||||
padding: 8px;
|
||||
}
|
||||
.p1-cta-text {
|
||||
position: absolute;
|
||||
top: 562px; left: 48px;
|
||||
width: 260px; height: 76px;
|
||||
border-radius: 38px;
|
||||
padding: 8px 24px;
|
||||
font-size: 34px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PAGE 2 insta_card_keyword.png
|
||||
bg: #3a3fdb (blue gradient)
|
||||
═══════════════════════════════════════════ */
|
||||
.p2-headline-mask {
|
||||
top: 148px; left: 56px;
|
||||
width: 880px; height: 200px;
|
||||
background: #3a3fdb;
|
||||
padding: 8px;
|
||||
}
|
||||
.p2-headline-text {
|
||||
position: absolute;
|
||||
top: 148px; left: 56px;
|
||||
width: 880px; height: 200px;
|
||||
padding: 8px;
|
||||
font-size: 88px;
|
||||
font-weight: 900;
|
||||
color: #ffffff;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.p2-body-mask {
|
||||
top: 370px; left: 56px;
|
||||
width: 880px; height: 80px;
|
||||
background: #3a3fdb;
|
||||
padding: 8px;
|
||||
}
|
||||
.p2-body-text {
|
||||
position: absolute;
|
||||
top: 370px; left: 56px;
|
||||
width: 880px; height: 80px;
|
||||
padding: 8px;
|
||||
font-size: 38px;
|
||||
font-weight: 500;
|
||||
color: #e0e4ff;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PAGE 3 insta_card_highlight.png
|
||||
bg: #3a3fdb
|
||||
═══════════════════════════════════════════ */
|
||||
.p3-headline-mask {
|
||||
top: 148px; left: 56px;
|
||||
width: 880px; height: 260px;
|
||||
background: #3a3fdb;
|
||||
padding: 8px;
|
||||
}
|
||||
.p3-headline-text {
|
||||
position: absolute;
|
||||
top: 148px; left: 56px;
|
||||
width: 880px; height: 260px;
|
||||
padding: 8px;
|
||||
font-size: 88px;
|
||||
font-weight: 900;
|
||||
color: #ffffff;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
.p3-body-mask {
|
||||
top: 430px; left: 56px;
|
||||
width: 880px; height: 80px;
|
||||
background: #3a3fdb;
|
||||
padding: 8px;
|
||||
}
|
||||
.p3-body-text {
|
||||
position: absolute;
|
||||
top: 430px; left: 56px;
|
||||
width: 880px; height: 80px;
|
||||
padding: 8px;
|
||||
font-size: 38px;
|
||||
font-weight: 500;
|
||||
color: #e0e4ff;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PAGE 4 insta_card_observation.png
|
||||
bg: #f2f2f0
|
||||
═══════════════════════════════════════════ */
|
||||
.p4-label-mask {
|
||||
top: 72px; left: 64px;
|
||||
width: 200px; height: 52px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p4-label-text {
|
||||
position: absolute;
|
||||
top: 72px; left: 64px;
|
||||
width: 200px; height: 52px;
|
||||
padding: 8px;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #2f6ef7;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.p4-headline-mask {
|
||||
top: 148px; left: 56px;
|
||||
width: 700px; height: 110px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p4-headline-text {
|
||||
position: absolute;
|
||||
top: 148px; left: 56px;
|
||||
width: 700px; height: 110px;
|
||||
padding: 8px;
|
||||
font-size: 72px;
|
||||
font-weight: 900;
|
||||
color: #1e2235;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.p4-body-mask {
|
||||
top: 290px; left: 56px;
|
||||
width: 700px; height: 180px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p4-body-text {
|
||||
position: absolute;
|
||||
top: 290px; left: 56px;
|
||||
width: 700px; height: 180px;
|
||||
padding: 8px;
|
||||
font-size: 36px;
|
||||
font-weight: 400;
|
||||
color: #3a3e50;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PAGE 5 insta_card_memo.png
|
||||
bg: #f2f2f0
|
||||
═══════════════════════════════════════════ */
|
||||
.p5-label-mask {
|
||||
top: 72px; left: 64px;
|
||||
width: 200px; height: 52px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p5-label-text {
|
||||
position: absolute;
|
||||
top: 72px; left: 64px;
|
||||
width: 200px; height: 52px;
|
||||
padding: 8px;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #2f6ef7;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.p5-headline-mask {
|
||||
top: 160px; left: 56px;
|
||||
width: 700px; height: 110px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p5-headline-text {
|
||||
position: absolute;
|
||||
top: 160px; left: 56px;
|
||||
width: 700px; height: 110px;
|
||||
padding: 8px;
|
||||
font-size: 70px;
|
||||
font-weight: 900;
|
||||
color: #1e2235;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.p5-body-mask {
|
||||
top: 308px; left: 56px;
|
||||
width: 700px; height: 180px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p5-body-text {
|
||||
position: absolute;
|
||||
top: 308px; left: 56px;
|
||||
width: 700px; height: 180px;
|
||||
padding: 8px;
|
||||
font-size: 34px;
|
||||
font-weight: 400;
|
||||
color: #3a3e50;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PAGE 6 insta_card_oneline.png
|
||||
bg: #f5f4f2
|
||||
═══════════════════════════════════════════ */
|
||||
.p6-headline-mask {
|
||||
top: 188px; left: 96px;
|
||||
width: 820px; height: 240px;
|
||||
background: #f5f4f2;
|
||||
padding: 8px;
|
||||
}
|
||||
.p6-headline-text {
|
||||
position: absolute;
|
||||
top: 188px; left: 96px;
|
||||
width: 820px; height: 240px;
|
||||
padding: 8px;
|
||||
font-size: 68px;
|
||||
font-weight: 900;
|
||||
color: #1e2235;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
.p6-body-mask {
|
||||
top: 448px; left: 96px;
|
||||
width: 620px; height: 120px;
|
||||
background: #f5f4f2;
|
||||
padding: 8px;
|
||||
}
|
||||
.p6-body-text {
|
||||
position: absolute;
|
||||
top: 448px; left: 96px;
|
||||
width: 620px; height: 120px;
|
||||
padding: 8px;
|
||||
font-size: 34px;
|
||||
font-weight: 400;
|
||||
color: #5a5e70;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PAGE 7 insta_card_checklist.png
|
||||
bg: #f5f4f2
|
||||
═══════════════════════════════════════════ */
|
||||
.p7-headline-mask {
|
||||
top: 110px; left: 56px;
|
||||
width: 740px; height: 110px;
|
||||
background: #f5f4f2;
|
||||
padding: 8px;
|
||||
}
|
||||
.p7-headline-text {
|
||||
position: absolute;
|
||||
top: 110px; left: 56px;
|
||||
width: 740px; height: 110px;
|
||||
padding: 8px;
|
||||
font-size: 74px;
|
||||
font-weight: 900;
|
||||
color: #1e2235;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
/* checklist items – 4 rows */
|
||||
.p7-item1-mask { top: 258px; left: 164px; width: 720px; height: 80px; background: #f5f4f2; padding: 8px; }
|
||||
.p7-item1-text { position: absolute; top: 258px; left: 164px; width: 720px; height: 80px; padding: 8px; font-size: 40px; font-weight: 500; color: #2a2d3e; word-wrap: break-word; overflow: hidden; display: flex; align-items: center; }
|
||||
.p7-item2-mask { top: 388px; left: 164px; width: 720px; height: 80px; background: #f5f4f2; padding: 8px; }
|
||||
.p7-item2-text { position: absolute; top: 388px; left: 164px; width: 720px; height: 80px; padding: 8px; font-size: 40px; font-weight: 500; color: #2a2d3e; word-wrap: break-word; overflow: hidden; display: flex; align-items: center; }
|
||||
.p7-item3-mask { top: 518px; left: 164px; width: 720px; height: 80px; background: #f5f4f2; padding: 8px; }
|
||||
.p7-item3-text { position: absolute; top: 518px; left: 164px; width: 720px; height: 80px; padding: 8px; font-size: 40px; font-weight: 500; color: #2a2d3e; word-wrap: break-word; overflow: hidden; display: flex; align-items: center; }
|
||||
.p7-item4-mask { top: 648px; left: 164px; width: 720px; height: 80px; background: #f5f4f2; padding: 8px; }
|
||||
.p7-item4-text { position: absolute; top: 648px; left: 164px; width: 720px; height: 80px; padding: 8px; font-size: 40px; font-weight: 500; color: #2a2d3e; word-wrap: break-word; overflow: hidden; display: flex; align-items: center; }
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PAGE 8 insta_card_study.png
|
||||
bg: #f2f2f0
|
||||
═══════════════════════════════════════════ */
|
||||
.p8-label-mask {
|
||||
top: 72px; left: 64px;
|
||||
width: 200px; height: 52px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p8-label-text {
|
||||
position: absolute;
|
||||
top: 72px; left: 64px;
|
||||
width: 200px; height: 52px;
|
||||
padding: 8px;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #2f6ef7;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.p8-headline-mask {
|
||||
top: 160px; left: 56px;
|
||||
width: 700px; height: 110px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p8-headline-text {
|
||||
position: absolute;
|
||||
top: 160px; left: 56px;
|
||||
width: 700px; height: 110px;
|
||||
padding: 8px;
|
||||
font-size: 72px;
|
||||
font-weight: 900;
|
||||
color: #1e2235;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.p8-body-mask {
|
||||
top: 306px; left: 56px;
|
||||
width: 700px; height: 180px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p8-body-text {
|
||||
position: absolute;
|
||||
top: 306px; left: 56px;
|
||||
width: 700px; height: 180px;
|
||||
padding: 8px;
|
||||
font-size: 34px;
|
||||
font-weight: 400;
|
||||
color: #3a3e50;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PAGE 9 insta_card_cta.png
|
||||
bg: #f5f4f2
|
||||
═══════════════════════════════════════════ */
|
||||
.p9-headline-mask {
|
||||
top: 182px; left: 56px;
|
||||
width: 970px; height: 120px;
|
||||
background: #f5f4f2;
|
||||
padding: 8px;
|
||||
}
|
||||
.p9-headline-text {
|
||||
position: absolute;
|
||||
top: 182px; left: 56px;
|
||||
width: 970px; height: 120px;
|
||||
padding: 8px;
|
||||
font-size: 82px;
|
||||
font-weight: 900;
|
||||
color: #1e2235;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
.p9-cta-mask {
|
||||
top: 332px; left: 180px;
|
||||
width: 720px; height: 88px;
|
||||
background: #2244cc;
|
||||
border-radius: 44px;
|
||||
padding: 8px;
|
||||
}
|
||||
.p9-cta-text {
|
||||
position: absolute;
|
||||
top: 332px; left: 180px;
|
||||
width: 720px; height: 88px;
|
||||
border-radius: 44px;
|
||||
padding: 8px;
|
||||
font-size: 42px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.p9-body-mask {
|
||||
top: 980px; left: 56px;
|
||||
width: 860px; height: 60px;
|
||||
background: #f5f4f2;
|
||||
padding: 8px;
|
||||
}
|
||||
.p9-body-text {
|
||||
position: absolute;
|
||||
top: 980px; left: 56px;
|
||||
width: 860px; height: 60px;
|
||||
padding: 8px;
|
||||
font-size: 30px;
|
||||
font-weight: 400;
|
||||
color: #5a5e70;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PAGE 10 insta_card_finish.png
|
||||
bg: #f2f2f0
|
||||
═══════════════════════════════════════════ */
|
||||
.p10-label-mask {
|
||||
top: 72px; left: 64px;
|
||||
width: 200px; height: 52px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p10-label-text {
|
||||
position: absolute;
|
||||
top: 72px; left: 64px;
|
||||
width: 200px; height: 52px;
|
||||
padding: 8px;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #2f6ef7;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.p10-headline-mask {
|
||||
top: 155px; left: 56px;
|
||||
width: 700px; height: 110px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p10-headline-text {
|
||||
position: absolute;
|
||||
top: 155px; left: 56px;
|
||||
width: 700px; height: 110px;
|
||||
padding: 8px;
|
||||
font-size: 72px;
|
||||
font-weight: 900;
|
||||
color: #1e2235;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.p10-body-mask {
|
||||
top: 302px; left: 56px;
|
||||
width: 680px; height: 180px;
|
||||
background: #f2f2f0;
|
||||
padding: 8px;
|
||||
}
|
||||
.p10-body-text {
|
||||
position: absolute;
|
||||
top: 302px; left: 56px;
|
||||
width: 680px; height: 180px;
|
||||
padding: 8px;
|
||||
font-size: 34px;
|
||||
font-weight: 400;
|
||||
color: #3a3e50;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* checklist icon (page 7) */
|
||||
.check-icon {
|
||||
position: absolute;
|
||||
width: 76px; height: 76px;
|
||||
background: #3366ee;
|
||||
border-radius: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.check-icon svg { width: 44px; height: 44px; }
|
||||
|
||||
/* quote mark (page 2 & 3) */
|
||||
.quote-mark {
|
||||
position: absolute;
|
||||
font-size: 100px;
|
||||
font-weight: 900;
|
||||
color: #ffffff;
|
||||
line-height: 1;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
/* left bar (page 6) */
|
||||
.left-bar {
|
||||
position: absolute;
|
||||
top: 196px; left: 64px;
|
||||
width: 10px; height: 232px;
|
||||
background: #7c5ce0;
|
||||
border-radius: 5px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
{% if page_no == 1 %}
|
||||
<!-- ══════════════════════════════════════
|
||||
PAGE 1 · COVER · insta_card_start.png
|
||||
══════════════════════════════════════ -->
|
||||
<div class="card" style="background-image: url('pages/insta_card_start.png');">
|
||||
<!-- headline mask + text -->
|
||||
<div class="mask p1-headline-mask"></div>
|
||||
<div class="mask p1-headline-text">{{ headline }}</div>
|
||||
<!-- body mask + text -->
|
||||
<div class="mask p1-body-mask"></div>
|
||||
<div class="mask p1-body-text">{{ body }}</div>
|
||||
<!-- cta mask + text -->
|
||||
<div class="mask p1-cta-mask"></div>
|
||||
<div class="mask p1-cta-text">{{ cta }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if page_no == 2 %}
|
||||
<!-- ══════════════════════════════════════
|
||||
PAGE 2 · insta_card_keyword.png
|
||||
══════════════════════════════════════ -->
|
||||
<div class="card" style="background-image: url('pages/insta_card_keyword.png');">
|
||||
<!-- quote mark mask -->
|
||||
<div class="mask" style="top:60px;left:48px;width:120px;height:100px;background:#3a3fdb;padding:8px;"></div>
|
||||
<div class="quote-mark" style="top:52px;left:50px;">"</div>
|
||||
<!-- headline -->
|
||||
<div class="mask p2-headline-mask"></div>
|
||||
<div class="mask p2-headline-text">{{ headline }}</div>
|
||||
<!-- body -->
|
||||
<div class="mask p2-body-mask"></div>
|
||||
<div class="mask p2-body-text">{{ body }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if page_no == 3 %}
|
||||
<!-- ══════════════════════════════════════
|
||||
PAGE 3 · insta_card_highlight.png
|
||||
══════════════════════════════════════ -->
|
||||
<div class="card" style="background-image: url('pages/insta_card_highlight.png');">
|
||||
<!-- quote mark mask -->
|
||||
<div class="mask" style="top:60px;left:48px;width:120px;height:100px;background:#3a3fdb;padding:8px;"></div>
|
||||
<div class="quote-mark" style="top:52px;left:50px;">"</div>
|
||||
<!-- headline -->
|
||||
<div class="mask p3-headline-mask"></div>
|
||||
<div class="mask p3-headline-text">{{ headline }}</div>
|
||||
<!-- body -->
|
||||
<div class="mask p3-body-mask"></div>
|
||||
<div class="mask p3-body-text">{{ body }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if page_no == 4 %}
|
||||
<!-- ══════════════════════════════════════
|
||||
PAGE 4 · insta_card_observation.png
|
||||
══════════════════════════════════════ -->
|
||||
<div class="card" style="background-image: url('pages/insta_card_observation.png');">
|
||||
<!-- day label -->
|
||||
<div class="mask p4-label-mask"></div>
|
||||
<div class="mask p4-label-text">{{ label }}</div>
|
||||
<!-- headline -->
|
||||
<div class="mask p4-headline-mask"></div>
|
||||
<div class="mask p4-headline-text">{{ headline }}</div>
|
||||
<!-- body -->
|
||||
<div class="mask p4-body-mask"></div>
|
||||
<div class="mask p4-body-text">{{ body }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if page_no == 5 %}
|
||||
<!-- ══════════════════════════════════════
|
||||
PAGE 5 · insta_card_memo.png
|
||||
══════════════════════════════════════ -->
|
||||
<div class="card" style="background-image: url('pages/insta_card_memo.png');">
|
||||
<!-- day label -->
|
||||
<div class="mask p5-label-mask"></div>
|
||||
<div class="mask p5-label-text">{{ label }}</div>
|
||||
<!-- headline -->
|
||||
<div class="mask p5-headline-mask"></div>
|
||||
<div class="mask p5-headline-text">{{ headline }}</div>
|
||||
<!-- body -->
|
||||
<div class="mask p5-body-mask"></div>
|
||||
<div class="mask p5-body-text">{{ body }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if page_no == 6 %}
|
||||
<!-- ══════════════════════════════════════
|
||||
PAGE 6 · insta_card_oneline.png
|
||||
══════════════════════════════════════ -->
|
||||
<div class="card" style="background-image: url('pages/insta_card_oneline.png');">
|
||||
<!-- purple left bar -->
|
||||
<div class="left-bar"></div>
|
||||
<!-- headline -->
|
||||
<div class="mask p6-headline-mask"></div>
|
||||
<div class="mask p6-headline-text">{{ headline }}</div>
|
||||
<!-- body -->
|
||||
<div class="mask p6-body-mask"></div>
|
||||
<div class="mask p6-body-text">{{ body }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if page_no == 7 %}
|
||||
<!-- ══════════════════════════════════════
|
||||
PAGE 7 · insta_card_checklist.png
|
||||
══════════════════════════════════════ -->
|
||||
<div class="card" style="background-image: url('pages/insta_card_checklist.png');">
|
||||
<!-- section title -->
|
||||
<div class="mask p7-headline-mask"></div>
|
||||
<div class="mask p7-headline-text">{{ headline }}</div>
|
||||
|
||||
<!-- check icons -->
|
||||
<div class="check-icon" style="top:252px;left:56px;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</div>
|
||||
<div class="check-icon" style="top:382px;left:56px;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</div>
|
||||
<div class="check-icon" style="top:512px;left:56px;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</div>
|
||||
<div class="check-icon" style="top:642px;left:56px;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</div>
|
||||
|
||||
<!-- checklist items -->
|
||||
<div class="mask p7-item1-mask"></div>
|
||||
<div class="mask p7-item1-text">{{ item1 }}</div>
|
||||
<div class="mask p7-item2-mask"></div>
|
||||
<div class="mask p7-item2-text">{{ item2 }}</div>
|
||||
<div class="mask p7-item3-mask"></div>
|
||||
<div class="mask p7-item3-text">{{ item3 }}</div>
|
||||
<div class="mask p7-item4-mask"></div>
|
||||
<div class="mask p7-item4-text">{{ item4 }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if page_no == 8 %}
|
||||
<!-- ══════════════════════════════════════
|
||||
PAGE 8 · insta_card_study.png
|
||||
══════════════════════════════════════ -->
|
||||
<div class="card" style="background-image: url('pages/insta_card_study.png');">
|
||||
<!-- day label -->
|
||||
<div class="mask p8-label-mask"></div>
|
||||
<div class="mask p8-label-text">{{ label }}</div>
|
||||
<!-- headline -->
|
||||
<div class="mask p8-headline-mask"></div>
|
||||
<div class="mask p8-headline-text">{{ headline }}</div>
|
||||
<!-- body -->
|
||||
<div class="mask p8-body-mask"></div>
|
||||
<div class="mask p8-body-text">{{ body }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if page_no == 9 %}
|
||||
<!-- ══════════════════════════════════════
|
||||
PAGE 9 · insta_card_cta.png
|
||||
══════════════════════════════════════ -->
|
||||
<div class="card" style="background-image: url('pages/insta_card_cta.png');">
|
||||
<!-- headline -->
|
||||
<div class="mask p9-headline-mask"></div>
|
||||
<div class="mask p9-headline-text">{{ headline }}</div>
|
||||
<!-- cta button -->
|
||||
<div class="mask p9-cta-mask"></div>
|
||||
<div class="mask p9-cta-text">{{ cta }}</div>
|
||||
<!-- body / next episode teaser -->
|
||||
<div class="mask p9-body-mask"></div>
|
||||
<div class="mask p9-body-text">{{ body }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if page_no == 10 %}
|
||||
<!-- ══════════════════════════════════════
|
||||
PAGE 10 · insta_card_finish.png
|
||||
══════════════════════════════════════ -->
|
||||
<div class="card" style="background-image: url('pages/insta_card_finish.png');">
|
||||
<!-- day label -->
|
||||
<div class="mask p10-label-mask"></div>
|
||||
<div class="mask p10-label-text">{{ label }}</div>
|
||||
<!-- headline -->
|
||||
<div class="mask p10-headline-mask"></div>
|
||||
<div class="mask p10-headline-text">{{ headline }}</div>
|
||||
<!-- body -->
|
||||
<div class="mask p10-body-mask"></div>
|
||||
<div class="mask p10-body-text">{{ body }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
12
insta-lab/app/templates/minimal/pages/_order.json
Normal file
12
insta-lab/app/templates/minimal/pages/_order.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"insta_card_start.png": 1,
|
||||
"insta_card_keyword.png": 2,
|
||||
"insta_card_highlight.png": 3,
|
||||
"insta_card_observation.png": 4,
|
||||
"insta_card_memo.png": 5,
|
||||
"insta_card_oneline.png": 6,
|
||||
"insta_card_checklist.png": 7,
|
||||
"insta_card_study.png": 8,
|
||||
"insta_card_cta.png": 9,
|
||||
"insta_card_finish.png": 10
|
||||
}
|
||||
9
insta-lab/app/test_card_writer_prompt.py
Normal file
9
insta-lab/app/test_card_writer_prompt.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from app import card_writer
|
||||
|
||||
|
||||
def test_default_prompt_has_length_guidance():
|
||||
p = card_writer.DEFAULT_PROMPT
|
||||
# 글자수 가이드가 프롬프트에 포함됐는지
|
||||
assert "22자" in p and "120자" in p
|
||||
# 포맷 placeholder는 유지
|
||||
assert "{category}" in p and "{keyword}" in p and "{articles}" in p
|
||||
67
insta-lab/app/test_package_api.py
Normal file
67
insta-lab/app/test_package_api.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import io, os, tempfile, zipfile, sys
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _client(monkeypatch):
|
||||
# Insert web-backend root (3 levels up from this file) so _shared is importable
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
from app import config, db
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setattr(config, "INSTA_DATA_PATH", tmp, raising=False)
|
||||
monkeypatch.setattr(db, "DB_PATH", os.path.join(tmp, "insta.db"), raising=False)
|
||||
db.init_db()
|
||||
from app.main import app
|
||||
return TestClient(app), db, tmp
|
||||
|
||||
|
||||
def test_package_zip_contains_pngs_and_caption(monkeypatch):
|
||||
client, db, tmp = _client(monkeypatch)
|
||||
# 슬레이트 + 2개 asset(실제 PNG 파일) 시드
|
||||
sid = db.add_card_slate({
|
||||
"keyword": "k",
|
||||
"category": "economy",
|
||||
"status": "rendered",
|
||||
"cover_copy": {"headline": "h"},
|
||||
"body_copies": [{"headline": "b", "body": "x"}] * 8,
|
||||
"cta_copy": {},
|
||||
"suggested_caption": "캡션입니다",
|
||||
"hashtags": ["#a", "#b"],
|
||||
})
|
||||
cards_dir = os.path.join(tmp, "insta_cards", str(sid))
|
||||
os.makedirs(cards_dir, exist_ok=True)
|
||||
for pg in (1, 2):
|
||||
fp = os.path.join(cards_dir, f"{pg:02d}.png")
|
||||
with open(fp, "wb") as f:
|
||||
f.write(b"\x89PNG\r\n" + b"0" * 2000)
|
||||
db.add_card_asset(slate_id=sid, page_index=pg, file_path=fp)
|
||||
r = client.get(f"/api/insta/slates/{sid}/package")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"] == "application/zip"
|
||||
z = zipfile.ZipFile(io.BytesIO(r.content))
|
||||
names = z.namelist()
|
||||
assert any(n.endswith(".png") for n in names)
|
||||
assert "caption.txt" in names
|
||||
cap = z.read("caption.txt").decode("utf-8")
|
||||
assert "캡션입니다" in cap and "#a" in cap
|
||||
|
||||
|
||||
def test_package_unknown_slate_404(monkeypatch):
|
||||
client, db, tmp = _client(monkeypatch)
|
||||
r = client.get("/api/insta/slates/999999/package")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_package_no_assets_409(monkeypatch):
|
||||
client, db, tmp = _client(monkeypatch)
|
||||
sid = db.add_card_slate({
|
||||
"keyword": "k",
|
||||
"category": "economy",
|
||||
"status": "draft",
|
||||
"cover_copy": {"headline": "h"},
|
||||
"body_copies": [{"headline": "b", "body": "x"}] * 8,
|
||||
"cta_copy": {},
|
||||
"suggested_caption": "c",
|
||||
"hashtags": [],
|
||||
})
|
||||
r = client.get(f"/api/insta/slates/{sid}/package")
|
||||
assert r.status_code == 409
|
||||
@@ -4,6 +4,7 @@ requests==2.32.3
|
||||
httpx>=0.27
|
||||
anthropic==0.52.0
|
||||
jinja2>=3.1.4
|
||||
playwright==1.48.0
|
||||
Pillow>=10
|
||||
pytest>=8.0
|
||||
pytest-asyncio>=0.24
|
||||
redis>=5.0
|
||||
|
||||
25
insta-lab/tests/test_auth.py
Normal file
25
insta-lab/tests/test_auth.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""verify_internal_key dependency — Windows webhook 인증."""
|
||||
import os
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from app.auth import verify_internal_key
|
||||
|
||||
|
||||
def test_valid_key_passes(monkeypatch):
|
||||
monkeypatch.setenv("INTERNAL_API_KEY", "secret123")
|
||||
# dependency가 raise 안 하면 통과
|
||||
verify_internal_key(x_internal_key="secret123")
|
||||
|
||||
|
||||
def test_invalid_key_raises_401(monkeypatch):
|
||||
monkeypatch.setenv("INTERNAL_API_KEY", "secret123")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
verify_internal_key(x_internal_key="wrong")
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
|
||||
def test_missing_env_key_raises_401(monkeypatch):
|
||||
monkeypatch.delenv("INTERNAL_API_KEY", raising=False)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
verify_internal_key(x_internal_key="any")
|
||||
assert exc.value.status_code == 401
|
||||
@@ -1,59 +0,0 @@
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db as db_module
|
||||
from app import card_renderer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_db_and_dirs(monkeypatch, tmp_path):
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
monkeypatch.setattr(db_module, "DB_PATH", path)
|
||||
monkeypatch.setattr(card_renderer, "CARDS_DIR", str(tmp_path / "cards"))
|
||||
db_module.init_db()
|
||||
yield path
|
||||
import gc
|
||||
gc.collect()
|
||||
for ext in ("", "-wal", "-shm"):
|
||||
try:
|
||||
os.remove(path + ext)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _seed_slate() -> int:
|
||||
return db_module.add_card_slate({
|
||||
"keyword": "테스트",
|
||||
"category": "economy",
|
||||
"status": "draft",
|
||||
"cover_copy": {"headline": "커버 헤드라인", "body": "서브카피", "accent_color": "#0F62FE"},
|
||||
"body_copies": [{"headline": f"본문 {i+1}", "body": f"내용 {i+1}"} for i in range(8)],
|
||||
"cta_copy": {"headline": "마무리", "body": "감사합니다", "cta": "팔로우"},
|
||||
})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_slate_produces_ten_pngs(tmp_db_and_dirs):
|
||||
sid = _seed_slate()
|
||||
paths = await card_renderer.render_slate(sid)
|
||||
assert len(paths) == 10
|
||||
for p in paths:
|
||||
assert os.path.exists(p)
|
||||
assert os.path.getsize(p) > 1000 # > 1 KB sanity
|
||||
db_module.update_slate_status(sid, "rendered")
|
||||
assets = db_module.list_card_assets(sid)
|
||||
assert {a["page_index"] for a in assets} == set(range(1, 11))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_falls_back_to_default_when_theme_html_missing(tmp_db_and_dirs):
|
||||
"""존재하지 않는 theme HTML 지정 시 default/card.html.j2로 폴백, 정상 PNG 생성."""
|
||||
sid = _seed_slate()
|
||||
paths = await card_renderer.render_slate(sid, template="ghost_theme/card.html.j2")
|
||||
assert len(paths) == 10
|
||||
for p in paths:
|
||||
assert os.path.exists(p)
|
||||
assert os.path.getsize(p) > 1000
|
||||
@@ -86,6 +86,14 @@ def _make_png(path: Path, size: tuple[int, int]) -> None:
|
||||
Image.new("RGB", size, color=(200, 200, 200)).save(path, format="PNG")
|
||||
|
||||
|
||||
def test_validate_images_accepts_higher_resolution_4_5_ratio(tmp_theme):
|
||||
"""1080x1350 외에도 같은 4:5 비율이면 통과 (예: 1122x1402, 디자인 도구 export 흔한 사이즈)."""
|
||||
pages = tmp_theme / "pages"
|
||||
for i in range(10):
|
||||
_make_png(pages / f"insta_card_{i:02d}.png", (1122, 1402))
|
||||
design_importer._validate_images(pages) # 예외 없으면 통과
|
||||
|
||||
|
||||
def test_validate_images_accepts_1080x1350(tmp_theme):
|
||||
pages = tmp_theme / "pages"
|
||||
for i in range(10):
|
||||
|
||||
80
insta-lab/tests/test_internal_router.py
Normal file
80
insta-lab/tests/test_internal_router.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""POST /api/internal/insta/update — Windows worker webhook."""
|
||||
import os
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from app.internal_router import router
|
||||
from app import db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _set_key(monkeypatch):
|
||||
monkeypatch.setenv("INTERNAL_API_KEY", "test-secret")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch):
|
||||
# SQLite in-memory test
|
||||
monkeypatch.setenv("INSTA_DATA_PATH", str(tmp_path))
|
||||
db.init_db()
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _make_task():
|
||||
return db.create_task("slate_render", {"slate_id": 42})
|
||||
|
||||
|
||||
def test_update_with_valid_key_updates_db(client):
|
||||
tid = _make_task()
|
||||
r = client.post(
|
||||
"/api/internal/insta/update",
|
||||
headers={"X-Internal-Key": "test-secret"},
|
||||
json={"task_id": tid, "status": "processing", "progress": 30},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
task = db.get_task(tid)
|
||||
assert task["status"] == "processing"
|
||||
assert task["progress"] == 30
|
||||
|
||||
|
||||
def test_update_with_invalid_key_returns_401(client):
|
||||
tid = _make_task()
|
||||
r = client.post(
|
||||
"/api/internal/insta/update",
|
||||
headers={"X-Internal-Key": "wrong"},
|
||||
json={"task_id": tid, "status": "processing", "progress": 30},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_update_succeeded_sets_result_path(client):
|
||||
tid = _make_task()
|
||||
r = client.post(
|
||||
"/api/internal/insta/update",
|
||||
headers={"X-Internal-Key": "test-secret"},
|
||||
json={
|
||||
"task_id": tid,
|
||||
"status": "succeeded",
|
||||
"progress": 100,
|
||||
"result_path": "/media/insta/42/01.png",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
task = db.get_task(tid)
|
||||
assert task["status"] == "succeeded"
|
||||
assert task["result_id"] is not None # slate_id from input_data
|
||||
|
||||
|
||||
def test_update_failed_records_error(client):
|
||||
tid = _make_task()
|
||||
r = client.post(
|
||||
"/api/internal/insta/update",
|
||||
headers={"X-Internal-Key": "test-secret"},
|
||||
json={"task_id": tid, "status": "failed", "progress": 0, "error": "Chromium crashed"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
task = db.get_task(tid)
|
||||
assert task["status"] == "failed"
|
||||
assert "Chromium" in (task.get("error") or "")
|
||||
@@ -58,7 +58,11 @@ def test_keywords_listing(client):
|
||||
|
||||
|
||||
def test_create_slate_kicks_background_task(client, monkeypatch):
|
||||
from app import main, card_writer, card_renderer
|
||||
"""Plan-B-Insta SP-4: 슬레이트 생성 후 Redis push → task status=processing (Windows worker 대기).
|
||||
|
||||
card_renderer는 NAS에서 제거됨. write_slate → Redis rpush 경로만 검증.
|
||||
"""
|
||||
from app import main, card_writer
|
||||
|
||||
def fake_write(keyword, category, articles=None):
|
||||
return db_module.add_card_slate({
|
||||
@@ -68,24 +72,25 @@ def test_create_slate_kicks_background_task(client, monkeypatch):
|
||||
"cta_copy": {"headline": "C", "body": "B", "cta": "F"},
|
||||
})
|
||||
|
||||
async def fake_render(slate_id, template="default/card.html.j2"):
|
||||
for i in range(1, 11):
|
||||
db_module.add_card_asset(slate_id, i, f"/tmp/{slate_id}_{i}.png", "h")
|
||||
return [f"/tmp/{slate_id}_{i}.png" for i in range(1, 11)]
|
||||
async def fake_rpush(queue, payload):
|
||||
pass # Redis 없이도 테스트 통과
|
||||
|
||||
monkeypatch.setattr(card_writer, "write_slate", fake_write)
|
||||
monkeypatch.setattr(card_renderer, "render_slate", fake_render)
|
||||
monkeypatch.setattr(main.redis_client, "rpush", fake_rpush)
|
||||
|
||||
resp = client.post("/api/insta/slates", json={"keyword": "K", "category": "economy"})
|
||||
assert resp.status_code == 200
|
||||
task_id = resp.json()["task_id"]
|
||||
# poll task
|
||||
# 잠시 대기 후 폴링 — background task가 완료될 때까지
|
||||
import time
|
||||
for _ in range(20):
|
||||
st = client.get(f"/api/insta/tasks/{task_id}").json()
|
||||
if st["status"] in ("succeeded", "failed"):
|
||||
if st["status"] != "pending":
|
||||
break
|
||||
assert st["status"] == "succeeded"
|
||||
time.sleep(0.1)
|
||||
# Redis push 후 task는 processing 상태 (Windows worker가 rendered로 전환)
|
||||
assert st["status"] == "processing"
|
||||
assert st["result_id"] is not None # slate_id가 result_id에 기록됨
|
||||
slate_id = st["result_id"]
|
||||
detail = client.get(f"/api/insta/slates/{slate_id}").json()
|
||||
assert detail["status"] == "rendered"
|
||||
assert len(detail["assets"]) == 10
|
||||
assert detail["keyword"] == "K"
|
||||
|
||||
63
insta-lab/tests/test_main_prompt_defaults.py
Normal file
63
insta-lab/tests/test_main_prompt_defaults.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import os
|
||||
import gc
|
||||
import json
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import db as db_module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
monkeypatch.setattr(db_module, "DB_PATH", path)
|
||||
db_module.init_db()
|
||||
from app import main
|
||||
monkeypatch.setattr(main, "DB_PATH", path)
|
||||
with TestClient(main.app) as c:
|
||||
yield c
|
||||
gc.collect()
|
||||
for ext in ("", "-wal", "-shm"):
|
||||
try:
|
||||
os.remove(path + ext)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def test_get_slate_writer_returns_default_when_unset(client):
|
||||
"""DB에 없으면 코드 기본 마스터 프롬프트를 200으로 반환 (404 아님)."""
|
||||
resp = client.get("/api/insta/templates/prompts/slate_writer")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["is_default"] is True
|
||||
assert "{keyword}" in body["template"]
|
||||
assert "{category}" in body["template"]
|
||||
|
||||
|
||||
def test_get_category_seeds_returns_default_when_unset(client):
|
||||
"""category_seeds 기본값은 유효한 JSON (카테고리→시드 배열)."""
|
||||
resp = client.get("/api/insta/templates/prompts/category_seeds")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["is_default"] is True
|
||||
seeds = json.loads(body["template"])
|
||||
assert "economy" in seeds and isinstance(seeds["economy"], list)
|
||||
|
||||
|
||||
def test_get_unknown_prompt_still_404(client):
|
||||
resp = client.get("/api/insta/templates/prompts/does_not_exist")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_saved_template_overrides_default(client):
|
||||
"""PUT로 저장하면 이후 GET은 저장본(is_default 없음)을 반환."""
|
||||
client.put("/api/insta/templates/prompts/slate_writer",
|
||||
json={"template": "내 커스텀 프롬프트", "description": "custom"})
|
||||
resp = client.get("/api/insta/templates/prompts/slate_writer")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["template"] == "내 커스텀 프롬프트"
|
||||
assert not body.get("is_default")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user