Compare commits
208 Commits
383f48c71e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c7036212e2 | |||
| 756d9fccf3 | |||
| ea5cf49cea | |||
| d07a8dad76 | |||
| d74bc189b5 | |||
| d4405204f9 | |||
| 2c157334dc | |||
| d840859fc9 | |||
| e115eee159 | |||
| fc1ebf134d | |||
| d71937b6ee | |||
| 0cc4505af7 | |||
| 9c18f0a467 | |||
| 8212a51f90 | |||
| 0d466b235c | |||
| 1129600341 | |||
| 2a0a2f3490 | |||
| 56d0f5b8a8 | |||
| 796ac6d39f | |||
| 18cea427be | |||
| 6c178006d3 | |||
| 084e4f1b4d | |||
| d048251a97 | |||
| ef1a7a92fd | |||
| 44dbe7c426 | |||
| e90e25d78f | |||
| d638666659 | |||
| 51eff1538e | |||
| ffb96de61d | |||
| c8ce6cb617 | |||
| 3c11b75a5f | |||
| 2c2828c8f0 | |||
| c62e3e70b9 | |||
| e1b1944f43 | |||
| 149e7c40fe | |||
| 28d489770a | |||
| 9d50aa4256 | |||
| bc0f583a0f | |||
| 7c5ca15b64 | |||
| 9fc764a78c | |||
| 83398c8413 | |||
| 7d1857c8a4 | |||
| c3a6e78954 | |||
| 5d0e80fb49 | |||
| af2fb57760 | |||
| 4d02d9c321 | |||
| c99017e68c | |||
| ce6c8d8f7d | |||
| 0d1b04d322 | |||
| 8b6b251225 | |||
| 1efe3d3a48 | |||
| 3a9d6e986e | |||
| bb0280274e | |||
| cd9a73254b | |||
| 332525a6f0 | |||
| 11f591e3d4 | |||
| 8788763b3d | |||
| b89e92440a | |||
| 5ad0adf719 | |||
| d98cd9afbe | |||
| 4e846a2d5f | |||
| 5d9be51dba | |||
| cd4fb27d5a | |||
| b94b5973d6 | |||
| f54ade2c0d | |||
| 2cbc830004 | |||
| d0c057358a | |||
| 7d7064ae93 | |||
| 789785fe3a | |||
| c3a3055060 | |||
| 3056e8d35f | |||
| 4ed3794f71 | |||
| 241c24943f | |||
| c756b20c77 | |||
| fba6dbf1fd | |||
| b13c088739 | |||
| 116b2540c2 | |||
| 62169ad33f | |||
| 0ef7d414b7 | |||
| 885d52d8f5 | |||
| e3088f7cc6 | |||
| 2996cf16d1 | |||
| 03ee5ce147 | |||
| 11212c4afd | |||
| 1b8548a73f | |||
| c4ba7e81e6 | |||
| e8270c5a63 | |||
| 4063f29cd3 | |||
| 03056a4747 | |||
| 8e7b4adabd | |||
| add433233a | |||
| 74f385c7bd | |||
| 3bc4f423db | |||
| a425bb8809 | |||
| 850638ae58 | |||
| 94a94e260c | |||
| c196da4902 | |||
| aaba4fbc46 | |||
| 9f897ea4a0 | |||
| 77efa9b653 | |||
| 8dbb1abaeb | |||
| 41ad56e3ef | |||
| bb0e771a4a | |||
| 160fc27279 | |||
| f3f6cccd33 | |||
| 2bfbd1dd93 | |||
| c5c260aefc | |||
| 378f5210d4 | |||
| cfbb3c24b8 | |||
| c7214b8896 | |||
| 4224333219 | |||
| 5613497367 | |||
| b25abea80a | |||
| ed30790f22 | |||
| 1d723764b4 | |||
| c0c4422c7c | |||
| fe4d3912a5 | |||
| f461f05ac0 | |||
| dfd3b1bb17 | |||
| 809eec9b15 | |||
| 512ed59dcd | |||
| 4ee4a1ae7d | |||
| fd40777177 | |||
| be9165efd2 | |||
| 99dca8df64 | |||
| 03e1dc1dbb | |||
| f57c790437 | |||
| 030367da6c | |||
| 429e3448e5 | |||
| 579e7387be | |||
| 8ef0ba81f2 | |||
| afb4175bd5 | |||
| af836df1ac | |||
| 8123f758a8 | |||
| 8ec3abb800 | |||
| 6d752acbe1 | |||
| f995f8739f | |||
| cad65dc869 | |||
| f4f518fc80 | |||
| db1f69c7a5 | |||
| ebfade655a | |||
| 234ccfe857 | |||
| 3f0b7bcd74 | |||
| f91a74237b | |||
| 95243a7f1f | |||
| 07b5c32f2f | |||
| 4ddcd75453 | |||
| 018459db88 | |||
| 42182014f0 | |||
| 03edfb04aa | |||
| 8b0c12b595 | |||
| e52e47fe3b | |||
| 8d25a1467a | |||
| 901d3535ee | |||
| 91caddb4b2 | |||
| abdfcbb144 | |||
| a94c73b134 | |||
| 387d2465b0 | |||
| 4073370e1b | |||
| 1775f7dd2d | |||
| 677d05fc31 | |||
| d87ad2421d | |||
| 20691b5057 | |||
| 3bf87a93fb | |||
| 4623c68d4e | |||
| f79dc87d75 | |||
| d4302acb6a | |||
| b7fd98c8c7 | |||
| 0b29283043 | |||
| 9dba1e74b0 | |||
| 4c9fe11fc9 | |||
| a356a5895f | |||
| 2e042e18c5 | |||
| 83e74ad1f4 | |||
| b70caddff1 | |||
| d6e34973a4 | |||
| 7007c90665 | |||
| ca7a502514 | |||
| dc471ecc60 | |||
| e91715bf2c | |||
| 1e4c1b42b7 | |||
| 0190a6c206 | |||
| 6ef4160da2 | |||
| 078c9f008a | |||
| 918151bda8 | |||
| 2ce6721c35 | |||
| c5303151c0 | |||
| ee61405ff1 | |||
| fef5f7a835 | |||
| e47ccdb762 | |||
| 4b6996b0f7 | |||
| 0f65aa53e4 | |||
| ea3485cde6 | |||
| d6366a38f3 | |||
| 0f8c71c552 | |||
| 1401c5703d | |||
| 92329f6fd5 | |||
| d0047c2b9d | |||
| 088944499c | |||
| a9fdbf8a93 | |||
| f46851d481 | |||
| 11b3700959 | |||
| 1db8a0063d | |||
| f017a61c79 | |||
| 1694823129 | |||
| a4614ebeae | |||
| 875e750f77 | |||
| 9cb40fb4e5 |
9
.mcp.json
Normal file
9
.mcp.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"co-gahusb": {
|
||||
"type": "http",
|
||||
"url": "https://gahusb.synology.me/api/co/mcp",
|
||||
"headers": { "Authorization": "Bearer ${CO_BUS_KEY}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
244
CHECK_POINT.md
244
CHECK_POINT.md
@@ -1,209 +1,121 @@
|
||||
# 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 자동 빌드)
|
||||
> NAS Docker (Synology Celeron J4025 2C 2.0GHz, 18GB). 16+ 컨테이너(14 서비스 + Redis + frontend + deployer).
|
||||
> 2026-06-12 갱신 — 5/18 CPU 진단·NAS↔Windows 분산부터 6/12 음악 파이프라인 신뢰성까지 반영.
|
||||
> 운영 세부(DB·스케줄러·env·함정)는 `memory/service_<name>.md`가 authoritative. 이 파일은 **무엇이 끝났고 다음에 뭘 하나**의 보드.
|
||||
|
||||
---
|
||||
|
||||
### 2. insta-lab Playwright Semaphore(1) ⭐
|
||||
## ✅ 완료 타임라인 (5/18 → 6/12)
|
||||
|
||||
**파일**: `insta-lab/app/main.py` (모듈 레벨 추가)
|
||||
```python
|
||||
import asyncio
|
||||
### 5/18~22 — CPU 진단 + NAS↔Windows 분산 + 로또 자율화
|
||||
- **CPU 폭주 즉시 5건**: 09:00 cron 5분 스태거링(insta/lotto/youtube/realestate) · lotto Monte Carlo 08:30 이동 · insta Playwright Semaphore(1) · healthcheck 60s · uvicorn `--workers 1` · realestate 수집 병렬화
|
||||
- **Redis 분산** (박재오 7결정): Redis 컨테이너 신설(7-alpine 256MB AOF) · insta/music/video-lab을 `queue:*-render` push 게이트웨이로 전환(렌더는 Windows web-ai 워커) · internal webhook + nginx 3-layer 차단 · stock webai_cache TTLCache
|
||||
- **video-lab 신설** (18801) — Windows video-render의 NAS 짝 (sora/veo/kling/seedance)
|
||||
- **로또 능동 시그널 v1** — lotto_signals/baselines, z-score, urgent/digest 텔레그램, cron 4종
|
||||
- **weight-evolver 자율 학습 v2** — weight_trials/auto_picks, 주간 generate→apply→evaluate 루프
|
||||
|
||||
# 모듈 레벨에 한 번만 선언
|
||||
RENDER_SEMAPHORE = asyncio.Semaphore(1) # Chromium 동시 실행 1개로 제한
|
||||
### 5/25~26 — tarot/saju 분리·신설 + UI
|
||||
- **tarot-lab 분리** (18250) — agent-office에서 독립, Claude 3-card
|
||||
- **saju-lab 신설** (18300) — saju-web TS→Python 포팅, lunar↔solar 내장, 궁합 포함
|
||||
- **saju UI v1 + v2 리디자인** + fortune_scores/lucky/monthly_flow 추가
|
||||
- image-lab public gateway + `/media/image/` 정적 서빙 · tarot max_tokens 2800 truncation fix
|
||||
|
||||
# 카드 렌더 백그라운드 함수에 감싸기
|
||||
async def _bg_render(task_id: str, slate_id: int):
|
||||
async with RENDER_SEMAPHORE:
|
||||
await card_renderer.render_slate(slate_id, ...)
|
||||
```
|
||||
### 5/28 — 공유 로그 인프라
|
||||
- **`_shared/access_log` 공용 모듈** (lotto/stock/music/insta/realestate 5종) — ring buffer + middleware + `/logs/recent`
|
||||
- agent-office `/agents/{id}/logs`가 서비스 로그 merge · 매일 03:00 agent_logs 90일 retention
|
||||
|
||||
- [x] card_renderer.render_slate를 Semaphore(1)로 감쌈 (2026-05-18, lazy init)
|
||||
- [ ] 동시 2개 요청 테스트 (curl 동시 2회 → 순차 처리되는지 확인)
|
||||
### 5/31 — 자율 인텔리전스 2종 (스마트에이전트 1·2번)
|
||||
- **로또 자가학습 백테스트·캘리브레이션 v3** — backtest_runs/winner_calibration, forward 가상구매 3전략, ε-게이팅 lift 학습, 일요회고 cron. 역대 캘리브레이션 백필 1197/1197 (6/11)
|
||||
- **주식 보유종목 인텔리전스** — holdings_signals, market_events/news_issues/portfolio_health, decide_action 매트릭스, EOD(16:50)+브리핑(08:30) cron
|
||||
|
||||
### 6/01~06 — 보안 + 인스타 카드뉴스
|
||||
- nginx CVE 대응 (CVE-2026-42945 · CVE-2026-9256 → 1.30.2)
|
||||
- **인스타 카드뉴스 품질 고도화 v2** + zip 패키지(10 PNG + caption.txt) + 글자수 가이드
|
||||
|
||||
### 6/11 — 자율 발급 + 오버사이트 (스마트에이전트 3번)
|
||||
- **인스타 자율 카드 발급** — 4신호 선별(selection.py) + Claude Haiku 카드가치 판단 + 승인 게이트 + 발행 상태머신. 텔레그램 issue_approve/reject/regen 콜백. **autonomous_issue 기본 OFF**
|
||||
- **에이전트 횡단 오버사이트(백엔드)** — `GET /api/agent-office/activity` 통합 피드 + 필터(agent_id/type/status/days). main `2c2828c` 배포
|
||||
- CLAUDE.md 카탈로그 슬림화(966→484, 서비스별 메모리 분담) · packs jti SQLite 영속화 · lotto deep CuratorError fallthrough fix
|
||||
|
||||
### 6/12 — 음악 파이프라인 신뢰성·복구 (직전 작업)
|
||||
- **자동 재시도**: orchestrator step 3회 backoff 재시도(publish 제외 — 업로드 비멱등)
|
||||
- **수동 재개**: `POST /api/music/pipeline/{id}/retry` — 실패 step 판별·재개, retrying 레이스 가드, publish+업로드완료 시 409
|
||||
- **실패 알림**: agent-office youtube_publisher가 신규 failed 감지 → 텔레그램 `⚠️실패` + `[🔄재시도]` 인라인 버튼 → music-lab retry 프록시
|
||||
- 커밋·push·자동배포 완료 (main = origin/main)
|
||||
|
||||
> **스마트에이전트 3종 전부 가동**: stock(보유종목) · insta(자율발급) · lotto(진화). CEO 오버사이트(통합 활동 피드) 백엔드 완료.
|
||||
|
||||
---
|
||||
|
||||
### 3. healthcheck interval 60s
|
||||
## 🔴 즉시 — 진행 중 / 대기
|
||||
|
||||
**파일**: `docker-compose.yml` (모든 9 컨테이너)
|
||||
```yaml
|
||||
# 변경 전
|
||||
healthcheck:
|
||||
interval: 30s
|
||||
### 1. ✅ agent oversight 프론트 NAS 배포 — 완료 (2026-06-12)
|
||||
- web-ui `ActivityTimeline`(AgentOffice 우측 기본 패널) main 머지(`d0bf5fd`) → NAS 라이브 반영·검증 완료 (index.html 갱신 + AgentOffice 번들 nginx 200)
|
||||
- **배포 방법**: Z: 매핑이 `!` TTY로 안 돼서 **SSH 직접 배포**(`bgg8988@gahusb.synology.me:2300`, tar + `scp -O` → assets 교체). Synology SFTP off라 `scp -O` 필수, images/videos는 불변이라 미러 제외. 상세 → `memory/feedback_windows_frontend_ssh_deploy.md`
|
||||
|
||||
# 변경 후
|
||||
healthcheck:
|
||||
interval: 60s
|
||||
```
|
||||
|
||||
- [x] docker-compose.yml 10개 healthcheck 일괄 변경 (9 백엔드 + frontend, 2026-05-18)
|
||||
- [ ] `docker compose up -d` 재기동
|
||||
- [ ] `docker stats` 로 CPU 5% 정도 감소 확인
|
||||
### 2. 운영 검증 (분산·자율 학습)
|
||||
- [ ] Redis 분산 E2E (NAS push → Windows 워커 → webhook 전체 흐름)
|
||||
- [ ] lotto weight-evolver 주간 사이클(월 generate+apply → 토 evaluate) 정상 동작 + evolution report 텔레그램(토 22:15)
|
||||
|
||||
---
|
||||
|
||||
### 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` 후 재기동.
|
||||
### Video Studio 프론트 `/studio` — 백엔드 완료, UI 미구현
|
||||
- **백엔드 완료·배포**: image-lab(NAS 18802) ✅ + image-render(Windows web-ai) ✅ + video-lab(기존) ✅ (`plans/2026-05-23-video-studio-backend.md` 전부)
|
||||
- **빠진 것**: web-ui React Flow 노드 캔버스(ImageGenNode → ImageToVideoNode). 백엔드 plan이 "프론트는 Plan 2"로 미뤘으나 Plan 2 미생성
|
||||
- spec: `docs/superpowers/specs/2026-05-23-video-studio-node-canvas-design.md` (untracked — 커밋 필요)
|
||||
- 목적: 무신사·우리카드 AI 영상 공모전 실전 제작 도구
|
||||
|
||||
---
|
||||
|
||||
### 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)
|
||||
### music 파이프라인 stuck 감지
|
||||
- 6/12 신뢰성 작업이 명시적으로 남긴 갭: `*_running` hang · `*_pending` 방치 · retrying 중 컨테이너 재시작 시 stuck(현 retry 가드가 state=failed라 재retry 불가)
|
||||
- 상세: `memory/service_music.md` "파이프라인 신뢰성/복구 — 범위 밖"
|
||||
|
||||
---
|
||||
|
||||
## 🟡 중기 (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()`
|
||||
- **Redis 큐 통합 모니터링** — agent-office에 `queue:*-render`/`queue:paused` 길이·상태 패널 (NAS↔Windows 작업 흐름 가시화)
|
||||
- **weight-evolver 성과 대시보드** — auto_picks 적중 추이 + weight_base 진화 그래프 (자율 학습 실효성 검증)
|
||||
- **lotto-signals 패턴 확장** — adaptive baseline + z-score + urgent 텔레그램을 stock(이상치)·realestate(경쟁률 급변)에 재사용
|
||||
- **nginx internal 차단 표준화** — insta/music/video/image 3-layer 차단을 공통 include로 추출
|
||||
- **agent-office 레거시 정리** — tarot_readings 테이블 잔존(tarot-lab 분리 후), seed "blog" 죽은 에이전트
|
||||
|
||||
### 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 부담 즉시 감소.
|
||||
### 보류 유지 (박재오 판단 대기)
|
||||
- stock 뉴스 스크랩 비동기화 — BackgroundScheduler I/O wait라 CPU 미미, 큰 리팩토링 vs 효과 불명확
|
||||
- lotto Monte Carlo 빈도(6→3회/일) — CPU 50%↓ vs 자율 학습 정확도 trade-off
|
||||
- 컨테이너 리소스 제한 — ❌ 박재오 금지(J4025 2C throughput 손해) · NAS 업그레이드 ⏸️ 보류(Redis 분산으로 우선순위↓)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 진단 커맨드 (NAS bash)
|
||||
|
||||
```bash
|
||||
# 실시간 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)
|
||||
top -b -n 1 | head -25 # CPU 상위
|
||||
docker stats --no-stream # 컨테이너별 CPU/메모리
|
||||
docker exec redis redis-cli PING # Redis 헬스
|
||||
docker exec redis redis-cli KEYS 'queue:*' # 큐 키 목록
|
||||
docker exec redis redis-cli LLEN queue:insta-render # 큐 길이
|
||||
docker logs agent-office 2>&1 | grep -E "APScheduler|executing" | tail -50
|
||||
|
||||
# insta-lab Chromium 프로세스 개수
|
||||
docker exec insta-lab ps aux | grep chromium | wc -l
|
||||
|
||||
# 컨테이너별 CPU/메모리 실시간
|
||||
docker stats --no-stream
|
||||
docker exec insta-lab ps aux | grep chromium | wc -l # (분할 후 0이어야 정상)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 참고
|
||||
|
||||
- 진단 풀 보고서: `C:\Users\jaeoh\Documents\Obsidian Vault\raw\2026-05-18-NAS-uvicorn-CPU-진단-개선안.md`
|
||||
- 위키 페이지: [[사업-개인-웹-플랫폼]] (CPU 부하 진단 섹션 + 컨테이너 표)
|
||||
- docker-compose.yml: 본 디렉토리 루트
|
||||
- 메모리 인덱스: `memory/MEMORY.md` (14 서비스 × `service_<name>.md` authoritative)
|
||||
- Windows 워커 짝: web-ai 레포 (insta/music/video/image-render)
|
||||
- spec/plan: `docs/superpowers/specs|plans/`
|
||||
- docker-compose.yml: 루트
|
||||
|
||||
## 변경 이력
|
||||
|
||||
- 2026-05-18: 페이지 신설. 즉시 5건 + 중기 4건 + 장기 3건. 진단 커맨드.
|
||||
- 2026-05-18: 페이지 신설. CPU 진단 즉시 5건 + 7결정 분산 가이드.
|
||||
- 2026-05-22: 분산·자율화 구현 반영. Redis 분할·lotto 능동시그널·weight-evolver.
|
||||
- 2026-06-12: **5/25~6/12 전체 작업 반영** — tarot/saju 분리·신설, _shared 로그, lotto v3 백테스트, stock 보유종목 인텔, nginx CVE, insta 카드뉴스 v2 + 자율발급, 에이전트 오버사이트, music 파이프라인 신뢰성. 미완성 큰 기능(Video Studio 프론트) + 후속(music stuck 감지) + 백로그 재편. 현재 트랙(oversight 프론트 배포) 명시.
|
||||
|
||||
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
|
||||
@@ -1,8 +1,6 @@
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from ..db import add_log
|
||||
|
||||
VALID_STATES = ("idle", "working", "waiting", "reporting")
|
||||
|
||||
class BaseAgent:
|
||||
@@ -29,8 +27,6 @@ class BaseAgent:
|
||||
if new_state == "idle":
|
||||
self._idle_since = time.time()
|
||||
|
||||
add_log(self.agent_id, f"State: {old} -> {new_state} ({detail})")
|
||||
|
||||
if self._ws_manager:
|
||||
await self._ws_manager.send_agent_state(self.agent_id, new_state, detail, task_id)
|
||||
if new_state == "working" and old != "working":
|
||||
|
||||
@@ -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.
|
||||
@@ -51,14 +71,32 @@ class InstaAgent(BaseAgent):
|
||||
config = get_agent_config(self.agent_id) or {}
|
||||
custom = config.get("custom_config", {}) or {}
|
||||
auto_select = bool(custom.get("auto_select", False))
|
||||
autonomous = bool(custom.get("autonomous_issue", False))
|
||||
threshold = float(custom.get("select_threshold", 0.6))
|
||||
max_per_day = int(custom.get("max_per_day", 2))
|
||||
dedup_window_days = int(custom.get("dedup_window_days", 14))
|
||||
|
||||
task_id = create_task(self.agent_id, "insta_daily", {"auto_select": auto_select},
|
||||
requires_approval=False)
|
||||
await self.transition("working", "뉴스 수집·키워드 추출", task_id)
|
||||
try:
|
||||
prefs = await service_proxy.insta_get_preferences()
|
||||
add_log(self.agent_id, f"insta preferences: {prefs}", "info", task_id)
|
||||
try:
|
||||
prefs = await service_proxy.insta_get_preferences()
|
||||
add_log(self.agent_id, f"insta preferences: {prefs}", "info", task_id)
|
||||
except Exception as _pref_err:
|
||||
add_log(self.agent_id, f"insta preferences unavailable: {_pref_err}", "warning", task_id)
|
||||
await self._run_collect_and_extract()
|
||||
if autonomous:
|
||||
ranked = await service_proxy.insta_ranked(threshold=threshold, limit=20, dedup_window_days=dedup_window_days)
|
||||
eligible = [r for r in ranked if r.get("eligible")][:max_per_day]
|
||||
if not eligible:
|
||||
await messaging.send_raw("📰 [인스타 큐레이터] 오늘은 발행할 가치 있는 주제가 없습니다.")
|
||||
else:
|
||||
for pick in eligible:
|
||||
await self._generate_and_preview(pick)
|
||||
update_task_status(task_id, "succeeded", {"issued": len(eligible)})
|
||||
await self.transition("idle", "자율 발급 후보 프리뷰 완료")
|
||||
return
|
||||
kws = await service_proxy.insta_list_keywords(used=False)
|
||||
if auto_select:
|
||||
await self._auto_render(kws)
|
||||
@@ -89,14 +127,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]:
|
||||
@@ -137,6 +179,27 @@ class InstaAgent(BaseAgent):
|
||||
full_caption = f"{caption}\n\n{hashtags}".strip()
|
||||
await _send_media_group(media, caption=full_caption)
|
||||
|
||||
async def _generate_and_preview(self, pick: dict) -> None:
|
||||
"""eligible 픽 → 슬레이트 생성·렌더 → 커버 프리뷰 + 승인 버튼."""
|
||||
created = await service_proxy.insta_create_slate(
|
||||
keyword=pick["keyword"], category=pick["category"], keyword_id=pick["id"],
|
||||
)
|
||||
st = await self._wait_task(created["task_id"], step="slate", timeout_sec=600)
|
||||
slate_id = st["result_id"]
|
||||
cover = await service_proxy.insta_get_asset_bytes(slate_id, 1)
|
||||
bd = pick.get("breakdown", {})
|
||||
caption = (f"🎴 <b>{pick['keyword']}</b> ({pick['category']})\n"
|
||||
f"점수 {pick.get('final_score')} · fresh {bd.get('freshness')} "
|
||||
f"fit {bd.get('account_fit')} claude {bd.get('claude')}\n승인하시겠어요?")
|
||||
kb = {"inline_keyboard": [[
|
||||
{"text": "✅ 승인", "callback_data": f"issue_approve_{slate_id}"},
|
||||
{"text": "❌ 반려", "callback_data": f"issue_reject_{slate_id}"},
|
||||
{"text": "🔄 재생성", "callback_data": f"issue_regen_{slate_id}"},
|
||||
]]}
|
||||
await messaging.send_photo(cover, caption=caption, reply_markup=kb)
|
||||
create_task(self.agent_id, "insta_issue", {"slate_id": slate_id, "keyword_id": pick["id"]},
|
||||
requires_approval=True)
|
||||
|
||||
async def on_command(self, command: str, params: dict) -> dict:
|
||||
if command == "extract":
|
||||
await self._run_collect_and_extract()
|
||||
@@ -164,6 +227,38 @@ class InstaAgent(BaseAgent):
|
||||
return {"ok": False}
|
||||
await self._render_and_push(kid)
|
||||
return {"ok": True}
|
||||
if action in ("issue_approve", "issue_reject"):
|
||||
sid = int(params.get("slate_id") or 0)
|
||||
if not sid:
|
||||
return {"ok": False}
|
||||
decision = "approved" if action == "issue_approve" else "rejected"
|
||||
await service_proxy.insta_decision(sid, decision)
|
||||
if decision == "approved":
|
||||
slate = await service_proxy.insta_get_slate(sid)
|
||||
media = []
|
||||
for a in slate["assets"][:10]:
|
||||
data = await service_proxy.insta_get_asset_bytes(sid, a["page_index"])
|
||||
media.append({"type": "photo", "_bytes": data})
|
||||
cap = f"{slate.get('suggested_caption','')}\n\n{' '.join(slate.get('hashtags', []) or [])}".strip()
|
||||
await _send_media_group(media, caption=cap)
|
||||
await messaging.send_raw(f"✅ 발행 완료 (slate {sid})")
|
||||
else:
|
||||
await messaging.send_raw(f"❌ 반려됨 (slate {sid})")
|
||||
return {"ok": True}
|
||||
if action == "issue_regen":
|
||||
sid = int(params.get("slate_id") or 0)
|
||||
if not sid:
|
||||
return {"ok": False}
|
||||
slate = await service_proxy.insta_get_slate(sid)
|
||||
await service_proxy.insta_decision(sid, "rejected")
|
||||
await self._generate_and_preview({
|
||||
"id": 0,
|
||||
"keyword": slate["keyword"],
|
||||
"category": slate["category"],
|
||||
"final_score": None,
|
||||
"breakdown": {},
|
||||
})
|
||||
return {"ok": True}
|
||||
return {"ok": False}
|
||||
|
||||
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
|
||||
|
||||
@@ -2,6 +2,10 @@ from .base import BaseAgent
|
||||
from ..db import create_task, update_task_status, add_log
|
||||
from ..curator.pipeline import curate_weekly, CuratorError
|
||||
|
||||
# urgent 텔레그램 발송 재시도 (전송 실패가 시그널 평가/태스크를 중단시키지 않도록)
|
||||
URGENT_SEND_MAX_ATTEMPTS = 3
|
||||
URGENT_SEND_RETRY_SEC = 60
|
||||
|
||||
|
||||
class LottoAgent(BaseAgent):
|
||||
agent_id = "lotto"
|
||||
@@ -22,38 +26,48 @@ class LottoAgent(BaseAgent):
|
||||
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 시그널 평가 (light/sim) 또는 deep_check (LLM 호출 후).
|
||||
|
||||
Phase 3 (Task 9): urgent 시그널 텔레그램 발송 + throttle/daily-cap 추가.
|
||||
"""
|
||||
"""비-LLM 시그널 평가. task_id wrap 적용."""
|
||||
from ..curator.signal_runner import run_signal_check
|
||||
from ..config import LOTTO_Z_NORMAL, LOTTO_Z_URGENT
|
||||
from ..db import add_log
|
||||
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
|
||||
|
||||
# 회차 단위 메트릭(drift/confidence) 가드를 위해 항상 최신 회차 가져옴
|
||||
from ..service_proxy import lotto_latest_draw
|
||||
current_draw_no = await lotto_latest_draw()
|
||||
|
||||
if source == "deep":
|
||||
from ..curator.pipeline import curate_weekly
|
||||
cw = await curate_weekly(source="signal_deep")
|
||||
# curate_weekly returns {"ok", "draw_no", "confidence", "tokens", "payload"}
|
||||
curate_result = {"confidence": cw.get("confidence")}
|
||||
# deep_check 시 curate_weekly가 반환하는 draw_no를 우선 사용 (직접 수집)
|
||||
if cw.get("draw_no"):
|
||||
current_draw_no = cw.get("draw_no")
|
||||
try:
|
||||
cw = await curate_weekly(source="signal_deep")
|
||||
curate_result = {"confidence": cw.get("confidence")}
|
||||
if cw.get("draw_no"):
|
||||
current_draw_no = cw.get("draw_no")
|
||||
except CuratorError as e:
|
||||
# 큐레이션 실패는 confidence 시그널만 포기 — sim/drift 평가는 계속(fallthrough)
|
||||
add_log("lotto", f"deep curate_weekly 실패 → sim/drift만 평가: {e}",
|
||||
level="warning", task_id=task_id)
|
||||
curate_result = None
|
||||
|
||||
outcome = await run_signal_check(
|
||||
source=source,
|
||||
@@ -62,35 +76,19 @@ class LottoAgent(BaseAgent):
|
||||
curate_result=curate_result,
|
||||
current_draw_no=current_draw_no,
|
||||
)
|
||||
add_log(
|
||||
self.agent_id,
|
||||
f"signal_check({source}) → overall={outcome['overall_fire']} results={len(outcome['results'])}",
|
||||
)
|
||||
|
||||
# --- Throttle + 텔레그램 urgent 발송 ---
|
||||
from ..config import LOTTO_THROTTLE_HOURS, LOTTO_URGENT_DAILY_MAX
|
||||
from ..db import (
|
||||
get_last_signal_notification, get_recent_urgent_count,
|
||||
mark_signal_notified,
|
||||
)
|
||||
from ..notifiers.telegram_lotto import send_urgent_signal
|
||||
|
||||
# urgent 텔레그램 + throttle (기존 동작 유지)
|
||||
if outcome["overall_fire"] == "urgent":
|
||||
if get_recent_urgent_count(hours=24) >= LOTTO_URGENT_DAILY_MAX:
|
||||
add_log(
|
||||
self.agent_id,
|
||||
"urgent daily cap 도달 → normal로 강등 (digest 합류)",
|
||||
level="warning",
|
||||
)
|
||||
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"):
|
||||
last = get_last_signal_notification(
|
||||
if get_last_signal_notification(
|
||||
metric=r["metric"], fire_level=r["fire_level"],
|
||||
hours=LOTTO_THROTTLE_HOURS,
|
||||
)
|
||||
if last:
|
||||
):
|
||||
blocked = True
|
||||
break
|
||||
if not blocked:
|
||||
@@ -100,52 +98,198 @@ class LottoAgent(BaseAgent):
|
||||
"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(self.agent_id, f"urgent 텔레그램 발송 완료 (시그널 {len(outcome['results'])}개 마킹)")
|
||||
await self._send_urgent_with_retry(event, outcome["results"], task_id)
|
||||
|
||||
fired_metrics = [
|
||||
r["metric"] for r in outcome["results"]
|
||||
if r["fire_level"] not in ("noop", "warmup")
|
||||
]
|
||||
update_task_status(task_id, "succeeded", result_data={
|
||||
"source": source,
|
||||
"overall_fire": outcome["overall_fire"],
|
||||
"n_results": len(outcome["results"]),
|
||||
"fired_metrics": fired_metrics,
|
||||
})
|
||||
add_log("lotto", f"signal_check({source}) → {outcome['overall_fire']} results={len(outcome['results'])}", task_id=task_id)
|
||||
return {"ok": True, **outcome}
|
||||
except Exception as e:
|
||||
add_log(self.agent_id, f"signal_check 예외: {e}", level="error")
|
||||
update_task_status(task_id, "failed", result_data={"error": str(e)})
|
||||
add_log("lotto", f"signal_check 예외: {e}", level="error", task_id=task_id)
|
||||
return {"ok": False, "message": f"{type(e).__name__}: {e}"}
|
||||
|
||||
async def _send_urgent_with_retry(self, event: dict, results: list, task_id: str) -> bool:
|
||||
"""urgent 텔레그램 발송 + 실패 시 재시도. 최종 실패해도 raise하지 않음(시그널 평가·태스크 보존).
|
||||
성공 시 fired 시그널을 notified로 마킹. 반환: 발송 성공 여부."""
|
||||
import asyncio
|
||||
from ..db import add_log, mark_signal_notified
|
||||
from ..notifiers.telegram_lotto import send_urgent_signal
|
||||
for attempt in range(1, URGENT_SEND_MAX_ATTEMPTS + 1):
|
||||
try:
|
||||
await send_urgent_signal(event)
|
||||
for r in results:
|
||||
if r["fire_level"] in ("normal", "urgent"):
|
||||
mark_signal_notified(r["signal_id"])
|
||||
add_log("lotto", f"urgent 텔레그램 발송 ({len(results)}개 시그널, attempt {attempt})", task_id=task_id)
|
||||
return True
|
||||
except Exception as e:
|
||||
if attempt < URGENT_SEND_MAX_ATTEMPTS:
|
||||
add_log("lotto", f"urgent 발송 실패(attempt {attempt}) → {URGENT_SEND_RETRY_SEC}s 후 재시도: {e}",
|
||||
level="warning", task_id=task_id)
|
||||
await asyncio.sleep(URGENT_SEND_RETRY_SEC)
|
||||
else:
|
||||
add_log("lotto", f"urgent 발송 {URGENT_SEND_MAX_ATTEMPTS}회 실패 — 미발송: {e}",
|
||||
level="error", task_id=task_id)
|
||||
return False
|
||||
return False
|
||||
|
||||
async def run_daily_digest(self) -> dict:
|
||||
"""일일 요약 — 지난 24h normal/urgent 발화를 묶어 텔레그램 1통."""
|
||||
"""일일 요약 — 지난 24h normal/urgent 발화 텔레그램 1통. task_id wrap."""
|
||||
from ..db import (
|
||||
get_recent_lotto_signals, get_signals_history, add_log,
|
||||
get_baseline,
|
||||
create_task, update_task_status, add_log,
|
||||
get_recent_lotto_signals, get_signals_history, get_baseline,
|
||||
)
|
||||
from ..notifiers.telegram_lotto import send_signal_summary
|
||||
|
||||
sigs = get_recent_lotto_signals(hours=24, min_fire="normal")
|
||||
total_24h = get_signals_history(days=1)
|
||||
evaluated = len(total_24h)
|
||||
|
||||
# weights_trend: drift_weights_cache의 prev/curr 차이
|
||||
trend = {}
|
||||
task_id = create_task("lotto", "daily_digest", {})
|
||||
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(self.agent_id, f"weights_trend 계산 실패: {e}", level="warning")
|
||||
sigs = get_recent_lotto_signals(hours=24, min_fire="normal")
|
||||
total_24h = get_signals_history(days=1)
|
||||
evaluated = len(total_24h)
|
||||
|
||||
digest = {
|
||||
"evaluated": evaluated,
|
||||
"fired": len(sigs),
|
||||
"signals": sigs,
|
||||
"weights_trend": trend,
|
||||
}
|
||||
await send_signal_summary(digest)
|
||||
add_log(self.agent_id, f"daily_digest 발송: 평가 {evaluated} / 발화 {len(sigs)}")
|
||||
return {"ok": True, **digest}
|
||||
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})
|
||||
|
||||
@@ -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,6 +26,7 @@ class YoutubePublisherAgent(BaseAgent):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._notified_state_per_pipeline: dict[int, tuple] = {}
|
||||
self._notified_failed: set[int] = set()
|
||||
|
||||
async def poll_state_changes(self) -> None:
|
||||
"""주기적으로 호출되어 *_pending 신규 진입 시 텔레그램 발송."""
|
||||
@@ -48,6 +49,32 @@ class YoutubePublisherAgent(BaseAgent):
|
||||
await self._notify_step(p)
|
||||
self._notified_state_per_pipeline[pid] = key
|
||||
|
||||
try:
|
||||
failed = await service_proxy.list_failed_pipelines()
|
||||
except Exception as e:
|
||||
logger.warning("failed 폴링 실패: %s", e)
|
||||
failed = []
|
||||
for p in failed:
|
||||
pid = p.get("id")
|
||||
if pid is None:
|
||||
continue
|
||||
if pid not in self._notified_failed:
|
||||
await self._notify_failed(p)
|
||||
self._notified_failed.add(pid)
|
||||
# 재개되어 failed에서 벗어난 파이프라인은 재알림 가능하도록 해제
|
||||
failed_ids = {p.get("id") for p in failed}
|
||||
self._notified_failed &= failed_ids
|
||||
|
||||
async def _notify_failed(self, p: dict) -> None:
|
||||
reason = p.get("failed_reason") or "?"
|
||||
step = reason.split(":", 1)[0].strip()
|
||||
title = p.get("track_title") or f"Pipeline #{p['id']}"
|
||||
text = f"⚠️ [{title}] 파이프라인 #{p['id']} '{step}' 실패\n사유: {reason}"
|
||||
kb = {"inline_keyboard": [[{"text": "🔄 재시도", "callback_data": f"ytpub_retry_{p['id']}"}]]}
|
||||
sent = await send_raw(text=text, reply_markup=kb)
|
||||
if sent.get("ok"):
|
||||
add_log(self.agent_id, f"pipeline {p['id']} 실패 알림", "warning")
|
||||
|
||||
async def _notify_step(self, pipeline: dict) -> None:
|
||||
state = pipeline["state"]
|
||||
title_name, step = _STEP_TITLES[state]
|
||||
|
||||
@@ -38,3 +38,16 @@ 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")),
|
||||
}
|
||||
|
||||
@@ -131,6 +131,33 @@ def init_db() -> None:
|
||||
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", "주식 트레이더"),
|
||||
@@ -236,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]
|
||||
|
||||
|
||||
@@ -282,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 [
|
||||
@@ -293,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
|
||||
]
|
||||
@@ -488,33 +534,58 @@ def get_conversation_stats(days: int = 7) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def get_activity_feed(limit: int = 50, offset: int = 0) -> dict:
|
||||
with _conn() as conn:
|
||||
total_row = conn.execute("""
|
||||
SELECT (SELECT COUNT(*) FROM agent_tasks) + (SELECT COUNT(*) FROM agent_logs) AS total
|
||||
""").fetchone()
|
||||
total = total_row["total"] if total_row else 0
|
||||
def get_activity_feed(limit: int = 50, offset: int = 0, agent_id: str = None,
|
||||
type: str = None, status: str = None, days: int = None) -> dict:
|
||||
# 브랜치별 WHERE (값은 ? 바인딩, type은 브랜치 선택용). status는 task 전용 → 주면 log 제외.
|
||||
task_where, task_params = [], []
|
||||
log_where, log_params = [], []
|
||||
if agent_id:
|
||||
task_where.append("agent_id=?"); task_params.append(agent_id)
|
||||
log_where.append("agent_id=?"); log_params.append(agent_id)
|
||||
if status:
|
||||
task_where.append("status=?"); task_params.append(status)
|
||||
if days and days > 0:
|
||||
task_where.append("created_at >= datetime('now', ?)"); task_params.append(f"-{int(days)} days")
|
||||
log_where.append("created_at >= datetime('now', ?)"); log_params.append(f"-{int(days)} days")
|
||||
include_tasks = type in (None, "task")
|
||||
include_logs = type in (None, "log") and not status
|
||||
|
||||
rows = conn.execute("""
|
||||
task_clause = (" WHERE " + " AND ".join(task_where)) if task_where else ""
|
||||
log_clause = (" WHERE " + " AND ".join(log_where)) if log_where else ""
|
||||
|
||||
branches, branch_params = [], []
|
||||
if include_tasks:
|
||||
branches.append(f"""
|
||||
SELECT 'task' AS type, agent_id, id AS task_id, task_type,
|
||||
status, NULL AS level,
|
||||
COALESCE(
|
||||
json_extract(result_data, '$.summary'),
|
||||
task_type
|
||||
) AS message,
|
||||
created_at, completed_at,
|
||||
result_data
|
||||
FROM agent_tasks
|
||||
UNION ALL
|
||||
COALESCE(json_extract(result_data, '$.summary'), task_type) AS message,
|
||||
created_at, completed_at, result_data
|
||||
FROM agent_tasks{task_clause}""")
|
||||
branch_params += task_params
|
||||
if include_logs:
|
||||
branches.append(f"""
|
||||
SELECT 'log' AS type, agent_id, task_id, NULL AS task_type,
|
||||
NULL AS status, level,
|
||||
message,
|
||||
created_at, NULL AS completed_at,
|
||||
NULL AS result_data
|
||||
FROM agent_logs
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""", (limit, offset)).fetchall()
|
||||
NULL AS status, level, message,
|
||||
created_at, NULL AS completed_at, NULL AS result_data
|
||||
FROM agent_logs{log_clause}""")
|
||||
branch_params += log_params
|
||||
|
||||
if not branches:
|
||||
return {"items": [], "total": 0}
|
||||
|
||||
union_sql = " UNION ALL ".join(branches) + " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||||
|
||||
with _conn() as conn:
|
||||
total = 0
|
||||
if include_tasks:
|
||||
total += conn.execute(
|
||||
f"SELECT COUNT(*) AS c FROM agent_tasks{task_clause}", task_params
|
||||
).fetchone()["c"]
|
||||
if include_logs:
|
||||
total += conn.execute(
|
||||
f"SELECT COUNT(*) AS c FROM agent_logs{log_clause}", log_params
|
||||
).fetchone()["c"]
|
||||
rows = conn.execute(union_sql, branch_params + [limit, offset]).fetchall()
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
@@ -549,6 +620,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:
|
||||
@@ -739,3 +824,20 @@ def get_all_baselines() -> List[Dict[str, Any]]:
|
||||
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():
|
||||
@@ -180,8 +198,9 @@ def conversation_stats(days: int = 7):
|
||||
return get_conversation_stats(days)
|
||||
|
||||
@app.get("/api/agent-office/activity")
|
||||
def activity_feed(limit: int = 50, offset: int = 0):
|
||||
return get_activity_feed(limit, offset)
|
||||
def activity_feed(limit: int = 50, offset: int = 0, agent_id: str | None = None,
|
||||
type: str | None = None, status: str | None = None, days: int | None = None):
|
||||
return get_activity_feed(limit, offset, agent_id=agent_id, type=type, status=status, days=days)
|
||||
|
||||
|
||||
# --- Realestate Agent Push Endpoint ---
|
||||
|
||||
@@ -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로 자동 발송.
|
||||
@@ -159,3 +159,108 @@ async def send_signal_summary(digest: Dict[str, Any]) -> None:
|
||||
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,7 +1,9 @@
|
||||
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")
|
||||
|
||||
@@ -20,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:
|
||||
@@ -56,6 +68,21 @@ async def _run_lotto_daily_digest():
|
||||
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:
|
||||
@@ -71,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(
|
||||
@@ -89,15 +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")
|
||||
# 09:00 cron 스태거링 — Celeron 2C/2.0GHz에서 동시 실행 시 CPU 폭주 (CHECK_POINT FU-A)
|
||||
scheduler.add_job(_run_insta_trends_collect, "cron", hour=9, minute=0, id="insta_trends_collect")
|
||||
# 외부 트렌드 수집은 장 마감 후 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(_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()
|
||||
@@ -202,6 +228,26 @@ async def insta_put_preferences(weights: Dict[str, float]) -> Dict[str, Any]:
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def insta_ranked(threshold: float = 0.6, limit: int = 20, dedup_window_days: int = 14) -> list:
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
r = await client.get(
|
||||
f"{INSTA_LAB_URL}/api/insta/keywords/ranked",
|
||||
params={"threshold": threshold, "limit": limit, "dedup_window_days": dedup_window_days},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()["items"]
|
||||
|
||||
|
||||
async def insta_decision(slate_id: int, decision: str) -> dict:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
r = await client.post(
|
||||
f"{INSTA_LAB_URL}/api/insta/slates/{slate_id}/decision",
|
||||
json={"decision": decision},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
# --- realestate-lab ---
|
||||
|
||||
async def realestate_collect() -> Dict[str, Any]:
|
||||
@@ -306,6 +352,25 @@ async def list_active_pipelines() -> list[dict]:
|
||||
return resp.json().get("pipelines", [])
|
||||
|
||||
|
||||
async def list_failed_pipelines() -> list[dict]:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(f"{MUSIC_LAB_URL}/api/music/pipeline?status=failed")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data if isinstance(data, list) else data.get("items", data.get("pipelines", []))
|
||||
|
||||
|
||||
async def pipeline_retry(pid: int) -> dict:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.post(f"{MUSIC_LAB_URL}/api/music/pipeline/{pid}/retry")
|
||||
out = {"status_code": resp.status_code}
|
||||
try:
|
||||
out.update(resp.json())
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
async def get_pipeline(pid: int) -> dict:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(f"{MUSIC_LAB_URL}/api/music/pipeline/{pid}")
|
||||
@@ -377,3 +442,63 @@ async def lotto_latest_draw() -> Optional[int]:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def lotto_evolver_status() -> Dict[str, Any]:
|
||||
"""GET /api/lotto/evolver/status — 이번주 trials + 다음주 base 정보."""
|
||||
from .config import LOTTO_BACKEND_URL
|
||||
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/evolver/status")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def lotto_evolver_evaluate() -> Dict[str, Any]:
|
||||
"""POST /api/lotto/evolver/evaluate-now — 회고 트리거 (텔레그램 리포트용)."""
|
||||
from .config import LOTTO_BACKEND_URL
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
resp = await client.post(f"{LOTTO_BACKEND_URL}/api/lotto/evolver/evaluate-now")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def lotto_backtest_review(draw_no: int) -> Dict[str, Any]:
|
||||
from .config import LOTTO_BACKEND_URL
|
||||
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/backtest/review/{draw_no}")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
|
||||
from .config import AGENT_CONTAINER_MAP
|
||||
|
||||
|
||||
async def fetch_service_logs(
|
||||
agent_id: str,
|
||||
since: Optional[str] = None,
|
||||
limit: int = 200,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""해당 에이전트가 가리키는 컨테이너의 /logs/recent 를 호출해서
|
||||
path_prefix 정규식으로 필터한 결과를 반환.
|
||||
|
||||
네트워크 실패 시 빈 리스트를 반환하고 warning 만 남김 (LogTab 이 죽지 않게).
|
||||
"""
|
||||
mapping = AGENT_CONTAINER_MAP.get(agent_id)
|
||||
if not mapping:
|
||||
return []
|
||||
host, port, path_re = mapping
|
||||
url = f"http://{host}:{port}/logs/recent"
|
||||
params: Dict[str, Any] = {"limit": limit}
|
||||
if since:
|
||||
params["since"] = since
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
resp = await client.get(url, params=params)
|
||||
data = resp.json().get("logs", [])
|
||||
except Exception as e:
|
||||
logger.warning("fetch_service_logs(%s) 실패: %s", agent_id, e)
|
||||
return []
|
||||
return [
|
||||
x for x in data
|
||||
if x.get("source") == "log"
|
||||
or path_re.match(x.get("path", "") or "")
|
||||
]
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""고수준 메시지 전송 API."""
|
||||
import json
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from ..config import TELEGRAM_CHAT_ID
|
||||
import httpx
|
||||
|
||||
from ..config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID
|
||||
from ..db import save_telegram_callback
|
||||
from .client import _enabled, api_call
|
||||
from .formatter import MessageKind, format_agent_message
|
||||
@@ -81,3 +84,26 @@ async def send_approval_request(
|
||||
{"label": "❌ 거절", "action": "reject"},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
async def send_photo(
|
||||
photo_bytes: bytes,
|
||||
caption: str = "",
|
||||
reply_markup: Optional[dict] = None,
|
||||
chat_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""PNG/JPEG 바이트를 sendPhoto로 전송. reply_markup으로 인라인 키보드 첨부 가능."""
|
||||
if not TELEGRAM_BOT_TOKEN:
|
||||
return {"ok": False, "reason": "no token"}
|
||||
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendPhoto"
|
||||
data: dict = {
|
||||
"chat_id": chat_id or TELEGRAM_CHAT_ID,
|
||||
"caption": caption[:1024],
|
||||
"parse_mode": "HTML",
|
||||
}
|
||||
if reply_markup:
|
||||
data["reply_markup"] = json.dumps(reply_markup, ensure_ascii=False)
|
||||
files = {"photo": ("cover.png", photo_bytes, "image/png")}
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.post(url, data=data, files=files)
|
||||
return resp.json()
|
||||
|
||||
@@ -40,6 +40,12 @@ async def _handle_callback(callback_query: dict) -> Optional[dict]:
|
||||
if callback_id.startswith("render_"):
|
||||
return await _handle_insta_render(callback_query, callback_id)
|
||||
|
||||
if callback_id.startswith("issue_"):
|
||||
return await _handle_insta_issue(callback_query, callback_id)
|
||||
|
||||
if callback_id.startswith("ytpub_retry_"):
|
||||
return await _handle_ytpub_retry(callback_query, callback_id)
|
||||
|
||||
cb = get_telegram_callback(callback_id)
|
||||
if not cb:
|
||||
return None
|
||||
@@ -132,6 +138,64 @@ async def _handle_insta_render(callback_query: dict, callback_id: str) -> dict:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
async def _handle_insta_issue(callback_query: dict, callback_id: str) -> dict:
|
||||
"""issue_{approve|reject|regen}_{slate_id} 콜백 → InstaAgent.on_callback.
|
||||
|
||||
callback_data 예시: issue_approve_8, issue_reject_8, issue_regen_8
|
||||
InstaAgent.on_callback("issue_approve" | "issue_reject" | "issue_regen", {"slate_id": <int>}) 로 dispatch.
|
||||
"""
|
||||
from .messaging import send_raw
|
||||
from ..agents import AGENT_REGISTRY
|
||||
|
||||
await api_call(
|
||||
"answerCallbackQuery",
|
||||
{"callback_query_id": callback_query["id"], "text": "처리 중..."},
|
||||
)
|
||||
|
||||
try:
|
||||
rest = callback_id.removeprefix("issue_") # 예: "approve_8"
|
||||
verb, sid = rest.rsplit("_", 1) # ("approve", "8")
|
||||
slate_id = int(sid)
|
||||
except (ValueError, AttributeError):
|
||||
await send_raw("⚠️ 잘못된 issue 콜백 데이터")
|
||||
return {"ok": False, "error": "invalid_callback_data"}
|
||||
|
||||
agent = AGENT_REGISTRY.get("insta")
|
||||
if not agent:
|
||||
await send_raw("⚠️ insta agent 미등록")
|
||||
return {"ok": False, "error": "agent_missing"}
|
||||
|
||||
try:
|
||||
return await agent.on_callback(f"issue_{verb}", {"slate_id": slate_id})
|
||||
except Exception as e:
|
||||
await send_raw(f"⚠️ issue 콜백 처리 실패: {e}")
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
async def _handle_ytpub_retry(callback_query: dict, callback_id: str) -> dict:
|
||||
"""ytpub_retry_{pipeline_id} 콜백 → music-lab pipeline retry 프록시."""
|
||||
from .. import service_proxy
|
||||
from .messaging import send_raw
|
||||
|
||||
await api_call(
|
||||
"answerCallbackQuery",
|
||||
{"callback_query_id": callback_query["id"], "text": "재시도 요청 중..."},
|
||||
)
|
||||
|
||||
try:
|
||||
pid = int(callback_id.removeprefix("ytpub_retry_"))
|
||||
except (ValueError, AttributeError):
|
||||
return {"ok": False, "error": "invalid_callback_data"}
|
||||
|
||||
res = await service_proxy.pipeline_retry(pid)
|
||||
sc = res.get("status_code")
|
||||
if sc in (200, 202):
|
||||
await send_raw(text=f"🔄 파이프라인 #{pid} 재개: {res.get('retrying_step', '?')}")
|
||||
else:
|
||||
await send_raw(text=f"⚠️ 재개 불가 (#{pid}): {res.get('detail', sc)}")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
async def _handle_message(message: dict, agent_dispatcher) -> Optional[dict]:
|
||||
"""슬래시 명령 메시지 처리."""
|
||||
from .router import parse_command, resolve_agent_command, HELP_TEXT
|
||||
|
||||
@@ -18,9 +18,11 @@ from app.db import (
|
||||
def test_init_and_seed():
|
||||
init_db()
|
||||
agents = get_all_agents()
|
||||
assert len(agents) == 2, f"Expected 2 agents, got {len(agents)}"
|
||||
ids = {a["agent_id"] for a in agents}
|
||||
assert ids == {"stock", "music"}, f"Unexpected agent ids: {ids}"
|
||||
# 시드된 핵심 에이전트 존재 검증 — 레지스트리 확장(insta/lotto/realestate/youtube 등)에 견고하도록
|
||||
# 고정 개수/집합이 아닌 subset으로 단언 (이전 len==2/{stock,music} 고정 단언은 stale였음).
|
||||
assert {"stock", "music"} <= ids, f"core agents missing: {ids}"
|
||||
assert len(agents) >= 2
|
||||
print(" [PASS] test_init_and_seed")
|
||||
|
||||
|
||||
@@ -93,6 +95,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)
|
||||
76
agent-office/tests/test_activity_feed_filters.py
Normal file
76
agent-office/tests/test_activity_feed_filters.py
Normal file
@@ -0,0 +1,76 @@
|
||||
# agent-office/tests/test_activity_feed_filters.py
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import gc
|
||||
|
||||
_fd, _TMP = tempfile.mkstemp(suffix=".db")
|
||||
os.close(_fd)
|
||||
os.unlink(_TMP)
|
||||
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import pytest
|
||||
from app import db
|
||||
|
||||
db.DB_PATH = _TMP
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db():
|
||||
db.DB_PATH = _TMP
|
||||
gc.collect()
|
||||
if os.path.exists(_TMP):
|
||||
os.remove(_TMP)
|
||||
db.init_db()
|
||||
yield
|
||||
gc.collect()
|
||||
if os.path.exists(_TMP):
|
||||
try:
|
||||
os.remove(_TMP)
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
|
||||
def test_filter_by_agent_id():
|
||||
db.create_task("lotto", "curate", {})
|
||||
db.create_task("stock", "brief", {})
|
||||
db.add_log("stock", "stock 로그")
|
||||
feed = db.get_activity_feed(limit=50, offset=0, agent_id="lotto")
|
||||
assert feed["total"] == 1
|
||||
assert all(i["agent_id"] == "lotto" for i in feed["items"])
|
||||
|
||||
|
||||
def test_filter_type_task_excludes_logs():
|
||||
db.create_task("lotto", "curate", {})
|
||||
db.add_log("lotto", "로그 한 줄")
|
||||
feed = db.get_activity_feed(limit=50, offset=0, type="task")
|
||||
assert feed["total"] == 1
|
||||
assert all(i["type"] == "task" for i in feed["items"])
|
||||
|
||||
|
||||
def test_filter_type_log_excludes_tasks():
|
||||
db.create_task("lotto", "curate", {})
|
||||
db.add_log("lotto", "로그 한 줄")
|
||||
feed = db.get_activity_feed(limit=50, offset=0, type="log")
|
||||
assert feed["total"] == 1
|
||||
assert all(i["type"] == "log" for i in feed["items"])
|
||||
|
||||
|
||||
def test_filter_status_tasks_only():
|
||||
t1 = db.create_task("lotto", "curate", {})
|
||||
t2 = db.create_task("lotto", "curate", {})
|
||||
db.update_task_status(t1, "succeeded", {})
|
||||
db.update_task_status(t2, "failed", {})
|
||||
db.add_log("lotto", "로그 한 줄") # status 필터 시 log는 제외돼야 함
|
||||
feed = db.get_activity_feed(limit=50, offset=0, status="succeeded")
|
||||
assert feed["total"] == 1
|
||||
assert all(i["type"] == "task" and i["status"] == "succeeded" for i in feed["items"])
|
||||
|
||||
|
||||
def test_no_filters_returns_all():
|
||||
db.create_task("lotto", "curate", {})
|
||||
db.add_log("stock", "로그")
|
||||
feed = db.get_activity_feed(limit=50, offset=0)
|
||||
assert feed["total"] == 2
|
||||
82
agent-office/tests/test_holdings_brief_format.py
Normal file
82
agent-office/tests/test_holdings_brief_format.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from app.notifiers import telegram_stock as ts
|
||||
|
||||
|
||||
def test_format_holdings_brief():
|
||||
payload = {
|
||||
"date": "2026-05-29",
|
||||
"holdings": [
|
||||
{"ticker": "005930", "name": "삼성전자", "action": "trim", "tech_score": 60.0,
|
||||
"exit_flags": {"ma50_break": True}, "issues": [{"type":"news","severity":"high","summary":"악재"}],
|
||||
"pnl_rate": 5.2, "reasons": "MA50 이탈"},
|
||||
{"ticker": "000660", "name": "SK하이닉스", "action": "hold", "tech_score": 75.0,
|
||||
"exit_flags": {}, "issues": [], "pnl_rate": -2.0, "reasons": "특이 신호 없음"},
|
||||
],
|
||||
"portfolio_health": {"positions": 2, "total_pnl_rate": 3.1, "max_weight": 0.6, "cash_ratio": 0.2},
|
||||
}
|
||||
txt = ts.format_holdings_brief(payload)
|
||||
assert "삼성전자" in txt
|
||||
assert "축소" in txt or "trim" in txt
|
||||
assert "%" in txt
|
||||
|
||||
|
||||
def test_format_holdings_brief_empty_holdings():
|
||||
"""빈 holdings + None portfolio_health에도 크래시 없음."""
|
||||
payload = {"date": "2026-05-29", "holdings": [], "portfolio_health": None}
|
||||
txt = ts.format_holdings_brief(payload)
|
||||
assert "보유종목 인텔리전스" in txt
|
||||
assert "자동매매" in txt
|
||||
|
||||
|
||||
def test_format_holdings_brief_missing_fields():
|
||||
"""pnl_rate None·name None·issues None 방어적 처리."""
|
||||
payload = {
|
||||
"date": None,
|
||||
"holdings": [
|
||||
{"ticker": "005930", "name": None, "action": "sell",
|
||||
"pnl_rate": None, "reasons": None, "issues": None},
|
||||
],
|
||||
"portfolio_health": {},
|
||||
}
|
||||
txt = ts.format_holdings_brief(payload)
|
||||
assert "005930" in txt # ticker fallback
|
||||
assert "🔴 매도" in txt
|
||||
|
||||
|
||||
def test_format_holdings_brief_sell_action():
|
||||
"""sell 액션은 🔴 매도로 표시."""
|
||||
payload = {
|
||||
"date": "2026-05-29",
|
||||
"holdings": [
|
||||
{"ticker": "000660", "name": "SK하이닉스", "action": "sell",
|
||||
"pnl_rate": -12.5, "reasons": "손절선 이탈", "issues": []},
|
||||
],
|
||||
"portfolio_health": {"positions": 1, "total_pnl_rate": -12.5,
|
||||
"max_weight": 1.0, "cash_ratio": 0.0},
|
||||
}
|
||||
txt = ts.format_holdings_brief(payload)
|
||||
assert "🔴 매도" in txt
|
||||
assert "-12.5%" in txt
|
||||
|
||||
|
||||
def test_format_holdings_brief_issue_severity_icons():
|
||||
"""이슈 심각도별 이모지 매핑 확인."""
|
||||
payload = {
|
||||
"date": "2026-05-29",
|
||||
"holdings": [
|
||||
{"ticker": "005930", "name": "삼성전자", "action": "hold", "pnl_rate": 2.0,
|
||||
"reasons": "특이 신호 없음",
|
||||
"issues": [
|
||||
{"type": "news", "severity": "high", "summary": "심각 악재"},
|
||||
{"type": "volume_surge", "severity": "med", "summary": "거래량 급증"},
|
||||
{"type": "price_move", "severity": "low", "summary": "소폭 변동"},
|
||||
]},
|
||||
],
|
||||
"portfolio_health": {},
|
||||
}
|
||||
txt = ts.format_holdings_brief(payload)
|
||||
assert "🔴" in txt # high severity
|
||||
assert "🟠" in txt # med severity
|
||||
assert "🟡" in txt # low severity
|
||||
169
agent-office/tests/test_insta_autonomous.py
Normal file
169
agent-office/tests/test_insta_autonomous.py
Normal file
@@ -0,0 +1,169 @@
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
_fd, _TMP = tempfile.mkstemp(suffix=".db")
|
||||
os.close(_fd)
|
||||
os.unlink(_TMP)
|
||||
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock
|
||||
from app.agents.insta import InstaAgent
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _init_db():
|
||||
import gc
|
||||
gc.collect()
|
||||
if os.path.exists(_TMP):
|
||||
os.remove(_TMP)
|
||||
from app.db import init_db
|
||||
init_db()
|
||||
yield
|
||||
gc.collect()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_autonomous_issue_previews_eligible(monkeypatch):
|
||||
agent = InstaAgent()
|
||||
agent.state = "idle"
|
||||
monkeypatch.setattr("app.agents.insta.get_agent_config",
|
||||
lambda aid: {"custom_config": {"autonomous_issue": True,
|
||||
"select_threshold": 0.5, "max_per_day": 2}})
|
||||
monkeypatch.setattr(agent, "transition", AsyncMock())
|
||||
monkeypatch.setattr(agent, "_run_collect_and_extract", AsyncMock())
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_ranked", AsyncMock(return_value=[
|
||||
{"id": 1, "keyword": "금리", "category": "economy", "eligible": True, "final_score": 0.8, "breakdown": {}},
|
||||
{"id": 2, "keyword": "x", "category": "economy", "eligible": False, "final_score": 0.1, "breakdown": {}},
|
||||
]))
|
||||
preview = AsyncMock()
|
||||
monkeypatch.setattr(agent, "_generate_and_preview", preview)
|
||||
monkeypatch.setattr("app.agents.insta.create_task", lambda *a, **k: "t1")
|
||||
monkeypatch.setattr("app.agents.insta.update_task_status", lambda *a, **k: None)
|
||||
monkeypatch.setattr("app.agents.insta.add_log", lambda *a, **k: None)
|
||||
await agent.on_schedule()
|
||||
assert preview.await_count == 1
|
||||
assert preview.await_args.args[0]["id"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_approve_publishes_and_delivers(monkeypatch):
|
||||
agent = InstaAgent()
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_decision",
|
||||
AsyncMock(return_value={"status": "published"}))
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_slate", AsyncMock(return_value={
|
||||
"assets": [{"page_index": i} for i in range(1, 11)],
|
||||
"suggested_caption": "cap", "hashtags": ["#a"]}))
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_asset_bytes", AsyncMock(return_value=b"png"))
|
||||
monkeypatch.setattr("app.agents.insta._send_media_group", AsyncMock(return_value={"ok": True}))
|
||||
monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock())
|
||||
res = await agent.on_callback("issue_approve", {"slate_id": 8})
|
||||
assert res["ok"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_reject_marks_rejected(monkeypatch):
|
||||
agent = InstaAgent()
|
||||
dec = AsyncMock(return_value={"status": "rejected"})
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_decision", dec)
|
||||
monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock())
|
||||
res = await agent.on_callback("issue_reject", {"slate_id": 8})
|
||||
assert res["ok"] is True
|
||||
dec.assert_awaited_once_with(8, "rejected")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_insta_issue_dispatch(monkeypatch):
|
||||
"""_handle_insta_issue: issue_approve_8 → on_callback('issue_approve', {slate_id:8})."""
|
||||
import sys
|
||||
# stub api_call so answerCallbackQuery doesn't hit real Telegram
|
||||
import app.telegram.webhook as wh
|
||||
monkeypatch.setattr(wh, "api_call", AsyncMock(return_value={"ok": True}))
|
||||
|
||||
agent = InstaAgent()
|
||||
on_cb = AsyncMock(return_value={"ok": True})
|
||||
monkeypatch.setattr(agent, "on_callback", on_cb)
|
||||
|
||||
from app.agents import AGENT_REGISTRY
|
||||
old = AGENT_REGISTRY.get("insta")
|
||||
AGENT_REGISTRY["insta"] = agent
|
||||
try:
|
||||
result = await wh._handle_insta_issue(
|
||||
{"id": "cq1", "data": "issue_approve_8"},
|
||||
"issue_approve_8",
|
||||
)
|
||||
finally:
|
||||
if old is None:
|
||||
AGENT_REGISTRY.pop("insta", None)
|
||||
else:
|
||||
AGENT_REGISTRY["insta"] = old
|
||||
|
||||
on_cb.assert_awaited_once_with("issue_approve", {"slate_id": 8})
|
||||
assert result["ok"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_insta_issue_invalid_data(monkeypatch):
|
||||
"""_handle_insta_issue: 잘못된 callback_data → ok=False, error=invalid_callback_data."""
|
||||
import app.telegram.webhook as wh
|
||||
monkeypatch.setattr(wh, "api_call", AsyncMock(return_value={"ok": True}))
|
||||
monkeypatch.setattr("app.telegram.messaging.send_raw", AsyncMock())
|
||||
|
||||
result = await wh._handle_insta_issue(
|
||||
{"id": "cq2", "data": "issue_bad"},
|
||||
"issue_bad",
|
||||
)
|
||||
assert result["ok"] is False
|
||||
assert result["error"] == "invalid_callback_data"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backward_compat_non_autonomous_uses_legacy_path(monkeypatch):
|
||||
"""autonomous_issue=False, auto_select=False → insta_ranked 미호출, _push_keyword_candidates 호출."""
|
||||
agent = InstaAgent()
|
||||
agent.state = "idle"
|
||||
monkeypatch.setattr("app.agents.insta.get_agent_config",
|
||||
lambda aid: {"custom_config": {"autonomous_issue": False, "auto_select": False}})
|
||||
monkeypatch.setattr(agent, "transition", AsyncMock())
|
||||
monkeypatch.setattr(agent, "_run_collect_and_extract", AsyncMock())
|
||||
# insta_get_preferences는 try/except 안에 있으므로 예외를 던져도 안전하지만 깔끔하게 mock
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_preferences",
|
||||
AsyncMock(return_value={}))
|
||||
# 비자율 경로에서 insta_ranked는 호출되면 안 된다
|
||||
ranked = AsyncMock()
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_ranked", ranked)
|
||||
# insta_list_keywords: 비자율 경로에서 반드시 호출
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_list_keywords",
|
||||
AsyncMock(return_value=[]))
|
||||
# auto_select=False → _push_keyword_candidates 경로
|
||||
push = AsyncMock()
|
||||
monkeypatch.setattr(agent, "_push_keyword_candidates", push)
|
||||
gen = AsyncMock()
|
||||
monkeypatch.setattr(agent, "_generate_and_preview", gen)
|
||||
monkeypatch.setattr("app.agents.insta.create_task", lambda *a, **k: "t1")
|
||||
monkeypatch.setattr("app.agents.insta.update_task_status", lambda *a, **k: None)
|
||||
monkeypatch.setattr("app.agents.insta.add_log", lambda *a, **k: None)
|
||||
await agent.on_schedule()
|
||||
ranked.assert_not_awaited() # 자율 경로(insta_ranked) 미진입 확인
|
||||
gen.assert_not_awaited() # _generate_and_preview 미호출 확인
|
||||
push.assert_awaited_once() # 기존 candidate-push 경로 진입 확인
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_regen_rejects_old_and_regenerates(monkeypatch):
|
||||
"""issue_regen: 기존 슬레이트 rejected 처리 후 같은 키워드로 _generate_and_preview 재호출."""
|
||||
agent = InstaAgent()
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_slate",
|
||||
AsyncMock(return_value={"keyword": "금리", "category": "economy"}))
|
||||
dec = AsyncMock(return_value={"status": "rejected"})
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_decision", dec)
|
||||
gen = AsyncMock()
|
||||
monkeypatch.setattr(agent, "_generate_and_preview", gen)
|
||||
res = await agent.on_callback("issue_regen", {"slate_id": 8})
|
||||
assert res["ok"] is True
|
||||
dec.assert_awaited_once_with(8, "rejected") # 이전 슬레이트 폐기
|
||||
gen.assert_awaited_once() # 같은 키워드로 재생성
|
||||
assert gen.await_args.args[0]["keyword"] == "금리"
|
||||
55
agent-office/tests/test_insta_keyword_filter.py
Normal file
55
agent-office/tests/test_insta_keyword_filter.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
_fd, _TMP = tempfile.mkstemp(suffix=".db")
|
||||
os.close(_fd)
|
||||
os.unlink(_TMP)
|
||||
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app.agents.insta import _dedup_and_filter_keywords, KEYWORD_MIN_SCORE
|
||||
|
||||
|
||||
def test_filters_below_threshold():
|
||||
"""score < 임계값(0.7) 키워드는 제외."""
|
||||
kws = [
|
||||
{"id": 1, "keyword": "금리인하", "category": "경제", "score": 0.9},
|
||||
{"id": 2, "keyword": "환율", "category": "경제", "score": 0.6}, # 컷
|
||||
{"id": 3, "keyword": "반도체", "category": "경제", "score": 0.71},
|
||||
]
|
||||
out = _dedup_and_filter_keywords(kws, min_score=0.7)
|
||||
kept = {k["keyword"] for k in out}
|
||||
assert kept == {"금리인하", "반도체"}
|
||||
|
||||
|
||||
def test_dedup_keeps_highest_score():
|
||||
"""동일 keyword 중복 시 최고 score 1개만 유지."""
|
||||
kws = [
|
||||
{"id": 1, "keyword": "AI", "category": "경제", "score": 0.75},
|
||||
{"id": 2, "keyword": "AI", "category": "기술", "score": 0.92}, # 같은 키워드, 더 높음
|
||||
]
|
||||
out = _dedup_and_filter_keywords(kws, min_score=0.7)
|
||||
assert len(out) == 1
|
||||
assert out[0]["id"] == 2
|
||||
assert out[0]["score"] == 0.92
|
||||
|
||||
|
||||
def test_sorted_by_score_desc():
|
||||
kws = [
|
||||
{"id": 1, "keyword": "a", "category": "c", "score": 0.72},
|
||||
{"id": 2, "keyword": "b", "category": "c", "score": 0.95},
|
||||
{"id": 3, "keyword": "c", "category": "c", "score": 0.80},
|
||||
]
|
||||
out = _dedup_and_filter_keywords(kws, min_score=0.7)
|
||||
assert [k["keyword"] for k in out] == ["b", "c", "a"]
|
||||
|
||||
|
||||
def test_empty_when_all_below_threshold():
|
||||
kws = [{"id": 1, "keyword": "x", "category": "c", "score": 0.4}]
|
||||
assert _dedup_and_filter_keywords(kws, min_score=0.7) == []
|
||||
|
||||
|
||||
def test_default_threshold_is_0_7():
|
||||
assert KEYWORD_MIN_SCORE == 0.7
|
||||
47
agent-office/tests/test_log_merge.py
Normal file
47
agent-office/tests/test_log_merge.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import pytest
|
||||
import respx
|
||||
import httpx
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.db import add_log, _conn
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_logs():
|
||||
with _conn() as conn:
|
||||
conn.execute("DELETE FROM agent_logs WHERE agent_id = 'lotto'")
|
||||
yield
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_agent_logs_endpoint_merges_db_and_service_logs():
|
||||
add_log("lotto", "큐레이션 완료: #1234 conf=0.78")
|
||||
respx.get("http://lotto:8000/logs/recent").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"logs": [
|
||||
{"ts": "2026-05-28T10:00:00Z", "source": "access",
|
||||
"method": "GET", "path": "/api/lotto/latest",
|
||||
"status": 200, "ms": 8,
|
||||
"message": "GET /api/lotto/latest → 200 (8ms)"},
|
||||
{"ts": "2026-05-28T10:00:02Z", "source": "log",
|
||||
"logger": "lotto", "level": "info",
|
||||
"message": "성과 통계 캐시 갱신"},
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/agent-office/agents/lotto/logs?limit=20")
|
||||
assert resp.status_code == 200
|
||||
logs = resp.json()["logs"]
|
||||
|
||||
sources = {x["source"] for x in logs}
|
||||
assert "agent" in sources
|
||||
assert "access" in sources
|
||||
assert "log" in sources
|
||||
|
||||
messages = [x["message"] for x in logs]
|
||||
assert any("큐레이션 완료" in m for m in messages)
|
||||
assert any("성과 통계 캐시 갱신" in m for m in messages)
|
||||
assert any("/api/lotto/latest" in m for m in messages)
|
||||
87
agent-office/tests/test_lotto_evolution_format.py
Normal file
87
agent-office/tests/test_lotto_evolution_format.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from app.notifiers.telegram_lotto import _format_evolution_report
|
||||
|
||||
|
||||
def test_evolution_report_winner_4plus():
|
||||
eval_result = {
|
||||
"ok": True,
|
||||
"draw_no": 1225,
|
||||
"week_start": "2026-05-18",
|
||||
"winner": {
|
||||
"day_of_week": 3,
|
||||
"weight": [0.18, 0.32, 0.20, 0.22, 0.08],
|
||||
"avg_score": 0.42,
|
||||
"max_correct": 4,
|
||||
"n_picks": 5,
|
||||
},
|
||||
"new_base": [0.18, 0.32, 0.20, 0.22, 0.08],
|
||||
"previous_base": [0.20, 0.20, 0.20, 0.20, 0.20],
|
||||
"update_reason": "winner_4plus",
|
||||
"per_day": [
|
||||
{"day_of_week": 0, "avg_score": 0.20, "max_correct": 2},
|
||||
{"day_of_week": 3, "avg_score": 0.42, "max_correct": 4},
|
||||
],
|
||||
}
|
||||
current_base = [0.20, 0.20, 0.20, 0.20, 0.20]
|
||||
text = _format_evolution_report(eval_result, current_base)
|
||||
assert "🧬" in text
|
||||
assert "1225" in text
|
||||
assert "목요일" in text or "Winner" in text
|
||||
assert "4개 일치" in text or "max=4" in text
|
||||
assert "winner_4plus" in text
|
||||
|
||||
|
||||
def test_evolution_report_unchanged():
|
||||
eval_result = {
|
||||
"ok": True,
|
||||
"draw_no": 1226,
|
||||
"week_start": "2026-05-25",
|
||||
"winner": {
|
||||
"day_of_week": 1,
|
||||
"weight": [0.21, 0.19, 0.20, 0.20, 0.20],
|
||||
"avg_score": 0.10,
|
||||
"max_correct": 2,
|
||||
"n_picks": 5,
|
||||
},
|
||||
"new_base": [0.20, 0.20, 0.20, 0.20, 0.20],
|
||||
"update_reason": "unchanged",
|
||||
"per_day": [],
|
||||
}
|
||||
current_base = [0.20, 0.20, 0.20, 0.20, 0.20]
|
||||
text = _format_evolution_report(eval_result, current_base)
|
||||
assert "unchanged" in text or "유지" in text
|
||||
assert "2개 일치" in text or "max=2" in text
|
||||
|
||||
|
||||
def test_evolution_report_empty_returns_empty():
|
||||
"""evaluate가 ok=False면 빈 문자열 (발송 skip)."""
|
||||
text = _format_evolution_report({"ok": False, "reason": "no_trials"}, [0.2]*5)
|
||||
assert text == ""
|
||||
|
||||
|
||||
def test_evolution_report_uses_previous_base_for_diff():
|
||||
"""previous_base와 new_base 차이가 메시지 diff에 정확히 반영됨."""
|
||||
eval_result = {
|
||||
"ok": True,
|
||||
"draw_no": 1227,
|
||||
"winner": {
|
||||
"day_of_week": 0,
|
||||
"weight": [0.30, 0.20, 0.20, 0.20, 0.10],
|
||||
"avg_score": 0.50,
|
||||
"max_correct": 4,
|
||||
"n_picks": 5,
|
||||
},
|
||||
"new_base": [0.30, 0.20, 0.20, 0.20, 0.10],
|
||||
"previous_base": [0.20, 0.20, 0.20, 0.20, 0.20],
|
||||
"update_reason": "winner_4plus",
|
||||
}
|
||||
# current_base는 stale (post-update 값) — previous_base가 우선 적용되어야 함
|
||||
text = _format_evolution_report(eval_result, [0.30, 0.20, 0.20, 0.20, 0.10])
|
||||
# freq: 0.20 → 0.30 (+0.10 = "++")
|
||||
# divers: 0.20 → 0.10 (-0.10 = "--")
|
||||
assert "0.20 → 0.30" in text # freq 증가
|
||||
assert "0.20 → 0.10" in text # divers 감소
|
||||
assert "(++)" in text or "(+)" in text # freq marker
|
||||
assert "(--)" in text or "(-)" in text # divers marker
|
||||
229
agent-office/tests/test_lotto_task_wrap.py
Normal file
229
agent-office/tests/test_lotto_task_wrap.py
Normal file
@@ -0,0 +1,229 @@
|
||||
# agent-office/tests/test_lotto_task_wrap.py
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import gc
|
||||
|
||||
_fd, _TMP = tempfile.mkstemp(suffix=".db")
|
||||
os.close(_fd)
|
||||
os.unlink(_TMP)
|
||||
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import pytest
|
||||
from app import db
|
||||
db.DB_PATH = _TMP
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db():
|
||||
# Re-patch DB_PATH at the start of every test (cross-file isolation)
|
||||
db.DB_PATH = _TMP
|
||||
gc.collect()
|
||||
if os.path.exists(_TMP):
|
||||
os.remove(_TMP)
|
||||
db.init_db()
|
||||
yield
|
||||
gc.collect()
|
||||
if os.path.exists(_TMP):
|
||||
try:
|
||||
os.remove(_TMP)
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_signal_check_creates_task_row(monkeypatch):
|
||||
"""run_signal_check이 agent_tasks에 row를 만들고 result_data를 저장."""
|
||||
from app.agents.lotto import LottoAgent
|
||||
from app.curator import signal_runner
|
||||
|
||||
async def fake_run_signal_check(**kwargs):
|
||||
return {
|
||||
"overall_fire": "normal",
|
||||
"results": [
|
||||
{"signal_id": 1, "metric": "sim_signal",
|
||||
"value": 0.6, "z_score": 1.7, "fire_level": "normal",
|
||||
"baseline_mu": 0.5, "baseline_sigma": 0.05, "payload": {}},
|
||||
],
|
||||
}
|
||||
monkeypatch.setattr(signal_runner, "run_signal_check", fake_run_signal_check)
|
||||
|
||||
from app import service_proxy
|
||||
async def fake_latest():
|
||||
return 1226
|
||||
monkeypatch.setattr(service_proxy, "lotto_latest_draw", fake_latest)
|
||||
|
||||
from app.notifiers import telegram_lotto
|
||||
async def fake_send(_event): pass
|
||||
monkeypatch.setattr(telegram_lotto, "send_urgent_signal", fake_send)
|
||||
|
||||
agent = LottoAgent()
|
||||
result = await agent.run_signal_check(source="light")
|
||||
assert result["ok"] is True
|
||||
|
||||
tasks = db.get_agent_tasks("lotto", task_type="signal_check", days=1)
|
||||
assert len(tasks) == 1
|
||||
t = tasks[0]
|
||||
assert t["status"] == "succeeded"
|
||||
assert t["result_data"]["source"] == "light"
|
||||
assert t["result_data"]["overall_fire"] == "normal"
|
||||
assert "sim_signal" in t["result_data"]["fired_metrics"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_signal_check_failure_marks_task_failed(monkeypatch):
|
||||
from app.agents.lotto import LottoAgent
|
||||
from app.curator import signal_runner
|
||||
from app import service_proxy
|
||||
|
||||
async def boom(**kwargs):
|
||||
raise RuntimeError("boom")
|
||||
monkeypatch.setattr(signal_runner, "run_signal_check", boom)
|
||||
|
||||
async def fake_latest():
|
||||
return 1226
|
||||
monkeypatch.setattr(service_proxy, "lotto_latest_draw", fake_latest)
|
||||
|
||||
agent = LottoAgent()
|
||||
result = await agent.run_signal_check(source="sim")
|
||||
assert result["ok"] is False
|
||||
|
||||
tasks = db.get_agent_tasks("lotto", task_type="signal_check", days=1)
|
||||
assert len(tasks) == 1
|
||||
assert tasks[0]["status"] == "failed"
|
||||
assert "boom" in tasks[0]["result_data"]["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deep_curate_error_still_evaluates_signals(monkeypatch):
|
||||
"""deep: curate_weekly가 CuratorError여도 sim/drift 시그널 평가는 계속(fallthrough)."""
|
||||
from app.agents.lotto import LottoAgent
|
||||
from app.curator import signal_runner, pipeline
|
||||
from app import service_proxy
|
||||
from app.notifiers import telegram_lotto
|
||||
|
||||
async def boom_curate(**kwargs):
|
||||
raise pipeline.CuratorError("curation 실패")
|
||||
monkeypatch.setattr(pipeline, "curate_weekly", boom_curate)
|
||||
|
||||
called = {"signal": False, "curate_result": "UNSET"}
|
||||
async def fake_signal(**kwargs):
|
||||
called["signal"] = True
|
||||
called["curate_result"] = kwargs.get("curate_result")
|
||||
return {"overall_fire": "normal", "results": [
|
||||
{"signal_id": 1, "metric": "sim_signal", "value": 0.6, "z_score": 1.7,
|
||||
"fire_level": "normal", "baseline_mu": 0.5, "baseline_sigma": 0.05, "payload": {}}]}
|
||||
monkeypatch.setattr(signal_runner, "run_signal_check", fake_signal)
|
||||
|
||||
async def fake_latest():
|
||||
return 1226
|
||||
monkeypatch.setattr(service_proxy, "lotto_latest_draw", fake_latest)
|
||||
async def fake_send(_e):
|
||||
pass
|
||||
monkeypatch.setattr(telegram_lotto, "send_urgent_signal", fake_send)
|
||||
|
||||
agent = LottoAgent()
|
||||
result = await agent.run_signal_check(source="deep")
|
||||
assert result["ok"] is True # CuratorError로 중단되지 않음
|
||||
assert called["signal"] is True # sim/drift 평가 계속됨
|
||||
assert called["curate_result"] is None # confidence는 None으로 fallthrough
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_urgent_send_retries_then_succeeds(monkeypatch):
|
||||
"""urgent 발송이 실패하면 재시도하고, 성공하면 True."""
|
||||
from app.agents.lotto import LottoAgent
|
||||
from app.notifiers import telegram_lotto
|
||||
import app.agents.lotto as lotto_mod
|
||||
monkeypatch.setattr(lotto_mod, "URGENT_SEND_RETRY_SEC", 0) # 실대기 제거
|
||||
|
||||
attempts = {"n": 0}
|
||||
async def flaky_send(_event):
|
||||
attempts["n"] += 1
|
||||
if attempts["n"] < 3:
|
||||
raise RuntimeError("telegram down")
|
||||
monkeypatch.setattr(telegram_lotto, "send_urgent_signal", flaky_send)
|
||||
|
||||
agent = LottoAgent()
|
||||
results = [{"signal_id": 1, "fire_level": "urgent"}]
|
||||
ok = await agent._send_urgent_with_retry({"x": 1}, results, task_id="t1")
|
||||
assert ok is True
|
||||
assert attempts["n"] == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_urgent_send_all_fail_returns_false_no_raise(monkeypatch):
|
||||
"""urgent 발송이 끝까지 실패해도 raise하지 않고 False (시그널 평가/태스크 보존)."""
|
||||
from app.agents.lotto import LottoAgent
|
||||
from app.notifiers import telegram_lotto
|
||||
import app.agents.lotto as lotto_mod
|
||||
monkeypatch.setattr(lotto_mod, "URGENT_SEND_RETRY_SEC", 0)
|
||||
|
||||
async def always_fail(_event):
|
||||
raise RuntimeError("telegram down")
|
||||
monkeypatch.setattr(telegram_lotto, "send_urgent_signal", always_fail)
|
||||
|
||||
agent = LottoAgent()
|
||||
ok = await agent._send_urgent_with_retry(
|
||||
{"x": 1}, [{"signal_id": 1, "fire_level": "urgent"}], task_id="t1")
|
||||
assert ok is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_daily_digest_creates_task(monkeypatch):
|
||||
"""run_daily_digest이 agent_tasks에 task 생성 + result_data 저장."""
|
||||
from app.agents.lotto import LottoAgent
|
||||
from app.notifiers import telegram_lotto
|
||||
|
||||
async def fake_send(_d): pass
|
||||
monkeypatch.setattr(telegram_lotto, "send_signal_summary", fake_send)
|
||||
|
||||
agent = LottoAgent()
|
||||
result = await agent.run_daily_digest()
|
||||
assert result["ok"] is True
|
||||
|
||||
tasks = db.get_agent_tasks("lotto", task_type="daily_digest", days=1)
|
||||
assert len(tasks) == 1
|
||||
assert tasks[0]["status"] == "succeeded"
|
||||
assert "fired" in tasks[0]["result_data"]
|
||||
assert "evaluated" in tasks[0]["result_data"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_weekly_evolution_report_creates_task(monkeypatch):
|
||||
"""run_weekly_evolution_report이 task 생성 + result_data 저장."""
|
||||
from app.agents.lotto import LottoAgent
|
||||
from app import service_proxy
|
||||
from app.notifiers import telegram_lotto
|
||||
|
||||
async def fake_eval():
|
||||
return {
|
||||
"ok": True, "draw_no": 1225,
|
||||
"winner": {"day_of_week": 3, "weight": [0.18, 0.32, 0.20, 0.22, 0.08],
|
||||
"avg_score": 0.42, "max_correct": 4, "n_picks": 5},
|
||||
"new_base": [0.18, 0.32, 0.20, 0.22, 0.08],
|
||||
"previous_base": [0.2] * 5,
|
||||
"update_reason": "winner_4plus",
|
||||
}
|
||||
async def fake_status():
|
||||
return {"current_base": [0.2] * 5}
|
||||
async def fake_send(_e, _b): pass
|
||||
|
||||
monkeypatch.setattr(service_proxy, "lotto_evolver_evaluate", fake_eval)
|
||||
monkeypatch.setattr(service_proxy, "lotto_evolver_status", fake_status)
|
||||
monkeypatch.setattr(telegram_lotto, "send_evolution_report", fake_send)
|
||||
|
||||
agent = LottoAgent()
|
||||
result = await agent.run_weekly_evolution_report()
|
||||
assert result["ok"] is True
|
||||
|
||||
tasks = db.get_agent_tasks("lotto", task_type="weekly_evolution_report", days=1)
|
||||
assert len(tasks) == 1
|
||||
r = tasks[0]["result_data"]
|
||||
assert tasks[0]["status"] == "succeeded"
|
||||
assert r["draw_no"] == 1225
|
||||
assert r["update_reason"] == "winner_4plus"
|
||||
assert r["winner_day_of_week"] == 3
|
||||
assert r["winner_max_correct"] == 4
|
||||
72
agent-office/tests/test_migrate_tarot.py
Normal file
72
agent-office/tests/test_migrate_tarot.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""migrate_tarot_to_lab.py 단위 테스트 — 멱등성 + 데이터 보존."""
|
||||
import sqlite3
|
||||
import sys
|
||||
import os
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def src_db(tmp_path):
|
||||
p = tmp_path / "agent_office.db"
|
||||
conn = sqlite3.connect(str(p))
|
||||
conn.execute("""
|
||||
CREATE TABLE tarot_readings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT, spread_type TEXT, category TEXT, question TEXT,
|
||||
cards TEXT, interpretation_json TEXT, summary TEXT, model TEXT,
|
||||
tokens_in INTEGER, tokens_out INTEGER, cost_usd REAL,
|
||||
confidence TEXT, favorite INTEGER, note TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
INSERT INTO tarot_readings (id, spread_type, category, cards, model, favorite)
|
||||
VALUES (1, 'three_card', '연애', '[]', 'm', 0),
|
||||
(2, 'one_card', '재물', '[]', 'm', 1)
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return str(p)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dst_db(tmp_path):
|
||||
return str(tmp_path / "tarot.db")
|
||||
|
||||
|
||||
def _import_migrate(src, dst, monkeypatch):
|
||||
monkeypatch.setenv("AGENT_OFFICE_DB", src)
|
||||
monkeypatch.setenv("TAROT_DB", dst)
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
|
||||
import migrate_tarot_to_lab as m
|
||||
import importlib
|
||||
importlib.reload(m)
|
||||
return m
|
||||
|
||||
|
||||
def test_first_run_copies_all_rows(src_db, dst_db, monkeypatch):
|
||||
m = _import_migrate(src_db, dst_db, monkeypatch)
|
||||
moved = m.migrate()
|
||||
assert moved == 2
|
||||
conn = sqlite3.connect(dst_db)
|
||||
rows = conn.execute("SELECT id, spread_type, category FROM tarot_readings ORDER BY id").fetchall()
|
||||
conn.close()
|
||||
assert rows == [(1, "three_card", "연애"), (2, "one_card", "재물")]
|
||||
|
||||
|
||||
def test_idempotent_second_run(src_db, dst_db, monkeypatch):
|
||||
m = _import_migrate(src_db, dst_db, monkeypatch)
|
||||
m.migrate()
|
||||
moved2 = m.migrate()
|
||||
assert moved2 == 0
|
||||
|
||||
|
||||
def test_partial_migration(src_db, dst_db, monkeypatch):
|
||||
"""dst에 id=1만 있는 상태에서 다시 돌리면 id=2만 옮김."""
|
||||
m = _import_migrate(src_db, dst_db, monkeypatch)
|
||||
m.migrate()
|
||||
conn = sqlite3.connect(dst_db)
|
||||
conn.execute("DELETE FROM tarot_readings WHERE id=2")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
moved = m.migrate()
|
||||
assert moved == 1
|
||||
@@ -40,6 +40,9 @@ async def test_poll_notifies_once_per_state():
|
||||
with patch(
|
||||
"app.agents.youtube_publisher.service_proxy.list_active_pipelines",
|
||||
new=AsyncMock(return_value=pipelines),
|
||||
), patch(
|
||||
"app.agents.youtube_publisher.service_proxy.list_failed_pipelines",
|
||||
new=AsyncMock(return_value=[]),
|
||||
), patch(
|
||||
"app.agents.youtube_publisher.send_raw",
|
||||
new=AsyncMock(return_value={"ok": True, "message_id": 99}),
|
||||
@@ -63,6 +66,8 @@ async def test_poll_renotifies_on_reject_regen(monkeypatch):
|
||||
"track_title": "Test", "feedback_count_per_step": {"cover": 1}}]
|
||||
list_mock = AsyncMock(side_effect=[pipelines_v1, pipelines_v2])
|
||||
with patch("app.agents.youtube_publisher.service_proxy.list_active_pipelines", list_mock), \
|
||||
patch("app.agents.youtube_publisher.service_proxy.list_failed_pipelines",
|
||||
new=AsyncMock(return_value=[])), \
|
||||
patch("app.agents.youtube_publisher.send_raw",
|
||||
new=AsyncMock(return_value={"ok": True, "message_id": 99})), \
|
||||
patch("app.agents.youtube_publisher.service_proxy.save_pipeline_telegram_msg",
|
||||
@@ -83,7 +88,7 @@ async def test_on_telegram_reply_approve_calls_feedback():
|
||||
new=AsyncMock(),
|
||||
) as mock_fb, patch(
|
||||
"app.agents.youtube_publisher.send_raw",
|
||||
new=AsyncMock(),
|
||||
new=AsyncMock(return_value={"ok": True, "message_id": 1}),
|
||||
):
|
||||
a = YoutubePublisherAgent()
|
||||
await a.on_telegram_reply(pipeline_id=42, step="cover", user_text="승인")
|
||||
@@ -99,7 +104,7 @@ async def test_on_telegram_reply_reject_with_feedback():
|
||||
new=AsyncMock(),
|
||||
) as mock_fb, patch(
|
||||
"app.agents.youtube_publisher.send_raw",
|
||||
new=AsyncMock(),
|
||||
new=AsyncMock(return_value={"ok": True, "message_id": 1}),
|
||||
):
|
||||
a = YoutubePublisherAgent()
|
||||
await a.on_telegram_reply(pipeline_id=43, step="meta", user_text="반려, 제목 짧게")
|
||||
|
||||
53
agent-office/tests/test_service_proxy_logs.py
Normal file
53
agent-office/tests/test_service_proxy_logs.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import pytest
|
||||
import respx
|
||||
import httpx
|
||||
|
||||
from app.service_proxy import fetch_service_logs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_fetch_service_logs_filters_by_path_prefix():
|
||||
# lotto 컨테이너 응답: lotto + personal 섞임
|
||||
respx.get("http://lotto:8000/logs/recent").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"logs": [
|
||||
{"ts": "2026-05-28T10:00:00Z", "source": "access",
|
||||
"method": "GET", "path": "/api/lotto/recommend",
|
||||
"status": 200, "ms": 12,
|
||||
"message": "GET /api/lotto/recommend → 200 (12ms)"},
|
||||
{"ts": "2026-05-28T10:00:01Z", "source": "access",
|
||||
"method": "GET", "path": "/api/blog/posts",
|
||||
"status": 200, "ms": 5,
|
||||
"message": "GET /api/blog/posts → 200 (5ms)"},
|
||||
{"ts": "2026-05-28T10:00:02Z", "source": "log",
|
||||
"logger": "lotto", "level": "info",
|
||||
"message": "성과 통계 캐시 갱신"},
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
result = await fetch_service_logs("lotto", limit=50)
|
||||
# lotto path 와 모든 log 이벤트만 통과
|
||||
paths = [x.get("path") for x in result]
|
||||
assert "/api/lotto/recommend" in paths
|
||||
assert "/api/blog/posts" not in paths
|
||||
# 비즈니스 로그도 포함
|
||||
assert any(x["source"] == "log" and x["message"] == "성과 통계 캐시 갱신"
|
||||
for x in result)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_service_logs_unknown_agent_returns_empty():
|
||||
result = await fetch_service_logs("nonexistent", limit=50)
|
||||
assert result == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_fetch_service_logs_handles_connection_error():
|
||||
respx.get("http://lotto:8000/logs/recent").mock(
|
||||
side_effect=httpx.ConnectError("connection refused")
|
||||
)
|
||||
result = await fetch_service_logs("lotto", limit=50)
|
||||
assert result == []
|
||||
38
agent-office/tests/test_sunday_review.py
Normal file
38
agent-office/tests/test_sunday_review.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from app.notifiers import telegram_lotto as tl
|
||||
|
||||
|
||||
def test_format_sunday_review_text():
|
||||
payload = {
|
||||
"draw_no": 1170,
|
||||
"winner_analysis": {"score_total": 0.41, "percentile": 0.33,
|
||||
"score_frequency": 0.4, "score_fingerprint": 0.5, "score_gap": 0.3,
|
||||
"score_cooccur": 0.45, "score_diversity": 0.6},
|
||||
"forward": [
|
||||
{"strategy": "engine_w", "label": "w1", "prizes": {"1st":0,"2nd":0,"3rd":0,"4th":1,"5th":12}, "best_match": 4, "avg_meta_score": 0.55},
|
||||
{"strategy": "random_null", "label": "-", "prizes": {"1st":0,"2nd":0,"3rd":0,"4th":0,"5th":10}, "best_match": 3, "avg_meta_score": 0.33},
|
||||
],
|
||||
"track_record": {},
|
||||
"calibration_trend": [{"draw_no":1170,"score_total":0.41,"percentile":0.33}],
|
||||
}
|
||||
txt = tl.format_sunday_review(payload)
|
||||
assert "1170" in txt
|
||||
assert "%" in txt # percentile 표기
|
||||
assert "engine" in txt.lower() or "엔진" in txt
|
||||
|
||||
|
||||
def test_format_sunday_review_no_calibration():
|
||||
payload = {"draw_no": 1171, "winner_analysis": None, "forward": []}
|
||||
txt = tl.format_sunday_review(payload)
|
||||
assert "1171" in txt
|
||||
assert "%" not in txt # no percentile section when calibration absent
|
||||
assert "데이터 없음" in txt
|
||||
|
||||
|
||||
def test_format_sunday_review_missing_prizes_no_crash():
|
||||
payload = {"draw_no": 1171, "winner_analysis": None,
|
||||
"forward": [{"strategy": "engine_w", "label": "w1", "best_match": 3}]} # no 'prizes'
|
||||
txt = tl.format_sunday_review(payload) # must NOT raise
|
||||
assert "1171" in txt
|
||||
123
agent-office/tests/test_sync_evolver_activity.py
Normal file
123
agent-office/tests/test_sync_evolver_activity.py
Normal file
@@ -0,0 +1,123 @@
|
||||
# agent-office/tests/test_sync_evolver_activity.py
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import gc
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
_fd, _TMP = tempfile.mkstemp(suffix=".db")
|
||||
os.close(_fd)
|
||||
os.unlink(_TMP)
|
||||
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import pytest
|
||||
from app import db
|
||||
db.DB_PATH = _TMP
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db():
|
||||
# Re-patch DB_PATH at the start of every test (cross-file isolation)
|
||||
db.DB_PATH = _TMP
|
||||
gc.collect()
|
||||
if os.path.exists(_TMP):
|
||||
os.remove(_TMP)
|
||||
db.init_db()
|
||||
yield
|
||||
gc.collect()
|
||||
if os.path.exists(_TMP):
|
||||
try:
|
||||
os.remove(_TMP)
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
|
||||
def _today_dow_clamped():
|
||||
"""오늘의 weekday() (일요일=6은 5로 clamp)."""
|
||||
KST = timezone(timedelta(hours=9))
|
||||
dow = datetime.now(KST).weekday()
|
||||
return 5 if dow == 6 else dow
|
||||
|
||||
|
||||
def _fake_status_with_picks(dow_with_picks):
|
||||
async def fake():
|
||||
return {
|
||||
"week_start": "2026-05-18",
|
||||
"current_base": [0.2] * 5,
|
||||
"trials": [
|
||||
{
|
||||
"id": 100 + i,
|
||||
"day_of_week": i,
|
||||
"weight": [0.2] * 5,
|
||||
"source": "perturb",
|
||||
"picks": ([
|
||||
{"id": j, "numbers": [1,2,3,4,5,6], "meta_score": 0.5}
|
||||
for j in range(5)
|
||||
] if i == dow_with_picks else []),
|
||||
}
|
||||
for i in range(6)
|
||||
],
|
||||
}
|
||||
return fake
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_evolver_activity_creates_apply_task(monkeypatch):
|
||||
"""오늘 trial에 picks가 있으면 evolver_apply task 1개 생성."""
|
||||
from app.agents.lotto import LottoAgent
|
||||
from app import service_proxy
|
||||
|
||||
dow = _today_dow_clamped()
|
||||
monkeypatch.setattr(service_proxy, "lotto_evolver_status", _fake_status_with_picks(dow))
|
||||
|
||||
agent = LottoAgent()
|
||||
await agent.sync_evolver_activity()
|
||||
|
||||
apply_tasks = db.get_agent_tasks("lotto", task_type="evolver_apply", days=1)
|
||||
assert len(apply_tasks) == 1
|
||||
assert apply_tasks[0]["result_data"]["n_picks"] == 5
|
||||
assert apply_tasks[0]["input_data"]["day_of_week"] == dow
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_evolver_activity_idempotent(monkeypatch):
|
||||
"""같은 날 두 번 호출해도 task는 1개만 (멱등)."""
|
||||
from app.agents.lotto import LottoAgent
|
||||
from app import service_proxy
|
||||
|
||||
dow = _today_dow_clamped()
|
||||
monkeypatch.setattr(service_proxy, "lotto_evolver_status", _fake_status_with_picks(dow))
|
||||
|
||||
agent = LottoAgent()
|
||||
await agent.sync_evolver_activity()
|
||||
await agent.sync_evolver_activity()
|
||||
|
||||
apply_tasks = db.get_agent_tasks("lotto", task_type="evolver_apply", days=1)
|
||||
assert len(apply_tasks) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_evolver_activity_no_picks_no_task(monkeypatch):
|
||||
"""오늘 trial에 picks가 없으면 task 생성하지 않음."""
|
||||
from app.agents.lotto import LottoAgent
|
||||
from app import service_proxy
|
||||
|
||||
async def fake_status():
|
||||
return {
|
||||
"week_start": "2026-05-18",
|
||||
"current_base": [0.2] * 5,
|
||||
"trials": [
|
||||
{"id": 100 + i, "day_of_week": i, "weight": [0.2]*5,
|
||||
"source": "perturb", "picks": []}
|
||||
for i in range(6)
|
||||
],
|
||||
}
|
||||
monkeypatch.setattr(service_proxy, "lotto_evolver_status", fake_status)
|
||||
|
||||
agent = LottoAgent()
|
||||
await agent.sync_evolver_activity()
|
||||
|
||||
apply_tasks = db.get_agent_tasks("lotto", task_type="evolver_apply", days=1)
|
||||
assert len(apply_tasks) == 0
|
||||
213
agent-office/tests/test_youtube_publisher_retry.py
Normal file
213
agent-office/tests/test_youtube_publisher_retry.py
Normal file
@@ -0,0 +1,213 @@
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
_fd, _TMP = tempfile.mkstemp(suffix=".db")
|
||||
os.close(_fd)
|
||||
os.unlink(_TMP)
|
||||
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _init_db():
|
||||
import gc
|
||||
gc.collect()
|
||||
if os.path.exists(_TMP):
|
||||
os.remove(_TMP)
|
||||
from app.db import init_db
|
||||
init_db()
|
||||
yield
|
||||
gc.collect()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_pipeline_notified_with_retry_button():
|
||||
from app.agents.youtube_publisher import YoutubePublisherAgent
|
||||
|
||||
agent = YoutubePublisherAgent()
|
||||
failed_pipeline = {
|
||||
"id": 7,
|
||||
"state": "failed",
|
||||
"failed_reason": "video: boom",
|
||||
"track_title": "T",
|
||||
}
|
||||
sent = AsyncMock(return_value={"ok": True, "message_id": 1})
|
||||
|
||||
with patch(
|
||||
"app.agents.youtube_publisher.service_proxy.list_active_pipelines",
|
||||
new=AsyncMock(return_value=[]),
|
||||
), patch(
|
||||
"app.agents.youtube_publisher.service_proxy.list_failed_pipelines",
|
||||
new=AsyncMock(return_value=[failed_pipeline]),
|
||||
), patch(
|
||||
"app.agents.youtube_publisher.send_raw",
|
||||
new=sent,
|
||||
):
|
||||
await agent.poll_state_changes()
|
||||
|
||||
assert sent.await_count == 1
|
||||
_, kwargs = sent.await_args
|
||||
assert "실패" in (kwargs.get("text") or "")
|
||||
assert kwargs["reply_markup"]["inline_keyboard"][0][0]["callback_data"] == "ytpub_retry_7"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_pipeline_no_duplicate_notification():
|
||||
"""같은 failed 파이프라인은 두 번째 poll에서 알림 안 함."""
|
||||
from app.agents.youtube_publisher import YoutubePublisherAgent
|
||||
|
||||
agent = YoutubePublisherAgent()
|
||||
failed_pipeline = {
|
||||
"id": 7,
|
||||
"state": "failed",
|
||||
"failed_reason": "video: boom",
|
||||
"track_title": "T",
|
||||
}
|
||||
sent = AsyncMock(return_value={"ok": True, "message_id": 1})
|
||||
|
||||
with patch(
|
||||
"app.agents.youtube_publisher.service_proxy.list_active_pipelines",
|
||||
new=AsyncMock(return_value=[]),
|
||||
), patch(
|
||||
"app.agents.youtube_publisher.service_proxy.list_failed_pipelines",
|
||||
new=AsyncMock(return_value=[failed_pipeline]),
|
||||
), patch(
|
||||
"app.agents.youtube_publisher.send_raw",
|
||||
new=sent,
|
||||
):
|
||||
await agent.poll_state_changes()
|
||||
await agent.poll_state_changes()
|
||||
|
||||
# 중복 방지: 같은 failed 파이프라인에 대해 1회만 알림
|
||||
assert sent.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_pipeline_renotify_after_recovery():
|
||||
"""failed에서 벗어난 파이프라인이 다시 failed 되면 재알림."""
|
||||
from app.agents.youtube_publisher import YoutubePublisherAgent
|
||||
|
||||
agent = YoutubePublisherAgent()
|
||||
failed_pipeline = {
|
||||
"id": 7,
|
||||
"state": "failed",
|
||||
"failed_reason": "video: boom",
|
||||
"track_title": "T",
|
||||
}
|
||||
sent = AsyncMock(return_value={"ok": True, "message_id": 1})
|
||||
|
||||
# 첫 번째 poll: failed 존재 → 알림
|
||||
with patch(
|
||||
"app.agents.youtube_publisher.service_proxy.list_active_pipelines",
|
||||
new=AsyncMock(return_value=[]),
|
||||
), patch(
|
||||
"app.agents.youtube_publisher.service_proxy.list_failed_pipelines",
|
||||
new=AsyncMock(return_value=[failed_pipeline]),
|
||||
), patch(
|
||||
"app.agents.youtube_publisher.send_raw",
|
||||
new=sent,
|
||||
):
|
||||
await agent.poll_state_changes()
|
||||
|
||||
assert sent.await_count == 1
|
||||
|
||||
# 두 번째 poll: failed 목록에서 사라짐(재개됨) → _notified_failed에서 제거
|
||||
with patch(
|
||||
"app.agents.youtube_publisher.service_proxy.list_active_pipelines",
|
||||
new=AsyncMock(return_value=[]),
|
||||
), patch(
|
||||
"app.agents.youtube_publisher.service_proxy.list_failed_pipelines",
|
||||
new=AsyncMock(return_value=[]),
|
||||
), patch(
|
||||
"app.agents.youtube_publisher.send_raw",
|
||||
new=sent,
|
||||
):
|
||||
await agent.poll_state_changes()
|
||||
|
||||
assert sent.await_count == 1 # 아직 추가 알림 없음
|
||||
|
||||
# 세 번째 poll: 다시 failed → 재알림 가능
|
||||
with patch(
|
||||
"app.agents.youtube_publisher.service_proxy.list_active_pipelines",
|
||||
new=AsyncMock(return_value=[]),
|
||||
), patch(
|
||||
"app.agents.youtube_publisher.service_proxy.list_failed_pipelines",
|
||||
new=AsyncMock(return_value=[failed_pipeline]),
|
||||
), patch(
|
||||
"app.agents.youtube_publisher.send_raw",
|
||||
new=sent,
|
||||
):
|
||||
await agent.poll_state_changes()
|
||||
|
||||
assert sent.await_count == 2 # 재알림
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_ytpub_retry_calls_proxy():
|
||||
from app import service_proxy
|
||||
from app.telegram import webhook
|
||||
|
||||
retry = AsyncMock(return_value={"status_code": 202, "ok": True, "retrying_step": "video"})
|
||||
fake_send = AsyncMock(return_value={"ok": True})
|
||||
fake_api_call = AsyncMock(return_value={"ok": True})
|
||||
|
||||
with patch.object(service_proxy, "pipeline_retry", retry), \
|
||||
patch("app.telegram.messaging.send_raw", fake_send), \
|
||||
patch("app.telegram.webhook.api_call", fake_api_call):
|
||||
res = await webhook._handle_ytpub_retry({"id": 1}, "ytpub_retry_7")
|
||||
|
||||
retry.assert_awaited_once_with(7)
|
||||
assert res["ok"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_ytpub_retry_invalid_data():
|
||||
from app.telegram import webhook
|
||||
|
||||
fake_send = AsyncMock(return_value={"ok": True})
|
||||
fake_api_call = AsyncMock(return_value={"ok": True})
|
||||
|
||||
with patch("app.telegram.messaging.send_raw", fake_send), \
|
||||
patch("app.telegram.webhook.api_call", fake_api_call):
|
||||
res = await webhook._handle_ytpub_retry({"id": 1}, "ytpub_retry_abc")
|
||||
|
||||
assert res["ok"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_poll_exception_is_silent():
|
||||
"""list_failed_pipelines 예외 시 poll이 조용히 넘어감 (active 알림에 영향 없음)."""
|
||||
from app.agents.youtube_publisher import YoutubePublisherAgent
|
||||
|
||||
agent = YoutubePublisherAgent()
|
||||
active_pipeline = {
|
||||
"id": 1,
|
||||
"state": "cover_pending",
|
||||
"cover_url": "/x.jpg",
|
||||
"track_title": "Track",
|
||||
"feedback_count_per_step": {},
|
||||
}
|
||||
sent = AsyncMock(return_value={"ok": True, "message_id": 1})
|
||||
|
||||
with patch(
|
||||
"app.agents.youtube_publisher.service_proxy.list_active_pipelines",
|
||||
new=AsyncMock(return_value=[active_pipeline]),
|
||||
), patch(
|
||||
"app.agents.youtube_publisher.service_proxy.list_failed_pipelines",
|
||||
new=AsyncMock(side_effect=Exception("network error")),
|
||||
), patch(
|
||||
"app.agents.youtube_publisher.service_proxy.save_pipeline_telegram_msg",
|
||||
new=AsyncMock(),
|
||||
), patch(
|
||||
"app.agents.youtube_publisher.send_raw",
|
||||
new=sent,
|
||||
):
|
||||
await agent.poll_state_changes()
|
||||
|
||||
# active 알림은 정상 발송
|
||||
assert sent.await_count == 1
|
||||
3
co-gahusb/.gitignore
vendored
Normal file
3
co-gahusb/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
19
co-gahusb/CLIENT_SETUP.md
Normal file
19
co-gahusb/CLIENT_SETUP.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# co-gahusb 클라이언트 설정
|
||||
|
||||
## 공통
|
||||
1. `CO_BUS_KEY` 환경변수를 각 머신에 설정(서버 `.env`의 값과 동일).
|
||||
2. 해당 repo 루트 `.mcp.json`에 co-gahusb HTTP MCP 등록(이 repo의 예시 참고).
|
||||
3. CLAUDE.md 역할 블록의 `/loop` 폴링 규약을 따른다.
|
||||
|
||||
## web-ai (다른 머신)
|
||||
web-ai 머신의 repo 루트에 아래 `.mcp.json` 생성, 역할 = **AI**:
|
||||
```json
|
||||
{ "mcpServers": { "co-gahusb": {
|
||||
"type": "http",
|
||||
"url": "https://gahusb.synology.me/api/co/mcp",
|
||||
"headers": { "Authorization": "Bearer ${CO_BUS_KEY}" } } } }
|
||||
```
|
||||
web-ai CLAUDE.md에 역할 블록 추가(role="AI", 소유권=web-ai repo, 동일 락 규약).
|
||||
|
||||
## Producer (오케스트레이터 세션)
|
||||
별도 repo 없이 조율 담당. `team_log()`로 전체 활동 감시, `create_task`로 분배, `acquire_lock`로 교차 작업 직렬화.
|
||||
12
co-gahusb/Dockerfile
Normal file
12
co-gahusb/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM python:3.12-slim-bookworm
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.server:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
0
co-gahusb/app/__init__.py
Normal file
0
co-gahusb/app/__init__.py
Normal file
21
co-gahusb/app/config.py
Normal file
21
co-gahusb/app/config.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# co-gahusb/app/config.py
|
||||
import os
|
||||
|
||||
REDIS_URL = os.environ.get("REDIS_URL", "redis://redis:6379")
|
||||
CO_BUS_KEY = os.environ.get("CO_BUS_KEY", "")
|
||||
|
||||
# 협업 역할 (세션별 1:1)
|
||||
ROLES = ("FE", "BE", "AI", "Producer")
|
||||
|
||||
# 교차 리소스 어드바이저리 락 대상 (이 외 이름도 락은 가능하나, 규약상 명시 대상)
|
||||
LOCKABLE_RESOURCES = (
|
||||
"nas-deploy",
|
||||
"stock-db-schema",
|
||||
"lotto-db-schema",
|
||||
"memory-mirror",
|
||||
"nginx-conf",
|
||||
"compose",
|
||||
)
|
||||
|
||||
DEFAULT_LOCK_TTL = 300
|
||||
TEAM_LOG_MAXLEN = 500
|
||||
66
co-gahusb/app/locks.py
Normal file
66
co-gahusb/app/locks.py
Normal file
@@ -0,0 +1,66 @@
|
||||
# co-gahusb/app/locks.py
|
||||
from redis.exceptions import WatchError
|
||||
|
||||
LOCK_PREFIX = "co:lock:"
|
||||
|
||||
|
||||
async def acquire_lock(r, resource, role, ttl_sec=300):
|
||||
key = LOCK_PREFIX + resource
|
||||
ok = await r.set(key, role, nx=True, ex=ttl_sec)
|
||||
if ok:
|
||||
return {"acquired": True}
|
||||
held_by = await r.get(key)
|
||||
ttl = await r.ttl(key)
|
||||
return {"acquired": False, "held_by": held_by, "ttl_remaining": max(ttl, 0)}
|
||||
|
||||
|
||||
async def release_lock(r, resource, role):
|
||||
key = LOCK_PREFIX + resource
|
||||
async with r.pipeline() as pipe:
|
||||
while True:
|
||||
try:
|
||||
await pipe.watch(key)
|
||||
owner = await pipe.get(key)
|
||||
if owner != role:
|
||||
await pipe.unwatch()
|
||||
return {"released": False, "held_by": owner}
|
||||
pipe.multi()
|
||||
pipe.delete(key)
|
||||
await pipe.execute()
|
||||
return {"released": True}
|
||||
except WatchError:
|
||||
continue
|
||||
|
||||
|
||||
async def heartbeat_lock(r, resource, role, ttl_sec=300):
|
||||
key = LOCK_PREFIX + resource
|
||||
async with r.pipeline() as pipe:
|
||||
while True:
|
||||
try:
|
||||
await pipe.watch(key)
|
||||
owner = await pipe.get(key)
|
||||
if owner != role:
|
||||
await pipe.unwatch()
|
||||
return {"renewed": False, "held_by": owner}
|
||||
pipe.multi()
|
||||
pipe.expire(key, ttl_sec)
|
||||
await pipe.execute()
|
||||
return {"renewed": True}
|
||||
except WatchError:
|
||||
continue
|
||||
|
||||
|
||||
async def list_locks(r):
|
||||
keys = await r.keys(LOCK_PREFIX + "*")
|
||||
out = []
|
||||
for key in keys:
|
||||
held_by = await r.get(key)
|
||||
if held_by is None:
|
||||
continue
|
||||
ttl = await r.ttl(key)
|
||||
out.append({
|
||||
"resource": key[len(LOCK_PREFIX):],
|
||||
"held_by": held_by,
|
||||
"ttl_remaining": max(ttl, 0),
|
||||
})
|
||||
return {"locks": out}
|
||||
138
co-gahusb/app/server.py
Normal file
138
co-gahusb/app/server.py
Normal file
@@ -0,0 +1,138 @@
|
||||
# co-gahusb/app/server.py
|
||||
import logging
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Mount, Route
|
||||
|
||||
from app import config, locks, store
|
||||
|
||||
log = logging.getLogger("co-gahusb")
|
||||
_auth_failed_logged = False
|
||||
|
||||
_redis = aioredis.from_url(config.REDIS_URL, decode_responses=True)
|
||||
|
||||
# DNS-rebinding 보호 비활성화: 실 보안은 nginx 앞단 Bearer 인증(MCP 도달 전 401)이다.
|
||||
# 원격 HTTPS + 정적키 모델이라 Host 화이트리스트는 보안가치 ~0이고, 도메인 변경 시 또 깨진다.
|
||||
mcp = FastMCP(
|
||||
"co-gahusb",
|
||||
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
|
||||
)
|
||||
|
||||
|
||||
# ---- 메시지 ----
|
||||
@mcp.tool()
|
||||
async def post_message(from_role: str, to_role: str, body: str, thread_id: str = "") -> dict:
|
||||
"""다른 역할의 우편함에 메시지를 보낸다."""
|
||||
res = await store.post_message(_redis, from_role, to_role, body, thread_id or None)
|
||||
await store.log_event(_redis, "message", f"{from_role}→{to_role}: {body[:60]}")
|
||||
return res
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def read_inbox(role: str, after_id: int = 0, mark_read: bool = False) -> dict:
|
||||
"""내 역할 우편함을 커서 기반으로 읽는다."""
|
||||
return await store.read_inbox(_redis, role, after_id, mark_read)
|
||||
|
||||
|
||||
# ---- 작업 ----
|
||||
@mcp.tool()
|
||||
async def create_task(title: str, assignee_role: str, created_by: str, detail: str = "") -> dict:
|
||||
"""작업을 만들어 특정 역할에 배정한다."""
|
||||
res = await store.create_task(_redis, title, assignee_role, created_by, detail or None)
|
||||
await store.log_event(_redis, "task", f"{created_by} created '{title}' → {assignee_role}")
|
||||
return res
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def claim_task(task_id: int, role: str) -> dict:
|
||||
"""open 작업을 점유(in_progress)한다. 이미 점유면 거부."""
|
||||
res = await store.claim_task(_redis, task_id, role)
|
||||
if res.get("ok"):
|
||||
await store.log_event(_redis, "task", f"{role} claimed task#{task_id}")
|
||||
return res
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def update_task(task_id: int, status: str, role: str, note: str = "") -> dict:
|
||||
"""작업 상태를 갱신한다 (open/in_progress/blocked/done)."""
|
||||
res = await store.update_task(_redis, task_id, status, role, note or None)
|
||||
await store.log_event(_redis, "task", f"{role} set task#{task_id} → {status}")
|
||||
return res
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def list_tasks(status: str = "", assignee_role: str = "") -> dict:
|
||||
"""작업 목록을 조회한다(상태/담당 필터)."""
|
||||
return await store.list_tasks(_redis, status or None, assignee_role or None)
|
||||
|
||||
|
||||
# ---- 락 ----
|
||||
@mcp.tool()
|
||||
async def acquire_lock(resource: str, role: str, ttl_sec: int = config.DEFAULT_LOCK_TTL) -> dict:
|
||||
"""공유 리소스 변경 전 어드바이저리 락을 획득한다. 점유 중이면 acquired=false."""
|
||||
res = await locks.acquire_lock(_redis, resource, role, ttl_sec)
|
||||
if res.get("acquired"):
|
||||
await store.log_event(_redis, "lock", f"{role} acquired {resource}")
|
||||
return res
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def release_lock(resource: str, role: str) -> dict:
|
||||
"""소유한 락을 해제한다."""
|
||||
res = await locks.release_lock(_redis, resource, role)
|
||||
if res.get("released"):
|
||||
await store.log_event(_redis, "lock", f"{role} released {resource}")
|
||||
return res
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def heartbeat_lock(resource: str, role: str, ttl_sec: int = config.DEFAULT_LOCK_TTL) -> dict:
|
||||
"""긴 작업 중 락 TTL을 갱신한다(소유자만)."""
|
||||
return await locks.heartbeat_lock(_redis, resource, role, ttl_sec)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def list_locks() -> dict:
|
||||
"""현재 점유 중인 모든 락을 조회한다."""
|
||||
return await locks.list_locks(_redis)
|
||||
|
||||
|
||||
# ---- 가시성 ----
|
||||
@mcp.tool()
|
||||
async def team_log(after_id: int = 0) -> dict:
|
||||
"""팀 전체 최근 활동 피드(메시지·작업·락)를 조회한다."""
|
||||
return await store.read_team_log(_redis, after_id)
|
||||
|
||||
|
||||
# ---- Bearer 인증 미들웨어 ----
|
||||
class BearerAuth(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request, call_next):
|
||||
global _auth_failed_logged
|
||||
if request.url.path.startswith("/health"):
|
||||
return await call_next(request)
|
||||
expected = f"Bearer {config.CO_BUS_KEY}"
|
||||
if not config.CO_BUS_KEY or request.headers.get("authorization") != expected:
|
||||
if not _auth_failed_logged:
|
||||
log.error("co-gahusb 인증 실패 (이후 동일 로그 생략)")
|
||||
_auth_failed_logged = True
|
||||
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
async def _health(request):
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
|
||||
_mcp_app = mcp.streamable_http_app()
|
||||
|
||||
app = Starlette(
|
||||
routes=[Route("/health", _health), Mount("/", app=_mcp_app)],
|
||||
middleware=[Middleware(BearerAuth)],
|
||||
lifespan=_mcp_app.router.lifespan_context,
|
||||
)
|
||||
157
co-gahusb/app/store.py
Normal file
157
co-gahusb/app/store.py
Normal file
@@ -0,0 +1,157 @@
|
||||
# co-gahusb/app/store.py
|
||||
import json
|
||||
import time
|
||||
|
||||
from app.config import TEAM_LOG_MAXLEN
|
||||
|
||||
MSG_SEQ = "co:msgseq"
|
||||
INBOX_PREFIX = "co:inbox:" # list of message ids per role
|
||||
MSG_PREFIX = "co:msg:" # hash per message
|
||||
READ_PREFIX = "co:read:" # last-read cursor per role
|
||||
|
||||
|
||||
def _now_iso():
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
|
||||
async def post_message(r, from_role, to_role, body, thread_id=None):
|
||||
mid = await r.incr(MSG_SEQ)
|
||||
payload = {
|
||||
"id": str(mid),
|
||||
"from_role": from_role,
|
||||
"to_role": to_role,
|
||||
"body": body,
|
||||
"thread_id": thread_id or "",
|
||||
"ts": _now_iso(),
|
||||
}
|
||||
await r.set(MSG_PREFIX + str(mid), json.dumps(payload))
|
||||
await r.rpush(INBOX_PREFIX + to_role, mid)
|
||||
return {"message_id": mid}
|
||||
|
||||
|
||||
async def read_inbox(r, role, after_id=0, mark_read=False):
|
||||
ids = await r.lrange(INBOX_PREFIX + role, 0, -1)
|
||||
ids = [int(x) for x in ids if int(x) > int(after_id)]
|
||||
messages = []
|
||||
for mid in ids:
|
||||
raw = await r.get(MSG_PREFIX + str(mid))
|
||||
if raw:
|
||||
d = json.loads(raw)
|
||||
d["id"] = int(d["id"])
|
||||
messages.append(d)
|
||||
cursor = ids[-1] if ids else int(after_id)
|
||||
if mark_read and ids:
|
||||
await r.set(READ_PREFIX + role, cursor)
|
||||
return {"messages": messages, "cursor": cursor}
|
||||
|
||||
|
||||
TASK_SEQ = "co:taskseq"
|
||||
TASK_PREFIX = "co:task:" # hash per task
|
||||
TASK_SET = "co:tasks" # set of task ids
|
||||
|
||||
VALID_STATUS = ("open", "in_progress", "blocked", "done")
|
||||
|
||||
|
||||
async def create_task(r, title, assignee_role, created_by, detail=None):
|
||||
tid = await r.incr(TASK_SEQ)
|
||||
task = {
|
||||
"id": str(tid),
|
||||
"title": title,
|
||||
"assignee_role": assignee_role,
|
||||
"status": "open",
|
||||
"detail": detail or "",
|
||||
"created_by": created_by,
|
||||
"note": "",
|
||||
"ts": _now_iso(),
|
||||
}
|
||||
await r.hset(TASK_PREFIX + str(tid), mapping=task)
|
||||
await r.sadd(TASK_SET, tid)
|
||||
return {"task_id": tid}
|
||||
|
||||
|
||||
async def _get_task(r, task_id):
|
||||
d = await r.hgetall(TASK_PREFIX + str(task_id))
|
||||
if not d:
|
||||
return None
|
||||
d["id"] = int(d["id"])
|
||||
return d
|
||||
|
||||
|
||||
async def claim_task(r, task_id, role):
|
||||
key = TASK_PREFIX + str(task_id)
|
||||
async with r.pipeline() as pipe:
|
||||
while True:
|
||||
try:
|
||||
await pipe.watch(key)
|
||||
status = await pipe.hget(key, "status")
|
||||
if status is None:
|
||||
await pipe.unwatch()
|
||||
return {"ok": False, "error": "not_found"}
|
||||
if status != "open":
|
||||
held = await pipe.hget(key, "assignee_role")
|
||||
await pipe.unwatch()
|
||||
return {"ok": False, "held_by": held}
|
||||
pipe.multi()
|
||||
pipe.hset(key, mapping={"status": "in_progress", "assignee_role": role})
|
||||
await pipe.execute()
|
||||
return {"ok": True, "task": await _get_task(r, task_id)}
|
||||
except Exception as e:
|
||||
from redis.exceptions import WatchError
|
||||
if isinstance(e, WatchError):
|
||||
continue
|
||||
raise
|
||||
|
||||
|
||||
async def update_task(r, task_id, status, role, note=None):
|
||||
if status not in VALID_STATUS:
|
||||
raise ValueError(f"invalid status: {status}")
|
||||
key = TASK_PREFIX + str(task_id)
|
||||
if not await r.exists(key):
|
||||
return {"ok": False, "error": "not_found"}
|
||||
mapping = {"status": status}
|
||||
if note is not None:
|
||||
mapping["note"] = note
|
||||
await r.hset(key, mapping=mapping)
|
||||
return {"ok": True, "task": await _get_task(r, task_id)}
|
||||
|
||||
|
||||
async def list_tasks(r, status=None, assignee_role=None):
|
||||
ids = sorted(int(x) for x in await r.smembers(TASK_SET))
|
||||
tasks = []
|
||||
for tid in ids:
|
||||
t = await _get_task(r, tid)
|
||||
if t is None:
|
||||
continue
|
||||
if status and t["status"] != status:
|
||||
continue
|
||||
if assignee_role and t["assignee_role"] != assignee_role:
|
||||
continue
|
||||
tasks.append(t)
|
||||
return {"tasks": tasks}
|
||||
|
||||
|
||||
LOG_SEQ = "co:logseq"
|
||||
LOG_LIST = "co:log" # list of event ids (capped)
|
||||
LOG_PREFIX = "co:logitem:"
|
||||
|
||||
|
||||
async def log_event(r, kind, text):
|
||||
eid = await r.incr(LOG_SEQ)
|
||||
item = {"id": eid, "kind": kind, "text": text, "ts": _now_iso()}
|
||||
await r.set(LOG_PREFIX + str(eid), json.dumps(item))
|
||||
await r.rpush(LOG_LIST, eid)
|
||||
await r.ltrim(LOG_LIST, -TEAM_LOG_MAXLEN, -1)
|
||||
return {"event_id": eid}
|
||||
|
||||
|
||||
async def read_team_log(r, after_id=0, limit=100):
|
||||
ids = [int(x) for x in await r.lrange(LOG_LIST, 0, -1)]
|
||||
ids = [i for i in ids if i > int(after_id)]
|
||||
ids = ids[-limit:]
|
||||
events = []
|
||||
for eid in ids:
|
||||
raw = await r.get(LOG_PREFIX + str(eid))
|
||||
if raw:
|
||||
events.append(json.loads(raw))
|
||||
cursor = ids[-1] if ids else int(after_id)
|
||||
return {"events": events, "cursor": cursor}
|
||||
3
co-gahusb/pytest.ini
Normal file
3
co-gahusb/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
7
co-gahusb/requirements.txt
Normal file
7
co-gahusb/requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
mcp>=1.2.0
|
||||
starlette>=0.37
|
||||
uvicorn[standard]==0.34.0
|
||||
redis>=5.0
|
||||
pytest>=8.0
|
||||
pytest-asyncio>=0.24
|
||||
fakeredis>=2.21
|
||||
0
co-gahusb/tests/__init__.py
Normal file
0
co-gahusb/tests/__init__.py
Normal file
11
co-gahusb/tests/conftest.py
Normal file
11
co-gahusb/tests/conftest.py
Normal file
@@ -0,0 +1,11 @@
|
||||
# co-gahusb/tests/conftest.py
|
||||
import pytest_asyncio
|
||||
import fakeredis.aioredis
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def r():
|
||||
client = fakeredis.aioredis.FakeRedis(decode_responses=True)
|
||||
await client.flushall()
|
||||
yield client
|
||||
await client.aclose()
|
||||
51
co-gahusb/tests/test_locks.py
Normal file
51
co-gahusb/tests/test_locks.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# co-gahusb/tests/test_locks.py
|
||||
from app import locks
|
||||
|
||||
|
||||
async def test_acquire_succeeds_then_blocks_other(r):
|
||||
res = await locks.acquire_lock(r, "nas-deploy", "BE", ttl_sec=300)
|
||||
assert res["acquired"] is True
|
||||
|
||||
res2 = await locks.acquire_lock(r, "nas-deploy", "FE", ttl_sec=300)
|
||||
assert res2["acquired"] is False
|
||||
assert res2["held_by"] == "BE"
|
||||
assert res2["ttl_remaining"] > 0
|
||||
|
||||
|
||||
async def test_release_only_by_owner(r):
|
||||
await locks.acquire_lock(r, "compose", "BE", ttl_sec=300)
|
||||
|
||||
bad = await locks.release_lock(r, "compose", "FE")
|
||||
assert bad["released"] is False
|
||||
|
||||
ok = await locks.release_lock(r, "compose", "BE")
|
||||
assert ok["released"] is True
|
||||
|
||||
again = await locks.acquire_lock(r, "compose", "FE", ttl_sec=300)
|
||||
assert again["acquired"] is True
|
||||
|
||||
|
||||
async def test_heartbeat_only_by_owner_renews_ttl(r):
|
||||
await locks.acquire_lock(r, "nginx-conf", "BE", ttl_sec=10)
|
||||
|
||||
bad = await locks.heartbeat_lock(r, "nginx-conf", "FE", ttl_sec=300)
|
||||
assert bad["renewed"] is False
|
||||
|
||||
ok = await locks.heartbeat_lock(r, "nginx-conf", "BE", ttl_sec=300)
|
||||
assert ok["renewed"] is True
|
||||
assert await r.ttl("co:lock:nginx-conf") > 100
|
||||
|
||||
|
||||
async def test_expired_lock_is_reacquirable(r):
|
||||
await locks.acquire_lock(r, "memory-mirror", "AI", ttl_sec=1)
|
||||
await r.delete("co:lock:memory-mirror")
|
||||
res = await locks.acquire_lock(r, "memory-mirror", "FE", ttl_sec=300)
|
||||
assert res["acquired"] is True
|
||||
|
||||
|
||||
async def test_list_locks(r):
|
||||
await locks.acquire_lock(r, "nas-deploy", "BE", ttl_sec=300)
|
||||
await locks.acquire_lock(r, "compose", "FE", ttl_sec=300)
|
||||
listed = await locks.list_locks(r)
|
||||
held = {l["resource"]: l["held_by"] for l in listed["locks"]}
|
||||
assert held == {"nas-deploy": "BE", "compose": "FE"}
|
||||
47
co-gahusb/tests/test_messages.py
Normal file
47
co-gahusb/tests/test_messages.py
Normal file
@@ -0,0 +1,47 @@
|
||||
# co-gahusb/tests/test_messages.py
|
||||
from app import store
|
||||
|
||||
|
||||
async def test_post_and_read_ordering(r):
|
||||
id1 = (await store.post_message(r, "Producer", "BE", "first"))["message_id"]
|
||||
id2 = (await store.post_message(r, "Producer", "BE", "second"))["message_id"]
|
||||
assert id2 > id1
|
||||
|
||||
res = await store.read_inbox(r, "BE")
|
||||
bodies = [m["body"] for m in res["messages"]]
|
||||
assert bodies == ["first", "second"]
|
||||
assert res["cursor"] == id2
|
||||
|
||||
|
||||
async def test_read_inbox_after_id(r):
|
||||
id1 = (await store.post_message(r, "Producer", "BE", "first"))["message_id"]
|
||||
await store.post_message(r, "Producer", "BE", "second")
|
||||
res = await store.read_inbox(r, "BE", after_id=id1)
|
||||
assert [m["body"] for m in res["messages"]] == ["second"]
|
||||
|
||||
|
||||
async def test_inboxes_isolated_per_role(r):
|
||||
await store.post_message(r, "Producer", "BE", "for-be")
|
||||
await store.post_message(r, "Producer", "FE", "for-fe")
|
||||
be = await store.read_inbox(r, "BE")
|
||||
fe = await store.read_inbox(r, "FE")
|
||||
assert [m["body"] for m in be["messages"]] == ["for-be"]
|
||||
assert [m["body"] for m in fe["messages"]] == ["for-fe"]
|
||||
|
||||
|
||||
async def test_mark_read_advances_cursor(r):
|
||||
await store.post_message(r, "Producer", "BE", "first")
|
||||
res = await store.read_inbox(r, "BE", mark_read=True)
|
||||
last = res["cursor"]
|
||||
await store.post_message(r, "Producer", "BE", "second")
|
||||
res2 = await store.read_inbox(r, "BE", after_id=last)
|
||||
assert [m["body"] for m in res2["messages"]] == ["second"]
|
||||
|
||||
|
||||
async def test_message_fields(r):
|
||||
await store.post_message(r, "Producer", "BE", "hi", thread_id="t1")
|
||||
res = await store.read_inbox(r, "BE")
|
||||
m = res["messages"][0]
|
||||
assert m["from_role"] == "Producer"
|
||||
assert m["thread_id"] == "t1"
|
||||
assert "ts" in m and "id" in m
|
||||
54
co-gahusb/tests/test_server.py
Normal file
54
co-gahusb/tests/test_server.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# co-gahusb/tests/test_server.py
|
||||
import os
|
||||
os.environ["CO_BUS_KEY"] = "test-key"
|
||||
|
||||
# config.CO_BUS_KEY는 import 시점에 한 번 읽히므로, 다른 테스트 모듈이 app.config를
|
||||
# 먼저 import하면 빈 값으로 굳는다. import 순서와 무관하게 모듈 속성을 직접 강제한다.
|
||||
from app import config
|
||||
config.CO_BUS_KEY = "test-key"
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
from app.server import app
|
||||
|
||||
|
||||
def test_health_open_without_auth():
|
||||
client = TestClient(app)
|
||||
res = client.get("/health")
|
||||
assert res.status_code == 200
|
||||
assert res.json()["status"] == "ok"
|
||||
|
||||
|
||||
def test_mcp_requires_bearer():
|
||||
client = TestClient(app)
|
||||
res = client.post("/mcp", json={})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
def test_mcp_wrong_key_rejected():
|
||||
client = TestClient(app)
|
||||
res = client.post("/mcp", json={}, headers={"Authorization": "Bearer wrong"})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
def test_mcp_valid_auth_passes_dns_host_check():
|
||||
# 유효한 키는 인증 게이트를 통과하고, MCP DNS-rebinding Host 검증에 막혀선 안 된다.
|
||||
# TestClient 기본 Host="testserver"는 localhost가 아니므로, 보호가 켜져 있으면 421.
|
||||
# 컨텍스트 매니저로 써야 lifespan(세션 매니저 task group)이 기동되어 MCP 핸들러까지 도달.
|
||||
with TestClient(app) as client:
|
||||
res = client.post(
|
||||
"/mcp",
|
||||
headers={
|
||||
"Authorization": "Bearer test-key",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
},
|
||||
json={
|
||||
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2024-11-05", "capabilities": {},
|
||||
"clientInfo": {"name": "smoke", "version": "0"},
|
||||
},
|
||||
},
|
||||
)
|
||||
assert res.status_code != 401 # 인증 통과
|
||||
assert res.status_code != 421 # Host 검증에 막히면 안 됨
|
||||
56
co-gahusb/tests/test_tasks.py
Normal file
56
co-gahusb/tests/test_tasks.py
Normal file
@@ -0,0 +1,56 @@
|
||||
# co-gahusb/tests/test_tasks.py
|
||||
import pytest
|
||||
from app import store
|
||||
|
||||
|
||||
async def test_create_and_list(r):
|
||||
res = await store.create_task(r, "deploy FE", "FE", created_by="Producer", detail="ship it")
|
||||
tid = res["task_id"]
|
||||
listed = await store.list_tasks(r)
|
||||
t = [t for t in listed["tasks"] if t["id"] == tid][0]
|
||||
assert t["title"] == "deploy FE"
|
||||
assert t["assignee_role"] == "FE"
|
||||
assert t["status"] == "open"
|
||||
assert t["created_by"] == "Producer"
|
||||
|
||||
|
||||
async def test_claim_then_duplicate_claim_rejected(r):
|
||||
tid = (await store.create_task(r, "x", "FE", created_by="Producer"))["task_id"]
|
||||
ok = await store.claim_task(r, tid, "FE")
|
||||
assert ok["ok"] is True
|
||||
assert ok["task"]["status"] == "in_progress"
|
||||
|
||||
dup = await store.claim_task(r, tid, "BE")
|
||||
assert dup["ok"] is False
|
||||
assert dup["held_by"] == "FE"
|
||||
|
||||
|
||||
async def test_update_status(r):
|
||||
tid = (await store.create_task(r, "x", "FE", created_by="Producer"))["task_id"]
|
||||
await store.claim_task(r, tid, "FE")
|
||||
res = await store.update_task(r, tid, "done", "FE", note="finished")
|
||||
assert res["ok"] is True
|
||||
assert res["task"]["status"] == "done"
|
||||
assert res["task"]["note"] == "finished"
|
||||
|
||||
|
||||
async def test_list_filters(r):
|
||||
t1 = (await store.create_task(r, "a", "FE", created_by="Producer"))["task_id"]
|
||||
await store.create_task(r, "b", "BE", created_by="Producer")
|
||||
await store.claim_task(r, t1, "FE")
|
||||
fe = await store.list_tasks(r, assignee_role="FE")
|
||||
assert [t["title"] for t in fe["tasks"]] == ["a"]
|
||||
in_prog = await store.list_tasks(r, status="in_progress")
|
||||
assert [t["title"] for t in in_prog["tasks"]] == ["a"]
|
||||
|
||||
|
||||
async def test_invalid_status_rejected(r):
|
||||
tid = (await store.create_task(r, "x", "FE", created_by="Producer"))["task_id"]
|
||||
with pytest.raises(ValueError):
|
||||
await store.update_task(r, tid, "bogus", "FE")
|
||||
|
||||
|
||||
async def test_update_nonexistent_task_returns_not_found(r):
|
||||
res = await store.update_task(r, 999, "done", "FE")
|
||||
assert res["ok"] is False
|
||||
assert res["error"] == "not_found"
|
||||
25
co-gahusb/tests/test_teamlog.py
Normal file
25
co-gahusb/tests/test_teamlog.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# co-gahusb/tests/test_teamlog.py
|
||||
from app import store
|
||||
|
||||
|
||||
async def test_log_event_and_read(r):
|
||||
await store.log_event(r, "message", "Producer→BE: hi")
|
||||
await store.log_event(r, "lock", "BE acquired nas-deploy")
|
||||
res = await store.read_team_log(r)
|
||||
msgs = [e["text"] for e in res["events"]]
|
||||
assert msgs == ["Producer→BE: hi", "BE acquired nas-deploy"]
|
||||
|
||||
|
||||
async def test_team_log_after_id(r):
|
||||
e1 = (await store.log_event(r, "message", "a"))["event_id"]
|
||||
await store.log_event(r, "message", "b")
|
||||
res = await store.read_team_log(r, after_id=e1)
|
||||
assert [e["text"] for e in res["events"]] == ["b"]
|
||||
|
||||
|
||||
async def test_team_log_capped(r):
|
||||
for i in range(10):
|
||||
await store.log_event(r, "message", f"m{i}")
|
||||
res = await store.read_team_log(r, limit=3)
|
||||
assert len(res["events"]) == 3
|
||||
assert res["events"][-1]["text"] == "m9"
|
||||
@@ -14,8 +14,15 @@ 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: 60s
|
||||
@@ -44,8 +51,15 @@ 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: 60s
|
||||
@@ -79,9 +93,16 @@ services:
|
||||
- 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:
|
||||
@@ -113,6 +134,28 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
image-lab:
|
||||
build: ./image-lab
|
||||
container_name: image-lab
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18802:8000"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
|
||||
- IMAGE_DATA_DIR=/app/data
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/data/image:/app/data
|
||||
depends_on:
|
||||
- redis
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
insta-lab:
|
||||
build:
|
||||
context: ./insta-lab
|
||||
@@ -134,8 +177,15 @@ services:
|
||||
- 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:
|
||||
@@ -156,8 +206,34 @@ 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: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
co-gahusb:
|
||||
build:
|
||||
context: ./co-gahusb
|
||||
container_name: co-gahusb
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18920:8000"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||
- CO_BUS_KEY=${CO_BUS_KEY:-}
|
||||
depends_on:
|
||||
- redis
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 60s
|
||||
@@ -205,6 +281,54 @@ services:
|
||||
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
|
||||
|
||||
personal:
|
||||
build:
|
||||
context: ./personal
|
||||
@@ -275,7 +399,11 @@ services:
|
||||
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:
|
||||
@@ -289,6 +417,7 @@ services:
|
||||
- packs-lab
|
||||
- travel-proxy
|
||||
- video-lab
|
||||
- image-lab
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
|
||||
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,980 @@
|
||||
# insta 자율 카드 발급 (스마트 에이전트 3번) 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:** InstaAgent가 매일 09:30 발행 가치 있는 주제만 자율 선별(4신호)해 카드를 생성·렌더하고, 카드별 텔레그램 승인 게이트로 사람이 최종 결정한 뒤 발급하며, 발행 상태·이력을 추적한다.
|
||||
|
||||
**Architecture:** insta-lab이 선별 점수(`selection.py` + `GET /keywords/ranked`)와 발행 상태머신(`card_slates` 컬럼 + `POST /slates/{id}/decision`)을 소유. agent-office `InstaAgent`가 cron 오케스트레이션 + 텔레그램 승인을 담당. 기존 슬레이트 생성·렌더·전달 흐름 재사용.
|
||||
|
||||
**Tech Stack:** Python 3.12 / FastAPI / SQLite / anthropic SDK(Haiku) / httpx / pytest. 기존 패턴: `card_writer.py`(Anthropic 클라이언트), `service_proxy.py`(insta httpx 헬퍼), `telegram/webhook.py`(콜백 prefix 디스패치).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-11-insta-autonomous-card-issuance-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 파일 | 변경 | 책임 |
|
||||
|------|------|------|
|
||||
| `insta-lab/app/db.py` | Modify | `card_slates`에 `published_at`/`decision_at` ALTER + `set_slate_decision`/`list_recent_issued_topics` 헬퍼 |
|
||||
| `insta-lab/app/selection.py` | Create | 순수 선별 점수(dedup/freshness/account_fit/combine+threshold) |
|
||||
| `insta-lab/app/selection_judge.py` | Create | Claude Haiku 일괄 카드가치 판단(외부 IO 격리) |
|
||||
| `insta-lab/app/main.py` | Modify | `GET /api/insta/keywords/ranked`, `POST /api/insta/slates/{id}/decision` |
|
||||
| `insta-lab/tests/test_selection.py` | Create | selection 순수 단위테스트 |
|
||||
| `insta-lab/tests/test_ranked_decision_api.py` | Create | ranked·decision 엔드포인트 테스트 |
|
||||
| `agent-office/app/service_proxy.py` | Modify | `insta_ranked`, `insta_decision` 헬퍼 |
|
||||
| `agent-office/app/agents/insta.py` | Modify | 자율 `on_schedule` 분기 + 프리뷰 + `issue_*` 콜백 |
|
||||
| `agent-office/app/telegram/webhook.py` | Modify | `issue_approve_/issue_reject_/issue_regen_` 디스패치 |
|
||||
| `agent-office/tests/test_insta_autonomous.py` | Create | 자율 on_schedule + 콜백 테스트 |
|
||||
| `web-backend/CLAUDE.md` + `memory/service_insta.md` | Modify | API 목록 + 메모리 갱신 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: insta-lab DB — 발행 상태 컬럼 + 헬퍼
|
||||
|
||||
**Files:**
|
||||
- Modify: `insta-lab/app/db.py`
|
||||
- Test: `insta-lab/tests/test_db_decision.py` (Create)
|
||||
|
||||
- [ ] **Step 1: 실패하는 테스트 작성**
|
||||
|
||||
`insta-lab/tests/test_db_decision.py`:
|
||||
```python
|
||||
import os
|
||||
import pytest
|
||||
from app import db, config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(config, "DB_PATH", str(tmp_path / "insta.db"))
|
||||
monkeypatch.setattr(db, "DB_PATH", str(tmp_path / "insta.db"))
|
||||
db.init_db()
|
||||
|
||||
|
||||
def test_set_slate_decision_approved_publishes(fresh_db):
|
||||
sid = db.add_card_slate({"keyword": "금리", "category": "economy"})
|
||||
db.set_slate_decision(sid, "approved")
|
||||
s = db.get_card_slate(sid)
|
||||
assert s["status"] == "published"
|
||||
assert s["published_at"] is not None
|
||||
assert s["decision_at"] is not None
|
||||
|
||||
|
||||
def test_set_slate_decision_rejected(fresh_db):
|
||||
sid = db.add_card_slate({"keyword": "환율", "category": "economy"})
|
||||
db.set_slate_decision(sid, "rejected")
|
||||
s = db.get_card_slate(sid)
|
||||
assert s["status"] == "rejected"
|
||||
assert s["decision_at"] is not None
|
||||
assert s["published_at"] is None
|
||||
|
||||
|
||||
def test_set_slate_decision_idempotent(fresh_db):
|
||||
sid = db.add_card_slate({"keyword": "주식", "category": "economy"})
|
||||
db.set_slate_decision(sid, "approved")
|
||||
first = db.get_card_slate(sid)["published_at"]
|
||||
db.set_slate_decision(sid, "approved") # 재호출 no-op
|
||||
assert db.get_card_slate(sid)["published_at"] == first
|
||||
|
||||
|
||||
def test_list_recent_issued_topics(fresh_db):
|
||||
a = db.add_card_slate({"keyword": "금리", "category": "economy"})
|
||||
b = db.add_card_slate({"keyword": "우울증", "category": "psychology"})
|
||||
db.set_slate_decision(a, "published") if False else db.set_slate_decision(a, "approved")
|
||||
db.set_slate_decision(b, "rejected")
|
||||
topics = db.list_recent_issued_topics(window_days=14)
|
||||
pairs = {(t["keyword"], t["category"]) for t in topics}
|
||||
assert ("금리", "economy") in pairs
|
||||
assert ("우울증", "psychology") in pairs
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실패 확인**
|
||||
|
||||
Run: `cd insta-lab && PYTHONPATH=.. python -m pytest tests/test_db_decision.py -q`
|
||||
Expected: FAIL — `db.set_slate_decision` 미존재 + `published_at` 컬럼 없음.
|
||||
|
||||
- [ ] **Step 3: `init_db()`에 idempotent ALTER 추가**
|
||||
|
||||
`insta-lab/app/db.py`의 `init_db()` 함수 끝(account_preferences seed 직후)에 추가:
|
||||
```python
|
||||
# 발행 상태 컬럼 (idempotent ALTER) — 자율 발급 파이프라인
|
||||
cs_cols = [r[1] for r in conn.execute("PRAGMA table_info(card_slates)").fetchall()]
|
||||
if "published_at" not in cs_cols:
|
||||
conn.execute("ALTER TABLE card_slates ADD COLUMN published_at TEXT")
|
||||
if "decision_at" not in cs_cols:
|
||||
conn.execute("ALTER TABLE card_slates ADD COLUMN decision_at TEXT")
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 헬퍼 함수 추가**
|
||||
|
||||
`insta-lab/app/db.py`의 card_slates 섹션(예: `update_slate_status` 아래)에 추가:
|
||||
```python
|
||||
def set_slate_decision(slate_id: int, decision: str) -> None:
|
||||
"""승인/반려 결정 기록. approved→published(+published_at), rejected→rejected.
|
||||
멱등: 이미 published면 published_at 유지."""
|
||||
now = "strftime('%Y-%m-%dT%H:%M:%fZ','now')"
|
||||
with _conn() as conn:
|
||||
if decision == "approved":
|
||||
conn.execute(
|
||||
f"UPDATE card_slates SET status='published', "
|
||||
f"published_at=COALESCE(published_at, {now}), decision_at={now} "
|
||||
f"WHERE id=?",
|
||||
(slate_id,),
|
||||
)
|
||||
elif decision == "rejected":
|
||||
conn.execute(
|
||||
f"UPDATE card_slates SET status='rejected', decision_at={now} WHERE id=?",
|
||||
(slate_id,),
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"invalid decision: {decision}")
|
||||
|
||||
|
||||
def list_recent_issued_topics(window_days: int = 14) -> List[Dict[str, Any]]:
|
||||
"""최근 window_days 내 published/rejected 슬레이트의 (keyword, category). dedup용."""
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT keyword, category FROM card_slates "
|
||||
"WHERE status IN ('published','rejected') "
|
||||
"AND COALESCE(published_at, decision_at) >= datetime('now', ?)",
|
||||
(f"-{int(window_days)} days",),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 테스트 통과 확인**
|
||||
|
||||
Run: `cd insta-lab && PYTHONPATH=.. python -m pytest tests/test_db_decision.py -q`
|
||||
Expected: 4 PASS.
|
||||
|
||||
- [ ] **Step 6: 커밋**
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||||
git add insta-lab/app/db.py insta-lab/tests/test_db_decision.py
|
||||
git commit -m "feat(insta-lab): 발행 상태 컬럼 + set_slate_decision/list_recent_issued_topics
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: insta-lab — `selection.py` 순수 점수
|
||||
|
||||
**Files:**
|
||||
- Create: `insta-lab/app/selection.py`
|
||||
- Test: `insta-lab/tests/test_selection.py`
|
||||
|
||||
- [ ] **Step 1: 실패하는 테스트 작성**
|
||||
|
||||
`insta-lab/tests/test_selection.py`:
|
||||
```python
|
||||
from app.selection import score_candidates
|
||||
|
||||
NOW = "2026-06-11T00:00:00Z"
|
||||
|
||||
|
||||
def _cand(kid, kw, cat, score, suggested_at):
|
||||
return {"id": kid, "keyword": kw, "category": cat, "score": score, "suggested_at": suggested_at}
|
||||
|
||||
|
||||
def test_dedup_excludes_recent_issued():
|
||||
cands = [_cand(1, "금리", "economy", 0.9, "2026-06-11T00:00:00Z")]
|
||||
issued = [{"keyword": "금리", "category": "economy"}]
|
||||
out = score_candidates(cands, issued, prefs={}, claude_scores=None, threshold=0.0, now_iso=NOW)
|
||||
assert out[0]["eligible"] is False # 최근 발행 주제 제외
|
||||
|
||||
|
||||
def test_freshness_recent_higher():
|
||||
fresh = _cand(1, "A", "economy", 0.5, "2026-06-11T00:00:00Z") # 0h
|
||||
stale = _cand(2, "B", "economy", 0.5, "2026-06-04T00:00:00Z") # 168h
|
||||
out = {c["id"]: c for c in score_candidates([fresh, stale], [], {}, None, threshold=0.0, now_iso=NOW)}
|
||||
assert out[1]["breakdown"]["freshness"] > out[2]["breakdown"]["freshness"]
|
||||
|
||||
|
||||
def test_account_fit_uses_weight():
|
||||
cands = [_cand(1, "A", "economy", 0.8, NOW), _cand(2, "B", "psychology", 0.8, NOW)]
|
||||
prefs = {"economy": 2.0, "psychology": 1.0}
|
||||
out = {c["id"]: c for c in score_candidates(cands, [], prefs, None, threshold=0.0, now_iso=NOW)}
|
||||
assert out[1]["breakdown"]["account_fit"] > out[2]["breakdown"]["account_fit"]
|
||||
|
||||
|
||||
def test_threshold_gate():
|
||||
cands = [_cand(1, "A", "economy", 0.1, "2026-06-01T00:00:00Z")] # 낮은 score+오래됨
|
||||
out = score_candidates(cands, [], {}, None, threshold=0.6, now_iso=NOW)
|
||||
assert out[0]["eligible"] is False
|
||||
|
||||
|
||||
def test_claude_missing_renormalizes():
|
||||
# claude_scores=None이면 freshness+account_fit만으로 정규화 (claude 항 제외)
|
||||
cands = [_cand(1, "A", "economy", 1.0, NOW)]
|
||||
out = score_candidates(cands, [], {"economy": 1.0}, None, threshold=0.0, now_iso=NOW)
|
||||
assert out[0]["breakdown"]["claude"] is None
|
||||
assert 0.0 <= out[0]["final_score"] <= 1.0
|
||||
|
||||
|
||||
def test_claude_included_when_provided():
|
||||
cands = [_cand(1, "A", "economy", 0.5, NOW)]
|
||||
out = score_candidates(cands, [], {"economy": 1.0}, {1: 1.0}, threshold=0.0, now_iso=NOW)
|
||||
assert out[0]["breakdown"]["claude"] == 1.0
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실패 확인**
|
||||
|
||||
Run: `cd insta-lab && PYTHONPATH=.. python -m pytest tests/test_selection.py -q`
|
||||
Expected: FAIL — `app.selection` 미존재.
|
||||
|
||||
- [ ] **Step 3: `selection.py` 작성**
|
||||
|
||||
`insta-lab/app/selection.py`:
|
||||
```python
|
||||
"""발행 가치 자율 선별 — 순수 점수 함수 (외부 IO 없음, 단위테스트 대상).
|
||||
|
||||
신호: dedup(게이트), freshness, account_fit, claude(선택).
|
||||
final = 가중합(존재하는 신호만 정규화). eligible = dedup통과 and final>=threshold.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
DEFAULT_WEIGHTS = {"freshness": 0.3, "account_fit": 0.3, "claude": 0.4}
|
||||
FRESH_WINDOW_HOURS = 168.0 # 7일 → 0
|
||||
|
||||
|
||||
def _parse_iso(s: str) -> datetime:
|
||||
return datetime.fromisoformat(s.replace("Z", "+00:00")).astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _norm(kw: str) -> str:
|
||||
return (kw or "").strip().lower()
|
||||
|
||||
|
||||
def _is_duplicate(keyword: str, category: str, issued: List[Dict[str, Any]]) -> bool:
|
||||
n = _norm(keyword)
|
||||
if not n:
|
||||
return False
|
||||
for it in issued:
|
||||
if it.get("category") != category:
|
||||
continue
|
||||
m = _norm(it.get("keyword", ""))
|
||||
if not m:
|
||||
continue
|
||||
if n == m or n in m or m in n:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _freshness(suggested_at: str, now: datetime) -> float:
|
||||
try:
|
||||
hours = (now - _parse_iso(suggested_at)).total_seconds() / 3600.0
|
||||
except Exception:
|
||||
return 0.0
|
||||
return max(0.0, min(1.0, 1.0 - hours / FRESH_WINDOW_HOURS))
|
||||
|
||||
|
||||
def score_candidates(
|
||||
candidates: List[Dict[str, Any]],
|
||||
issued_topics: List[Dict[str, Any]],
|
||||
prefs: Dict[str, float],
|
||||
claude_scores: Optional[Dict[int, float]] = None,
|
||||
weights: Optional[Dict[str, float]] = None,
|
||||
threshold: float = 0.6,
|
||||
now_iso: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
w = weights or DEFAULT_WEIGHTS
|
||||
now = _parse_iso(now_iso) if now_iso else datetime.now(timezone.utc)
|
||||
max_w = max(prefs.values()) if prefs else 1.0
|
||||
out: List[Dict[str, Any]] = []
|
||||
for c in candidates:
|
||||
cat = c.get("category", "")
|
||||
dup = _is_duplicate(c.get("keyword", ""), cat, issued_topics)
|
||||
freshness = _freshness(c.get("suggested_at", ""), now)
|
||||
weight = prefs.get(cat, 1.0)
|
||||
account_fit = max(0.0, min(1.0, (weight / max_w) * float(c.get("score", 0.0))))
|
||||
claude = None
|
||||
if claude_scores is not None and c["id"] in claude_scores:
|
||||
claude = max(0.0, min(1.0, float(claude_scores[c["id"]])))
|
||||
# 존재하는 신호만 가중 정규화
|
||||
parts = [("freshness", freshness), ("account_fit", account_fit)]
|
||||
if claude is not None:
|
||||
parts.append(("claude", claude))
|
||||
total_w = sum(w[name] for name, _ in parts)
|
||||
final = sum(w[name] * val for name, val in parts) / total_w if total_w else 0.0
|
||||
eligible = (not dup) and (final >= threshold)
|
||||
out.append({
|
||||
"id": c["id"], "keyword": c.get("keyword"), "category": cat,
|
||||
"final_score": round(final, 4), "eligible": eligible,
|
||||
"breakdown": {"dedup_excluded": dup, "freshness": round(freshness, 4),
|
||||
"account_fit": round(account_fit, 4), "claude": claude},
|
||||
})
|
||||
out.sort(key=lambda x: (-x["eligible"], -x["final_score"]))
|
||||
return out
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 테스트 통과 확인**
|
||||
|
||||
Run: `cd insta-lab && PYTHONPATH=.. python -m pytest tests/test_selection.py -q`
|
||||
Expected: 6 PASS.
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
```bash
|
||||
git add insta-lab/app/selection.py insta-lab/tests/test_selection.py
|
||||
git commit -m "feat(insta-lab): selection.py 순수 선별 점수(4신호)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: insta-lab — Claude 카드가치 판단 (`selection_judge.py`)
|
||||
|
||||
**Files:**
|
||||
- Create: `insta-lab/app/selection_judge.py`
|
||||
- Test: `insta-lab/tests/test_selection_judge.py`
|
||||
|
||||
- [ ] **Step 1: 실패하는 테스트 작성**
|
||||
|
||||
`insta-lab/tests/test_selection_judge.py`:
|
||||
```python
|
||||
from app import selection_judge
|
||||
|
||||
|
||||
def test_parse_judge_response_ok():
|
||||
raw = '[{"keyword_id": 1, "score": 0.8}, {"keyword_id": 2, "score": 0.3}]'
|
||||
assert selection_judge.parse_judge_response(raw) == {1: 0.8, 2: 0.3}
|
||||
|
||||
|
||||
def test_parse_judge_response_codefence():
|
||||
raw = '```json\n[{"keyword_id": 5, "score": 0.5}]\n```'
|
||||
assert selection_judge.parse_judge_response(raw) == {5: 0.5}
|
||||
|
||||
|
||||
def test_parse_judge_response_garbage_returns_empty():
|
||||
assert selection_judge.parse_judge_response("not json") == {}
|
||||
|
||||
|
||||
def test_judge_candidates_no_key_returns_empty(monkeypatch):
|
||||
monkeypatch.setattr(selection_judge, "ANTHROPIC_API_KEY", "")
|
||||
assert selection_judge.judge_candidates([{"id": 1, "keyword": "x", "category": "economy"}]) == {}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실패 확인**
|
||||
|
||||
Run: `cd insta-lab && PYTHONPATH=.. python -m pytest tests/test_selection_judge.py -q`
|
||||
Expected: FAIL — 모듈 미존재.
|
||||
|
||||
- [ ] **Step 3: `selection_judge.py` 작성**
|
||||
|
||||
`insta-lab/app/selection_judge.py`:
|
||||
```python
|
||||
"""Claude Haiku 일괄 카드가치 판단. 실패/미설정 시 빈 dict (graceful)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from anthropic import Anthropic
|
||||
|
||||
from .config import ANTHROPIC_API_KEY, ANTHROPIC_MODEL_HAIKU
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROMPT = """다음 인스타 카드뉴스 후보 키워드들을 카드로 만들 가치(흥미·시의성·정보성)와
|
||||
리스크(민감·논란)를 종합해 0~1 점수로 평가해라. 코드펜스 없이 JSON 배열로만 출력:
|
||||
[{{"keyword_id": <id>, "score": <0~1>}}, ...]
|
||||
|
||||
후보:
|
||||
{items}"""
|
||||
|
||||
|
||||
def _strip_codefence(s: str) -> str:
|
||||
s = s.strip()
|
||||
if s.startswith("```"):
|
||||
s = re.sub(r"^```(?:json)?\s*|\s*```$", "", s).strip()
|
||||
return s
|
||||
|
||||
|
||||
def parse_judge_response(raw: str) -> Dict[int, float]:
|
||||
try:
|
||||
data = json.loads(_strip_codefence(raw))
|
||||
return {int(d["keyword_id"]): float(d["score"]) for d in data}
|
||||
except Exception:
|
||||
logger.warning("judge 응답 파싱 실패")
|
||||
return {}
|
||||
|
||||
|
||||
def judge_candidates(candidates: List[Dict[str, Any]]) -> Dict[int, float]:
|
||||
if not ANTHROPIC_API_KEY or not candidates:
|
||||
return {}
|
||||
items = "\n".join(f'- id={c["id"]}: {c["keyword"]} ({c["category"]})' for c in candidates)
|
||||
try:
|
||||
client = Anthropic(api_key=ANTHROPIC_API_KEY)
|
||||
resp = client.messages.create(
|
||||
model=ANTHROPIC_MODEL_HAIKU, max_tokens=512,
|
||||
messages=[{"role": "user", "content": PROMPT.format(items=items)}],
|
||||
)
|
||||
return parse_judge_response(resp.content[0].text)
|
||||
except Exception:
|
||||
logger.exception("judge_candidates 호출 실패")
|
||||
return {}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 테스트 통과 확인**
|
||||
|
||||
Run: `cd insta-lab && PYTHONPATH=.. python -m pytest tests/test_selection_judge.py -q`
|
||||
Expected: 4 PASS.
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
```bash
|
||||
git add insta-lab/app/selection_judge.py insta-lab/tests/test_selection_judge.py
|
||||
git commit -m "feat(insta-lab): Claude Haiku 카드가치 판단(graceful)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: insta-lab — `GET /api/insta/keywords/ranked`
|
||||
|
||||
**Files:**
|
||||
- Modify: `insta-lab/app/main.py`
|
||||
- Test: `insta-lab/tests/test_ranked_decision_api.py` (Create)
|
||||
|
||||
- [ ] **Step 1: 실패하는 테스트 작성**
|
||||
|
||||
`insta-lab/tests/test_ranked_decision_api.py`:
|
||||
```python
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, config, selection_judge
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(config, "DB_PATH", str(tmp_path / "insta.db"))
|
||||
monkeypatch.setattr(db, "DB_PATH", str(tmp_path / "insta.db"))
|
||||
monkeypatch.setattr(selection_judge, "judge_candidates", lambda c: {}) # Claude mock
|
||||
db.init_db()
|
||||
from app.main import app
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_ranked_returns_sorted_eligible(client, monkeypatch):
|
||||
db.add_trending_keyword({"keyword": "금리", "category": "economy", "score": 0.9})
|
||||
r = client.get("/api/insta/keywords/ranked?threshold=0.0&limit=10")
|
||||
assert r.status_code == 200
|
||||
items = r.json()["items"]
|
||||
assert len(items) >= 1
|
||||
assert "final_score" in items[0] and "eligible" in items[0]
|
||||
|
||||
|
||||
def test_decision_approve_publishes(client):
|
||||
sid = db.add_card_slate({"keyword": "금리", "category": "economy"})
|
||||
r = client.post(f"/api/insta/slates/{sid}/decision", json={"decision": "approved"})
|
||||
assert r.status_code == 200
|
||||
assert db.get_card_slate(sid)["status"] == "published"
|
||||
|
||||
|
||||
def test_decision_reject(client):
|
||||
sid = db.add_card_slate({"keyword": "환율", "category": "economy"})
|
||||
r = client.post(f"/api/insta/slates/{sid}/decision", json={"decision": "rejected"})
|
||||
assert r.status_code == 200
|
||||
assert db.get_card_slate(sid)["status"] == "rejected"
|
||||
|
||||
|
||||
def test_decision_invalid_400(client):
|
||||
sid = db.add_card_slate({"keyword": "x", "category": "economy"})
|
||||
r = client.post(f"/api/insta/slates/{sid}/decision", json={"decision": "maybe"})
|
||||
assert r.status_code == 400
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실패 확인**
|
||||
|
||||
Run: `cd insta-lab && PYTHONPATH=.. python -m pytest tests/test_ranked_decision_api.py -q`
|
||||
Expected: FAIL — 라우트 미존재 (404).
|
||||
|
||||
- [ ] **Step 3: ranked 라우트 추가**
|
||||
|
||||
`insta-lab/app/main.py` import 블록에 추가:
|
||||
```python
|
||||
from datetime import datetime, timezone
|
||||
from . import selection, selection_judge
|
||||
```
|
||||
|
||||
`list_keywords` 엔드포인트 아래에 추가:
|
||||
```python
|
||||
@app.get("/api/insta/keywords/ranked")
|
||||
def ranked_keywords(limit: int = Query(20, ge=1, le=100), threshold: float = Query(0.6, ge=0.0, le=1.0)):
|
||||
candidates = db.list_trending_keywords(used=False)
|
||||
if not candidates:
|
||||
return {"items": []}
|
||||
issued = db.list_recent_issued_topics(window_days=14)
|
||||
prefs = {p["category"]: p["weight"] for p in db.get_preferences()}
|
||||
claude_scores = selection_judge.judge_candidates(candidates)
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
scored = selection.score_candidates(
|
||||
candidates, issued, prefs, claude_scores=claude_scores,
|
||||
threshold=threshold, now_iso=now_iso,
|
||||
)
|
||||
return {"items": scored[:limit]}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: decision 라우트 추가**
|
||||
|
||||
`insta-lab/app/main.py`의 슬레이트 섹션(예: `delete_slate` 위)에 추가:
|
||||
```python
|
||||
class DecisionBody(BaseModel):
|
||||
decision: str # "approved" | "rejected"
|
||||
|
||||
|
||||
@app.post("/api/insta/slates/{slate_id}/decision")
|
||||
def slate_decision(slate_id: int, body: DecisionBody):
|
||||
if not db.get_card_slate(slate_id):
|
||||
raise HTTPException(404, "slate not found")
|
||||
if body.decision not in ("approved", "rejected"):
|
||||
raise HTTPException(400, "decision must be approved|rejected")
|
||||
db.set_slate_decision(slate_id, body.decision)
|
||||
return db.get_card_slate(slate_id)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 테스트 통과 확인**
|
||||
|
||||
Run: `cd insta-lab && PYTHONPATH=.. python -m pytest tests/test_ranked_decision_api.py -q`
|
||||
Expected: 4 PASS.
|
||||
|
||||
- [ ] **Step 6: 전체 insta-lab 회귀 + 커밋**
|
||||
```bash
|
||||
cd insta-lab && PYTHONPATH=.. python -m pytest tests/ -q # 전부 PASS 확인
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||||
git add insta-lab/app/main.py insta-lab/tests/test_ranked_decision_api.py
|
||||
git commit -m "feat(insta-lab): GET /keywords/ranked + POST /slates/{id}/decision
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: agent-office — service_proxy 헬퍼
|
||||
|
||||
**Files:**
|
||||
- Modify: `agent-office/app/service_proxy.py`
|
||||
|
||||
> 기존 `insta_*` 헬퍼와 동일 패턴(httpx로 `INSTA_LAB_URL` 호출)을 따른다. Task 3 작업 전 `insta_create_slate`(167행) 본문을 열어 base URL·timeout·client 사용 방식을 그대로 모방할 것.
|
||||
|
||||
- [ ] **Step 1: 헬퍼 2개 추가**
|
||||
|
||||
`agent-office/app/service_proxy.py`의 insta 헬퍼 묶음 끝(예: `insta_put_preferences` 아래)에 추가 — 기존 헬퍼의 `async with httpx.AsyncClient(...)` / base URL 변수명을 동일하게 사용:
|
||||
```python
|
||||
async def insta_ranked(threshold: float = 0.6, limit: int = 20) -> list:
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
r = await client.get(
|
||||
f"{INSTA_LAB_URL}/api/insta/keywords/ranked",
|
||||
params={"threshold": threshold, "limit": limit},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()["items"]
|
||||
|
||||
|
||||
async def insta_decision(slate_id: int, decision: str) -> dict:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
r = await client.post(
|
||||
f"{INSTA_LAB_URL}/api/insta/slates/{slate_id}/decision",
|
||||
json={"decision": decision},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
```
|
||||
|
||||
> 주의: 기존 헬퍼가 `INSTA_LAB_URL`이 아닌 다른 변수명(예: `_INSTA_BASE`)을 쓰면 그 이름으로 맞출 것. timeout(120s)은 ranked의 Claude 호출 대비 여유.
|
||||
|
||||
- [ ] **Step 2: import sanity**
|
||||
|
||||
Run: `cd agent-office && PYTHONPATH=.. python -c "from app import service_proxy; print('OK')"`
|
||||
Expected: OK (httpx 미설치면 pip install httpx 후).
|
||||
|
||||
- [ ] **Step 3: 커밋**
|
||||
```bash
|
||||
git add agent-office/app/service_proxy.py
|
||||
git commit -m "feat(agent-office): service_proxy insta_ranked/insta_decision
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: agent-office — InstaAgent 자율 발급 경로 + 프리뷰
|
||||
|
||||
**Files:**
|
||||
- Modify: `agent-office/app/agents/insta.py`
|
||||
- Test: `agent-office/tests/test_insta_autonomous.py` (Create)
|
||||
|
||||
- [ ] **Step 1: 실패하는 테스트 작성**
|
||||
|
||||
`agent-office/tests/test_insta_autonomous.py`:
|
||||
```python
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from app.agents.insta import InstaAgent
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_autonomous_issue_previews_eligible(monkeypatch):
|
||||
agent = InstaAgent()
|
||||
agent.state = "idle"
|
||||
monkeypatch.setattr("app.agents.insta.get_agent_config",
|
||||
lambda aid: {"custom_config": {"autonomous_issue": True,
|
||||
"select_threshold": 0.5, "max_per_day": 2}})
|
||||
monkeypatch.setattr(agent, "transition", AsyncMock())
|
||||
monkeypatch.setattr(agent, "_run_collect_and_extract", AsyncMock())
|
||||
sp = "app.agents.insta.service_proxy"
|
||||
monkeypatch.setattr(f"{sp}.insta_ranked", AsyncMock(return_value=[
|
||||
{"id": 1, "keyword": "금리", "category": "economy", "eligible": True, "final_score": 0.8,
|
||||
"breakdown": {}},
|
||||
{"id": 2, "keyword": "x", "category": "economy", "eligible": False, "final_score": 0.1,
|
||||
"breakdown": {}},
|
||||
]))
|
||||
preview = AsyncMock()
|
||||
monkeypatch.setattr(agent, "_generate_and_preview", preview)
|
||||
monkeypatch.setattr("app.agents.insta.create_task", lambda *a, **k: "t1")
|
||||
monkeypatch.setattr("app.agents.insta.update_task_status", lambda *a, **k: None)
|
||||
monkeypatch.setattr("app.agents.insta.add_log", lambda *a, **k: None)
|
||||
|
||||
await agent.on_schedule()
|
||||
# eligible 1건만 프리뷰
|
||||
assert preview.await_count == 1
|
||||
assert preview.await_args.args[0]["id"] == 1
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실패 확인**
|
||||
|
||||
Run: `cd agent-office && PYTHONPATH=.. python -m pytest tests/test_insta_autonomous.py -q`
|
||||
Expected: FAIL — 자율 분기/`_generate_and_preview` 미존재.
|
||||
|
||||
- [ ] **Step 3: `on_schedule`에 자율 분기 추가**
|
||||
|
||||
`agent-office/app/agents/insta.py`의 `on_schedule`에서 `auto_select` 분기 직전에 자율 경로를 추가. `custom` 읽은 직후:
|
||||
```python
|
||||
autonomous = bool(custom.get("autonomous_issue", False))
|
||||
threshold = float(custom.get("select_threshold", 0.6))
|
||||
max_per_day = int(custom.get("max_per_day", 2))
|
||||
```
|
||||
그리고 `add_log(...) → _run_collect_and_extract()` 다음의 분기를 교체:
|
||||
```python
|
||||
await self._run_collect_and_extract()
|
||||
if autonomous:
|
||||
ranked = await service_proxy.insta_ranked(threshold=threshold, limit=20)
|
||||
eligible = [r for r in ranked if r.get("eligible")][:max_per_day]
|
||||
if not eligible:
|
||||
await messaging.send_raw("📰 [인스타 큐레이터] 오늘은 발행할 가치 있는 주제가 없습니다.")
|
||||
else:
|
||||
for pick in eligible:
|
||||
await self._generate_and_preview(pick)
|
||||
update_task_status(task_id, "succeeded", {"issued": len(eligible)})
|
||||
await self.transition("idle", "자율 발급 후보 프리뷰 완료")
|
||||
return
|
||||
kws = await service_proxy.insta_list_keywords(used=False)
|
||||
if auto_select:
|
||||
... # 기존 유지
|
||||
```
|
||||
(기존 `kws = ... / if auto_select` 블록은 그대로 둔다.)
|
||||
|
||||
- [ ] **Step 4: `_generate_and_preview` 메서드 추가**
|
||||
|
||||
`insta.py`에 추가 — 슬레이트 생성·렌더(기존 흐름) 후 커버 프리뷰 발송:
|
||||
```python
|
||||
async def _generate_and_preview(self, pick: dict) -> None:
|
||||
"""eligible 픽 → 슬레이트 생성·렌더 → 커버 프리뷰 + 승인 버튼."""
|
||||
created = await service_proxy.insta_create_slate(
|
||||
keyword=pick["keyword"], category=pick["category"], keyword_id=pick["id"],
|
||||
)
|
||||
st = await self._wait_task(created["task_id"], step="slate", timeout_sec=600)
|
||||
slate_id = st["result_id"]
|
||||
cover = await service_proxy.insta_get_asset_bytes(slate_id, 1)
|
||||
bd = pick.get("breakdown", {})
|
||||
caption = (f"🎴 <b>{pick['keyword']}</b> ({pick['category']})\n"
|
||||
f"점수 {pick.get('final_score')} · fresh {bd.get('freshness')} "
|
||||
f"fit {bd.get('account_fit')} claude {bd.get('claude')}\n승인하시겠어요?")
|
||||
kb = {"inline_keyboard": [[
|
||||
{"text": "✅ 승인", "callback_data": f"issue_approve_{slate_id}"},
|
||||
{"text": "❌ 반려", "callback_data": f"issue_reject_{slate_id}"},
|
||||
{"text": "🔄 재생성", "callback_data": f"issue_regen_{slate_id}"},
|
||||
]]}
|
||||
await messaging.send_photo(cover, caption=caption, reply_markup=kb)
|
||||
create_task(self.agent_id, "insta_issue", {"slate_id": slate_id, "keyword_id": pick["id"]},
|
||||
requires_approval=True)
|
||||
```
|
||||
|
||||
> `messaging.send_photo(bytes, caption, reply_markup)`가 없으면 Task 6.5로 추가(아래). 있으면 그대로 사용.
|
||||
|
||||
- [ ] **Step 5: 테스트 통과 확인**
|
||||
|
||||
Run: `cd agent-office && PYTHONPATH=.. python -m pytest tests/test_insta_autonomous.py::test_autonomous_issue_previews_eligible -q`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: 커밋**
|
||||
```bash
|
||||
git add agent-office/app/agents/insta.py agent-office/tests/test_insta_autonomous.py
|
||||
git commit -m "feat(agent-office): InstaAgent 자율 발급 경로 + 커버 프리뷰
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6.5: agent-office — `messaging.send_photo` (없을 경우만)
|
||||
|
||||
**Files:**
|
||||
- Modify: `agent-office/app/telegram/messaging.py`
|
||||
|
||||
- [ ] **Step 1: 존재 확인**
|
||||
|
||||
Run: `grep -n "def send_photo" agent-office/app/telegram/messaging.py`
|
||||
이미 있으면 이 Task 건너뜀.
|
||||
|
||||
- [ ] **Step 2: 없으면 추가**
|
||||
|
||||
`messaging.py`에 `send_raw` 패턴(TELEGRAM_BOT_TOKEN/CHAT_ID 사용)을 따라 추가:
|
||||
```python
|
||||
async def send_photo(photo_bytes: bytes, caption: str = "", reply_markup: dict = None) -> dict:
|
||||
if not TELEGRAM_BOT_TOKEN:
|
||||
return {"ok": False, "reason": "no token"}
|
||||
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendPhoto"
|
||||
data = {"chat_id": TELEGRAM_CHAT_ID, "caption": caption[:1024], "parse_mode": "HTML"}
|
||||
if reply_markup:
|
||||
data["reply_markup"] = json.dumps(reply_markup, ensure_ascii=False)
|
||||
files = {"photo": ("cover.png", photo_bytes, "image/png")}
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.post(url, data=data, files=files)
|
||||
return resp.json()
|
||||
```
|
||||
(상단에 `import json`, `import httpx`, `from ..config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID` 필요분 확인.)
|
||||
|
||||
- [ ] **Step 3: 커밋**
|
||||
```bash
|
||||
git add agent-office/app/telegram/messaging.py
|
||||
git commit -m "feat(agent-office): messaging.send_photo (인라인 키보드 첨부 사진)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: agent-office — `issue_*` 콜백 처리
|
||||
|
||||
**Files:**
|
||||
- Modify: `agent-office/app/agents/insta.py` (`on_callback`)
|
||||
- Test: `agent-office/tests/test_insta_autonomous.py` (추가)
|
||||
|
||||
- [ ] **Step 1: 실패하는 테스트 추가**
|
||||
|
||||
`test_insta_autonomous.py`에 추가:
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_approve_publishes_and_delivers(monkeypatch):
|
||||
agent = InstaAgent()
|
||||
sp = "app.agents.insta.service_proxy"
|
||||
dec = AsyncMock(return_value={"status": "published"})
|
||||
monkeypatch.setattr(f"{sp}.insta_decision", dec)
|
||||
monkeypatch.setattr(f"{sp}.insta_get_slate", AsyncMock(return_value={
|
||||
"assets": [{"page_index": i} for i in range(1, 11)],
|
||||
"suggested_caption": "cap", "hashtags": ["#a"]}))
|
||||
monkeypatch.setattr(f"{sp}.insta_get_asset_bytes", AsyncMock(return_value=b"png"))
|
||||
monkeypatch.setattr("app.agents.insta._send_media_group", AsyncMock(return_value={"ok": True}))
|
||||
monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock())
|
||||
res = await agent.on_callback("issue_approve", {"slate_id": 8})
|
||||
assert res["ok"] is True
|
||||
dec.assert_awaited_once_with(8, "approved")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_reject_marks_rejected(monkeypatch):
|
||||
agent = InstaAgent()
|
||||
dec = AsyncMock(return_value={"status": "rejected"})
|
||||
monkeypatch.setattr("app.agents.insta.service_proxy.insta_decision", dec)
|
||||
monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock())
|
||||
res = await agent.on_callback("issue_reject", {"slate_id": 8})
|
||||
assert res["ok"] is True
|
||||
dec.assert_awaited_once_with(8, "rejected")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실패 확인**
|
||||
|
||||
Run: `cd agent-office && PYTHONPATH=.. python -m pytest tests/test_insta_autonomous.py -q`
|
||||
Expected: FAIL — issue_* 액션 미처리.
|
||||
|
||||
- [ ] **Step 3: `on_callback`에 issue_* 분기 추가**
|
||||
|
||||
`insta.py`의 `on_callback`을 확장:
|
||||
```python
|
||||
async def on_callback(self, action: str, params: dict) -> dict:
|
||||
if action == "render":
|
||||
kid = int(params.get("keyword_id") or 0)
|
||||
if not kid:
|
||||
return {"ok": False}
|
||||
await self._render_and_push(kid)
|
||||
return {"ok": True}
|
||||
if action in ("issue_approve", "issue_reject"):
|
||||
sid = int(params.get("slate_id") or 0)
|
||||
if not sid:
|
||||
return {"ok": False}
|
||||
decision = "approved" if action == "issue_approve" else "rejected"
|
||||
await service_proxy.insta_decision(sid, decision)
|
||||
if decision == "approved":
|
||||
slate = await service_proxy.insta_get_slate(sid)
|
||||
media = []
|
||||
for a in slate["assets"][:10]:
|
||||
data = await service_proxy.insta_get_asset_bytes(sid, a["page_index"])
|
||||
media.append({"type": "photo", "_bytes": data})
|
||||
cap = f"{slate.get('suggested_caption','')}\n\n{' '.join(slate.get('hashtags',[]) or [])}".strip()
|
||||
await _send_media_group(media, caption=cap)
|
||||
await messaging.send_raw(f"✅ 발행 완료 (slate {sid})")
|
||||
else:
|
||||
await messaging.send_raw(f"❌ 반려됨 (slate {sid})")
|
||||
return {"ok": True}
|
||||
if action == "issue_regen":
|
||||
sid = int(params.get("slate_id") or 0)
|
||||
if not sid:
|
||||
return {"ok": False}
|
||||
slate = await service_proxy.insta_get_slate(sid)
|
||||
await service_proxy.insta_decision(sid, "rejected") # 이전 폐기
|
||||
await self._generate_and_preview({
|
||||
"id": 0, "keyword": slate["keyword"], "category": slate["category"],
|
||||
"final_score": None, "breakdown": {},
|
||||
})
|
||||
return {"ok": True}
|
||||
return {"ok": False}
|
||||
```
|
||||
|
||||
> `insta_create_slate`는 `keyword_id` 없이도 동작(기존 시그니처 `keyword_id: Optional`). regen은 keyword_id=0 → mark_keyword_used 생략.
|
||||
|
||||
- [ ] **Step 4: 테스트 통과 확인**
|
||||
|
||||
Run: `cd agent-office && PYTHONPATH=.. python -m pytest tests/test_insta_autonomous.py -q`
|
||||
Expected: 모두 PASS.
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
```bash
|
||||
git add agent-office/app/agents/insta.py agent-office/tests/test_insta_autonomous.py
|
||||
git commit -m "feat(agent-office): issue_approve/reject/regen 콜백 처리
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: agent-office — 텔레그램 콜백 디스패치
|
||||
|
||||
**Files:**
|
||||
- Modify: `agent-office/app/telegram/webhook.py`
|
||||
|
||||
- [ ] **Step 1: `_handle_callback`에 issue_* 분기 추가**
|
||||
|
||||
`webhook.py`의 `_handle_callback`에서 `render_` 분기 아래에 추가:
|
||||
```python
|
||||
if callback_id.startswith("issue_"):
|
||||
return await _handle_insta_issue(callback_query, callback_id)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `_handle_insta_issue` 추가**
|
||||
|
||||
`_handle_insta_render`(103행~)를 본떠 추가:
|
||||
```python
|
||||
async def _handle_insta_issue(callback_query: dict, callback_id: str) -> dict:
|
||||
"""issue_{approve|reject|regen}_{slate_id} → InstaAgent.on_callback."""
|
||||
from ..agents import AGENT_REGISTRY # _handle_insta_render와 동일 방식으로 에이전트 해석
|
||||
try:
|
||||
rest = callback_id.removeprefix("issue_") # "approve_8"
|
||||
verb, sid = rest.rsplit("_", 1)
|
||||
slate_id = int(sid)
|
||||
except (ValueError, AttributeError):
|
||||
return {"ok": False, "error": "invalid_callback_data"}
|
||||
agent = AGENT_REGISTRY.get("insta")() if callable(AGENT_REGISTRY.get("insta")) else AGENT_REGISTRY.get("insta")
|
||||
return await agent.on_callback(f"issue_{verb}", {"slate_id": slate_id})
|
||||
```
|
||||
|
||||
> `_handle_insta_render`가 에이전트를 얻는 정확한 방식(레지스트리/팩토리)을 그대로 복사할 것. 위 `AGENT_REGISTRY` 줄은 그 방식으로 대체한다.
|
||||
|
||||
- [ ] **Step 3: import sanity + 수동 점검**
|
||||
|
||||
Run: `cd agent-office && PYTHONPATH=.. python -c "from app.telegram import webhook; print('OK')"`
|
||||
Expected: OK.
|
||||
|
||||
- [ ] **Step 4: 커밋**
|
||||
```bash
|
||||
git add agent-office/app/telegram/webhook.py
|
||||
git commit -m "feat(agent-office): issue_* 텔레그램 콜백 디스패치
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: 문서 + 배포 + 검증
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-backend/CLAUDE.md`, `memory/service_insta.md`
|
||||
|
||||
- [ ] **Step 1: CLAUDE.md insta API 목록에 2개 추가**
|
||||
|
||||
`### insta-lab` API 표에 추가:
|
||||
```
|
||||
| GET | `/api/insta/keywords/ranked` | 4신호 선별 점수 + eligible (자율 발급용) |
|
||||
| POST | `/api/insta/slates/{id}/decision` | 승인/반려 (approved→published) |
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 전체 테스트 회귀**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd insta-lab && PYTHONPATH=.. python -m pytest tests/ app/test_package_api.py -q
|
||||
cd ../agent-office && PYTHONPATH=.. python -m pytest tests/ -q
|
||||
```
|
||||
Expected: 모두 PASS (사전존재 stale 제외).
|
||||
|
||||
- [ ] **Step 3: 커밋 + push (NAS 배포)**
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||||
git add CLAUDE.md docs/superpowers/plans/2026-06-11-insta-autonomous-card-issuance.md
|
||||
git commit -m "docs(insta): 자율 발급 API 문서 + 구현 계획
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 활성화 + 프로덕션 검증**
|
||||
|
||||
배포 완료 후 (deployer rebuild ~3분):
|
||||
```bash
|
||||
# autonomous_issue 켜기 (agent_config custom_config)
|
||||
curl -X PUT https://gahusb.synology.me/api/agent-office/agents/insta \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"custom_config": {"autonomous_issue": true, "select_threshold": 0.6, "max_per_day": 2}}'
|
||||
|
||||
# 수동 트리거 대신 ranked 직접 확인
|
||||
curl -s "https://gahusb.synology.me/api/insta/keywords/ranked?threshold=0.0&limit=5" | python -m json.tool
|
||||
```
|
||||
Expected: ranked 응답에 `final_score`/`eligible`/`breakdown`. 09:30 cron 또는 수동 command로 프리뷰가 텔레그램에 도착하는지 확인.
|
||||
|
||||
- [ ] **Step 5: 메모리 갱신**
|
||||
|
||||
`memory/service_insta.md`에 자율 발급 파이프라인(4신호 선별·승인 게이트·상태머신) 추가 + 스마트에이전트 3종 완료 표시.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- 선별 4신호 → Task 2(freshness/account_fit/dedup) + Task 3(claude). ✓
|
||||
- threshold 게이트 0~N → Task 2 + Task 6(max_per_day). ✓
|
||||
- 승인 게이트 + 콜백 → Task 6(프리뷰) + Task 7(approve/reject/regen) + Task 8(디스패치). ✓
|
||||
- 상태머신 + 발행이력 → Task 1. ✓
|
||||
- 하위호환(autonomous_issue=false) → Task 6 Step 3(기존 블록 유지). ✓
|
||||
- graceful Claude 실패 → Task 3(빈 dict) + Task 2(renormalize). ✓
|
||||
- 성과지표 제외(YAGNI) → 계획에 없음. ✓
|
||||
|
||||
**Placeholder scan:** 모든 코드 스텝에 실제 코드 포함. 단 Task 5/8의 "기존 변수명/에이전트 해석 방식 모방"은 실제 파일 확인을 요구하는 의도적 지시(해당 파일이 코드 소유) — placeholder 아님.
|
||||
|
||||
**Type consistency:** `score_candidates(candidates, issued_topics, prefs, claude_scores, weights, threshold, now_iso)` Task2 정의 ↔ Task4 호출 일치. `set_slate_decision(slate_id, decision)` Task1 ↔ Task4 일치. `insta_ranked(threshold, limit)`/`insta_decision(slate_id, decision)` Task5 ↔ Task6/7 일치. 콜백 액션 `issue_approve/issue_reject/issue_regen` Task7 ↔ Task8 prefix 파싱 일치. `_generate_and_preview(pick)` Task6 정의 ↔ Task7(regen) 호출 일치.
|
||||
1187
docs/superpowers/plans/2026-06-12-co-gahusb-team-bus.md
Normal file
1187
docs/superpowers/plans/2026-06-12-co-gahusb-team-bus.md
Normal file
File diff suppressed because it is too large
Load Diff
556
docs/superpowers/plans/2026-06-12-music-pipeline-reliability.md
Normal file
556
docs/superpowers/plans/2026-06-12-music-pipeline-reliability.md
Normal file
@@ -0,0 +1,556 @@
|
||||
# music/YouTube 파이프라인 신뢰성·복구 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:** 파이프라인 step 실패를 자동 재시도(일시적, publish 제외)로 흡수하고, 영구 실패는 terminal `failed`로 둔 뒤 실패 step부터 수동 재개(텔레그램 [🔄재시도])할 수 있게 한다.
|
||||
|
||||
**Architecture:** music-lab `orchestrator.run_step`에 bounded 재시도 루프 + `POST /pipeline/{id}/retry` 재개 엔드포인트 + `db.get_last_failed_step`. agent-office `youtube_publisher`가 `failed` 감지 → 텔레그램 알림+버튼, `webhook`이 `ytpub_retry_{pid}` 콜백을 music-lab retry로 프록시.
|
||||
|
||||
**Tech Stack:** Python 3.12 / FastAPI / SQLite / asyncio / pytest. 기존 패턴: `orchestrator.run_step`(BackgroundTask), `main.py` pipeline 엔드포인트(404/409 + `_db_module`), `service_proxy`(httpx + `MUSIC_LAB_URL`), `telegram/webhook.py`(callback prefix 디스패치).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-12-music-pipeline-reliability-design.md`
|
||||
|
||||
> **테스트 fixture 주의**: music-lab/agent-office 각 `tests/conftest.py`의 DB 격리 방식(`db.DB_PATH` monkeypatch + `init_db`)을 먼저 확인하고 아래 테스트의 fixture를 그 관례에 맞춰라. 아래 코드는 `db.DB_PATH`를 tmp로 monkeypatch하는 표준 패턴을 가정한다.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 파일 | 변경 | 책임 |
|
||||
|------|------|------|
|
||||
| `music-lab/app/db.py` | Modify | `get_last_failed_step(pid)` 추가 |
|
||||
| `music-lab/app/pipeline/orchestrator.py` | Modify | `_dispatch_step` 추출 + `run_step` 재시도 루프 |
|
||||
| `music-lab/app/main.py` | Modify | `POST /api/music/pipeline/{pid}/retry` |
|
||||
| `music-lab/tests/test_pipeline_retry.py` | Create | db + orchestrator + endpoint 테스트 |
|
||||
| `agent-office/app/service_proxy.py` | Modify | `pipeline_retry(pid)`, `list_failed_pipelines()` |
|
||||
| `agent-office/app/agents/youtube_publisher.py` | Modify | `failed` 감지 → 텔레그램 알림+버튼 |
|
||||
| `agent-office/app/telegram/webhook.py` | Modify | `ytpub_retry_` 디스패치 |
|
||||
| `agent-office/tests/test_youtube_publisher_retry.py` | Create | 알림 + 콜백 테스트 |
|
||||
| `web-backend/CLAUDE.md` + `memory/service_music.md` | Modify | API 표 + 메모리 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: music-lab db — `get_last_failed_step`
|
||||
|
||||
**Files:** Modify `music-lab/app/db.py`; Test `music-lab/tests/test_pipeline_retry.py` (Create)
|
||||
|
||||
- [ ] **Step 1: 실패 테스트 작성**
|
||||
|
||||
`music-lab/tests/test_pipeline_retry.py` (fixture는 music-lab conftest 관례에 맞춰 조정):
|
||||
```python
|
||||
import pytest
|
||||
from app import db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _tmp_db(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(db, "DB_PATH", str(tmp_path / "music.db"))
|
||||
db.init_db()
|
||||
|
||||
|
||||
def _make_pipeline_with_failed_step(step: str) -> int:
|
||||
pid = db.create_pipeline(track_id=1) # 시그니처는 conftest/db 확인 후 맞출 것
|
||||
job = db.create_pipeline_job(pid, step)
|
||||
db.update_pipeline_job(job, status="failed", error="boom")
|
||||
db.update_pipeline_state(pid, "failed", failed_reason=f"{step}: boom")
|
||||
return pid
|
||||
|
||||
|
||||
def test_get_last_failed_step_returns_step():
|
||||
pid = _make_pipeline_with_failed_step("video")
|
||||
assert db.get_last_failed_step(pid) == "video"
|
||||
|
||||
|
||||
def test_get_last_failed_step_none_when_no_failure():
|
||||
pid = db.create_pipeline(track_id=1)
|
||||
db.create_pipeline_job(pid, "cover") # status 기본(running/succeeded), failed 아님
|
||||
assert db.get_last_failed_step(pid) is None
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실패 확인**
|
||||
|
||||
Run: `cd music-lab && PYTHONPATH=.. python -m pytest tests/test_pipeline_retry.py::test_get_last_failed_step_returns_step -v`
|
||||
Expected: FAIL — `db.get_last_failed_step` 미존재. (create_pipeline 시그니처가 다르면 helper를 db의 실제 생성 함수에 맞춰 수정.)
|
||||
|
||||
- [ ] **Step 3: 구현**
|
||||
|
||||
`music-lab/app/db.py`의 pipeline_jobs 섹션(`list_pipeline_jobs` 근처)에 추가:
|
||||
```python
|
||||
def get_last_failed_step(pid: int) -> Optional[str]:
|
||||
"""파이프라인의 가장 최근 status='failed' pipeline_job의 step. 없으면 None."""
|
||||
with _connect() as conn: # music-lab의 커넥션 헬퍼 이름에 맞출 것
|
||||
row = conn.execute(
|
||||
"SELECT step FROM pipeline_jobs "
|
||||
"WHERE pipeline_id = ? AND status = 'failed' "
|
||||
"ORDER BY id DESC LIMIT 1",
|
||||
(pid,),
|
||||
).fetchone()
|
||||
return row["step"] if row else None
|
||||
```
|
||||
(`_connect`/`_conn` 등 실제 커넥션 컨텍스트매니저 이름은 db.py 상단 확인 후 일치시킬 것.)
|
||||
|
||||
- [ ] **Step 4: 통과 확인**
|
||||
|
||||
Run: `cd music-lab && PYTHONPATH=.. python -m pytest tests/test_pipeline_retry.py -v -k get_last_failed`
|
||||
Expected: 2 PASS.
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
```bash
|
||||
git add music-lab/app/db.py music-lab/tests/test_pipeline_retry.py
|
||||
git commit -m "feat(music-lab): get_last_failed_step — 파이프라인 재개용 실패 step 판별
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: orchestrator 자동 재시도
|
||||
|
||||
**Files:** Modify `music-lab/app/pipeline/orchestrator.py`; Test `music-lab/tests/test_pipeline_retry.py`
|
||||
|
||||
- [ ] **Step 1: 실패 테스트 작성** (test_pipeline_retry.py에 추가)
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from app.pipeline import orchestrator
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_backoff(monkeypatch):
|
||||
monkeypatch.setattr(orchestrator, "STEP_RETRY_BACKOFF_SEC", [0, 0])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retryable_step_retries_then_succeeds(monkeypatch):
|
||||
pid = db.create_pipeline(track_id=1)
|
||||
calls = {"n": 0}
|
||||
|
||||
async def flaky(step, p, ctx, feedback):
|
||||
calls["n"] += 1
|
||||
if calls["n"] < 3:
|
||||
raise RuntimeError("transient")
|
||||
return {"next_state": "video_pending", "fields": {}}
|
||||
|
||||
monkeypatch.setattr(orchestrator, "_dispatch_step", flaky)
|
||||
monkeypatch.setattr(orchestrator, "_resolve_input", lambda p: {"genre": "x", "title": "t", "moods": [], "tracks": [], "audio_path": "", "duration_sec": 0})
|
||||
|
||||
await orchestrator.run_step(pid, "cover")
|
||||
assert calls["n"] == 3
|
||||
assert db.get_pipeline(pid)["state"] == "video_pending"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retryable_step_exhausts_to_failed(monkeypatch):
|
||||
pid = db.create_pipeline(track_id=1)
|
||||
|
||||
async def always_fail(step, p, ctx, feedback):
|
||||
raise RuntimeError("permanent")
|
||||
|
||||
monkeypatch.setattr(orchestrator, "_dispatch_step", always_fail)
|
||||
monkeypatch.setattr(orchestrator, "_resolve_input", lambda p: {"genre": "x", "title": "t", "moods": [], "tracks": [], "audio_path": "", "duration_sec": 0})
|
||||
|
||||
await orchestrator.run_step(pid, "cover")
|
||||
assert db.get_pipeline(pid)["state"] == "failed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_not_retried(monkeypatch):
|
||||
pid = db.create_pipeline(track_id=1)
|
||||
calls = {"n": 0}
|
||||
|
||||
async def fail_publish(step, p, ctx, feedback):
|
||||
calls["n"] += 1
|
||||
raise RuntimeError("upload error")
|
||||
|
||||
monkeypatch.setattr(orchestrator, "_dispatch_step", fail_publish)
|
||||
monkeypatch.setattr(orchestrator, "_resolve_input", lambda p: {"genre": "x", "title": "t", "moods": [], "tracks": [], "audio_path": "", "duration_sec": 0})
|
||||
|
||||
await orchestrator.run_step(pid, "publish")
|
||||
assert calls["n"] == 1 # 재시도 없음
|
||||
assert db.get_pipeline(pid)["state"] == "failed"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실패 확인**
|
||||
|
||||
Run: `cd music-lab && PYTHONPATH=.. python -m pytest tests/test_pipeline_retry.py -v -k "retry or publish_not"`
|
||||
Expected: FAIL — `_dispatch_step`/`STEP_RETRY_BACKOFF_SEC` 미존재.
|
||||
|
||||
- [ ] **Step 3: 구현 — `_dispatch_step` 추출 + 재시도 루프**
|
||||
|
||||
`orchestrator.py` 상단 상수 추가:
|
||||
```python
|
||||
STEP_MAX_RETRIES = 2 # 추가 재시도 횟수 (총 시도 = +1)
|
||||
STEP_RETRY_BACKOFF_SEC = [5, 15]
|
||||
NON_RETRY_STEPS = {"publish"}
|
||||
```
|
||||
|
||||
기존 if/elif 분기(현재 `run_step` 내 lines 32-45)를 헬퍼로 추출:
|
||||
```python
|
||||
async def _dispatch_step(step: str, p: dict, ctx: dict, feedback: str) -> dict:
|
||||
if step == "cover":
|
||||
return await _run_cover(p, ctx, feedback)
|
||||
if step == "video":
|
||||
return await _run_video(p, ctx)
|
||||
if step == "thumb":
|
||||
return await _run_thumb(p, ctx, feedback)
|
||||
if step == "meta":
|
||||
return await _run_meta(p, ctx, feedback)
|
||||
if step == "review":
|
||||
return await _run_review(p, ctx)
|
||||
if step == "publish":
|
||||
return await _run_publish(p, ctx)
|
||||
raise ValueError(f"unknown step: {step}")
|
||||
```
|
||||
|
||||
`run_step`의 try 블록(step 실행부)을 재시도 루프로 교체:
|
||||
```python
|
||||
try:
|
||||
ctx = _resolve_input(p)
|
||||
except ValueError as e:
|
||||
db.update_pipeline_job(job_id, status="failed", error=str(e))
|
||||
db.update_pipeline_state(pipeline_id, "failed", failed_reason=f"{step}: {e}")
|
||||
return
|
||||
|
||||
attempts = 1 if step in NON_RETRY_STEPS else (STEP_MAX_RETRIES + 1)
|
||||
last_err = None
|
||||
for i in range(attempts):
|
||||
try:
|
||||
result = await _dispatch_step(step, p, ctx, feedback)
|
||||
db.update_pipeline_job(job_id, status="succeeded")
|
||||
db.update_pipeline_state(pipeline_id, result["next_state"], **result.get("fields", {}))
|
||||
return
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
logger.exception("step %s 실패 (pipeline %s, attempt %d/%d)", step, pipeline_id, i + 1, attempts)
|
||||
if i < attempts - 1:
|
||||
await asyncio.sleep(STEP_RETRY_BACKOFF_SEC[min(i, len(STEP_RETRY_BACKOFF_SEC) - 1)])
|
||||
db.update_pipeline_job(job_id, status="failed", error=str(last_err))
|
||||
db.update_pipeline_state(pipeline_id, "failed", failed_reason=f"{step}: {last_err}")
|
||||
```
|
||||
(`asyncio`는 이미 import됨.)
|
||||
|
||||
- [ ] **Step 4: 통과 확인**
|
||||
|
||||
Run: `cd music-lab && PYTHONPATH=.. python -m pytest tests/test_pipeline_retry.py -v -k "retry or publish_not"`
|
||||
Expected: 3 PASS.
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
```bash
|
||||
git add music-lab/app/pipeline/orchestrator.py music-lab/tests/test_pipeline_retry.py
|
||||
git commit -m "feat(music-lab): orchestrator step 자동 재시도 (publish 제외)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: retry 엔드포인트
|
||||
|
||||
**Files:** Modify `music-lab/app/main.py`; Test `music-lab/tests/test_pipeline_retry.py`
|
||||
|
||||
- [ ] **Step 1: 실패 테스트 작성**
|
||||
|
||||
```python
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
from app.main import app
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_retry_failed_pipeline_retriggers(client, monkeypatch):
|
||||
pid = db.create_pipeline(track_id=1)
|
||||
job = db.create_pipeline_job(pid, "video")
|
||||
db.update_pipeline_job(job, status="failed", error="boom")
|
||||
db.update_pipeline_state(pid, "failed", failed_reason="video: boom")
|
||||
|
||||
called = {}
|
||||
from app.pipeline import orchestrator
|
||||
async def fake_run(p, step, *a):
|
||||
called["pid"], called["step"] = p, step
|
||||
monkeypatch.setattr(orchestrator, "run_step", fake_run)
|
||||
|
||||
r = client.post(f"/api/music/pipeline/{pid}/retry")
|
||||
assert r.status_code in (200, 202)
|
||||
assert r.json()["retrying_step"] == "video"
|
||||
|
||||
|
||||
def test_retry_non_failed_409(client):
|
||||
pid = db.create_pipeline(track_id=1) # state='created'
|
||||
r = client.post(f"/api/music/pipeline/{pid}/retry")
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_retry_publish_with_video_id_rejected(client):
|
||||
pid = db.create_pipeline(track_id=1)
|
||||
job = db.create_pipeline_job(pid, "publish")
|
||||
db.update_pipeline_job(job, status="failed", error="x")
|
||||
db.update_pipeline_state(pid, "failed", failed_reason="publish: x", youtube_video_id="abc123")
|
||||
r = client.post(f"/api/music/pipeline/{pid}/retry")
|
||||
assert r.status_code == 409
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실패 확인**
|
||||
|
||||
Run: `cd music-lab && PYTHONPATH=.. python -m pytest tests/test_pipeline_retry.py -v -k retry_`
|
||||
Expected: FAIL — 라우트 404.
|
||||
|
||||
- [ ] **Step 3: 구현**
|
||||
|
||||
`music-lab/app/main.py`의 `cancel_pipeline` 아래에 추가:
|
||||
```python
|
||||
@app.post("/api/music/pipeline/{pid}/retry", status_code=202)
|
||||
async def retry_pipeline(pid: int, bg: BackgroundTasks):
|
||||
p = _db_module.get_pipeline(pid)
|
||||
if not p:
|
||||
raise HTTPException(404)
|
||||
if p["state"] != "failed":
|
||||
raise HTTPException(409, f"재개 불가 (state={p['state']})")
|
||||
failed_step = _db_module.get_last_failed_step(pid)
|
||||
if not failed_step:
|
||||
# 폴백: failed_reason "{step}: ..." prefix
|
||||
reason = p.get("failed_reason") or ""
|
||||
failed_step = reason.split(":", 1)[0].strip() or None
|
||||
if not failed_step:
|
||||
raise HTTPException(409, "실패 step을 판별할 수 없음")
|
||||
if failed_step == "publish" and p.get("youtube_video_id"):
|
||||
raise HTTPException(409, "이미 업로드됨 (중복 방지)")
|
||||
bg.add_task(orchestrator.run_step, pid, failed_step)
|
||||
return {"ok": True, "retrying_step": failed_step}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 통과 확인 + 전체 회귀**
|
||||
|
||||
Run: `cd music-lab && PYTHONPATH=.. python -m pytest tests/test_pipeline_retry.py -v` → 모두 PASS
|
||||
Run: `cd music-lab && PYTHONPATH=.. python -m pytest tests/ -q` → 회귀 0
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
```bash
|
||||
git add music-lab/app/main.py music-lab/tests/test_pipeline_retry.py
|
||||
git commit -m "feat(music-lab): POST /pipeline/{id}/retry — 실패 step 수동 재개
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: agent-office service_proxy — pipeline_retry + list_failed
|
||||
|
||||
**Files:** Modify `agent-office/app/service_proxy.py`
|
||||
|
||||
> **먼저 확인**: `list_active_pipelines`가 호출하는 `GET /api/music/pipeline?status=active`가 failed를 포함하는지. 미포함이면 music-lab의 pipeline list 엔드포인트가 `status=failed`도 지원하는지 확인하고, 없으면 그 엔드포인트에 failed 필터를 추가(별도 작은 수정)하거나 `status` 화이트리스트에 'failed' 추가.
|
||||
|
||||
- [ ] **Step 1: 헬퍼 추가** — 기존 `list_active_pipelines`/`post_pipeline_feedback` 패턴(async with httpx.AsyncClient + MUSIC_LAB_URL) 그대로:
|
||||
```python
|
||||
async def list_failed_pipelines() -> list[dict]:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(f"{MUSIC_LAB_URL}/api/music/pipeline?status=failed")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data if isinstance(data, list) else data.get("items", data.get("pipelines", []))
|
||||
|
||||
|
||||
async def pipeline_retry(pid: int) -> dict:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.post(f"{MUSIC_LAB_URL}/api/music/pipeline/{pid}/retry")
|
||||
# 409(재개 불가/중복)도 본문 반환 위해 raise 안 함
|
||||
return {"status_code": resp.status_code, **(resp.json() if resp.headers.get("content-type","").startswith("application/json") else {})}
|
||||
```
|
||||
(`list_active_pipelines`가 이미 failed를 포함하면 `list_failed_pipelines`는 생략하고 Task 5에서 active 목록에서 state=='failed' 필터.)
|
||||
|
||||
- [ ] **Step 2: import sanity** — `cd agent-office && PYTHONPATH=.. python -c "from app import service_proxy; print('OK')"` → OK
|
||||
|
||||
- [ ] **Step 3: 커밋**
|
||||
```bash
|
||||
git add agent-office/app/service_proxy.py
|
||||
git commit -m "feat(agent-office): service_proxy pipeline_retry + list_failed_pipelines
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: youtube_publisher — failed 감지 + 텔레그램 알림/버튼
|
||||
|
||||
**Files:** Modify `agent-office/app/agents/youtube_publisher.py`; Test `agent-office/tests/test_youtube_publisher_retry.py` (Create)
|
||||
|
||||
- [ ] **Step 1: 실패 테스트 작성**
|
||||
|
||||
`agent-office/tests/test_youtube_publisher_retry.py` (DB fixture는 agent-office conftest 관례 따름):
|
||||
```python
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock
|
||||
from app.agents.youtube_publisher import YoutubePublisherAgent
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_pipeline_notified_with_retry_button(monkeypatch):
|
||||
agent = YoutubePublisherAgent()
|
||||
monkeypatch.setattr(
|
||||
"app.agents.youtube_publisher.service_proxy.list_active_pipelines",
|
||||
AsyncMock(return_value=[
|
||||
{"id": 7, "state": "failed", "failed_reason": "video: boom", "track_title": "T"}
|
||||
]),
|
||||
)
|
||||
sent = AsyncMock(return_value={"ok": True, "message_id": 1})
|
||||
monkeypatch.setattr("app.agents.youtube_publisher.send_raw", sent)
|
||||
|
||||
await agent.poll_state_changes()
|
||||
assert sent.await_count == 1
|
||||
args, kwargs = sent.await_args
|
||||
text = kwargs.get("text") or (args[0] if args else "")
|
||||
assert "실패" in text
|
||||
# 인라인 retry 버튼 callback_data
|
||||
rm = kwargs.get("reply_markup") or {}
|
||||
cb = rm["inline_keyboard"][0][0]["callback_data"]
|
||||
assert cb == "ytpub_retry_7"
|
||||
|
||||
# 중복 방지: 같은 failed 재폴링 시 미발송
|
||||
await agent.poll_state_changes()
|
||||
assert sent.await_count == 1
|
||||
```
|
||||
(주의: `send_raw`가 `reply_markup`을 지원하는지 messaging 확인 — 미지원 시 Task에 messaging.send_raw에 reply_markup 인자 추가 포함. insta는 send_photo로 했으나 여기선 텍스트+버튼이므로 send_raw에 reply_markup 필요.)
|
||||
|
||||
- [ ] **Step 2: 실패 확인** — `cd agent-office && PYTHONPATH=.. python -m pytest tests/test_youtube_publisher_retry.py -v` → FAIL (failed 미처리)
|
||||
|
||||
- [ ] **Step 3: 구현** — `poll_state_changes`에 failed 분기 추가:
|
||||
```python
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._notified_state_per_pipeline: dict[int, tuple] = {}
|
||||
self._notified_failed: set[int] = set()
|
||||
```
|
||||
`poll_state_changes` 루프 내, `*_pending` 처리 뒤:
|
||||
```python
|
||||
if state == "failed" and pid not in self._notified_failed:
|
||||
await self._notify_failed(p)
|
||||
self._notified_failed.add(pid)
|
||||
if state != "failed":
|
||||
self._notified_failed.discard(pid) # 재개 후 다시 실패하면 재알림
|
||||
```
|
||||
새 메서드:
|
||||
```python
|
||||
async def _notify_failed(self, p: dict) -> None:
|
||||
reason = p.get("failed_reason") or "?"
|
||||
step = reason.split(":", 1)[0].strip()
|
||||
title = p.get("track_title") or f"Pipeline #{p['id']}"
|
||||
text = f"⚠️ [{title}] 파이프라인 #{p['id']} '{step}' 실패\n사유: {reason}"
|
||||
kb = {"inline_keyboard": [[{"text": "🔄 재시도", "callback_data": f"ytpub_retry_{p['id']}"}]]}
|
||||
await send_raw(text=text, reply_markup=kb)
|
||||
add_log(self.agent_id, f"pipeline {p['id']} 실패 알림", "warning")
|
||||
```
|
||||
`send_raw`가 `reply_markup`을 받도록 `agent-office/app/telegram/messaging.py`의 `send_raw` 시그니처 확인/확장(이미 지원하면 그대로).
|
||||
|
||||
- [ ] **Step 4: 통과 확인** — `cd agent-office && PYTHONPATH=.. python -m pytest tests/test_youtube_publisher_retry.py -v` → PASS + 전체 회귀
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
```bash
|
||||
git add agent-office/app/agents/youtube_publisher.py agent-office/app/telegram/messaging.py agent-office/tests/test_youtube_publisher_retry.py
|
||||
git commit -m "feat(agent-office): youtube_publisher 파이프라인 실패 텔레그램 알림+재시도 버튼
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: webhook ytpub_retry 디스패치
|
||||
|
||||
**Files:** Modify `agent-office/app/telegram/webhook.py`; Test `agent-office/tests/test_youtube_publisher_retry.py`
|
||||
|
||||
> **먼저 확인**: `_handle_callback`의 prefix 분기 구조 + 기존 핸들러(`_handle_insta_issue` 등)가 service_proxy를 호출/회신하는 패턴.
|
||||
|
||||
- [ ] **Step 1: 실패 테스트 추가**
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_ytpub_retry_calls_proxy(monkeypatch):
|
||||
from app.telegram import webhook
|
||||
retry = AsyncMock(return_value={"status_code": 202, "ok": True, "retrying_step": "video"})
|
||||
monkeypatch.setattr("app.telegram.webhook.service_proxy.pipeline_retry", retry, raising=False)
|
||||
monkeypatch.setattr("app.telegram.webhook.send_raw", AsyncMock(), raising=False)
|
||||
res = await webhook._handle_ytpub_retry({"id": 1}, "ytpub_retry_7")
|
||||
retry.assert_awaited_once_with(7)
|
||||
```
|
||||
(import 경로/`send_raw` 위치는 webhook.py 실제에 맞춤.)
|
||||
|
||||
- [ ] **Step 2: 실패 확인** → FAIL (`_handle_ytpub_retry` 미존재)
|
||||
|
||||
- [ ] **Step 3: 구현** — `_handle_callback`에 분기:
|
||||
```python
|
||||
if callback_id.startswith("ytpub_retry_"):
|
||||
return await _handle_ytpub_retry(callback_query, callback_id)
|
||||
```
|
||||
핸들러:
|
||||
```python
|
||||
async def _handle_ytpub_retry(callback_query: dict, callback_id: str) -> dict:
|
||||
try:
|
||||
pid = int(callback_id.removeprefix("ytpub_retry_"))
|
||||
except (ValueError, AttributeError):
|
||||
return {"ok": False, "error": "invalid_callback_data"}
|
||||
res = await service_proxy.pipeline_retry(pid)
|
||||
sc = res.get("status_code")
|
||||
if sc in (200, 202):
|
||||
await send_raw(text=f"🔄 파이프라인 #{pid} 재개: {res.get('retrying_step','?')}")
|
||||
else:
|
||||
await send_raw(text=f"⚠️ 재개 불가 (#{pid}): {res.get('detail', sc)}")
|
||||
return {"ok": True}
|
||||
```
|
||||
(`service_proxy`/`send_raw` import는 webhook.py 기존 방식 따름.)
|
||||
|
||||
- [ ] **Step 4: 통과 확인** + 전체 agent-office 회귀
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
```bash
|
||||
git add agent-office/app/telegram/webhook.py agent-office/tests/test_youtube_publisher_retry.py
|
||||
git commit -m "feat(agent-office): ytpub_retry 텔레그램 콜백 → music-lab retry 프록시
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 문서 + 배포 + 메모리
|
||||
|
||||
**Files:** Modify `web-backend/CLAUDE.md`, `memory/service_music.md`
|
||||
|
||||
- [ ] **Step 1: CLAUDE.md music API 표에 추가**
|
||||
```
|
||||
| POST | `/api/music/pipeline/{id}/retry` | 실패 파이프라인 실패 step부터 재개 (publish+업로드완료 시 409) |
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 전체 회귀**
|
||||
```bash
|
||||
cd music-lab && PYTHONPATH=.. python -m pytest tests/ -q
|
||||
cd ../agent-office && PYTHONPATH=.. python -m pytest tests/ -q
|
||||
```
|
||||
Expected: 모두 PASS (사전존재 stale 제외).
|
||||
|
||||
- [ ] **Step 3: 커밋 + push (NAS 배포)**
|
||||
```bash
|
||||
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||||
git add CLAUDE.md docs/superpowers/plans/2026-06-12-music-pipeline-reliability.md
|
||||
git commit -m "docs(music): 파이프라인 retry API 문서 + 구현 계획
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 메모리 갱신** — `service_music.md`에 신뢰성/복구(자동 재시도 publish 제외 + 수동 retry 엔드포인트 + youtube_publisher 실패 알림) 추가.
|
||||
|
||||
- [ ] **Step 5: 프로덕션 확인(경량)** — 배포 후 `POST /api/music/pipeline/<없는id>/retry` → 404, 실제 failed 파이프라인 있으면 retry 동작. (없으면 단위 테스트로 갈음.)
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- 자동 재시도(publish 제외, _resolve_input 제외) → Task 2 ✓
|
||||
- 수동 재개(실패 step, publish+video_id 가드) → Task 1(step 판별)+Task 3 ✓
|
||||
- 실패 알림 + [🔄재시도] → Task 5 ✓
|
||||
- 재시도 콜백 → Task 4(proxy)+Task 6(dispatch) ✓
|
||||
- stuck 감지 제외(YAGNI) → 계획에 없음 ✓
|
||||
|
||||
**Placeholder scan:** 코드 스텝 모두 구체. "conftest 관례 확인"·"list_active가 failed 포함하는지 확인"은 기존 코드 소유를 존중하는 의도적 검증 지시(placeholder 아님).
|
||||
|
||||
**Type consistency:** `get_last_failed_step(pid)` Task1↔Task3 일치. `_dispatch_step(step,p,ctx,feedback)` Task2 정의↔테스트 mock 일치. `run_step(pid, step)` 시그니처 기존 일치. callback `ytpub_retry_{pid}` Task5 생성↔Task6 파싱 일치. `pipeline_retry(pid)` Task4↔Task6 일치. retry 응답 `retrying_step`/`status_code` Task3↔Task4↔Task6 일치.
|
||||
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) 흐름 자체는 변경 안 함(품질·패키지만 개선).
|
||||
@@ -0,0 +1,120 @@
|
||||
# insta 자율 카드 발급 (스마트 에이전트 3번) — 설계
|
||||
|
||||
> 작성 2026-06-11. InstaAgent를 "후보 푸시/단순 auto_select"에서 **선별 지능 + 승인 게이트 + 카덴스/추적**을 갖춘 자율 발급 파이프라인으로 확장.
|
||||
|
||||
## 1. 목표
|
||||
|
||||
매일 09:30, InstaAgent가 **발행할 가치 있는 주제만 자율 선별**해 카드를 생성·렌더하고, **카드별 승인 게이트**로 사람이 최종 결정(브랜드 안전)한 뒤 업로드용 카드를 발급한다. 발행 상태·이력을 추적해 중복 회피·카덴스 판단에 환류한다.
|
||||
|
||||
Instagram Graph API는 사용하지 않는다(수동 업로드). "발행(published)" = 승인되어 업로드 준비가 끝난 카드 상태 + 텔레그램으로 전달.
|
||||
|
||||
## 2. 현재 상태 (배경)
|
||||
|
||||
- insta-lab: 뉴스수집→키워드추출→슬레이트 생성(`POST /slates`)→Redis push→Windows 워커 렌더→webhook이 `card_assets` 등록. (2026-06-11 렌더 갭 복구 완료, slate 상태 `draft→rendered`.)
|
||||
- agent-office `InstaAgent`: 09:30 cron에서 collect+extract 후 (기본) 텔레그램 후보 버튼 푸시 / (`auto_select=True`) 카테고리 1위 키워드 자동 렌더+미디어그룹 발송. 버튼 탭 → `render_{kid}` 콜백 → 슬레이트 생성·렌더·발송.
|
||||
- `account_preferences`(카테고리 가중치) 존재. 발행 성과 추적은 없음.
|
||||
|
||||
즉 "생성→렌더→전달"은 동작한다. 본 설계는 그 앞단의 **자율 선별**과 뒷단의 **승인·추적**을 추가한다.
|
||||
|
||||
## 3. 요구사항 (확정)
|
||||
|
||||
- **선별 신호 4종**: ① 중복 회피(최근 발행/반려 주제 제외) ② 신선도(뉴스 최신성) ③ 계정 컨셉 적합도(카테고리 가중치) ④ Claude 판단(카드가치·흥미·리스크). 가중합 → threshold 게이트.
|
||||
- **카덴스**: 에이전트 결정 — 매일 09:30, threshold 이상인 픽만 `max_per_day`까지(0~N 가변). 가치 없으면 발행 안 함.
|
||||
- **승인**: 카드별 게이트. 자동 생성 후 텔레그램 프리뷰 `[✅승인][❌반려][🔄재생성]`. 승인만 published.
|
||||
- **추적**: slate 상태 ∈ `{draft, rendered, rejected, published}` + 발행 이력. decision=approved→`published`, decision=rejected→`rejected`("approved"는 별도 저장 상태가 아니라 decision 액션). 성과 지표(좋아요·도달)는 범위 외(YAGNI — IG API 없어 수동).
|
||||
|
||||
## 4. 아키텍처 (접근법 A: 데이터 있는 곳에서 선별, 에이전트는 오케스트레이션)
|
||||
|
||||
```
|
||||
[09:30 cron] InstaAgent.on_schedule (autonomous_issue=True)
|
||||
1. collect + extract (기존 재사용)
|
||||
2. GET /api/insta/keywords/ranked?threshold&limit ← insta-lab: 4신호 점수
|
||||
3. eligible 픽마다(max_per_day): create_slate → wait render (기존 재사용)
|
||||
4. 텔레그램 프리뷰(커버1장+요약) + [✅][❌][🔄] + agent_task(requires_approval) → waiting
|
||||
|
||||
[telegram webhook] → InstaAgent.on_callback
|
||||
issue_approve_{id} → POST /slates/{id}/decision{approved} → published + 10장 미디어그룹 + /package zip
|
||||
issue_reject_{id} → POST /slates/{id}/decision{rejected}
|
||||
issue_regen_{id} → 같은 키워드로 슬레이트 재생성(새 카피) → 새 프리뷰 (이전 슬레이트 폐기)
|
||||
```
|
||||
|
||||
경계: **insta-lab = 선별 점수 + 상태머신(DB 소유)**, **agent-office = cron 오케스트레이션 + 텔레그램 승인**.
|
||||
|
||||
## 5. insta-lab 상세
|
||||
|
||||
### 5.1 `app/selection.py` (순수 함수)
|
||||
입력: 후보 키워드 리스트, 발행/반려 이력, 카테고리 선호 가중치, (선택) Claude 판단 점수.
|
||||
출력: 후보별 `{keyword_id, final_score, breakdown:{dedup,freshness,account_fit,claude}, eligible}`.
|
||||
|
||||
신호별 정의:
|
||||
- **dedup** (0 또는 1, exclude 게이트): 최근 `dedup_window_days`(기본 14) 내 `published`/`rejected` 슬레이트와 동일 키워드(정규화 후 exact/substring) + 동일 카테고리면 `eligible=False`로 제외.
|
||||
- **freshness** (0~1): 키워드 `suggested_at`이 최근일수록 높음(예: 24h=1.0, 선형 감쇠, 7일+=0).
|
||||
- **account_fit** (0~1): `account_preferences[category].weight`(정규화) × 키워드 자체 score.
|
||||
- **claude** (0~1): Claude Haiku가 후보 일괄 평가(아래 5.3). 실패 시 이 항 제외하고 나머지로 정규화(graceful).
|
||||
- **final_score** = 가중합 `w_fresh*freshness + w_fit*account_fit + w_claude*claude` (dedup 제외 통과한 것만). 기본 가중치 `{fresh:0.3, fit:0.3, claude:0.4}`. `eligible = (dedup 통과) and (final_score >= threshold)`.
|
||||
|
||||
### 5.2 엔드포인트
|
||||
- `GET /api/insta/keywords/ranked?limit=N&threshold=T`
|
||||
- 내부에서: 미사용 키워드 조회 + 발행/반려 이력 조회 + 선호 조회 + Claude 일괄 호출 → `selection.py` → 정렬된 후보 + breakdown + `eligible` 반환.
|
||||
- `POST /api/insta/slates/{id}/decision` body `{"decision": "approved"|"rejected"}`
|
||||
- approved → `status='published'`, `published_at=now`, `decision_at=now` (멱등: 이미 published면 no-op).
|
||||
- rejected → `status='rejected'`, `decision_at=now`.
|
||||
|
||||
### 5.3 Claude 판단 프롬프트 (insta-lab, 기존 ANTHROPIC 클라이언트 재사용)
|
||||
- 1회 호출로 후보 N개 일괄 평가. 입력: 각 후보 `{keyword, category}`. 출력: JSON `[{keyword_id, score(0~1), reason}]`.
|
||||
- 기준: 카드뉴스로 만들 가치(흥미·시의성·정보성) 및 리스크(민감·논란). 모델 `ANTHROPIC_MODEL_HAIKU`.
|
||||
- 실패/파싱오류 → 빈 결과 반환 → selection이 claude 항 제외.
|
||||
|
||||
### 5.4 스키마 (idempotent ALTER)
|
||||
- `card_slates`에 `published_at TEXT NULL`, `decision_at TEXT NULL` 추가.
|
||||
- 상태값: `draft → rendered → approved/rejected → published`. (approved는 과도기 상태 없이 decision=approved 시 바로 published로 둔다 — 단순화. rejected는 종결.)
|
||||
- 발행 이력 = `SELECT keyword, category, published_at FROM card_slates WHERE status IN ('published','rejected') AND COALESCE(published_at,decision_at) >= datetime('now', '-D days')`.
|
||||
|
||||
## 6. agent-office 상세
|
||||
|
||||
### 6.1 `InstaAgent.on_schedule`
|
||||
- `custom_config.autonomous_issue` 분기. False면 **기존 동작 유지**(candidate-push / auto_select) — 하위호환.
|
||||
- True면: collect+extract(기존) → `service_proxy.insta_ranked(threshold, limit=max_per_day)` → `eligible` 픽 순회(최대 `max_per_day`):
|
||||
- 슬레이트 생성·렌더 대기(기존 `_render_and_push`의 생성·대기 부분 재사용/분리) → **프리뷰 발송**(6.3) → `create_task(requires_approval=True)` → `waiting` 상태.
|
||||
- eligible 0개 → "오늘 발행할 가치 있는 주제 없음" 1통.
|
||||
|
||||
### 6.2 콜백 (telegram webhook → `on_callback`)
|
||||
- `issue_approve_{slate_id}`: `insta_decision(slate_id, "approved")` → 전체 10장 미디어그룹 + `/package` zip 전달 + "✅ 발행 완료" → 해당 task succeeded.
|
||||
- `issue_reject_{slate_id}`: `insta_decision(slate_id, "rejected")` → "❌ 반려됨" → task 종료.
|
||||
- `issue_regen_{slate_id}`: 해당 슬레이트의 키워드로 새 슬레이트 생성(새 Claude 카피)·렌더 → 새 프리뷰. 이전 슬레이트는 rejected 처리.
|
||||
|
||||
### 6.3 텔레그램 프리뷰 (미디어그룹은 인라인 키보드 불가)
|
||||
- 커버(01.png) 단장 사진 + 캡션: 키워드·카테고리·`final_score`·breakdown 요약 + inline `[✅승인][❌반려][🔄재생성]` (`callback_data=issue_*_{slate_id}`).
|
||||
|
||||
### 6.4 설정 (`agent_config.custom_config`)
|
||||
- `autonomous_issue` (bool, 기본 false), `select_threshold` (기본 0.6), `max_per_day` (기본 2), `dedup_window_days` (기본 14).
|
||||
|
||||
### 6.5 service_proxy 추가
|
||||
- `insta_ranked(threshold, limit)` → `GET /keywords/ranked`
|
||||
- `insta_decision(slate_id, decision)` → `POST /slates/{id}/decision`
|
||||
|
||||
## 7. 에러 처리 / 엣지
|
||||
|
||||
- ranked의 Claude 실패 → 룰 점수만으로 진행(graceful), 경고 로그.
|
||||
- eligible 0개 → 안내 1통(또는 무음 옵션, 기본 안내).
|
||||
- 렌더 실패 → task failed 통지, 프리뷰 미발송.
|
||||
- 승인 미응답 → 슬레이트 pending(rendered) 유지, 자동 발행 안 함(안전). 만료 없음.
|
||||
- 멱등: 중복 승인/반려 no-op. cron 재실행 시 이미 발행/반려 주제는 dedup으로 회피.
|
||||
- regen 무한루프 방지: regen은 사용자 트리거(버튼)라 자동 반복 없음.
|
||||
|
||||
## 8. 테스트
|
||||
|
||||
- **insta-lab**: `selection.py` 순수 단위테스트(dedup 최근 제외 / freshness 정렬 / account_fit 가중 / 가중합·threshold 게이트 / claude 실패 시 정규화). ranked 엔드포인트(Claude mock). decision 엔드포인트(approved→published+published_at, rejected, 멱등).
|
||||
- **agent-office**: 자율 `on_schedule`(proxy mock: ranked eligible→슬레이트 생성→프리뷰 발송 + task requires_approval). 콜백 approve/reject/regen(proxy·messaging mock).
|
||||
|
||||
## 9. 범위 외 (YAGNI)
|
||||
|
||||
- 발행 성과 지표(좋아요·도달) 수집/학습 — IG API 미사용, 수동 입력 부담으로 제외.
|
||||
- 신뢰도 하이브리드 자동발행(승인 생략) — 승인 게이트로 통일.
|
||||
- 임베딩 기반 유사도 dedup — 정규화 exact/substring + 카테고리로 충분(추후 필요 시 확장).
|
||||
|
||||
## 10. 영향받는 파일
|
||||
|
||||
- insta-lab: `app/selection.py`(신규), `app/main.py`(ranked·decision 라우트), `app/db.py`(컬럼 ALTER + 발행이력/상태 헬퍼), `tests/`.
|
||||
- agent-office: `app/agents/insta.py`(자율 경로·콜백), `app/service_proxy.py`(2 헬퍼), `app/webhook.py`(issue_* 콜백 디스패치), `tests/`.
|
||||
- web-backend/CLAUDE.md insta API 목록 + `service_insta.md` 메모리 갱신.
|
||||
127
docs/superpowers/specs/2026-06-12-co-gahusb-team-bus-design.md
Normal file
127
docs/superpowers/specs/2026-06-12-co-gahusb-team-bus-design.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# co-gahusb — 세션 간 협업 팀 버스 설계
|
||||
|
||||
작성일: 2026-06-12
|
||||
대상 repo: `web-backend` (서버) + `web-ui`/`web-ai` (클라이언트 배선)
|
||||
목적: 독립 실행되는 4개 Claude Code 세션(FE/BE/AI/Producer)이 역할을 갖고 비동기로 소통·협업하되, 공유 DB/리소스는 동시 쓰기를 방지한다.
|
||||
|
||||
## 배경
|
||||
|
||||
web-ui / web-backend / web-ai 세션은 각각 독립 프로세스라 서로의 컨텍스트를 못 본다. 협업하려면 세 곳(서로 다른 머신 포함)에서 닿는 공유 메시지 버스가 필요하다. 사용자가 방식 B(독립 MCP 서버)를 선택했고, 민감한 공유 영역의 동시 쓰기 분리를 핵심 요구로 명시했다.
|
||||
|
||||
## 결정 사항 (브레인스토밍 확정)
|
||||
|
||||
- 호스팅: 신규 독립 컨테이너 **`co-gahusb`**, NAS, 포트 **18920**(18900 agent-office 옆, 미사용 확인).
|
||||
- 전송/인증: **HTTP streamable MCP** + 정적 **Bearer 키**([[reference_webai_auth_pattern]] 재사용). nginx `/api/co/` → `co-gahusb:18920`, `Authorization` forward.
|
||||
- 백엔드: **Redis**(기존 공유 컨테이너 `redis://redis:6379`). 전 연산 원자적 → SQLite multi-writer 함정([[reference_sqlite_concurrency]]) 회피.
|
||||
- 동시쓰기 분리: **소유권 파티션 + 어드바이저리 락**.
|
||||
- 역할: web-ui=FE, web-backend=BE, web-ai=AI, 이 세션=Producer.
|
||||
- 수신: 각 세션 **/loop 폴링**(`read_inbox` + `list_tasks`).
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
[FE 세션 web-ui] [BE 세션 web-backend] [AI 세션 web-ai(다른 머신)] [Producer 세션]
|
||||
\ | / /
|
||||
\ | / /
|
||||
──────── .mcp.json HTTP + Bearer ───────────────────────────────
|
||||
│
|
||||
nginx /api/co/ (Authorization forward)
|
||||
│
|
||||
co-gahusb:18920 (FastMCP streamable-http)
|
||||
│
|
||||
Redis (원자적 연산)
|
||||
```
|
||||
|
||||
서버 구현: **Python `mcp` SDK(FastMCP) + streamable-http transport**(모든 lab이 FastAPI/Python 스택과 일관). 단일 책임 모듈로 분리:
|
||||
- `app/server.py` — FastMCP 인스턴스 + 툴 등록 + ASGI 앱(streamable-http) + Bearer 인증 미들웨어
|
||||
- `app/store.py` — Redis 데이터 액세스 레이어(메시지/작업/락), 전 함수 원자적
|
||||
- `app/locks.py` — 락 Lua 스크립트(소유자 확인 후 release/heartbeat)
|
||||
- `app/models.py` — 입출력 dataclass/스키마
|
||||
- `app/config.py` — env(REDIS_URL, CO_BUS_KEY, 포트)
|
||||
|
||||
## MCP 툴 표면 (MVP — YAGNI)
|
||||
|
||||
| 분류 | 툴 | 시그니처 → 반환 |
|
||||
|------|-----|------|
|
||||
| 메시지 | `post_message` | `(from_role, to_role, body, thread_id?)` → `{message_id}` |
|
||||
| 메시지 | `read_inbox` | `(role, after_id?, mark_read?=false)` → `{messages:[{id, from_role, body, thread_id, ts}], cursor}` |
|
||||
| 작업 | `create_task` | `(title, assignee_role, detail?, created_by)` → `{task_id}` |
|
||||
| 작업 | `claim_task` | `(task_id, role)` → `{ok, task}` (이미 claim 시 `{ok:false, held_by}`) |
|
||||
| 작업 | `update_task` | `(task_id, status, role, note?)` → `{ok, task}` (status ∈ open/in_progress/blocked/done) |
|
||||
| 작업 | `list_tasks` | `(status?, assignee_role?)` → `{tasks:[...]}` |
|
||||
| 락 | `acquire_lock` | `(resource, role, ttl_sec=300)` → `{acquired, held_by?, ttl_remaining?}` |
|
||||
| 락 | `release_lock` | `(resource, role)` → `{released}` (소유자 아니면 `{released:false}`) |
|
||||
| 락 | `heartbeat_lock` | `(resource, role, ttl_sec=300)` → `{renewed}` (소유자만) |
|
||||
| 락 | `list_locks` | `()` → `{locks:[{resource, held_by, ttl_remaining}]}` |
|
||||
| 가시성 | `team_log` | `(after_id?)` → `{events:[...], cursor}` (최근 활동 피드) |
|
||||
|
||||
## Redis 데이터 모델 (전부 원자적)
|
||||
|
||||
- **메시지**: `co:inbox:{role}` = Redis **Stream**. `post_message`=XADD, `read_inbox`=XREAD(`after_id` 커서, 비파괴). `mark_read`는 `co:read:{role}` 키에 마지막 id 저장.
|
||||
- **작업**: `co:task:{id}` Hash(title/assignee/status/detail/created_by/ts), `co:tasks` Set(id 목록), `INCR co:taskseq`로 id. `claim_task`/`update_task`는 **Lua 스크립트**로 read-modify-write 원자화(중복 claim/경합 방지).
|
||||
- **락**: 획득 = `SET co:lock:{resource} {role} NX EX {ttl}`(원자적). `release_lock`/`heartbeat_lock` = **Lua**로 `GET` 소유자 일치 확인 후 `DEL`/`EXPIRE`(check-and-act 원자화 → 남의 락 조작 불가).
|
||||
- **활동로그**: `co:log` = 캡트 Stream(`XADD ... MAXLEN ~ 500`). 메시지·작업·락 이벤트 기록 → Producer 오버사이트.
|
||||
|
||||
## 동시 쓰기 분리 (핵심 요구)
|
||||
|
||||
**1차 — 정적 소유권 파티션** (락 불필요한 자연 분리):
|
||||
- `web-ui` → FE만, `web-backend` → BE만, `web-ai` → AI만 쓰기. 각 세션은 자기 repo만 편집 → git 충돌 원천 차단.
|
||||
|
||||
**2차 — 교차 리소스 어드바이저리 락** (여러 역할이 건드릴 수 있는 민감 영역만):
|
||||
- 예약 resource 명: `nas-deploy`, `stock-db-schema`, `lotto-db-schema`, `memory-mirror`(web-ui↔web-ai 미러), `nginx-conf`, `compose`.
|
||||
- 규약: 위 리소스 변경 전 `acquire_lock` 필수. 점유 중이면 `{acquired:false, held_by, ttl_remaining}` → 대기. **TTL 자동 해제로 세션 사망 시 데드락 방지**, 긴 작업은 `heartbeat_lock` 갱신.
|
||||
- 어드바이저리(협조적): 버스는 FS를 강제 잠그지 않음 → 각 세션 CLAUDE.md에 "공유 리소스 = 락 먼저" 규약 명문화로 강제.
|
||||
|
||||
## 클라이언트 배선
|
||||
|
||||
- 각 repo `.mcp.json`:
|
||||
```json
|
||||
{ "mcpServers": { "co-gahusb": {
|
||||
"type": "http",
|
||||
"url": "https://gahusb.synology.me/api/co/mcp",
|
||||
"headers": { "Authorization": "Bearer ${CO_BUS_KEY}" } } } }
|
||||
```
|
||||
(키는 커밋 금지 — 각 머신 env/로컬에서 주입. `.mcp.json`엔 placeholder, 실제 키는 `.env`/환경변수.)
|
||||
- 각 repo CLAUDE.md에 역할 블록 추가: "너는 역할 X / 모든 co-gahusb 툴에 role=X / 공유 리소스 변경 전 acquire_lock / `/loop`로 inbox·tasks 폴링".
|
||||
- web-ai는 다른 머신 → 해당 머신에서 `.mcp.json` 적용(스펙에 절차 명시).
|
||||
|
||||
## 인프라 등재 (신규 컨테이너 추가 의무 위치 — [[reference_nas_url_routing]], [[reference_deploy_nas_services_whitelist]])
|
||||
|
||||
1. `docker-compose.yml` — `co-gahusb` 서비스(build, `REDIS_URL`, `depends_on: redis`, `CO_BUS_KEY` env, `${RUNTIME_PATH}` 볼륨 불요(상태는 Redis)).
|
||||
2. nginx `default.conf` — **public `location /api/co/`** 추가(7번째 등재 규칙; `/api/internal/` 불필요).
|
||||
3. deploy 스크립트 SERVICES 화이트리스트에 `co-gahusb` 등재.
|
||||
4. `${RUNTIME_PATH}` 절대경로 — 본 서비스는 영속 볼륨 없음(Redis 백엔드)이라 코드 디렉토리만.
|
||||
5. frontend `depends_on` — 불필요(백엔드 전용 서비스).
|
||||
6. `.env` — `CO_BUS_KEY` 추가(커밋 금지).
|
||||
|
||||
## 에러 / 엣지 처리
|
||||
|
||||
- 인증 실패 → 401, 1회만 ERROR 로그 후 조용([[reference_webai_auth_pattern]]).
|
||||
- 락 획득 실패 → 예외 아닌 `{acquired:false, held_by, ttl_remaining}` 정상 반환.
|
||||
- 만료 락 → Redis TTL 자동 소멸(별도 GC 불필요).
|
||||
- 알 수 없는 role/resource → 명시적 에러 메시지.
|
||||
- Redis 연결 실패 → 503 + 명확한 메시지.
|
||||
|
||||
## 테스트 (TDD, pytest + fakeredis)
|
||||
|
||||
- **락**: 두 역할 같은 resource 획득 → 2번째 거부 / TTL 만료 후 획득 / 소유자 아닌 release·heartbeat 거부 / heartbeat 갱신 후 ttl 증가.
|
||||
- **메시지**: XADD 순서대로 `after_id` 커서 읽기 / mark_read 후 재읽기 시 제외 / 다른 role 우편함 격리.
|
||||
- **작업**: create→claim(중복 claim 거부)→update status 전이 / list 필터.
|
||||
- **인증**: 키 일치 통과 / 불일치 401.
|
||||
- **team_log**: 이벤트 기록 + MAXLEN 캡.
|
||||
|
||||
## 구현 순서 (phase)
|
||||
|
||||
1. 스캐폴드: 디렉토리/Dockerfile/requirements/config (기존 lab 구조 미러)
|
||||
2. `store.py` + `locks.py` (TDD, fakeredis) — 락 → 메시지 → 작업 → team_log
|
||||
3. `server.py` — FastMCP 툴 등록 + Bearer 인증 + ASGI
|
||||
4. 인프라 등재 6위치 (compose/nginx/deploy/env)
|
||||
5. 클라이언트 배선: web-ui·web-backend `.mcp.json` + CLAUDE.md 역할 블록 (web-ai는 절차 문서화)
|
||||
6. 배포(Gitea push → webhook) + 스모크 테스트(헬스/인증/락 경합)
|
||||
|
||||
## 비범위 (YAGNI)
|
||||
|
||||
- 실시간 push(텔레그램) — 후속. 우선 /loop 폴링.
|
||||
- SQLite 감사로그 — Redis 캡트 스트림으로 충분.
|
||||
- 웹 대시보드 — agent-office 오버사이트와 추후 통합 여지.
|
||||
- 락의 FS 레벨 강제 — 어드바이저리로 충분(세션은 협조적).
|
||||
@@ -0,0 +1,105 @@
|
||||
# music/YouTube 파이프라인 신뢰성·복구 — 설계
|
||||
|
||||
> 작성 2026-06-12. YouTube 자동화 파이프라인의 step 실패를 자동 재시도(일시적)하고, 영구 실패는 실패 step부터 수동 재개(텔레그램 [🔄재시도])할 수 있게 한다. "music/YouTube 파이프라인 고도화" 중 **신뢰성/복구** 슬라이스.
|
||||
|
||||
## 1. 목표
|
||||
|
||||
파이프라인 step(`cover→video→thumb→meta→review→publish`) 실패가 ① 일시적이면 자동 재시도로 흡수하고, ② 영구적이면 terminal `failed`로 둔 뒤 **이전 산출물을 보존한 채 실패 step부터 재개**할 수 있게 한다. 현재는 step 한 번 실패하면 전체 파이프라인이 terminal `failed`가 되고 복구 경로가 없어 처음부터 다시 만들어야 한다.
|
||||
|
||||
## 2. 배경 (현재 동작)
|
||||
|
||||
- `orchestrator.run_step(pipeline_id, step, feedback)`: `pipeline_jobs` row 생성 → step 실행 → 성공 시 `update_pipeline_state(next_state)`, 예외 시 `pipeline_jobs.status='failed'` + 파이프라인 `state='failed'` + `failed_reason="{step}: {e}"`. **재시도/재개 없음.**
|
||||
- 항상 `bg.add_task(orchestrator.run_step, pid, step, ...)`로 BackgroundTask 호출(start_pipeline→cover, feedback→next_step, publish_pipeline→publish).
|
||||
- 이전 step 산출물(`cover_url`/`video_url`/`thumbnail_url`/`metadata_json`/`review_json`)은 파이프라인 row에 **보존**됨 → 실패 step만 재실행하면 이어갈 수 있는 구조.
|
||||
- `state_machine`: STEPS, `_APPROVE_NEXT`, TERMINAL_STATES={published, cancelled, **failed**, awaiting_manual}.
|
||||
- `agent-office youtube_publisher.poll_state_changes`: `*_pending` 신규 진입만 텔레그램 알림. **`failed`는 무알림(silent)** — 사용자가 실패를 모름.
|
||||
|
||||
## 3. 요구사항 (확정)
|
||||
|
||||
- **자동 재시도**: step 실행 실패 시 `STEP_MAX_RETRIES`(기본 2 → 총 3회)까지 backoff 재시도. 소진 후 terminal `failed`.
|
||||
- `_resolve_input` 에러(입력/설정)는 재시도 안 함(재시도해도 안 고쳐짐).
|
||||
- **`publish` step은 자동 재시도 제외** — youtube 업로드는 비멱등(중복 업로드 위험). 1회 시도 후 실패면 즉시 terminal.
|
||||
- 재시도 대상 = `cover/video/thumb/meta/review`.
|
||||
- **수동 재개**: terminal `failed` 파이프라인을 실패 step부터 재실행. 이전 산출물 보존.
|
||||
- publish 재개 가드: `youtube_video_id`가 이미 있으면 재개 거부(원 업로드 성공 가능성 → 중복 방지).
|
||||
- **실패 알림**: 영구 실패 시 텔레그램 알림 + 인라인 `[🔄재시도]` 버튼(현재 silent 갭 해소).
|
||||
- **범위 밖(YAGNI)**: stuck 감지(*_running hang / *_pending 방치). 수동 재시도로 복구 가능하므로 이번 슬라이스 제외.
|
||||
|
||||
## 4. 아키텍처
|
||||
|
||||
3 컴포넌트:
|
||||
```
|
||||
[music-lab orchestrator] run_step: step 실행을 재시도 루프로 (publish 제외) → 소진 시 failed
|
||||
[music-lab API] POST /api/music/pipeline/{id}/retry → 실패 step부터 run_step 재트리거
|
||||
[agent-office] youtube_publisher: failed 감지 → 텔레그램 알림+[🔄재시도]
|
||||
webhook: ytpub_retry_{pid} → service_proxy.pipeline_retry → music-lab retry
|
||||
```
|
||||
|
||||
## 5. music-lab 상세
|
||||
|
||||
### 5.1 자동 재시도 (`pipeline/orchestrator.py`)
|
||||
- 상수: `STEP_MAX_RETRIES = 2`, `STEP_RETRY_BACKOFF_SEC = [5, 15]`(시도 간 대기), `NON_RETRY_STEPS = {"publish"}`.
|
||||
- `run_step`의 step 실행부(현재 try lines 31-47)를 루프로:
|
||||
```
|
||||
attempts = 1 if step in NON_RETRY_STEPS else (STEP_MAX_RETRIES + 1)
|
||||
for i in range(attempts):
|
||||
try:
|
||||
result = await _dispatch_step(step, p, ctx, feedback)
|
||||
update_pipeline_job(job_id, status="succeeded")
|
||||
update_pipeline_state(pipeline_id, result["next_state"], **fields)
|
||||
return
|
||||
except Exception as e:
|
||||
last = e
|
||||
if i < attempts - 1:
|
||||
add_log/pipeline_job note "retry {i+1}"
|
||||
await asyncio.sleep(STEP_RETRY_BACKOFF_SEC[min(i, len-1)])
|
||||
# 소진
|
||||
update_pipeline_job(job_id, status="failed", error=str(last))
|
||||
update_pipeline_state(pipeline_id, "failed", failed_reason=f"{step}: {last}")
|
||||
```
|
||||
- `_resolve_input` 실패는 루프 진입 전 early-return(현행 유지, 재시도 X).
|
||||
- 재시도 시도 가시화: `pipeline_jobs`에 attempt별 기록(또는 error 메시지에 "attempt n/N").
|
||||
|
||||
### 5.2 resume 엔드포인트 (`main.py`)
|
||||
- `POST /api/music/pipeline/{id}/retry`:
|
||||
- 파이프라인 조회 없으면 404.
|
||||
- `state != "failed"` → 409 "재개 불가 (state=...)".
|
||||
- 실패 step 판별: `db.get_last_failed_step(pipeline_id)` (pipeline_jobs에서 status='failed' 최신 step). 없으면 `failed_reason.split(":")[0].strip()` 폴백.
|
||||
- 실패 step이 `publish`이고 `youtube_video_id`가 이미 있으면 → 409 "이미 업로드됨 (중복 방지)".
|
||||
- `bg.add_task(orchestrator.run_step, pid, failed_step)` 재트리거. 반환 `{ok: true, retrying_step}`.
|
||||
- `db.get_last_failed_step(pipeline_id) -> str | None` 헬퍼 신규.
|
||||
|
||||
## 6. agent-office 상세
|
||||
|
||||
### 6.1 실패 알림 (`agents/youtube_publisher.py`)
|
||||
- `poll_state_changes`: `_STEP_TITLES`(*_pending) 처리 후, `state == "failed"` 인 파이프라인도 검사.
|
||||
- 신규 failed(중복 방지: `self._notified_failed: set[int]`, 또는 기존 dict에 ('failed', reason_hash))면 텔레그램 발송:
|
||||
`⚠️ [{track_title}] 파이프라인 #{id} '{step}' 실패\n사유: {failed_reason}` + 인라인 `[🔄 재시도]` (callback_data `ytpub_retry_{id}`).
|
||||
- 발송 후 notified 기록.
|
||||
- `service_proxy.list_active_pipelines()`가 failed를 포함하는지 확인 — 미포함이면 failed도 반환하도록 보강(또는 별도 조회). (plan에서 확인.)
|
||||
|
||||
### 6.2 재시도 콜백 (`telegram/webhook.py`)
|
||||
- `_handle_callback`에 `callback_id.startswith("ytpub_retry_")` 분기 → `_handle_ytpub_retry`.
|
||||
- `_handle_ytpub_retry`: `pid = int(callback_id.removeprefix("ytpub_retry_"))` → `service_proxy.pipeline_retry(pid)` → 결과 텔레그램 회신("재개: {step}" / 거부 사유).
|
||||
- `service_proxy.pipeline_retry(pid)` 신규: `POST {MUSIC_LAB_URL}/api/music/pipeline/{pid}/retry`.
|
||||
|
||||
## 7. 에러 처리 / 엣지
|
||||
|
||||
- 재시도 backoff 중 컨테이너 재시작 → 해당 step 작업 유실, 파이프라인 비-terminal stuck. 범위 밖이나 수동 [🔄재시도]로 복구 가능(안전망).
|
||||
- resume 시 state≠failed → 409(중복 재개·동시성 방지). 텔레그램 [🔄재시도] 중복 탭도 멱등 거부.
|
||||
- pipeline_jobs에 failed row 없고 state만 failed → `failed_reason` prefix 폴백.
|
||||
- publish 재개 + `youtube_video_id` 존재 → 409(중복 업로드 방지).
|
||||
- 알림 중복: notified 기록으로 같은 failed 1회만 발송.
|
||||
|
||||
## 8. 테스트
|
||||
|
||||
- **orchestrator (재시도)**: step 2회 실패 후 성공 → next_state 도달(3시도). 끝까지 실패 → failed. publish는 1시도 후 즉시 failed(재시도 X). `_resolve_input` 실패 → 재시도 없이 failed.
|
||||
- **API retry**: failed→run_step 재트리거(mock 확인) + retrying_step 반환. 비-failed→409. publish+youtube_video_id→409.
|
||||
- **db**: `get_last_failed_step` — 최신 failed job step 반환, 없으면 None.
|
||||
- **agent-office**: poll 신규 failed→텔레그램 발송(중복 방지). `_handle_ytpub_retry`→service_proxy.pipeline_retry 호출 + pid 파싱.
|
||||
|
||||
## 9. 영향받는 파일
|
||||
|
||||
- music-lab: `app/pipeline/orchestrator.py`(재시도 루프 + `_dispatch_step` 추출), `app/main.py`(retry 엔드포인트), `app/db.py`(`get_last_failed_step`), `tests/`.
|
||||
- agent-office: `app/agents/youtube_publisher.py`(failed 알림), `app/telegram/webhook.py`(ytpub_retry 디스패치), `app/service_proxy.py`(`pipeline_retry`, 필요 시 `list_active_pipelines` failed 포함), `tests/`.
|
||||
- web-backend/CLAUDE.md music API 표 + `service_music.md` 메모리 갱신.
|
||||
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"
|
||||
@@ -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자 이내
|
||||
초과하면 잘리므로 간결하고 임팩트 있게 작성한다.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -124,6 +124,13 @@ def init_db() -> None:
|
||||
(cat, 1.0),
|
||||
)
|
||||
|
||||
# 발행 상태 컬럼 (idempotent ALTER) — 자율 발급 파이프라인
|
||||
cs_cols = [r[1] for r in conn.execute("PRAGMA table_info(card_slates)").fetchall()]
|
||||
if "published_at" not in cs_cols:
|
||||
conn.execute("ALTER TABLE card_slates ADD COLUMN published_at TEXT")
|
||||
if "decision_at" not in cs_cols:
|
||||
conn.execute("ALTER TABLE card_slates ADD COLUMN decision_at TEXT")
|
||||
|
||||
|
||||
# ── news_articles ────────────────────────────────────────────────
|
||||
def add_news_article(row: Dict[str, Any]) -> int:
|
||||
@@ -217,6 +224,39 @@ def update_slate_status(slate_id: int, status: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def set_slate_decision(slate_id: int, decision: str) -> None:
|
||||
"""승인/반려 결정 기록. approved→published(+published_at), rejected→rejected.
|
||||
멱등: 이미 published면 published_at 유지."""
|
||||
now = "strftime('%Y-%m-%dT%H:%M:%fZ','now')"
|
||||
with _conn() as conn:
|
||||
if decision == "approved":
|
||||
conn.execute(
|
||||
f"UPDATE card_slates SET status='published', "
|
||||
f"published_at=COALESCE(published_at, {now}), decision_at={now} "
|
||||
f"WHERE id=?",
|
||||
(slate_id,),
|
||||
)
|
||||
elif decision == "rejected":
|
||||
conn.execute(
|
||||
f"UPDATE card_slates SET status='rejected', decision_at={now} WHERE id=?",
|
||||
(slate_id,),
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"invalid decision: {decision}")
|
||||
|
||||
|
||||
def list_recent_issued_topics(window_days: int = 14) -> List[Dict[str, Any]]:
|
||||
"""최근 window_days 내 published/rejected 슬레이트의 (keyword, category). dedup용."""
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT keyword, category FROM card_slates "
|
||||
"WHERE status IN ('published','rejected') "
|
||||
"AND COALESCE(published_at, decision_at) >= datetime('now', ?)",
|
||||
(f"-{int(window_days)} days",),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_card_slate(slate_id: int) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM card_slates WHERE id=?", (slate_id,)).fetchone()
|
||||
|
||||
@@ -10,18 +10,50 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import db
|
||||
from . import config, db
|
||||
from .auth import verify_internal_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _register_rendered_assets(slate_id: int) -> int:
|
||||
"""워커가 저장한 10장 PNG를 card_assets로 등록.
|
||||
|
||||
cutover(2026-05-19) 후 렌더는 Windows insta-render 워커가 NAS SMB 볼륨에
|
||||
직접 쓰지만, NAS DB에 card_assets를 등록하는 단계가 누락됐었다. 이 함수가
|
||||
그 갭을 메운다. 워커 출력 경로 후보를 순서대로 스캔해 실제 파일만 등록한다
|
||||
(경로 정합 가드: CARDS_DIR 하위 / INSTA_DATA_PATH 직하 둘 다 수용).
|
||||
|
||||
저장하는 file_path는 insta-lab 컨테이너 내부 절대경로 →
|
||||
get_asset(FileResponse) / package(zip)가 그대로 읽는다.
|
||||
"""
|
||||
candidates = [
|
||||
os.path.join(config.CARDS_DIR, str(slate_id)), # /app/data/insta_cards/{id}
|
||||
os.path.join(config.INSTA_DATA_PATH, str(slate_id)), # /app/data/{id}
|
||||
]
|
||||
for base in candidates:
|
||||
if not os.path.isdir(base):
|
||||
continue
|
||||
count = 0
|
||||
for page in range(1, 11):
|
||||
fp = os.path.join(base, f"{page:02d}.png")
|
||||
if os.path.exists(fp) and os.path.getsize(fp) > 0:
|
||||
db.add_card_asset(slate_id, page, fp)
|
||||
count += 1
|
||||
if count:
|
||||
logger.info("card_assets 등록: slate=%s pages=%d dir=%s", slate_id, count, base)
|
||||
return count
|
||||
logger.warning("렌더 PNG를 찾지 못함: slate=%s (후보=%s)", slate_id, candidates)
|
||||
return 0
|
||||
|
||||
|
||||
class UpdatePayload(BaseModel):
|
||||
task_id: str
|
||||
status: str = Field(..., description="processing|succeeded|failed")
|
||||
@@ -56,12 +88,16 @@ def insta_update(payload: UpdatePayload):
|
||||
result_id=result_id,
|
||||
error=payload.error,
|
||||
)
|
||||
# succeeded 시 slate_status도 'rendered'로 갱신 (cutover 후 NAS가 처리)
|
||||
# succeeded 시 slate_status도 'rendered'로 갱신 + card_assets 등록 (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)
|
||||
try:
|
||||
_register_rendered_assets(result_id)
|
||||
except Exception:
|
||||
logger.exception("card_assets 등록 %s 실패 (무시)", result_id)
|
||||
logger.info(
|
||||
"internal/insta/update task=%s status=%s progress=%d",
|
||||
payload.task_id, payload.status, payload.progress,
|
||||
|
||||
@@ -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,15 +1,19 @@
|
||||
"""FastAPI entrypoint for insta-lab."""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
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,
|
||||
@@ -18,7 +22,7 @@ from .config import (
|
||||
)
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from . import db, news_collector, keyword_extractor, card_writer, trend_collector
|
||||
from . import db, news_collector, keyword_extractor, card_writer, trend_collector, selection, selection_judge
|
||||
from .internal_router import router as internal_router
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -27,6 +31,7 @@ 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(
|
||||
@@ -148,6 +153,35 @@ def list_keywords(
|
||||
return {"items": db.list_trending_keywords(category=category, used=used)}
|
||||
|
||||
|
||||
# judge(Claude)에 보낼 최대 후보 수 — 미사용 키워드 대량 누적 시 응답 truncation으로
|
||||
# claude 점수가 전부 null로 degrade되는 것을 방지 (base score 상위 N개만 평가).
|
||||
JUDGE_CANDIDATE_CAP = 30
|
||||
|
||||
|
||||
@app.get("/api/insta/keywords/ranked")
|
||||
def ranked_keywords(
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
threshold: float = Query(0.6, ge=0.0, le=1.0),
|
||||
dedup_window_days: int = Query(14, ge=1, le=90),
|
||||
):
|
||||
candidates = db.list_trending_keywords(used=False)
|
||||
if not candidates:
|
||||
return {"items": []}
|
||||
# base score 상위 JUDGE_CANDIDATE_CAP개로 제한 → judge·선별 동일 집합에 적용(claude 신호 일관)
|
||||
candidates = sorted(
|
||||
candidates, key=lambda c: float(c.get("score", 0.0)), reverse=True
|
||||
)[:JUDGE_CANDIDATE_CAP]
|
||||
issued = db.list_recent_issued_topics(window_days=dedup_window_days)
|
||||
prefs = {p["category"]: p["weight"] for p in db.get_preferences()}
|
||||
claude_scores = selection_judge.judge_candidates(candidates)
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
scored = selection.score_candidates(
|
||||
candidates, issued, prefs, claude_scores=claude_scores,
|
||||
threshold=threshold, now_iso=now_iso,
|
||||
)
|
||||
return {"items": scored[:limit]}
|
||||
|
||||
|
||||
# ── Slates ───────────────────────────────────────────────────────
|
||||
class SlateRequest(BaseModel):
|
||||
keyword: str
|
||||
@@ -171,6 +205,7 @@ async def _bg_create_slate(task_id: str, keyword: str, category: str, keyword_id
|
||||
"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:
|
||||
@@ -217,6 +252,7 @@ async def _bg_render(task_id: str, slate_id: int):
|
||||
"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("queue push failed")
|
||||
@@ -243,6 +279,53 @@ 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"'
|
||||
})
|
||||
|
||||
|
||||
class DecisionBody(BaseModel):
|
||||
decision: str # "approved" | "rejected"
|
||||
|
||||
|
||||
@app.post("/api/insta/slates/{slate_id}/decision")
|
||||
def slate_decision(slate_id: int, body: DecisionBody):
|
||||
if not db.get_card_slate(slate_id):
|
||||
raise HTTPException(404, "slate not found")
|
||||
if body.decision not in ("approved", "rejected"):
|
||||
raise HTTPException(400, "decision must be approved|rejected")
|
||||
db.set_slate_decision(slate_id, body.decision)
|
||||
return db.get_card_slate(slate_id)
|
||||
|
||||
|
||||
@app.delete("/api/insta/slates/{slate_id}")
|
||||
def delete_slate(slate_id: int):
|
||||
if not db.get_card_slate(slate_id):
|
||||
@@ -271,12 +354,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}")
|
||||
|
||||
83
insta-lab/app/selection.py
Normal file
83
insta-lab/app/selection.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""발행 가치 자율 선별 — 순수 점수 함수 (외부 IO 없음, 단위테스트 대상).
|
||||
|
||||
신호: dedup(게이트), freshness, account_fit, claude(선택).
|
||||
final = 가중합(존재하는 신호만 정규화). eligible = dedup통과 and final>=threshold.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
DEFAULT_WEIGHTS = {"freshness": 0.3, "account_fit": 0.3, "claude": 0.4}
|
||||
FRESH_WINDOW_HOURS = 168.0 # 7일 → 0
|
||||
|
||||
|
||||
def _parse_iso(s: str) -> datetime:
|
||||
return datetime.fromisoformat(s.replace("Z", "+00:00")).astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _norm(kw: str) -> str:
|
||||
return (kw or "").strip().lower()
|
||||
|
||||
|
||||
def _is_duplicate(keyword: str, category: str, issued: List[Dict[str, Any]]) -> bool:
|
||||
n = _norm(keyword)
|
||||
if not n:
|
||||
return False
|
||||
for it in issued:
|
||||
if it.get("category") != category:
|
||||
continue
|
||||
m = _norm(it.get("keyword", ""))
|
||||
if not m:
|
||||
continue
|
||||
if n == m or n in m or m in n:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _freshness(suggested_at: str, now: datetime) -> float:
|
||||
try:
|
||||
hours = (now - _parse_iso(suggested_at)).total_seconds() / 3600.0
|
||||
except Exception:
|
||||
return 0.0
|
||||
return max(0.0, min(1.0, 1.0 - hours / FRESH_WINDOW_HOURS))
|
||||
|
||||
|
||||
def score_candidates(
|
||||
candidates: List[Dict[str, Any]],
|
||||
issued_topics: List[Dict[str, Any]],
|
||||
prefs: Dict[str, float],
|
||||
claude_scores: Optional[Dict[int, float]] = None,
|
||||
weights: Optional[Dict[str, float]] = None,
|
||||
threshold: float = 0.6,
|
||||
now_iso: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
w = weights or DEFAULT_WEIGHTS
|
||||
now = _parse_iso(now_iso) if now_iso else datetime.now(timezone.utc)
|
||||
max_w = max(prefs.values()) if prefs else 1.0
|
||||
if max_w <= 0:
|
||||
max_w = 1.0
|
||||
out: List[Dict[str, Any]] = []
|
||||
for c in candidates:
|
||||
cat = c.get("category", "")
|
||||
dup = _is_duplicate(c.get("keyword", ""), cat, issued_topics)
|
||||
freshness = _freshness(c.get("suggested_at", ""), now)
|
||||
weight = prefs.get(cat, 1.0)
|
||||
account_fit = max(0.0, min(1.0, (weight / max_w) * float(c.get("score", 0.0))))
|
||||
claude = None
|
||||
if claude_scores is not None and c["id"] in claude_scores:
|
||||
claude = max(0.0, min(1.0, float(claude_scores[c["id"]])))
|
||||
parts = [("freshness", freshness), ("account_fit", account_fit)]
|
||||
if claude is not None:
|
||||
parts.append(("claude", claude))
|
||||
total_w = sum(w[name] for name, _ in parts)
|
||||
final = sum(w[name] * val for name, val in parts) / total_w if total_w else 0.0
|
||||
eligible = (not dup) and (final >= threshold)
|
||||
out.append({
|
||||
"id": c["id"], "keyword": c.get("keyword"), "category": cat,
|
||||
"final_score": round(final, 4), "eligible": eligible,
|
||||
"breakdown": {"dedup_excluded": dup, "freshness": round(freshness, 4),
|
||||
"account_fit": round(account_fit, 4), "claude": claude},
|
||||
})
|
||||
out.sort(key=lambda x: (-x["eligible"], -x["final_score"]))
|
||||
return out
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user