Compare commits
122 Commits
534ded59e8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 696c2ade15 | |||
| c024087c94 | |||
| d0bf5fdd50 | |||
| f6b8badd12 | |||
| 833b590afb | |||
| ce980b6eff | |||
| 4dc70a6fc6 | |||
| 57dfb3a3aa | |||
| 1dc5bc3391 | |||
| 76e6fa5e69 | |||
| ae6454ed37 | |||
| 2afcf487a1 | |||
| 0bc2ef3b98 | |||
| 726ed77b31 | |||
| 2a89d52634 | |||
| 6958714021 | |||
| 52677c606a | |||
| 96191b2d7c | |||
| 5b29854251 | |||
| 597e6504e1 | |||
| b15cbbb1b6 | |||
| dacd01e6b9 | |||
| a57ac23064 | |||
| ecc1ab0954 | |||
| d8dcf682c4 | |||
| 86f020182a | |||
| d29fdac4a0 | |||
| be762e1ee8 | |||
| 1664fbda09 | |||
| 3c64a4604f | |||
| 29f37a1642 | |||
| e1804ad181 | |||
| 6fdc2593be | |||
| 9bc31d23f5 | |||
| 0d1e8b3c2d | |||
| f8874b2aea | |||
| da694266d4 | |||
| 1bf1f1405b | |||
| e0834b1275 | |||
| 5acf7db27c | |||
| 76c7bcc62b | |||
| 9453474c69 | |||
| f924c25f16 | |||
| 7d89a664aa | |||
| 50ec52ab6e | |||
| 78e7e68bb0 | |||
| fd84e17f0b | |||
| a6d52c9725 | |||
| cc9028ac3d | |||
| 47b5eab3ff | |||
| 7f42c40efc | |||
| d34bedcb4c | |||
| 5f7e66c220 | |||
| 6040d5fd7f | |||
| dd719f5b2e | |||
| e91b7feada | |||
| ac098faeea | |||
| 6e5aabc94c | |||
| 69d17f787a | |||
| 435e6fb1bc | |||
| 2d2895c9a4 | |||
| 36665ec308 | |||
| 2dd92d025f | |||
| 66be5105a8 | |||
| c274a8f5e7 | |||
| 8fd7f83586 | |||
| 3e30612b38 | |||
| eab52ca424 | |||
| e634cdedba | |||
| 192c8a8c8c | |||
| a6721e6536 | |||
| 94569a4c45 | |||
| 6d73a075f7 | |||
| 840cc28043 | |||
| 423304dce3 | |||
| 024e340e0c | |||
| b46f4aed80 | |||
| 09e2b67039 | |||
| f3551815d1 | |||
| bc6c45dee3 | |||
| d08b20a4b5 | |||
| 44bbff297f | |||
| 1387d91ac5 | |||
| ce84e277a4 | |||
| 4c82fa9b21 | |||
| d91be529eb | |||
| 1a7dfe73e4 | |||
| cdf8759aef | |||
| 2042457000 | |||
| c998753eea | |||
| a846ab89e6 | |||
| ef392f02ed | |||
| 2543dc335d | |||
| b99d720179 | |||
| 734bc6532e | |||
| 5fd32030ab | |||
| e8d33906ba | |||
| 6533743100 | |||
| e42b643731 | |||
| ee5700dc95 | |||
| ec5fee8429 | |||
| 96cc5e7839 | |||
| e6742e06ba | |||
| b713f00bf9 | |||
| 0dce449124 | |||
| 2c32659f6a | |||
| add2d8044c | |||
| 2e9b0daec6 | |||
| 46589c05b1 | |||
| 2a9c8cb619 | |||
| bcaf217b72 | |||
| 18e309a14b | |||
| 80598cda93 | |||
| e49457ca46 | |||
| e04e2b010c | |||
| 3fd923400f | |||
| 6d1f4914ca | |||
| 1630109856 | |||
| 50d427e367 | |||
| 07f1d34f4c | |||
| d2838dfb7a | |||
| ce09f804b6 |
3
.gitignore
vendored
@@ -26,3 +26,6 @@ dist-ssr
|
|||||||
|
|
||||||
# Superpowers visual companion (mockup files)
|
# Superpowers visual companion (mockup files)
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
|
||||||
|
# git worktrees (subagent-driven 격리 작업)
|
||||||
|
.worktrees/
|
||||||
|
|||||||
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}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
109
CHECK_POINT.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# web-ui CHECK_POINT
|
||||||
|
|
||||||
|
> React 18 + Vite + react-router-dom v6. Dev port 3007. NAS Docker 백엔드의 프론트엔드 (nginx :8080).
|
||||||
|
> 2026-05-22 갱신.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟢 현재 상태 (양호)
|
||||||
|
|
||||||
|
- 라우트 16개 (12 메인 + 4 서브) 정상 운영
|
||||||
|
- agent-office 3×3 그리드 재설계 완료 (5/7~14, WebP 93% 축소, WS 재연결 백오프)
|
||||||
|
- `/insta` 슬레이트 캐러셀 + 반응형 (5/15~16)
|
||||||
|
- Vite proxy 7개 (NAS API + Fear&Greed + VIX + Treasury + WTI + Brent)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 최근 완료 (5/18~22)
|
||||||
|
|
||||||
|
- 2026-05-22: **거래 데스크 AI 투자 탭 제거** (e42b643) — web-ai signal_v1 legacy 이전과 정합 (V2 단독 운영 반영)
|
||||||
|
- 2026-05-22: stock 총 매입을 각 종목 매입가 단순 합으로 표시 (6533743)
|
||||||
|
- 2026-05-22: agent-office 모바일 사이드패널 전체화면 토글 + music 에이전트 이미지 교체 (ee5700d)
|
||||||
|
- 2026-05-14: agent-office Grid 재설계 (canvas 폐기), AGENT_META + GRID_SLOTS 중앙화
|
||||||
|
- 2026-05-15~16: `/insta` 신규 페이지 + InstaCards.jsx + src/api.js(530줄) 음악·인스타·텔레그램 API 확장
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔴 즉시 (1~3일)
|
||||||
|
|
||||||
|
### 1. `/insta` 비동기 폴링 구현 ⭐ (백엔드 준비 완료 → 구현 시점 도래)
|
||||||
|
- **배경**: web-backend insta-lab이 Redis 분할(SP-4) 완료 → `_bg_render`가 Redis push, `GET /api/insta/tasks/{task_id}` 폴링 엔드포인트 존재. **이제 frontend가 비동기 폴링으로 전환해야 정합**
|
||||||
|
- **파일**: `src/pages/insta/InstaCards.jsx`
|
||||||
|
- [ ] 슬레이트 생성 → `task_id` 받고 폴링 (2~5초 간격, NAS 부담 ↓)
|
||||||
|
- [ ] progress bar UI (0~100%) + `queue:paused` 상태 표시 (박재오 작업 중 = Windows 워커 정지)
|
||||||
|
- [ ] failed 상태 처리 (오류 메시지·재시도 버튼)
|
||||||
|
|
||||||
|
### 2. agent-office WebSocket 안정성 점검
|
||||||
|
- 5/7~14 재설계 + 5/22 모바일 토글 직후 운영 확인
|
||||||
|
- [ ] 브라우저 콘솔 WS 끊김 → 재연결 지수 백오프 실제 작동
|
||||||
|
- [ ] 4 테스트(TaskTab·CommandTab·AgentCard·ScoreNodeCard) 통과 재확인
|
||||||
|
|
||||||
|
### 3. agent-office lotto sim_consensus 노출
|
||||||
|
- **배경**: web-backend `/api/lotto/best`에 5종 점수 array 노출됨 (lotto-signals) + weight-evolver 자율 학습 도입
|
||||||
|
- [ ] agent-office lotto 에이전트 카드에 5종 점수·시그널 상태 표시
|
||||||
|
- [ ] (선택) weight-evolver 진화 상태 미니 패널
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 중기 (1~2주)
|
||||||
|
|
||||||
|
### 4. `/insta` 카드 템플릿 UI 고도화
|
||||||
|
- 현재 default theme PNG 미리보기만. hedgy75 테마 추가 시 theme 선택 UI 필요
|
||||||
|
- [ ] 테마 선택 dropdown (default / hedgy75)
|
||||||
|
- [ ] 미리보기 컴포넌트 페이지 종류별 분기
|
||||||
|
|
||||||
|
### 5. `/music` Sonic Forge 발행 모니터링
|
||||||
|
- music-lab Redis 분할(SP-6) + Windows music-render 도입 → 발행 상태 모니터링 패널 필요
|
||||||
|
- [ ] 발행 큐·실패·재시도 로그 표시 (Redis 큐 길이 연동)
|
||||||
|
- [ ] 텔레그램 5단계 승인 UX 점검
|
||||||
|
|
||||||
|
### 6. NAS↔Windows 작업 흐름 가시화 (신규)
|
||||||
|
- web-ai 워커 3종 + Redis 큐 도입으로 작업 분산 흐름이 복잡해짐
|
||||||
|
- [ ] agent-office 또는 신규 `/node` 페이지에 큐 상태·Windows 노드 헬스 표시 (web-ai/web-backend 추가 아이디어와 연동)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟢 장기 (1개월+)
|
||||||
|
|
||||||
|
### 7. 모바일 UX 일관 적용
|
||||||
|
- BottomNav + PullToRefresh + MobileSheet + SwipeableView 있음. 신규 페이지 적용 부족
|
||||||
|
- [ ] `/insta` 모바일 캐러셀 swipe + `/agent-office` 모바일 그리드 압축
|
||||||
|
|
||||||
|
### 8. `/lab` 페이지 확장
|
||||||
|
- 현재 sword-stream · day-calc 2개
|
||||||
|
- [ ] 박재오 데모 콘텐츠 큐 결정 (예: weight-evolver 진화 그래프, AI 음악 빠른 청취)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 추가 아이디어 (신규 2026-05-22)
|
||||||
|
|
||||||
|
- **`/node` Windows AI 노드 대시보드** — ai_trade + insta/music/video-render + task-watcher 상태, Redis 큐 길이, `queue:paused` 토글 버튼(task-watcher C안 = "토글 UI 1개"). web-ai/web-backend 모니터링 아이디어의 frontend 진입점
|
||||||
|
- **video 생성 미리보기 페이지** — video-lab(SP-8) + Windows video-render 4 provider 결과 비교 그리드. 무신사 공모전 MU-진 영상 버전 관리에 활용
|
||||||
|
- **weight-evolver 진화 시각화** — auto_picks 적중 추이 + weight base diff 그래프 (`/lab` 또는 lotto 페이지)
|
||||||
|
- **위키 페이지 수 정합** — [[사업-개인-웹-플랫폼]]에 "17개" 박혀 있으나 실제 16개 (12 메인 + 4 서브). *박재오 위키 갱신 항목* (web-ui 코드 아님)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 빌드 & 배포
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # 개발 (port 3007, Vite proxy)
|
||||||
|
npm run build # 빌드 (rimraf dist + Vite build)
|
||||||
|
npm run release:nas # 자동 배포 (deploy-nas.cjs)
|
||||||
|
```
|
||||||
|
|
||||||
|
배포: Windows `robocopy dist Z:\docker\webpage\frontend\` / macOS `rsync` → nginx 자동 reload
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 참고
|
||||||
|
|
||||||
|
- 위키: [[사업-개인-웹-플랫폼]] (백엔드 통합 인덱스)
|
||||||
|
- 라우트: `src/routes.jsx` (navLinks 메타) / Vite 프록시: `vite.config.js`
|
||||||
|
- API: 모든 페이지 `/api/` 상대 경로 (Mixed Content 방지)
|
||||||
|
- 백엔드 짝: web-backend CHECK_POINT (insta-lab Redis 분할 → /insta 비동기 폴링 정합 필요)
|
||||||
|
|
||||||
|
## 변경 이력
|
||||||
|
|
||||||
|
- 2026-05-18: 페이지 신설. 즉시 3 + 중기 3 + 장기 2.
|
||||||
|
- 2026-05-22: 최근 완료 3건 반영(AI 투자 탭 제거·stock 매입 표시·모바일 사이드패널). **`/insta` 비동기 폴링을 즉시 1순위로 승격** (백엔드 insta-lab Redis 분할 완료 → frontend 정합 필요). lotto sim_consensus 노출 + NAS↔Windows 작업 흐름 가시화 항목 추가. 추가 아이디어 4건 신설 (/node 대시보드·video 미리보기·evolver 시각화·위키 페이지 수 정합).
|
||||||
136
CLAUDE.md
@@ -27,8 +27,18 @@
|
|||||||
| `/lab/music` | `MusicStudio` | AI 음악 생성 스튜디오 (Sonic Forge) |
|
| `/lab/music` | `MusicStudio` | AI 음악 생성 스튜디오 (Sonic Forge) |
|
||||||
| `/todo` | `Todo` | 태스크 보드 |
|
| `/todo` | `Todo` | 태스크 보드 |
|
||||||
| `/insta` | `InstaCards` | 뉴스 키워드 발굴 → AI 카드 10장 자동 생성 → 인스타 업로드 |
|
| `/insta` | `InstaCards` | 뉴스 키워드 발굴 → AI 카드 10장 자동 생성 → 인스타 업로드 |
|
||||||
| `/agent-office` | `AgentOffice` | AI 에이전트 가상 오피스 (WebSocket + 채팅) |
|
| `/agent-office` | `AgentOffice` | AI 에이전트 가상 오피스 (WebSocket + 채팅 + LogTab 5초 폴링 source 뱃지) |
|
||||||
| `/portfolio` | `Portfolio` | 개인 포트폴리오 (프로필·경력·프로젝트·자기소개) |
|
| `/portfolio` | `Portfolio` | 개인 포트폴리오 (프로필·경력·프로젝트·자기소개) |
|
||||||
|
| `/saju` | `Saju` | 호령 사주 v2 — 메인/입력 (mobile night-bg + desktop mt-wash 산수화, useViewportMode 1024px 분기) |
|
||||||
|
| `/saju/result?rid=N` | `SajuResult` | 사주 풀이 결과 (4탭: Basic/Chart/Flow/Traits) |
|
||||||
|
| `/saju/today?rid=N` | `Today` | 오늘의 운세 (FortuneRing + 4 ScoreCard + LuckyBox + good_signs/warnings) |
|
||||||
|
| `/saju/compatibility` | `Compatibility` | 궁합 입력 (두 사람 폼) |
|
||||||
|
| `/saju/compatibility/result?cid=N` | `CompatibilityResult` | 궁합 점수 + 요약 + strengths/challenges |
|
||||||
|
| `/saju/me` | `SajuMe` | 마이페이지 placeholder ("곧 만나요" + 4 비활성 카드) |
|
||||||
|
| `/tarot` | `Tarot` | 타로 메인 (agent-office에서 분리, tarot-lab API) |
|
||||||
|
| `/tarot/today` | `TarotTodayCard` | 오늘의 카드 (one_card spread) |
|
||||||
|
| `/tarot/reading` | `TarotReading` | 멀티 카드 스프레드 리딩 (three_card 등) |
|
||||||
|
| `/tarot/history` | `TarotHistory` | 리딩 이력 조회 |
|
||||||
|
|
||||||
라우트 정의: `src/routes.jsx` / 라우터 설정: `src/Router.jsx`
|
라우트 정의: `src/routes.jsx` / 라우터 설정: `src/Router.jsx`
|
||||||
|
|
||||||
@@ -128,6 +138,23 @@ proxy: {
|
|||||||
| 포트폴리오 | GET | `/api/profile/public` — personal 서비스 |
|
| 포트폴리오 | GET | `/api/profile/public` — personal 서비스 |
|
||||||
| 포트폴리오 | POST | `/api/profile/auth` — personal 서비스 |
|
| 포트폴리오 | POST | `/api/profile/auth` — personal 서비스 |
|
||||||
| 포트폴리오 | CRUD | `/api/profile/careers`, `/api/profile/projects`, `/api/profile/skills`, `/api/profile/introductions` — personal 서비스 |
|
| 포트폴리오 | CRUD | `/api/profile/careers`, `/api/profile/projects`, `/api/profile/skills`, `/api/profile/introductions` — personal 서비스 |
|
||||||
|
| 사주 | POST | `/api/saju/interpret` — body: `{ year, month, day, hour, gender, calendar_type, is_leap_month? }` → reading_id + saju_data + analysis + fortune_scores + lucky + monthly_flow |
|
||||||
|
| 사주 | GET | `/api/saju/readings/:id` — 저장된 사주 조회 (`useSajuReading` hook 사용) |
|
||||||
|
| 사주 | GET | `/api/saju/current-fortune?reading_id=N` — 현재 연도 세운 |
|
||||||
|
| 사주 | PATCH/DELETE | `/api/saju/readings/:id` — 즐겨찾기·메모 / 삭제 |
|
||||||
|
| 사주 | GET | `/api/saju/readings?page=1&size=20&favorite=bool` — 목록 |
|
||||||
|
| 궁합 | POST | `/api/saju/compat/interpret` — body: `{ person_a, person_b }` → compat_id + score + interpretation |
|
||||||
|
| 궁합 | GET | `/api/saju/compat/readings/:id` — 궁합 결과 |
|
||||||
|
| 궁합 | PATCH/DELETE | `/api/saju/compat/readings/:id` |
|
||||||
|
| 타로 | POST | `/api/tarot/interpret` — body: `{ spread_type, category, question, cards }` → interpretation_json (DB 저장 X) |
|
||||||
|
| 타로 | POST | `/api/tarot/readings` — 확정 후 저장 |
|
||||||
|
| 타로 | GET | `/api/tarot/readings?page=1&spread_type=X&category=Y` — 목록 |
|
||||||
|
| 타로 | GET/PATCH/DELETE | `/api/tarot/readings/:id` |
|
||||||
|
| 영상 생성 | POST | `/api/video/generate` — body: `{ provider, prompt, params }` → task_id (sora/veo/kling/seedance) |
|
||||||
|
| 영상 생성 | GET | `/api/video/tasks/:id`, `/api/video/providers` |
|
||||||
|
| 이미지 생성 | POST | `/api/image/generate` — body: `{ provider, prompt, params }` → task_id (gpt_image/nano_banana/flux) |
|
||||||
|
| 이미지 생성 | GET | `/api/image/tasks/:id`, `/api/image/providers` |
|
||||||
|
| 에이전트 로그 | GET | `/api/agent-office/agents/:id/logs?limit=50` — DB agent_logs + 컨테이너 `/logs/recent` 병합 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -332,6 +359,102 @@ handleGenerate()
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 호령 사주 v2 — `/saju` 라우트 트리
|
||||||
|
|
||||||
|
2026-05-27 풀 리디자인 (Phase 1-6, 30 commits). v1 `components/` + `Saju.css` 일괄 삭제 후 신규 디자인 시스템 도입.
|
||||||
|
|
||||||
|
### 디자인 컨셉
|
||||||
|
|
||||||
|
한국 전통 명리학 미학 + 호령 캐릭터. Inter/Roboto 같은 generic AI sans 회피.
|
||||||
|
- **타이포**: Nanum Myeongjo (display, weight 800) + Nanum Gothic (body) + Gowun Batang (fallback serif). `index.html` head에서 preconnect + link 일괄 로드 (기존 Noto Serif KR도 v1 호환 유지)
|
||||||
|
- **컬러**: navy `#1F2A44` dominant + gold `#D4AF37` accent + ivory `#F7F2E8` paper. 화면별 단일 accent (홈=navy, 오늘=gold, 궁합=green, 사주풀이=purple, 마이=gray)
|
||||||
|
- **차별화 요소**: `OrnateFrame` (4 코너 꺽쇠 + double border), `MascotBubble` (paw-bob 2.4s 애니메이션), `OrnamentBloom` (꽃봉오리 SVG), `mt-wash` (산수화 SVG 데스크탑 배경)
|
||||||
|
|
||||||
|
### 디렉토리 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
src/pages/saju/
|
||||||
|
├── _shell/ # 디자인 시스템 + 네비
|
||||||
|
│ ├── tokens.css # CSS 변수 (.saju-v2 scope)
|
||||||
|
│ ├── shell.css # paper-bg/night-bg/mt-wash/screenIn/paw-bob
|
||||||
|
│ ├── useViewportMode.js # 1024px breakpoint hook
|
||||||
|
│ ├── BottomNav.jsx # 모바일 5항목
|
||||||
|
│ ├── DesktopHeader.jsx # 데스크탑 헤더 nav
|
||||||
|
│ ├── Mascot.jsx # 7 variant 매핑 (full/head/upper/greeting/thinking/pointing/happy)
|
||||||
|
│ ├── MascotBubble.jsx # 4 tone (ivory/navy/green/purple)
|
||||||
|
│ ├── OrnateFrame.jsx
|
||||||
|
│ ├── OrnamentBloom.jsx
|
||||||
|
│ ├── TopRibbon.jsx
|
||||||
|
│ ├── TitleBlock.jsx
|
||||||
|
│ ├── PrimaryButton.jsx
|
||||||
|
│ ├── GhostButton.jsx
|
||||||
|
│ ├── InputRow.jsx
|
||||||
|
│ ├── Icons.jsx # 5 nav + IconPaw/Chevron/Sparkle
|
||||||
|
│ └── helpers/
|
||||||
|
│ ├── hexA.js # hex + alpha → rgba
|
||||||
|
│ ├── daeunLabel.js # 나이 → 8 인생 단계 label
|
||||||
|
│ ├── deriveTraits.js # element_scores → 6 성향
|
||||||
|
│ └── colorMap.js # 오행 한자 → CSS var + 한글/한자
|
||||||
|
├── views/ # mobile/desktop 컴포넌트 분리
|
||||||
|
│ ├── home.{mobile,desktop}.jsx
|
||||||
|
│ ├── saju.{mobile,desktop}.jsx # 4탭 (Basic/Chart/Flow/Traits)
|
||||||
|
│ ├── today.{mobile,desktop}.jsx
|
||||||
|
│ └── match.{mobile,desktop}.jsx
|
||||||
|
├── hooks/
|
||||||
|
│ ├── useSajuForm.js # 폼 상태 (year/month/day/hour/gender/calendar_type, handleChange(field,value) 콜백)
|
||||||
|
│ └── useSajuReading.js # rid 기반 { data, loading, error }
|
||||||
|
├── Saju.jsx # /saju 진입 router
|
||||||
|
├── SajuResult.jsx # /saju/result 진입 (Empty/Loading/Error state)
|
||||||
|
├── Today.jsx
|
||||||
|
├── Compatibility.jsx
|
||||||
|
├── CompatibilityResult.jsx
|
||||||
|
└── Me.jsx # placeholder
|
||||||
|
```
|
||||||
|
|
||||||
|
### 응답 schema 매핑 (saju-lab → view)
|
||||||
|
|
||||||
|
`useSajuReading(rid).data` 구조:
|
||||||
|
- `saju_data.{year,month,day,hour}` 각 `{stem, stem_kr, branch, branch_kr, ten_god, fortune}` (4기둥)
|
||||||
|
- `analysis_data.element_scores` (한자 키 `木/火/土/金/水`) — view에서 `wood/fire/earth/metal/water`로 매핑 (`HANJA_TO_ID`)
|
||||||
|
- `analysis_data.day_master_strength.{result, score, reasons}` (신강신약)
|
||||||
|
- `daeun_data` (8개): `{age, start_year, end_year, stem, branch, stem_kr, branch_kr}` — 현재 판정 `start_year ≤ currentYear ≤ end_year`
|
||||||
|
- `interpretation_json.{summary, items: [{key,title,content,evidence}], advice}`
|
||||||
|
- `fortune_scores.{wealth, romance, social, career, overall}` (0-100)
|
||||||
|
- `lucky.{color: string[], number, direction, good_signs: string[], warnings: string[]}`
|
||||||
|
|
||||||
|
### 반응형 전략
|
||||||
|
|
||||||
|
1024px breakpoint로 모바일/데스크탑 컴포넌트 트리 완전 분리:
|
||||||
|
- 모바일 (< 1024): `night-bg` 또는 `paper-bg`, BottomNav 하단 fixed + safe-area
|
||||||
|
- 데스크탑 (≥ 1024): `mt-wash` 산수화 배경, DesktopHeader sticky top, content max-width 1200px
|
||||||
|
|
||||||
|
### 호령 자산
|
||||||
|
|
||||||
|
`public/images/saju/horyung/` 7 PNG (horyung-main/bust/front/greeting/thinking/pointing/happy). Mascot variant API가 매핑:
|
||||||
|
- `full` → horyung-main, `head` → horyung-bust, `upper` → horyung-front, 나머지는 1:1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 타로 — `/tarot` 라우트 트리
|
||||||
|
|
||||||
|
agent-office에서 독립 라우트로 분리 (백엔드는 `tarot-lab` 컨테이너).
|
||||||
|
|
||||||
|
| 경로 | 컴포넌트 | 백엔드 |
|
||||||
|
|------|----------|--------|
|
||||||
|
| `/tarot` | `Tarot` | tarot-lab `/api/tarot/interpret` |
|
||||||
|
| `/tarot/today` | `TarotTodayCard` | one_card spread |
|
||||||
|
| `/tarot/reading` | `TarotReading` | three_card spread + 멀티 |
|
||||||
|
| `/tarot/history` | `TarotHistory` | `/api/tarot/readings` 목록 |
|
||||||
|
|
||||||
|
해석 흐름 (interpret ↔ save 분리):
|
||||||
|
1. 사용자가 카드 배치 → `POST /api/tarot/interpret` → Claude 응답 (DB 저장 X)
|
||||||
|
2. 사용자 확정 또는 reroll 결정
|
||||||
|
3. 확정 후 `POST /api/tarot/readings` → DB 저장 + reading_id 반환
|
||||||
|
|
||||||
|
`useTarotReading(id)` + `useTarotShuffle()` hook (`src/pages/tarot/hooks/`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Windows AI 서버 (`C:\Users\jaeoh\Desktop\workspace\music_ai`)
|
## Windows AI 서버 (`C:\Users\jaeoh\Desktop\workspace\music_ai`)
|
||||||
|
|
||||||
NAS의 music-lab 컨테이너 대신 Windows PC(RTX 5070 Ti)에서 MusicGen을 로컬 추론하는 별도 서버.
|
NAS의 music-lab 컨테이너 대신 Windows PC(RTX 5070 Ti)에서 MusicGen을 로컬 추론하는 별도 서버.
|
||||||
@@ -372,3 +495,14 @@ web-ui → POST /api/music/generate (NAS music-lab)
|
|||||||
```
|
```
|
||||||
|
|
||||||
Windows 방화벽에서 포트 8765 인바운드 허용 필요.
|
Windows 방화벽에서 포트 8765 인바운드 허용 필요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 협업 팀 버스 (co-gahusb) — 이 세션의 역할: **FE**
|
||||||
|
|
||||||
|
이 세션은 프론트엔드(FE) 역할이다. co-gahusb MCP 툴로 다른 세션(BE/AI/Producer)과 협업한다.
|
||||||
|
- **소유권**: 이 세션은 `web-ui` repo만 쓴다(BE=web-backend, AI=web-ai).
|
||||||
|
- **공유 리소스 변경 전 반드시 `acquire_lock(resource, "FE")`**: 대상 = `nas-deploy`, `stock-db-schema`, `lotto-db-schema`, `memory-mirror`, `nginx-conf`, `compose`. 점유 중이면 대기, 긴 작업은 `heartbeat_lock`, 끝나면 `release_lock`.
|
||||||
|
- **모든 툴 호출에 `role="FE"`** (또는 `from_role`/`created_by`에 FE).
|
||||||
|
- **수신**: `/loop`로 주기적으로 `read_inbox("FE", after_id=<last>)` + `list_tasks(assignee_role="FE")` 확인.
|
||||||
|
- 키 `CO_BUS_KEY`는 환경변수로 주입(커밋 금지).
|
||||||
|
|||||||
1271
docs/superpowers/plans/2026-05-17-agent-office-grid-redesign.md
Normal file
642
docs/superpowers/plans/2026-05-25-ai-trade-hotfix.md
Normal file
@@ -0,0 +1,642 @@
|
|||||||
|
# ai_trade Hotfix — Code Review F1·F2·F3·F4 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:** ai_trade(V2) 코드 리뷰 7개 finding 중 High 3건(F1·F2·F3) + Medium 1건(F4)을 TDD로 수정. F5/F6은 별도 plan, F7은 pushback.
|
||||||
|
|
||||||
|
**Architecture:** 모두 ai_trade/ 내부 단일 모듈 수정. (1) config.py default 경로 — legacy/ 경유. (2) kis_client.py — asyncio.Lock으로 `_throttle()` 직렬화. (3) scheduler.py + pull_worker.py — post-close를 시간 윈도우가 아닌 "일 1회 + 16:00 이후" 상태기반으로 변경. (4) chronos_predictor.py — confidence 산식을 absolute spread 기반으로 통일.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12, asyncio, pytest + pytest-asyncio + respx, httpx.
|
||||||
|
|
||||||
|
**Test runner:** `.venv` 한글 경로 깨짐 + 리뷰어 Python 312 경로 부재 보고로, 시스템 Python 사용. 정확한 경로는 `where python` 으로 우선 확인. 기본 시도 순서:
|
||||||
|
1. `python -m pytest ai_trade/tests -q` (PATH의 Python)
|
||||||
|
2. `py -3.12 -m pytest ai_trade/tests -q` (py launcher)
|
||||||
|
3. 둘 다 실패 시 환경 셋업이 선행 작업 — plan 진행 중단하고 박재오에게 보고.
|
||||||
|
|
||||||
|
**Working directory:** `C:\Users\jaeoh\Desktop\workspace\web-ai` (web-ai repo). Commit/push도 이 디렉토리에서만.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| 파일 | 변경 종류 | 책임 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| `ai_trade/config.py` | Modify L31-36 | V1_TOKEN_PATH default를 `legacy/signal_v1/data/kis_token.json`로 |
|
||||||
|
| `ai_trade/kis_client.py` | Modify L40-62 | `_throttle_lock: asyncio.Lock` 추가, `_throttle()`을 lock 안에서 실행 |
|
||||||
|
| `ai_trade/scheduler.py` | Modify L79-84 | `_is_post_close_trigger(now, last_post_close_date)` 시그니처 변경 — 상태기반 |
|
||||||
|
| `ai_trade/pull_worker.py` | Modify L1-58 | `poll_loop`에 `last_post_close_date` state 추가, 호출부 갱신 |
|
||||||
|
| `ai_trade/chronos_predictor.py` | Modify L106, L127 | spread 계산을 absolute (q90-q10)로, confidence 산식 `max(0, 1 - spread/0.6)` |
|
||||||
|
| `ai_trade/tests/test_kis_client.py` | Add 1 test | concurrent gather throttle test |
|
||||||
|
| `ai_trade/tests/test_scheduler.py` | Add 3 tests | post-close 상태기반 트리거 |
|
||||||
|
| `ai_trade/tests/test_pull_worker.py` | Add 1 test | 첫 호출 안 됐다가 16:00 이후 5분 cycle에서 호출됨 |
|
||||||
|
| `ai_trade/tests/test_chronos_predictor.py` | Add 2 tests | median≈0에서도 conf 정상, spread 클수록 conf↓ |
|
||||||
|
| `ai_trade/tests/test_main.py` | Modify | v1_token_path default 변경 반영 (있다면) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: F1 — KIS 토큰 경로 default를 legacy/ 경유로
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ai_trade/config.py:31-36`
|
||||||
|
- Test: `ai_trade/tests/test_config_token_path.py` (Create)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ai_trade/tests/test_config_token_path.py
|
||||||
|
"""F1 — V1_TOKEN_PATH default가 legacy/signal_v1/ 경유인지 검증."""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ai_trade.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_v1_token_default_path_uses_legacy_dir(monkeypatch):
|
||||||
|
"""env에 V1_TOKEN_PATH 없으면 legacy/signal_v1/data/kis_token.json"""
|
||||||
|
monkeypatch.delenv("V1_TOKEN_PATH", raising=False)
|
||||||
|
settings = Settings()
|
||||||
|
expected_suffix = Path("legacy") / "signal_v1" / "data" / "kis_token.json"
|
||||||
|
assert str(settings.v1_token_path).endswith(str(expected_suffix)), (
|
||||||
|
f"expected default to end with {expected_suffix}, got {settings.v1_token_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_v1_token_env_override_wins(monkeypatch, tmp_path):
|
||||||
|
"""env로 명시한 경로가 default를 덮어씀."""
|
||||||
|
custom = tmp_path / "custom_token.json"
|
||||||
|
monkeypatch.setenv("V1_TOKEN_PATH", str(custom))
|
||||||
|
settings = Settings()
|
||||||
|
assert settings.v1_token_path == custom
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m pytest ai_trade/tests/test_config_token_path.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `test_v1_token_default_path_uses_legacy_dir` FAILs (default가 `signal_v1/...` 임). env override는 PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Fix config.py**
|
||||||
|
|
||||||
|
`ai_trade/config.py:31-36` 변경:
|
||||||
|
|
||||||
|
```python
|
||||||
|
v1_token_path: Path = field(
|
||||||
|
default_factory=lambda: Path(
|
||||||
|
os.getenv("V1_TOKEN_PATH",
|
||||||
|
str(Path(__file__).parent.parent / "legacy" / "signal_v1" / "data" / "kis_token.json"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m pytest ai_trade/tests/test_config_token_path.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 2 passed.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify full test suite still passes**
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m pytest ai_trade/tests -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 모든 기존 테스트 PASS (token path 기본값 변경이 다른 test에 영향 없는지 확인).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ai_trade/config.py ai_trade/tests/test_config_token_path.py
|
||||||
|
git commit -m "fix(ai_trade): V1_TOKEN_PATH default를 legacy/signal_v1/ 경유로 수정 (F1)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: F2 — KIS throttle을 asyncio.Lock으로 직렬화
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ai_trade/kis_client.py:40-62`
|
||||||
|
- Test: `ai_trade/tests/test_kis_client.py` (Modify — 새 test 추가)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`ai_trade/tests/test_kis_client.py` 파일 끝에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
import time as time_module
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_throttle_serializes_concurrent_gather(kis_client_factory):
|
||||||
|
"""5개 동시 요청이 asyncio.gather로 들어와도 0.5초 간격으로 직렬화되어야 함 (F2).
|
||||||
|
|
||||||
|
초당 2회 = 0.5초 간격. 5개 요청이면 최소 (5-1)*0.5 = 2.0초 소요.
|
||||||
|
Race condition 있으면 5개가 거의 동시에 나가서 2초 훨씬 안쪽에 끝남.
|
||||||
|
"""
|
||||||
|
sample = {"output2": []}
|
||||||
|
respx.get(
|
||||||
|
"https://openapivts.koreainvestment.com:29443/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice"
|
||||||
|
).mock(return_value=httpx.Response(200, json=sample))
|
||||||
|
|
||||||
|
client = kis_client_factory()
|
||||||
|
try:
|
||||||
|
start = time_module.monotonic()
|
||||||
|
await asyncio.gather(*[client.get_minute_ohlcv(f"00593{i}") for i in range(5)])
|
||||||
|
elapsed = time_module.monotonic() - start
|
||||||
|
# 5개 throttle = 최소 (5-1)*0.5 = 2.0초. tolerance 0.3초.
|
||||||
|
assert elapsed >= 1.7, (
|
||||||
|
f"throttle race condition: 5 concurrent calls took only {elapsed:.2f}s, "
|
||||||
|
f"expected >=1.7s (0.5s * 4 inter-call gaps)"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m pytest ai_trade/tests/test_kis_client.py::test_throttle_serializes_concurrent_gather -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL — elapsed가 0.5초 이하 (race로 동시 깸).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add asyncio.Lock to KISClient**
|
||||||
|
|
||||||
|
`ai_trade/kis_client.py:40` `__init__` 끝에 한 줄 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self._token_cache: tuple[str, float] | None = None # (token, file_mtime)
|
||||||
|
self._last_throttle_at = 0.0
|
||||||
|
self._throttle_lock = asyncio.Lock()
|
||||||
|
```
|
||||||
|
|
||||||
|
그리고 `_throttle()` (L58-62)을 lock으로 감쌈:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _throttle(self) -> None:
|
||||||
|
async with self._throttle_lock:
|
||||||
|
elapsed = time.monotonic() - self._last_throttle_at
|
||||||
|
if elapsed < _THROTTLE_INTERVAL:
|
||||||
|
await asyncio.sleep(_THROTTLE_INTERVAL - elapsed)
|
||||||
|
self._last_throttle_at = time.monotonic()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m pytest ai_trade/tests/test_kis_client.py::test_throttle_serializes_concurrent_gather -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS — elapsed >= 1.7s.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify full kis_client suite still passes**
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m pytest ai_trade/tests/test_kis_client.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 모든 test PASS (기존 429 retry 등 영향 없는지 확인).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ai_trade/kis_client.py ai_trade/tests/test_kis_client.py
|
||||||
|
git commit -m "fix(ai_trade): KIS throttle을 asyncio.Lock으로 직렬화 (F2)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: F3 — post-close 트리거를 상태기반으로 변경
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ai_trade/scheduler.py:79-84`
|
||||||
|
- Modify: `ai_trade/pull_worker.py:1-58`
|
||||||
|
- Test: `ai_trade/tests/test_scheduler.py` (add 3 tests)
|
||||||
|
|
||||||
|
**Why state-based:** 16:00:00-16:00:59 윈도우는 5분 sleep + 비결정적 cycle 시작 시각과 충돌. "오늘 아직 post-close 안 돌렸고 현재 시각 ≥ 16:00 이면 trigger 후 today 표시" 로 변경.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
`ai_trade/tests/test_scheduler.py` 파일 끝에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datetime import date as _date
|
||||||
|
from ai_trade.scheduler import _is_post_close_trigger
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_close_trigger_fires_at_1601_if_not_yet_today():
|
||||||
|
"""16:01에 깬 cycle도 오늘 아직 안 돌렸으면 trigger (F3)."""
|
||||||
|
now = _kst(2026, 5, 18, 16, 1)
|
||||||
|
assert _is_post_close_trigger(now, last_post_close_date=None) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_close_trigger_skips_if_already_today():
|
||||||
|
"""이미 오늘 돌렸으면 trigger 안 함."""
|
||||||
|
now = _kst(2026, 5, 18, 16, 5)
|
||||||
|
today = _date(2026, 5, 18)
|
||||||
|
assert _is_post_close_trigger(now, last_post_close_date=today) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_close_trigger_skips_before_1600():
|
||||||
|
"""16:00 전에는 trigger 안 함."""
|
||||||
|
now = _kst(2026, 5, 18, 15, 59)
|
||||||
|
assert _is_post_close_trigger(now, last_post_close_date=None) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_close_trigger_fires_next_day_after_reset():
|
||||||
|
"""다음 영업일이 되면 last_post_close_date < today.date() 이므로 다시 trigger."""
|
||||||
|
now = _kst(2026, 5, 19, 16, 0)
|
||||||
|
yesterday = _date(2026, 5, 18)
|
||||||
|
assert _is_post_close_trigger(now, last_post_close_date=yesterday) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_close_trigger_skips_on_holiday():
|
||||||
|
"""휴장일에는 trigger 안 함 (2026-05-05 어린이날)."""
|
||||||
|
now = _kst(2026, 5, 5, 16, 30)
|
||||||
|
assert _is_post_close_trigger(now, last_post_close_date=None) is False
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m pytest ai_trade/tests/test_scheduler.py -v -k post_close
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL — `_is_post_close_trigger`가 신규 시그니처(`last_post_close_date` 인자) 미지원.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Modify scheduler.py:79-84**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _is_post_close_trigger(now: datetime, last_post_close_date) -> bool:
|
||||||
|
"""16:00 KST 이후 오늘 아직 post-close cycle 안 돌렸으면 True (F3 상태기반).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
now: 현재 KST datetime.
|
||||||
|
last_post_close_date: 마지막 post-close 실행 영업일 date 객체 (None=미실행).
|
||||||
|
"""
|
||||||
|
if not _is_market_day(now):
|
||||||
|
return False
|
||||||
|
if now.time() < time(16, 0):
|
||||||
|
return False
|
||||||
|
today = now.date()
|
||||||
|
return last_post_close_date != today
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run scheduler tests**
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m pytest ai_trade/tests/test_scheduler.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 신규 5개 PASS. 기존 test도 PASS (다른 함수 영향 없음).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update pull_worker.py to track last_post_close_date**
|
||||||
|
|
||||||
|
`ai_trade/pull_worker.py` 의 `poll_loop` (L18-58)을 다음으로 교체:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def poll_loop(
|
||||||
|
client: StockClient, state: PollState, shutdown: asyncio.Event,
|
||||||
|
kis_client: KISClient | None = None,
|
||||||
|
chronos=None,
|
||||||
|
dedup=None,
|
||||||
|
settings=None,
|
||||||
|
) -> None:
|
||||||
|
"""FastAPI lifespan 에서 asyncio.create_task 로 시작."""
|
||||||
|
logger.info("poll_loop started")
|
||||||
|
last_post_close_date = None
|
||||||
|
while not shutdown.is_set():
|
||||||
|
now = datetime.now(KST)
|
||||||
|
if _is_market_day(now) and _is_polling_window(now):
|
||||||
|
try:
|
||||||
|
await _run_polling_cycle(client, state, kis_client=kis_client)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("poll cycle failed")
|
||||||
|
# Minute momentum 갱신 (매 cycle)
|
||||||
|
try:
|
||||||
|
update_minute_momentum_for_all(state)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("minute momentum update failed")
|
||||||
|
# Post-close trigger (상태기반: 16:00 이후 + 오늘 미실행)
|
||||||
|
if (
|
||||||
|
_is_post_close_trigger(now, last_post_close_date)
|
||||||
|
and chronos is not None and kis_client is not None
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
await _run_post_close_cycle(kis_client, chronos, state)
|
||||||
|
last_post_close_date = now.date()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("post-close cycle failed")
|
||||||
|
# Phase 4: generate signals
|
||||||
|
if dedup is not None and settings is not None:
|
||||||
|
try:
|
||||||
|
from ai_trade.signal_generator import generate_signals
|
||||||
|
generate_signals(state, dedup, settings)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("generate_signals failed")
|
||||||
|
interval = _next_interval(now)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(shutdown.wait(), timeout=interval)
|
||||||
|
break
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
continue
|
||||||
|
logger.info("poll_loop ended")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Add pull_worker test**
|
||||||
|
|
||||||
|
`ai_trade/tests/test_pull_worker.py` 파일 끝에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
from datetime import datetime as _dt
|
||||||
|
from zoneinfo import ZoneInfo as _ZI
|
||||||
|
import asyncio as _asyncio
|
||||||
|
|
||||||
|
|
||||||
|
async def test_post_close_fires_at_1601_when_not_yet_today(monkeypatch):
|
||||||
|
"""16:01에 깬 cycle도 post_close 안 돌렸으면 호출됨 (F3 회귀)."""
|
||||||
|
from ai_trade import pull_worker
|
||||||
|
|
||||||
|
_kst = _ZI("Asia/Seoul")
|
||||||
|
now_at_1601 = _dt(2026, 5, 18, 16, 1, tzinfo=_kst)
|
||||||
|
|
||||||
|
real_dt = _dt
|
||||||
|
|
||||||
|
class FrozenDateTime:
|
||||||
|
@staticmethod
|
||||||
|
def now(tz=None):
|
||||||
|
return now_at_1601
|
||||||
|
|
||||||
|
monkeypatch.setattr(pull_worker, "datetime", FrozenDateTime)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
pull_worker, "_is_market_day", lambda n: True,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
pull_worker, "_is_polling_window", lambda n: True,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
pull_worker, "_next_interval", lambda n: 0.01,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
pull_worker, "_run_polling_cycle", AsyncMock(),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
pull_worker, "update_minute_momentum_for_all", lambda s: None,
|
||||||
|
)
|
||||||
|
post_close = AsyncMock()
|
||||||
|
monkeypatch.setattr(pull_worker, "_run_post_close_cycle", post_close)
|
||||||
|
|
||||||
|
state = MagicMock()
|
||||||
|
chronos = MagicMock()
|
||||||
|
kis = MagicMock()
|
||||||
|
shutdown = _asyncio.Event()
|
||||||
|
|
||||||
|
async def _stop_soon():
|
||||||
|
await _asyncio.sleep(0.05)
|
||||||
|
shutdown.set()
|
||||||
|
|
||||||
|
_asyncio.create_task(_stop_soon())
|
||||||
|
await pull_worker.poll_loop(
|
||||||
|
client=MagicMock(),
|
||||||
|
state=state,
|
||||||
|
shutdown=shutdown,
|
||||||
|
kis_client=kis,
|
||||||
|
chronos=chronos,
|
||||||
|
dedup=None,
|
||||||
|
settings=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert post_close.await_count >= 1, "post-close가 16:01에 호출되지 않음 (F3 회귀)"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run pull_worker test**
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m pytest ai_trade/tests/test_pull_worker.py::test_post_close_fires_at_1601_when_not_yet_today -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run full ai_trade suite**
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m pytest ai_trade/tests -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 모두 PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ai_trade/scheduler.py ai_trade/pull_worker.py ai_trade/tests/test_scheduler.py ai_trade/tests/test_pull_worker.py
|
||||||
|
git commit -m "fix(ai_trade): post-close trigger를 상태기반으로 변경 (F3)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: F4 — Chronos confidence를 absolute spread 기반으로 통일
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ai_trade/chronos_predictor.py:106, 127`
|
||||||
|
- Test: `ai_trade/tests/test_chronos_predictor.py` (add 2 tests)
|
||||||
|
|
||||||
|
**Why absolute:** Phase 4 spec amendment (web-ui commit 534ded5)가 absolute spread로 hard gate를 결정. confidence도 같은 철학으로. 새 산식: `conf = max(0, min(1, 1 - spread / SPREAD_THRESHOLD))` — spread가 0.6에 도달하면 conf=0, 0이면 conf=1.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
기존 `ai_trade/tests/test_chronos_predictor.py` 끝에 추가 (파일이 없거나 비어있으면 신규):
|
||||||
|
|
||||||
|
```python
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
import torch
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_pipeline():
|
||||||
|
"""predict_quantiles만 stub하는 가짜 pipeline."""
|
||||||
|
class FakePipeline:
|
||||||
|
def __init__(self, q10_price, q50_price, q90_price):
|
||||||
|
self._q10, self._q50, self._q90 = q10_price, q50_price, q90_price
|
||||||
|
def predict_quantiles(self, contexts, prediction_length, quantile_levels):
|
||||||
|
n = len(contexts)
|
||||||
|
tensor = torch.tensor(
|
||||||
|
[[[self._q10, self._q50, self._q90]]] * n,
|
||||||
|
dtype=torch.float32,
|
||||||
|
)
|
||||||
|
return tensor, None
|
||||||
|
return FakePipeline
|
||||||
|
|
||||||
|
|
||||||
|
def _make_predictor_with(pipeline_obj):
|
||||||
|
"""ChronosPredictor 인스턴스 (실제 모델 안 부르고 pipeline만 주입)."""
|
||||||
|
from ai_trade.chronos_predictor import ChronosPredictor
|
||||||
|
p = ChronosPredictor.__new__(ChronosPredictor)
|
||||||
|
p._pipeline = pipeline_obj
|
||||||
|
p._device = "cpu"
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def test_confidence_high_when_spread_near_zero(fake_pipeline):
|
||||||
|
"""median≈0, spread≈0 (q10=q90=last_close)일 때 conf≈1 (F4)."""
|
||||||
|
last_close = 100000.0
|
||||||
|
p = _make_predictor_with(fake_pipeline(last_close, last_close, last_close))
|
||||||
|
ohlcv = {"A": [{"close": last_close}] * 30}
|
||||||
|
out = p.predict_batch(ohlcv)
|
||||||
|
assert out["A"].conf > 0.95, (
|
||||||
|
f"median≈0 + spread≈0인데 conf={out['A'].conf} (F4 회귀: relative spread로 폭증)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_confidence_drops_with_spread(fake_pipeline):
|
||||||
|
"""spread 0.3일 때 conf≈0.5 (1 - 0.3/0.6 = 0.5)."""
|
||||||
|
last_close = 100000.0
|
||||||
|
# q10=85000 → -0.15, q90=115000 → 0.15, spread=0.30
|
||||||
|
p = _make_predictor_with(fake_pipeline(85000.0, 100000.0, 115000.0))
|
||||||
|
ohlcv = {"A": [{"close": last_close}] * 30}
|
||||||
|
out = p.predict_batch(ohlcv)
|
||||||
|
# 1 - 0.30/0.60 = 0.50
|
||||||
|
assert 0.45 < out["A"].conf < 0.55, (
|
||||||
|
f"absolute spread 0.30에서 conf={out['A'].conf} (expected ≈0.5)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_confidence_zero_at_threshold_spread(fake_pipeline):
|
||||||
|
"""spread가 threshold(0.6) 이상이면 conf=0."""
|
||||||
|
last_close = 100000.0
|
||||||
|
# q10=70000 → -0.30, q90=130000 → 0.30, spread=0.60
|
||||||
|
p = _make_predictor_with(fake_pipeline(70000.0, 100000.0, 130000.0))
|
||||||
|
ohlcv = {"A": [{"close": last_close}] * 30}
|
||||||
|
out = p.predict_batch(ohlcv)
|
||||||
|
assert out["A"].conf < 0.05, (
|
||||||
|
f"spread=threshold에서 conf={out['A'].conf} (expected ≈0)"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m pytest ai_trade/tests/test_chronos_predictor.py -v -k confidence
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `test_confidence_high_when_spread_near_zero` 가 FAIL — 현행 relative spread 산식 때문에 median≈0에서 conf가 0으로 폭락.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Fix chronos_predictor.py**
|
||||||
|
|
||||||
|
`ai_trade/chronos_predictor.py` 상단에 상수 추가 (L13 근처):
|
||||||
|
|
||||||
|
```python
|
||||||
|
_SPREAD_THRESHOLD = 0.6 # F4: signal_generator hard gate와 동일 (absolute return spread)
|
||||||
|
```
|
||||||
|
|
||||||
|
L106 (modern API 경로) 변경:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# shape: [num_series, prediction_length, 3]
|
||||||
|
for i, ticker in enumerate(tickers):
|
||||||
|
q10_price, q50_price, q90_price = quantiles_np[i, 0, :]
|
||||||
|
last_close = daily_ohlcv_dict[ticker][-1]["close"]
|
||||||
|
median = float((q50_price - last_close) / last_close)
|
||||||
|
q10 = float((q10_price - last_close) / last_close)
|
||||||
|
q90 = float((q90_price - last_close) / last_close)
|
||||||
|
# F4: absolute spread (q90-q10) 기반 — signal_generator hard gate와 통일.
|
||||||
|
# median≈0 zero-shot 케이스에서 conf가 0으로 폭락하던 relative 산식 제거.
|
||||||
|
spread = q90 - q10
|
||||||
|
conf = float(max(0.0, min(1.0, 1.0 - spread / _SPREAD_THRESHOLD)))
|
||||||
|
results[ticker] = ChronosPrediction(
|
||||||
|
median=median, q10=q10, q90=q90, conf=conf, as_of=now_iso,
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
```
|
||||||
|
|
||||||
|
L127 (legacy API 경로) 동일하게 변경:
|
||||||
|
|
||||||
|
```python
|
||||||
|
spread = q90 - q10
|
||||||
|
conf = float(max(0.0, min(1.0, 1.0 - spread / _SPREAD_THRESHOLD)))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m pytest ai_trade/tests/test_chronos_predictor.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 신규 3개 모두 PASS. 기존 test도 PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run full ai_trade suite**
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m pytest ai_trade/tests -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 모두 PASS. signal_generator 테스트(`_compute_buy_confidence` 가 `pred["conf"]` 사용) 도 영향 받을 수 있으니 주시.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ai_trade/chronos_predictor.py ai_trade/tests/test_chronos_predictor.py
|
||||||
|
git commit -m "fix(ai_trade): Chronos confidence를 absolute spread 기반으로 통일 (F4)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: 전체 회귀 확인 + push
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run full ai_trade suite + count**
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m pytest ai_trade/tests -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- 기존 56 tests + 신규 (config 2 + kis_client 1 + scheduler 5 + pull_worker 1 + chronos_predictor 3) = **68 tests** 정도 PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Quick sanity — server boot smoke test (시간 허용 시)**
|
||||||
|
|
||||||
|
```
|
||||||
|
cd ai_trade && python -c "from main import app; print('app import OK')"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no import errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Checklist
|
||||||
|
|
||||||
|
이 plan을 다 작성한 뒤 다음을 확인:
|
||||||
|
|
||||||
|
1. **F1**: config.py default + 2 test (default + env override) ✅
|
||||||
|
2. **F2**: `_throttle_lock` 추가 + 1 concurrent test ✅
|
||||||
|
3. **F3**: `_is_post_close_trigger(now, last_post_close_date)` 시그니처 변경 + `poll_loop` 상태 추적 + 5 scheduler test + 1 pull_worker test ✅
|
||||||
|
4. **F4**: `_SPREAD_THRESHOLD=0.6` 상수 + 두 분기(modern + legacy) 모두 absolute spread 적용 + 3 chronos_predictor test ✅
|
||||||
|
|
||||||
|
**누락 가능 항목**:
|
||||||
|
- `test_main.py` 가 `v1_token_path` default를 직접 검증한다면 Task 1에서 같이 갱신. 위 patch는 Settings 객체 통해서만 다루므로 영향 없음(검증 완료).
|
||||||
|
- Task 3 pull_worker test의 `FrozenDateTime.now`는 `datetime.now(KST)` 호출만 stub함. 다른 datetime 사용 부분 영향 없음 (verified L28).
|
||||||
|
- Task 4 test는 ChronosPredictor `__new__`로 우회 — 실제 HuggingFace 모델 로딩 안 함.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Handoff
|
||||||
|
|
||||||
|
**Plan complete and saved to `docs/superpowers/plans/2026-05-25-ai-trade-hotfix.md`.**
|
||||||
|
|
||||||
|
두 가지 실행 옵션:
|
||||||
|
|
||||||
|
**1. Subagent-Driven (recommended)** — task 별 fresh subagent dispatch + two-stage review. F2/F3 같이 미묘한 동시성/상태 변경에 유리.
|
||||||
|
|
||||||
|
**2. Inline Execution** — 현 세션에서 직접 task별 진행 + checkpoint.
|
||||||
|
|
||||||
|
박재오 결정 대기.
|
||||||
704
docs/superpowers/plans/2026-05-25-render-queue-reliability.md
Normal file
@@ -0,0 +1,704 @@
|
|||||||
|
# Render Queue Reliability — Code Review F6 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:** 4개 render worker(insta/music/video/image-render)가 BLPOP 직후 crash 시 작업 손실되는 문제 해결. BLMOVE(또는 BRPOPLPUSH)로 atomic dequeue + processing list 패턴 + startup recovery + retry/dead-letter.
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
1. 각 worker가 unique `worker_id` 보유: `<queue>-<hostname>-<pid>` (env로 override 가능).
|
||||||
|
2. atomic dequeue: `BLMOVE queue:<x>-render processing:<x>-render:<worker_id> RIGHT LEFT 5` — 5초 timeout. (`BRPOPLPUSH`는 Redis 6.2+ deprecated, `BLMOVE`가 후속).
|
||||||
|
3. 작업 성공: `LREM processing:<x>-render:<worker_id> 1 <payload>` — 정확 1개 제거.
|
||||||
|
4. 작업 실패: payload에 `attempts` counter 증가시켜 main queue 끝으로 LPUSH; 한계(기본 3) 초과 시 `dead_letter:<queue>` 로 이동.
|
||||||
|
5. **Startup recovery**: worker 시작 시 자신의 processing list가 비어있지 않으면 → 모두 main queue로 되돌림 (재시도). attempts 증가.
|
||||||
|
6. NAS측 producer는 무변경 (LPUSH 그대로). 단, payload schema에 `attempts: int` (optional) 필드 명시 — producer는 안 채워도 worker가 default 0으로.
|
||||||
|
|
||||||
|
**Shared module 전략:** 4개 worker가 동일 패턴이므로 `services/_shared/reliable_queue.py` 1개 만들고 각 Dockerfile에서 `COPY services/_shared /app/_shared` 후 `from _shared.reliable_queue import ReliableQueue`. compose entry/dockerfile 변경 4건. (DRY > inline 4중복.)
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12, redis.asyncio 5.x, fakeredis (pytest dep), pytest-asyncio.
|
||||||
|
|
||||||
|
**Working directory:** `C:\Users\jaeoh\Desktop\workspace\web-ai`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| 파일 | 변경 | 책임 |
|
||||||
|
|------|------|------|
|
||||||
|
| `services/_shared/__init__.py` | Create | namespace package |
|
||||||
|
| `services/_shared/reliable_queue.py` | Create | `ReliableQueue` 클래스 — dequeue, ack, fail, recover |
|
||||||
|
| `services/_shared/tests/test_reliable_queue.py` | Create | fakeredis 단위 테스트 6개 |
|
||||||
|
| `services/_shared/requirements.txt` | Create | redis>=5.0, fakeredis (test only) |
|
||||||
|
| `services/insta-render/Dockerfile` | Modify | `COPY services/_shared /app/_shared` + PYTHONPATH |
|
||||||
|
| `services/insta-render/worker.py` | Modify L1~ | BLPOP → ReliableQueue 사용 |
|
||||||
|
| `services/insta-render/tests/test_worker.py` | Append | 1 integration test (recovery) |
|
||||||
|
| `services/music-render/Dockerfile` | Modify | shared copy |
|
||||||
|
| `services/music-render/worker.py` | Modify | ReliableQueue 사용 |
|
||||||
|
| `services/music-render/tests/test_worker.py` | Append | recovery test |
|
||||||
|
| `services/video-render/Dockerfile` | Modify | shared copy |
|
||||||
|
| `services/video-render/worker.py` | Modify | ReliableQueue 사용 |
|
||||||
|
| `services/video-render/tests/test_worker.py` | Append | recovery test |
|
||||||
|
| `services/image-render/Dockerfile` | Modify | shared copy |
|
||||||
|
| `services/image-render/worker.py` | Modify | ReliableQueue 사용 |
|
||||||
|
| `services/image-render/tests/test_worker.py` | Append | recovery test |
|
||||||
|
| `services/docker-compose.yml` (있다면) | Verify | build context가 services/ 루트 포함하는지 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: ReliableQueue 공유 모듈 작성
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `services/_shared/__init__.py`
|
||||||
|
- Create: `services/_shared/reliable_queue.py`
|
||||||
|
- Create: `services/_shared/tests/__init__.py`
|
||||||
|
- Create: `services/_shared/tests/test_reliable_queue.py`
|
||||||
|
- Create: `services/_shared/requirements.txt`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create namespace package**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# services/_shared/__init__.py
|
||||||
|
```
|
||||||
|
(빈 파일)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# services/_shared/tests/__init__.py
|
||||||
|
```
|
||||||
|
(빈 파일)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write failing tests first**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# services/_shared/tests/test_reliable_queue.py
|
||||||
|
"""F6 — ReliableQueue: atomic dequeue + recovery + retry."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
import fakeredis.aioredis
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from _shared.reliable_queue import ReliableQueue
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def redis():
|
||||||
|
r = fakeredis.aioredis.FakeRedis(decode_responses=False)
|
||||||
|
yield r
|
||||||
|
await r.flushall()
|
||||||
|
await r.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_dequeue_atomically_moves_to_processing(redis):
|
||||||
|
"""BLMOVE: queue → processing 원자적 이동."""
|
||||||
|
q = ReliableQueue(redis, queue_key="queue:test", worker_id="w1")
|
||||||
|
await redis.lpush("queue:test", json.dumps({"task_id": "t1"}).encode())
|
||||||
|
payload, raw = await q.dequeue(timeout=1)
|
||||||
|
assert payload["task_id"] == "t1"
|
||||||
|
# main queue는 비어있고, processing list에 들어있어야 함
|
||||||
|
assert await redis.llen("queue:test") == 0
|
||||||
|
assert await redis.llen("processing:queue:test:w1") == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_dequeue_returns_none_on_timeout(redis):
|
||||||
|
q = ReliableQueue(redis, queue_key="queue:test", worker_id="w1")
|
||||||
|
result = await q.dequeue(timeout=1)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ack_removes_from_processing(redis):
|
||||||
|
q = ReliableQueue(redis, queue_key="queue:test", worker_id="w1")
|
||||||
|
await redis.lpush("queue:test", json.dumps({"task_id": "t1"}).encode())
|
||||||
|
payload, raw = await q.dequeue(timeout=1)
|
||||||
|
await q.ack(raw)
|
||||||
|
assert await redis.llen("processing:queue:test:w1") == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_recover_returns_orphaned_to_main_queue(redis):
|
||||||
|
"""startup recovery: 잔존 processing list 항목을 main queue로 되돌림."""
|
||||||
|
# 이전 crash 시뮬레이션: processing list에 잔존
|
||||||
|
orphan = json.dumps({"task_id": "t1", "attempts": 0}).encode()
|
||||||
|
await redis.lpush("processing:queue:test:w1", orphan)
|
||||||
|
q = ReliableQueue(redis, queue_key="queue:test", worker_id="w1")
|
||||||
|
recovered = await q.recover()
|
||||||
|
assert recovered == 1
|
||||||
|
assert await redis.llen("processing:queue:test:w1") == 0
|
||||||
|
# 다시 dequeue 가능
|
||||||
|
payload, raw = await q.dequeue(timeout=1)
|
||||||
|
assert payload["task_id"] == "t1"
|
||||||
|
assert payload["attempts"] == 1 # incremented on recover
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fail_below_max_attempts_returns_to_main_queue(redis):
|
||||||
|
q = ReliableQueue(redis, queue_key="queue:test", worker_id="w1", max_attempts=3)
|
||||||
|
await redis.lpush("queue:test", json.dumps({"task_id": "t1", "attempts": 0}).encode())
|
||||||
|
payload, raw = await q.dequeue(timeout=1)
|
||||||
|
await q.fail(raw, payload)
|
||||||
|
assert await redis.llen("processing:queue:test:w1") == 0
|
||||||
|
assert await redis.llen("queue:test") == 1
|
||||||
|
# attempts 증가됐는지
|
||||||
|
requeued_raw = await redis.lindex("queue:test", 0)
|
||||||
|
requeued = json.loads(requeued_raw)
|
||||||
|
assert requeued["attempts"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fail_at_max_attempts_moves_to_dead_letter(redis):
|
||||||
|
q = ReliableQueue(redis, queue_key="queue:test", worker_id="w1", max_attempts=3)
|
||||||
|
await redis.lpush(
|
||||||
|
"queue:test", json.dumps({"task_id": "t1", "attempts": 2}).encode()
|
||||||
|
)
|
||||||
|
payload, raw = await q.dequeue(timeout=1)
|
||||||
|
await q.fail(raw, payload)
|
||||||
|
# attempts 2 → 3 (== max) → dead-letter
|
||||||
|
assert await redis.llen("queue:test") == 0
|
||||||
|
assert await redis.llen("processing:queue:test:w1") == 0
|
||||||
|
assert await redis.llen("dead_letter:queue:test") == 1
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add requirements**
|
||||||
|
|
||||||
|
```text
|
||||||
|
# services/_shared/requirements.txt
|
||||||
|
redis>=5.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
별도 dev requirements (test):
|
||||||
|
|
||||||
|
```text
|
||||||
|
# services/_shared/tests/requirements-dev.txt (optional)
|
||||||
|
fakeredis>=2.20.0
|
||||||
|
pytest>=8.0.0
|
||||||
|
pytest-asyncio>=0.23.0
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they fail**
|
||||||
|
|
||||||
|
```
|
||||||
|
cd services/_shared && python -m pytest tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: ImportError — reliable_queue.py 미존재.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Write reliable_queue.py**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# services/_shared/reliable_queue.py
|
||||||
|
"""F6 — Reliable Redis queue with processing list + recovery + retry.
|
||||||
|
|
||||||
|
Pattern: BLMOVE main → processing (atomic), then either ack (LREM processing) or
|
||||||
|
fail (LREM processing + re-enqueue or dead-letter).
|
||||||
|
|
||||||
|
Startup recovery: any items left in the worker's processing list from a previous
|
||||||
|
crash are pushed back to main queue (with attempts incremented).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def default_worker_id(queue_key: str) -> str:
|
||||||
|
"""env > hostname-pid."""
|
||||||
|
explicit = os.getenv("WORKER_ID")
|
||||||
|
if explicit:
|
||||||
|
return explicit
|
||||||
|
return f"{queue_key}-{socket.gethostname()}-{os.getpid()}"
|
||||||
|
|
||||||
|
|
||||||
|
class ReliableQueue:
|
||||||
|
"""Wraps a redis client to provide BLMOVE-backed atomic dequeue +
|
||||||
|
processing list + retry/dead-letter.
|
||||||
|
|
||||||
|
Producer side stays unchanged: LPUSH queue:<x> <json payload>.
|
||||||
|
Worker side: dequeue() → process → ack(raw) on success or fail(raw, payload) on error.
|
||||||
|
Startup: await queue.recover() to re-enqueue orphans.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
redis,
|
||||||
|
queue_key: str,
|
||||||
|
worker_id: Optional[str] = None,
|
||||||
|
max_attempts: int = 3,
|
||||||
|
):
|
||||||
|
self._redis = redis
|
||||||
|
self._queue_key = queue_key
|
||||||
|
self._worker_id = worker_id or default_worker_id(queue_key)
|
||||||
|
self._processing_key = f"processing:{queue_key}:{self._worker_id}"
|
||||||
|
self._dead_letter_key = f"dead_letter:{queue_key}"
|
||||||
|
self._max_attempts = max_attempts
|
||||||
|
|
||||||
|
@property
|
||||||
|
def processing_key(self) -> str:
|
||||||
|
return self._processing_key
|
||||||
|
|
||||||
|
async def dequeue(self, timeout: int = 5) -> Optional[tuple[dict, bytes]]:
|
||||||
|
"""Atomically move 1 item from main queue tail to processing head.
|
||||||
|
|
||||||
|
Returns (parsed_dict, raw_bytes) or None on timeout.
|
||||||
|
Caller MUST call ack(raw) on success or fail(raw, payload) on error.
|
||||||
|
"""
|
||||||
|
raw = await self._redis.blmove(
|
||||||
|
self._queue_key, self._processing_key,
|
||||||
|
timeout=timeout, src="RIGHT", dest="LEFT",
|
||||||
|
)
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.error("invalid payload on dequeue, moving to dead-letter: %r", raw[:200])
|
||||||
|
await self._redis.lrem(self._processing_key, 1, raw)
|
||||||
|
await self._redis.lpush(self._dead_letter_key, raw)
|
||||||
|
return None
|
||||||
|
return payload, raw
|
||||||
|
|
||||||
|
async def ack(self, raw: bytes) -> None:
|
||||||
|
"""Successful processing — remove from processing list."""
|
||||||
|
removed = await self._redis.lrem(self._processing_key, 1, raw)
|
||||||
|
if removed == 0:
|
||||||
|
logger.warning("ack on missing payload (already removed?): %r", raw[:100])
|
||||||
|
|
||||||
|
async def fail(self, raw: bytes, payload: dict) -> None:
|
||||||
|
"""Failed processing — remove from processing list and either re-enqueue or dead-letter."""
|
||||||
|
await self._redis.lrem(self._processing_key, 1, raw)
|
||||||
|
attempts = int(payload.get("attempts", 0)) + 1
|
||||||
|
if attempts >= self._max_attempts:
|
||||||
|
payload["attempts"] = attempts
|
||||||
|
await self._redis.lpush(self._dead_letter_key, json.dumps(payload).encode())
|
||||||
|
logger.error(
|
||||||
|
"task moved to dead-letter after %d attempts: task_id=%s",
|
||||||
|
attempts, payload.get("task_id"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
payload["attempts"] = attempts
|
||||||
|
await self._redis.lpush(self._queue_key, json.dumps(payload).encode())
|
||||||
|
logger.info(
|
||||||
|
"task re-enqueued (attempt %d/%d): task_id=%s",
|
||||||
|
attempts, self._max_attempts, payload.get("task_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def recover(self) -> int:
|
||||||
|
"""Startup: move all orphans from this worker's processing list back to main queue.
|
||||||
|
|
||||||
|
Increments attempts counter (orphan == implicit failure).
|
||||||
|
Returns count of recovered items.
|
||||||
|
"""
|
||||||
|
count = 0
|
||||||
|
while True:
|
||||||
|
raw = await self._redis.lpop(self._processing_key)
|
||||||
|
if raw is None:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
await self._redis.lpush(self._dead_letter_key, raw)
|
||||||
|
count += 1
|
||||||
|
continue
|
||||||
|
payload["attempts"] = int(payload.get("attempts", 0)) + 1
|
||||||
|
if payload["attempts"] >= self._max_attempts:
|
||||||
|
await self._redis.lpush(self._dead_letter_key, json.dumps(payload).encode())
|
||||||
|
else:
|
||||||
|
await self._redis.lpush(self._queue_key, json.dumps(payload).encode())
|
||||||
|
count += 1
|
||||||
|
if count:
|
||||||
|
logger.info("recovered %d orphaned items for worker %s", count, self._worker_id)
|
||||||
|
return count
|
||||||
|
```
|
||||||
|
|
||||||
|
**참고: redis-py blmove API**: `client.blmove(first_list, second_list, timeout, src=..., dest=...)`. timeout=0 은 block forever. payload는 bytes로 받음 (`decode_responses=False` 가정).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run tests to verify they pass**
|
||||||
|
|
||||||
|
```
|
||||||
|
cd services/_shared && python -m pytest tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 6 PASS.
|
||||||
|
|
||||||
|
만약 ImportError (`fakeredis` 미설치) 발생 시:
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m pip install fakeredis pytest-asyncio
|
||||||
|
```
|
||||||
|
|
||||||
|
또한 `pytest.ini` 또는 `conftest.py`에 `asyncio_mode = "auto"` 필요. 신규 conftest:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# services/_shared/tests/conftest.py
|
||||||
|
import pytest
|
||||||
|
pytest_plugins = ["pytest_asyncio"]
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_collection_modifyitems(config, items):
|
||||||
|
for item in items:
|
||||||
|
if "asyncio" in item.fixturenames or item.get_closest_marker("asyncio") is not None:
|
||||||
|
continue
|
||||||
|
# auto-mark all async tests
|
||||||
|
if item.function.__name__.startswith("test_"):
|
||||||
|
import asyncio, inspect
|
||||||
|
if inspect.iscoroutinefunction(item.function):
|
||||||
|
item.add_marker(pytest.mark.asyncio)
|
||||||
|
```
|
||||||
|
|
||||||
|
또는 더 간단히 `services/_shared/pytest.ini`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add services/_shared/
|
||||||
|
git commit -m "feat(services): _shared/reliable_queue 신설 — BLMOVE + processing list + retry (F6 part 1)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: insta-render에 ReliableQueue 적용
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `services/insta-render/Dockerfile`
|
||||||
|
- Modify: `services/insta-render/worker.py`
|
||||||
|
- Modify: `services/insta-render/tests/test_worker.py` (append)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update Dockerfile**
|
||||||
|
|
||||||
|
`services/insta-render/Dockerfile` 에 `_shared` 복사 추가. 기존 Dockerfile 패턴을 먼저 읽고, `COPY services/insta-render /app` 같은 라인이 있다면 그 위 또는 옆에:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
COPY services/_shared /app/_shared
|
||||||
|
ENV PYTHONPATH=/app:/app/_shared:${PYTHONPATH}
|
||||||
|
```
|
||||||
|
|
||||||
|
build context가 `services/` 루트여야 함. compose에서 `build: { context: ./services, dockerfile: insta-render/Dockerfile }` 인지 확인 — 아니라면 context 조정 필요.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Modify worker.py — failing test first**
|
||||||
|
|
||||||
|
`services/insta-render/tests/test_worker.py` 끝에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import json
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_worker_calls_ack_on_success():
|
||||||
|
"""성공 시 ack() 호출 (F6)."""
|
||||||
|
import worker
|
||||||
|
fake_payload = {"task_id": "t1", "job_type": "card_generation", "params": {}}
|
||||||
|
fake_raw = json.dumps(fake_payload).encode()
|
||||||
|
|
||||||
|
fake_queue = AsyncMock()
|
||||||
|
fake_queue.dequeue = AsyncMock(side_effect=[(fake_payload, fake_raw), None])
|
||||||
|
fake_queue.ack = AsyncMock()
|
||||||
|
fake_queue.fail = AsyncMock()
|
||||||
|
fake_queue.recover = AsyncMock(return_value=0)
|
||||||
|
|
||||||
|
with patch.object(worker, "ReliableQueue", return_value=fake_queue), \
|
||||||
|
patch.object(worker, "_dispatch") as disp:
|
||||||
|
# poll_once로 1 cycle만 실행 (실제 loop 끊기 위해)
|
||||||
|
await worker.poll_once(fake_queue)
|
||||||
|
disp.assert_called_once()
|
||||||
|
fake_queue.ack.assert_called_once_with(fake_raw)
|
||||||
|
fake_queue.fail.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_worker_calls_fail_on_dispatch_exception():
|
||||||
|
"""dispatch 예외 시 fail() 호출 — 작업 손실 안 됨 (F6)."""
|
||||||
|
import worker
|
||||||
|
fake_payload = {"task_id": "t2", "job_type": "card_generation", "params": {}}
|
||||||
|
fake_raw = json.dumps(fake_payload).encode()
|
||||||
|
|
||||||
|
fake_queue = AsyncMock()
|
||||||
|
fake_queue.dequeue = AsyncMock(return_value=(fake_payload, fake_raw))
|
||||||
|
fake_queue.ack = AsyncMock()
|
||||||
|
fake_queue.fail = AsyncMock()
|
||||||
|
|
||||||
|
with patch.object(worker, "_dispatch", side_effect=RuntimeError("boom")):
|
||||||
|
await worker.poll_once(fake_queue)
|
||||||
|
fake_queue.fail.assert_called_once_with(fake_raw, fake_payload)
|
||||||
|
fake_queue.ack.assert_not_called()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run test to fail**
|
||||||
|
|
||||||
|
```
|
||||||
|
cd services/insta-render && python -m pytest tests/ -v -k "ack_on_success or fail_on_dispatch"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: AttributeError (`worker.poll_once` 미존재, `worker.ReliableQueue` 미존재).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Rewrite insta-render worker.py**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Redis 기반 worker — F6 신뢰성 패턴 적용 (BLMOVE + processing list + retry)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
|
||||||
|
from _shared.reliable_queue import ReliableQueue
|
||||||
|
from nas_client import webhook_update_task
|
||||||
|
# 기존 dispatch 대상 import 유지
|
||||||
|
from card_renderer import render_card
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
REDIS_URL = os.getenv("REDIS_URL", "redis://192.168.45.54:6379")
|
||||||
|
QUEUE_KEY = "queue:insta-render"
|
||||||
|
PAUSED_KEY = "queue:paused"
|
||||||
|
|
||||||
|
|
||||||
|
_DISPATCH_TABLE = {
|
||||||
|
"card_generation": "render_card",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _dispatch(payload: dict) -> None:
|
||||||
|
job_type = payload.get("job_type", "")
|
||||||
|
task_id = payload.get("task_id", "")
|
||||||
|
params = payload.get("params", {})
|
||||||
|
fn_name = _DISPATCH_TABLE.get(job_type)
|
||||||
|
if fn_name is None:
|
||||||
|
logger.error("unknown job_type=%s task=%s", job_type, task_id)
|
||||||
|
webhook_update_task(task_id, "failed", 0, "", error=f"unknown job_type: {job_type}")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
fn = getattr(sys.modules[__name__], fn_name)
|
||||||
|
except AttributeError:
|
||||||
|
webhook_update_task(task_id, "failed", 0, "", error=f"internal dispatch error: {fn_name}")
|
||||||
|
return
|
||||||
|
fn(task_id, params)
|
||||||
|
|
||||||
|
|
||||||
|
async def poll_once(queue: ReliableQueue) -> bool:
|
||||||
|
"""1 cycle: dequeue → dispatch → ack/fail. Returns True if a job was handled."""
|
||||||
|
result = await queue.dequeue(timeout=5)
|
||||||
|
if result is None:
|
||||||
|
return False
|
||||||
|
payload, raw = result
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(_dispatch, payload)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("dispatch failed task_id=%s", payload.get("task_id"))
|
||||||
|
await queue.fail(raw, payload)
|
||||||
|
return True
|
||||||
|
await queue.ack(raw)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def worker_loop():
|
||||||
|
redis = aioredis.from_url(REDIS_URL, decode_responses=False)
|
||||||
|
queue = ReliableQueue(redis, queue_key=QUEUE_KEY)
|
||||||
|
logger.info("insta-render worker started worker_id=%s", queue._worker_id)
|
||||||
|
# F6: startup recovery
|
||||||
|
try:
|
||||||
|
recovered = await queue.recover()
|
||||||
|
if recovered:
|
||||||
|
logger.info("recovered %d orphaned items at startup", recovered)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("startup recover failed")
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
paused = await redis.get(PAUSED_KEY)
|
||||||
|
if paused == b"1":
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
continue
|
||||||
|
await poll_once(queue)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("worker_loop cancelled")
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("worker_loop iteration 실패, 5초 후 재시도")
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
asyncio.run(worker_loop())
|
||||||
|
```
|
||||||
|
|
||||||
|
**NOTE**: 기존 `insta-render/worker.py`의 dispatch table·import는 실제 파일을 보고 매핑 유지. 위 예시는 minimal — job_type / function 이름은 기존 파일과 맞춰야 함. 변경 전 `Read services/insta-render/worker.py`로 정확한 dispatch table 확인할 것.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests**
|
||||||
|
|
||||||
|
```
|
||||||
|
cd services/insta-render && python -m pytest tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 신규 2 PASS, 기존 PASS (dispatch table test 등).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add services/insta-render/
|
||||||
|
git commit -m "fix(insta-render): F6 ReliableQueue 적용 — BLMOVE + ack/fail (F6 part 2)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: music-render에 동일 적용
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `services/music-render/Dockerfile`, `worker.py`
|
||||||
|
- Modify: `services/music-render/tests/test_worker.py` (append)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Dockerfile에 `COPY services/_shared` 추가**
|
||||||
|
- [ ] **Step 2: Test 추가 (Task 2 패턴 동일, 단 dispatch target은 `run_suno_generation` 등 기존 패턴)**
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_music_worker_ack_on_success():
|
||||||
|
import worker
|
||||||
|
payload = {"task_id": "t1", "job_type": "suno_generation", "params": {}}
|
||||||
|
raw = json.dumps(payload).encode()
|
||||||
|
fake_queue = AsyncMock()
|
||||||
|
fake_queue.dequeue = AsyncMock(return_value=(payload, raw))
|
||||||
|
fake_queue.ack = AsyncMock()
|
||||||
|
with patch.object(worker, "_dispatch"):
|
||||||
|
await worker.poll_once(fake_queue)
|
||||||
|
fake_queue.ack.assert_called_once_with(raw)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_music_worker_fail_on_exception():
|
||||||
|
import worker
|
||||||
|
payload = {"task_id": "t2", "job_type": "suno_generation", "params": {}}
|
||||||
|
raw = json.dumps(payload).encode()
|
||||||
|
fake_queue = AsyncMock()
|
||||||
|
fake_queue.dequeue = AsyncMock(return_value=(payload, raw))
|
||||||
|
fake_queue.fail = AsyncMock()
|
||||||
|
with patch.object(worker, "_dispatch", side_effect=RuntimeError("x")):
|
||||||
|
await worker.poll_once(fake_queue)
|
||||||
|
fake_queue.fail.assert_called_once_with(raw, payload)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run test to fail**
|
||||||
|
- [ ] **Step 4: Rewrite music-render worker.py — `worker_loop` 구조는 insta-render와 동일, `_dispatch` + `_DISPATCH_TABLE`은 기존 12개 함수 그대로 유지**
|
||||||
|
- [ ] **Step 5: Run tests**
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add services/music-render/
|
||||||
|
git commit -m "fix(music-render): F6 ReliableQueue 적용 (F6 part 3)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: video-render에 동일 적용
|
||||||
|
|
||||||
|
(Task 3와 동일 패턴 — sora/veo/kling/seedance 4 provider table 유지)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Dockerfile 수정**
|
||||||
|
- [ ] **Step 2: 신규 test 2개 추가 (`test_video_worker_ack_on_success`, `test_video_worker_fail_on_exception`) — job_type은 `sora_generation`**
|
||||||
|
- [ ] **Step 3: Run failing test**
|
||||||
|
- [ ] **Step 4: Rewrite worker.py — 동일 패턴**
|
||||||
|
- [ ] **Step 5: Run tests**
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add services/video-render/
|
||||||
|
git commit -m "fix(video-render): F6 ReliableQueue 적용 (F6 part 4)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: image-render에 동일 적용
|
||||||
|
|
||||||
|
(gpt_image / nano_banana / flux 3 provider table 유지)
|
||||||
|
|
||||||
|
- [ ] **Step 1-6: Task 3/4 동일 패턴**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add services/image-render/
|
||||||
|
git commit -m "fix(image-render): F6 ReliableQueue 적용 (F6 part 5)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: 운영 검증 + push
|
||||||
|
|
||||||
|
- [ ] **Step 1: 전체 services test 실행**
|
||||||
|
|
||||||
|
```
|
||||||
|
cd services && for d in _shared insta-render music-render video-render image-render; do
|
||||||
|
echo "--- $d ---"
|
||||||
|
(cd $d && python -m pytest tests/ -q) || true
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
(또는 PowerShell:)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
foreach ($d in @("_shared","insta-render","music-render","video-render","image-render")) {
|
||||||
|
Write-Output "--- $d ---"
|
||||||
|
Push-Location services/$d
|
||||||
|
python -m pytest tests/ -q
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 4개 worker 각 신규 2개 + _shared 6개 + 기존 test 전부 PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Docker build 시뮬 (옵션, 시간 허용 시)**
|
||||||
|
|
||||||
|
```
|
||||||
|
cd services && docker compose build insta-render music-render video-render image-render
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: build context에 `_shared` 포함됨 검증.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 운영 deploy 시 주의사항 (수동)**
|
||||||
|
|
||||||
|
NAS에서 컨테이너 재배포 시:
|
||||||
|
1. `redis-cli -h 192.168.45.54 KEYS 'processing:*'` 로 기존 orphan 확인 — 있다면 worker_id 다르면 안 잡힘. 수동으로 `LMOVE` 해야 할 수도 있음.
|
||||||
|
2. `redis-cli -h 192.168.45.54 KEYS 'dead_letter:*'` 로 dead-letter 모니터 — 누적되면 alerting 필요.
|
||||||
|
3. WORKER_ID env로 unique 하게 (`WORKER_ID=insta-render-prod-1` 등) 권장 — hostname이 컨테이너 재기동 시 바뀌면 orphan 추적 안 됨.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
1. **atomic dequeue**: `BLMOVE` 단일 명령 — Redis 단일 트랜잭션 ✅
|
||||||
|
2. **ack on success**: `LREM processing 1 raw` — 정확 1개 ✅
|
||||||
|
3. **fail with retry**: attempts < max → 재큐, attempts >= max → dead-letter ✅
|
||||||
|
4. **startup recovery**: orphan 자동 재큐 (attempts 증가) ✅
|
||||||
|
5. **4 worker 적용**: insta/music/video/image 동일 패턴 ✅
|
||||||
|
6. **NAS producer 호환**: LPUSH 그대로, payload schema에 attempts 선택적 ✅
|
||||||
|
|
||||||
|
**미커버 (의도적)**:
|
||||||
|
- dead-letter monitor/alert — 운영 작업 (CHECK_POINT 백로그)
|
||||||
|
- worker_id env 미설정 시 hostname 변경 시 orphan 분실 — 운영 가이드에 명시
|
||||||
|
|
||||||
|
**가정 검증**:
|
||||||
|
- `redis-py.aioredis.blmove` 시그니처: `(first_list, second_list, timeout, src='LEFT', dest='RIGHT')`. redis>=5.0 권장.
|
||||||
|
- fakeredis: `fakeredis.aioredis.FakeRedis` (>=2.20.0) 가 BLMOVE 지원함 — 미지원 시 plan 적용 전 검증.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Handoff
|
||||||
|
|
||||||
|
**Plan complete and saved to `docs/superpowers/plans/2026-05-25-render-queue-reliability.md`.**
|
||||||
|
|
||||||
|
**1. Subagent-Driven (recommended)** — Task 별 fresh subagent. 4개 worker는 패턴 같으나 dispatch table은 각 worker 고유 — subagent가 정확히 일관성 유지하도록 review checkpoint.
|
||||||
|
|
||||||
|
**2. Inline Execution** — 현 세션 실행.
|
||||||
|
|
||||||
|
박재오 결정 대기. Plan 1·2 마친 후 진입 권장 (작업량 가장 큼 — 4개 worker × 약 1시간 = 4시간).
|
||||||
593
docs/superpowers/plans/2026-05-25-state-signals-lifecycle.md
Normal file
@@ -0,0 +1,593 @@
|
|||||||
|
# state.signals Lifecycle — Code Review F5 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:** `state.signals` 가 무한 dict 누적되는 문제를 해결. expires_at + cycle_id 부착해서 Phase 5 consumer (agent-office `/signal`) 가 stale 신호를 안전하게 무시할 수 있게.
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
1. `Signal` dict에 `expires_at: ISO str`, `cycle_id: int` 필드 추가.
|
||||||
|
2. `PollState.signal_cycle_id: int` (process 단위 auto-increment).
|
||||||
|
3. `generate_signals(state, dedup, settings)` 진입마다 `cycle_id += 1`.
|
||||||
|
4. emit하는 모든 signal에 `expires_at = as_of + SIGNAL_TTL_SECONDS`, `cycle_id = state.signal_cycle_id` 부착.
|
||||||
|
5. `state.purge_expired_signals(now)` helper — 매 cycle 끝에 호출하여 만료된 항목 제거.
|
||||||
|
6. `state.get_active_signals(now) → list[dict]` — Phase 5 consumer가 호출할 read API. 만료된 것 제외.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12, asyncio, pytest. 기존 cycle 흐름과 호환되도록 generate_signals 인터페이스는 그대로.
|
||||||
|
|
||||||
|
**Why expires_at + cycle_id (not pop-on-read):** consumer가 polling 실패해도 신호 손실 없음. cycle_id로 "이번 cycle에 새로 emit된 신호" 식별 가능 → Phase 5에서 incremental fetch 가능.
|
||||||
|
|
||||||
|
**Working directory:** `C:\Users\jaeoh\Desktop\workspace\web-ai`.
|
||||||
|
|
||||||
|
**Test runner:** `python -m pytest ai_trade/tests -q` (또는 `py -3.12 -m`). 환경 부재 시 plan 진행 중단.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| 파일 | 변경 | 책임 |
|
||||||
|
|------|------|------|
|
||||||
|
| `ai_trade/config.py` | Add 1 field | `signal_ttl_seconds: int` (default 300) |
|
||||||
|
| `ai_trade/state.py` | Modify | `signal_cycle_id: int`, helper 2개 (`get_active_signals`, `purge_expired_signals`) |
|
||||||
|
| `ai_trade/signal_generator.py` | Modify L22-50, 133, 99-111, 174-186 | cycle_id 증가 + expires_at/cycle_id 부착 |
|
||||||
|
| `ai_trade/pull_worker.py` | Modify L46-51 근처 | cycle 끝에 purge 호출 |
|
||||||
|
| `ai_trade/tests/test_state_signals_lifecycle.py` | Create | 5 test (expires, cycle_id, purge, active list) |
|
||||||
|
| `ai_trade/tests/test_signal_generator.py` | Modify | 기존 emit test에 expires_at/cycle_id 필드 검증 추가 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: PollState에 cycle_id + lifecycle helper 추가
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ai_trade/state.py`
|
||||||
|
- Test: `ai_trade/tests/test_state_signals_lifecycle.py` (Create)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ai_trade/tests/test_state_signals_lifecycle.py
|
||||||
|
"""F5 — state.signals lifecycle (expires_at + cycle_id)."""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ai_trade.state import PollState
|
||||||
|
|
||||||
|
KST = ZoneInfo("Asia/Seoul")
|
||||||
|
|
||||||
|
|
||||||
|
def test_initial_signal_cycle_id_is_zero():
|
||||||
|
state = PollState()
|
||||||
|
assert state.signal_cycle_id == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_active_signals_excludes_expired():
|
||||||
|
state = PollState()
|
||||||
|
now = datetime(2026, 5, 25, 10, 0, tzinfo=KST)
|
||||||
|
future = (now + timedelta(seconds=300)).isoformat()
|
||||||
|
past = (now - timedelta(seconds=60)).isoformat()
|
||||||
|
state.signals = {
|
||||||
|
"A": {"ticker": "A", "expires_at": future, "cycle_id": 1, "action": "buy"},
|
||||||
|
"B": {"ticker": "B", "expires_at": past, "cycle_id": 1, "action": "buy"},
|
||||||
|
}
|
||||||
|
active = state.get_active_signals(now)
|
||||||
|
tickers = [s["ticker"] for s in active]
|
||||||
|
assert "A" in tickers
|
||||||
|
assert "B" not in tickers
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_active_signals_treats_missing_expires_as_expired():
|
||||||
|
"""expires_at 없는 legacy 신호는 expired로 간주."""
|
||||||
|
state = PollState()
|
||||||
|
now = datetime(2026, 5, 25, 10, 0, tzinfo=KST)
|
||||||
|
state.signals = {"C": {"ticker": "C", "action": "buy"}}
|
||||||
|
assert state.get_active_signals(now) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_purge_expired_signals_removes_expired():
|
||||||
|
state = PollState()
|
||||||
|
now = datetime(2026, 5, 25, 10, 0, tzinfo=KST)
|
||||||
|
future = (now + timedelta(seconds=300)).isoformat()
|
||||||
|
past = (now - timedelta(seconds=60)).isoformat()
|
||||||
|
state.signals = {
|
||||||
|
"A": {"ticker": "A", "expires_at": future, "cycle_id": 1},
|
||||||
|
"B": {"ticker": "B", "expires_at": past, "cycle_id": 1},
|
||||||
|
}
|
||||||
|
state.purge_expired_signals(now)
|
||||||
|
assert "A" in state.signals
|
||||||
|
assert "B" not in state.signals
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m pytest ai_trade/tests/test_state_signals_lifecycle.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `AttributeError: signal_cycle_id` 또는 `get_active_signals` 미구현.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Modify state.py**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""PollState — process-wide singleton."""
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PollState:
|
||||||
|
portfolio: dict | None = None
|
||||||
|
news_sentiment: dict | None = None
|
||||||
|
screener_preview: dict | None = None
|
||||||
|
minute_bars: dict[str, deque] = field(default_factory=dict)
|
||||||
|
asking_price: dict[str, dict] = field(default_factory=dict)
|
||||||
|
# Phase 3b additions
|
||||||
|
daily_ohlcv: dict[str, list[dict]] = field(default_factory=dict)
|
||||||
|
chronos_predictions: dict[str, dict] = field(default_factory=dict)
|
||||||
|
minute_momentum: dict[str, str] = field(default_factory=dict)
|
||||||
|
signals: dict[str, dict] = field(default_factory=dict)
|
||||||
|
# F5 lifecycle
|
||||||
|
signal_cycle_id: int = 0
|
||||||
|
last_updated: dict[str, str] = field(default_factory=dict)
|
||||||
|
fetch_errors: dict[str, int] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def get_active_signals(self, now: datetime) -> list[dict]:
|
||||||
|
"""expires_at > now 인 신호만 반환. expires_at 없으면 expired 취급."""
|
||||||
|
active: list[dict] = []
|
||||||
|
for sig in self.signals.values():
|
||||||
|
expires_at = sig.get("expires_at")
|
||||||
|
if not expires_at:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
exp_dt = datetime.fromisoformat(expires_at)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if exp_dt > now:
|
||||||
|
active.append(sig)
|
||||||
|
return active
|
||||||
|
|
||||||
|
def purge_expired_signals(self, now: datetime) -> int:
|
||||||
|
"""만료된 signal 제거. 제거된 개수 반환."""
|
||||||
|
to_drop = []
|
||||||
|
for ticker, sig in self.signals.items():
|
||||||
|
expires_at = sig.get("expires_at")
|
||||||
|
if not expires_at:
|
||||||
|
to_drop.append(ticker)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
exp_dt = datetime.fromisoformat(expires_at)
|
||||||
|
except ValueError:
|
||||||
|
to_drop.append(ticker)
|
||||||
|
continue
|
||||||
|
if exp_dt <= now:
|
||||||
|
to_drop.append(ticker)
|
||||||
|
for t in to_drop:
|
||||||
|
del self.signals[t]
|
||||||
|
return len(to_drop)
|
||||||
|
|
||||||
|
|
||||||
|
state = PollState()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m pytest ai_trade/tests/test_state_signals_lifecycle.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 4 PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify full suite still passes**
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m pytest ai_trade/tests -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 기존 test 전부 PASS (state.signals dict 인터페이스 그대로).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ai_trade/state.py ai_trade/tests/test_state_signals_lifecycle.py
|
||||||
|
git commit -m "feat(ai_trade): state.signals에 expires_at + cycle_id lifecycle 추가 (F5 part 1)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: config에 SIGNAL_TTL_SECONDS 추가
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ai_trade/config.py`
|
||||||
|
- Test: `ai_trade/tests/test_state_signals_lifecycle.py` (append)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test**
|
||||||
|
|
||||||
|
`test_state_signals_lifecycle.py` 끝에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_signal_ttl_seconds_default(monkeypatch):
|
||||||
|
monkeypatch.delenv("SIGNAL_TTL_SECONDS", raising=False)
|
||||||
|
from ai_trade.config import Settings
|
||||||
|
s = Settings()
|
||||||
|
assert s.signal_ttl_seconds == 300
|
||||||
|
|
||||||
|
|
||||||
|
def test_signal_ttl_seconds_env_override(monkeypatch):
|
||||||
|
monkeypatch.setenv("SIGNAL_TTL_SECONDS", "60")
|
||||||
|
from ai_trade.config import Settings
|
||||||
|
s = Settings()
|
||||||
|
assert s.signal_ttl_seconds == 60
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to fail**
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m pytest ai_trade/tests/test_state_signals_lifecycle.py -v -k signal_ttl
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: AttributeError.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add field to config.py**
|
||||||
|
|
||||||
|
`Settings` 클래스 안에 추가 (다른 *_threshold 옆):
|
||||||
|
|
||||||
|
```python
|
||||||
|
signal_ttl_seconds: int = field(
|
||||||
|
default_factory=lambda: int(os.getenv("SIGNAL_TTL_SECONDS", "300"))
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test**
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m pytest ai_trade/tests/test_state_signals_lifecycle.py -v -k signal_ttl
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 2 PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ai_trade/config.py ai_trade/tests/test_state_signals_lifecycle.py
|
||||||
|
git commit -m "feat(ai_trade): SIGNAL_TTL_SECONDS env 추가 (F5 part 2)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: signal_generator에 cycle_id + expires_at 부착
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ai_trade/signal_generator.py`
|
||||||
|
- Test: `ai_trade/tests/test_signal_generator.py` (append)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests**
|
||||||
|
|
||||||
|
기존 `test_signal_generator.py` 끝에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo as _ZI
|
||||||
|
|
||||||
|
_KST_TEST = _ZI("Asia/Seoul")
|
||||||
|
|
||||||
|
|
||||||
|
def test_emit_attaches_cycle_id_and_expires_at(
|
||||||
|
state_with_buy_setup, dedup_clean, settings_default,
|
||||||
|
):
|
||||||
|
"""매 emit 시 cycle_id + expires_at 부착 (F5)."""
|
||||||
|
from ai_trade.signal_generator import generate_signals
|
||||||
|
before = datetime.now(_KST_TEST)
|
||||||
|
generate_signals(state_with_buy_setup, dedup_clean, settings_default)
|
||||||
|
after = datetime.now(_KST_TEST)
|
||||||
|
sig = state_with_buy_setup.signals["005930"]
|
||||||
|
assert sig["cycle_id"] == 1
|
||||||
|
assert "expires_at" in sig
|
||||||
|
exp_dt = datetime.fromisoformat(sig["expires_at"])
|
||||||
|
# as_of + 300s (default) — tolerance 5s
|
||||||
|
assert before + timedelta(seconds=295) < exp_dt < after + timedelta(seconds=305)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cycle_id_increments_each_call(
|
||||||
|
state_with_buy_setup, dedup_clean, settings_default,
|
||||||
|
):
|
||||||
|
"""generate_signals 호출마다 cycle_id += 1."""
|
||||||
|
from ai_trade.signal_generator import generate_signals
|
||||||
|
generate_signals(state_with_buy_setup, dedup_clean, settings_default)
|
||||||
|
assert state_with_buy_setup.signal_cycle_id == 1
|
||||||
|
# 2번째 호출 — dedup이 막아도 cycle_id는 증가해야 함
|
||||||
|
generate_signals(state_with_buy_setup, dedup_clean, settings_default)
|
||||||
|
assert state_with_buy_setup.signal_cycle_id == 2
|
||||||
|
```
|
||||||
|
|
||||||
|
**NOTE:** 기존 test_signal_generator.py에 `state_with_buy_setup` / `dedup_clean` / `settings_default` 같은 fixture가 있을 것. 만약 이름이 다르면 실제 fixture 이름에 맞춰 조정. 검증: `grep -n "@pytest.fixture" ai_trade/tests/test_signal_generator.py`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m pytest ai_trade/tests/test_signal_generator.py -v -k "cycle_id or expires"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: KeyError 또는 AttributeError.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Modify signal_generator.py**
|
||||||
|
|
||||||
|
`generate_signals` 함수 (L22-25)를 변경:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def generate_signals(state, dedup, settings) -> None:
|
||||||
|
"""Phase 4 entry — state-mutating. F5: cycle_id += 1 + expires_at 부착."""
|
||||||
|
state.signal_cycle_id += 1
|
||||||
|
_evaluate_sell_signals(state, dedup, settings)
|
||||||
|
_evaluate_buy_signals(state, dedup, settings)
|
||||||
|
```
|
||||||
|
|
||||||
|
`_build_buy_signal` (L99-111)에 두 필드 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _build_buy_signal(state, ticker: str, name: str, rank: int | None, confidence: float) -> dict:
|
||||||
|
ap = state.asking_price[ticker]
|
||||||
|
as_of_dt = datetime.now(KST)
|
||||||
|
expires_at = (as_of_dt + timedelta(seconds=getattr(_current_settings(), "signal_ttl_seconds", 300))).isoformat()
|
||||||
|
return {
|
||||||
|
"ticker": ticker,
|
||||||
|
"name": name,
|
||||||
|
"action": "buy",
|
||||||
|
"confidence_webai": confidence,
|
||||||
|
"current_price": ap["current_price"],
|
||||||
|
"avg_price": None,
|
||||||
|
"pnl_pct": None,
|
||||||
|
"context": _build_context(state, ticker, rank),
|
||||||
|
"as_of": as_of_dt.isoformat(),
|
||||||
|
"cycle_id": state.signal_cycle_id,
|
||||||
|
"expires_at": expires_at,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
같이 `_build_sell_signal` (L174-186):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _build_sell_signal(state, holding: dict, confidence: float, reason: str, settings=None) -> dict:
|
||||||
|
ticker = holding["ticker"]
|
||||||
|
as_of_dt = datetime.now(KST)
|
||||||
|
ttl = getattr(settings, "signal_ttl_seconds", 300) if settings else 300
|
||||||
|
expires_at = (as_of_dt + timedelta(seconds=ttl)).isoformat()
|
||||||
|
return {
|
||||||
|
"ticker": ticker,
|
||||||
|
"name": holding.get("name", ticker),
|
||||||
|
"action": "sell",
|
||||||
|
"confidence_webai": confidence,
|
||||||
|
"current_price": holding.get("current_price"),
|
||||||
|
"avg_price": holding.get("avg_price"),
|
||||||
|
"pnl_pct": holding.get("pnl_pct"),
|
||||||
|
"context": _build_context(state, ticker, rank=None, sell_reason=reason),
|
||||||
|
"as_of": as_of_dt.isoformat(),
|
||||||
|
"cycle_id": state.signal_cycle_id,
|
||||||
|
"expires_at": expires_at,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`_build_buy_signal`이 settings를 안 받고 있으니, 호출부도 갱신해야 함. 현실적으로 두 함수에 `settings` 인자를 추가하는 것이 깔끔. 변경:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _evaluate_buy_signals(state, dedup, settings) -> None:
|
||||||
|
candidates = _buy_candidates(state)
|
||||||
|
for ticker, name, rank in candidates:
|
||||||
|
existing = state.signals.get(ticker)
|
||||||
|
if existing is not None and existing.get("action") == "sell":
|
||||||
|
logger.debug("buy %s skipped: same-cycle sell precedence", ticker)
|
||||||
|
continue
|
||||||
|
if not _check_buy_hard_gate(state, ticker, settings):
|
||||||
|
logger.debug("buy %s skipped: hard gate failed", ticker)
|
||||||
|
continue
|
||||||
|
confidence = _compute_buy_confidence(state, ticker, rank)
|
||||||
|
if confidence <= settings.confidence_threshold:
|
||||||
|
logger.debug("buy %s skipped: confidence %.3f <= %.3f",
|
||||||
|
ticker, confidence, settings.confidence_threshold)
|
||||||
|
continue
|
||||||
|
if dedup.is_recent(ticker, "buy", within_hours=24):
|
||||||
|
logger.debug("buy %s skipped: dedup 24h", ticker)
|
||||||
|
continue
|
||||||
|
state.signals[ticker] = _build_buy_signal(state, ticker, name, rank, confidence, settings)
|
||||||
|
dedup.record(ticker, "buy", confidence=confidence)
|
||||||
|
logger.info("signal emit %s buy conf=%.3f rank=%s cycle=%d",
|
||||||
|
ticker, confidence, rank, state.signal_cycle_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_buy_signal(state, ticker, name, rank, confidence, settings) -> dict:
|
||||||
|
ap = state.asking_price[ticker]
|
||||||
|
as_of_dt = datetime.now(KST)
|
||||||
|
ttl = settings.signal_ttl_seconds
|
||||||
|
expires_at = (as_of_dt + timedelta(seconds=ttl)).isoformat()
|
||||||
|
return {
|
||||||
|
"ticker": ticker,
|
||||||
|
"name": name,
|
||||||
|
"action": "buy",
|
||||||
|
"confidence_webai": confidence,
|
||||||
|
"current_price": ap["current_price"],
|
||||||
|
"avg_price": None,
|
||||||
|
"pnl_pct": None,
|
||||||
|
"context": _build_context(state, ticker, rank),
|
||||||
|
"as_of": as_of_dt.isoformat(),
|
||||||
|
"cycle_id": state.signal_cycle_id,
|
||||||
|
"expires_at": expires_at,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
매도 측도 마찬가지로 `settings`를 통과시킴. `_try_stop_loss` 등은 이미 `settings`를 받으므로 `_build_sell_signal(..., settings=settings)` 로 호출.
|
||||||
|
|
||||||
|
import 추가 (signal_generator.py 상단):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
```
|
||||||
|
|
||||||
|
(기존 import에 `timedelta` 만 추가)
|
||||||
|
|
||||||
|
`_current_settings()` 같은 헬퍼는 만들지 않음 — settings를 명시적으로 전달.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m pytest ai_trade/tests/test_signal_generator.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 신규 2개 PASS, 기존 PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ai_trade/signal_generator.py ai_trade/tests/test_signal_generator.py
|
||||||
|
git commit -m "feat(ai_trade): emit signal에 cycle_id + expires_at 부착 (F5 part 3)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: pull_worker가 cycle 끝에 purge 호출
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ai_trade/pull_worker.py`
|
||||||
|
- Test: `ai_trade/tests/test_pull_worker.py` (append)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test**
|
||||||
|
|
||||||
|
`test_pull_worker.py` 끝에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def test_poll_loop_purges_expired_signals(monkeypatch):
|
||||||
|
"""매 cycle 끝에 expired signal이 제거됨 (F5)."""
|
||||||
|
from ai_trade import pull_worker
|
||||||
|
from ai_trade.state import PollState
|
||||||
|
from datetime import datetime as _dt
|
||||||
|
from zoneinfo import ZoneInfo as _ZI
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
import asyncio as _asyncio
|
||||||
|
|
||||||
|
_kst = _ZI("Asia/Seoul")
|
||||||
|
now = _dt(2026, 5, 18, 10, 0, tzinfo=_kst)
|
||||||
|
|
||||||
|
class FrozenDT:
|
||||||
|
@staticmethod
|
||||||
|
def now(tz=None): return now
|
||||||
|
|
||||||
|
state = PollState()
|
||||||
|
state.signals = {
|
||||||
|
"OLD": {"ticker": "OLD", "expires_at": _dt(2026, 5, 18, 9, 0, tzinfo=_kst).isoformat(), "cycle_id": 1},
|
||||||
|
"FRESH": {"ticker": "FRESH", "expires_at": _dt(2026, 5, 18, 10, 30, tzinfo=_kst).isoformat(), "cycle_id": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(pull_worker, "datetime", FrozenDT)
|
||||||
|
monkeypatch.setattr(pull_worker, "_is_market_day", lambda n: True)
|
||||||
|
monkeypatch.setattr(pull_worker, "_is_polling_window", lambda n: True)
|
||||||
|
monkeypatch.setattr(pull_worker, "_next_interval", lambda n: 0.01)
|
||||||
|
monkeypatch.setattr(pull_worker, "_run_polling_cycle", AsyncMock())
|
||||||
|
monkeypatch.setattr(pull_worker, "update_minute_momentum_for_all", lambda s: None)
|
||||||
|
monkeypatch.setattr(pull_worker, "_is_post_close_trigger", lambda *a, **k: False)
|
||||||
|
|
||||||
|
shutdown = _asyncio.Event()
|
||||||
|
async def stop_soon():
|
||||||
|
await _asyncio.sleep(0.05)
|
||||||
|
shutdown.set()
|
||||||
|
_asyncio.create_task(stop_soon())
|
||||||
|
|
||||||
|
await pull_worker.poll_loop(
|
||||||
|
client=MagicMock(), state=state, shutdown=shutdown,
|
||||||
|
kis_client=MagicMock(), chronos=MagicMock(),
|
||||||
|
dedup=None, settings=None,
|
||||||
|
)
|
||||||
|
assert "OLD" not in state.signals
|
||||||
|
assert "FRESH" in state.signals
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to fail**
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m pytest ai_trade/tests/test_pull_worker.py::test_poll_loop_purges_expired_signals -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL — OLD가 남아있음.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add purge call in poll_loop**
|
||||||
|
|
||||||
|
`ai_trade/pull_worker.py` `poll_loop` 안, signals 생성 이후 (또는 cycle 끝 직전) 한 줄 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Phase 4: generate signals
|
||||||
|
if dedup is not None and settings is not None:
|
||||||
|
try:
|
||||||
|
from ai_trade.signal_generator import generate_signals
|
||||||
|
generate_signals(state, dedup, settings)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("generate_signals failed")
|
||||||
|
# F5: 만료된 signal purge (consumer 미사용 케이스 보호)
|
||||||
|
try:
|
||||||
|
state.purge_expired_signals(datetime.now(KST))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("purge_expired_signals failed")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test**
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m pytest ai_trade/tests/test_pull_worker.py::test_poll_loop_purges_expired_signals -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run full suite**
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m pytest ai_trade/tests -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 모두 PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ai_trade/pull_worker.py ai_trade/tests/test_pull_worker.py
|
||||||
|
git commit -m "feat(ai_trade): poll_loop가 매 cycle 끝에 expired signal purge (F5 part 4)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: 전체 회귀 + push
|
||||||
|
|
||||||
|
- [ ] **Step 1: Final pytest**
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m pytest ai_trade/tests -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 모두 PASS (총 신규 약 9개 + 기존 56개).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
1. **expires_at + cycle_id 부착**: `_build_buy_signal`, `_build_sell_signal` 양쪽 ✅
|
||||||
|
2. **cycle_id 증가**: `generate_signals` 진입에서 단 1회 ✅
|
||||||
|
3. **purge**: poll_loop cycle 마지막에 1회 호출 ✅
|
||||||
|
4. **get_active_signals**: Phase 5 consumer가 호출할 read API 존재 ✅
|
||||||
|
5. **legacy 신호 호환**: `expires_at` 없는 신호는 expired 취급 → 안전 ✅
|
||||||
|
|
||||||
|
**미커버 항목 (의도적)**:
|
||||||
|
- Phase 5 consumer가 처리 후 explicit drain하는 API는 이 plan에서 안 다룸 (consumer가 read-only로도 충분 — expires_at + dedup으로 idempotent).
|
||||||
|
- agent-office `/signal` HTTP endpoint는 Phase 5 plan 영역.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Handoff
|
||||||
|
|
||||||
|
**Plan complete and saved to `docs/superpowers/plans/2026-05-25-state-signals-lifecycle.md`.**
|
||||||
|
|
||||||
|
**1. Subagent-Driven (recommended)** — Task 별 fresh subagent.
|
||||||
|
**2. Inline Execution** — 현 세션 실행.
|
||||||
|
|
||||||
|
박재오 결정 대기. Plan 1 (hotfix) 마친 뒤 진입 권장.
|
||||||
765
docs/superpowers/plans/2026-06-11-agent-oversight-timeline.md
Normal file
@@ -0,0 +1,765 @@
|
|||||||
|
# 에이전트 횡단 오버사이트 타임라인 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:** AgentOffice 우측 패널(에이전트 미선택 시)에 전 에이전트 활동을 시간순으로 보여주는 횡단 오버사이트 타임라인을 추가한다.
|
||||||
|
|
||||||
|
**Architecture:** 백엔드 `GET /api/agent-office/activity`(필터 지원)를 소비. `useActivityFeed` 훅이 페이지네이션·필터·WS refreshTrigger 재조회를 담당하고, `ActivityTimeline`이 `ActivityFilters` + `ActivityItem` 리스트 + IntersectionObserver 무한스크롤을 조립한다. AgentOffice는 `selectedAgent===null`일 때 기존 `EmptyDetailPanel`을 `ActivityTimeline`으로 교체한다.
|
||||||
|
|
||||||
|
**Tech Stack:** React 18, vitest + @testing-library/react(v16, `renderHook` 사용), 기존 `ao-*` CSS 컨벤션, `AGENT_META` 색상/표시명 재사용.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 구조
|
||||||
|
|
||||||
|
| 파일 | 책임 |
|
||||||
|
|------|------|
|
||||||
|
| `src/api.js` (수정) | `agentActivity({agent_id,type,status,days,limit,offset})` 헬퍼 추가 |
|
||||||
|
| `src/pages/agent-office/hooks/useActivityFeed.js` (생성) | items/total/loading/error/hasMore 상태, 필터·refreshTrigger 재조회, loadMore append |
|
||||||
|
| `src/pages/agent-office/components/ActivityItem.jsx` (생성) | 한 행: agent 색·표시명 + 메시지 + 상태/level 뱃지 + 시간/duration, 클릭 → onSelectAgent |
|
||||||
|
| `src/pages/agent-office/components/ActivityFilters.jsx` (생성) | agent/type/status/days select 4종, type=log 시 status 비활성 |
|
||||||
|
| `src/pages/agent-office/components/ActivityTimeline.jsx` (생성) | 컨테이너: 헤더 + 필터 + 리스트 + sentinel + 상태 |
|
||||||
|
| `src/pages/agent-office/AgentOffice.jsx` (수정) | null 분기를 ActivityTimeline으로 교체 |
|
||||||
|
| `src/pages/agent-office/AgentOffice.css` (수정) | 타임라인 baseline 스타일 (Task 7) → designer 마감 (Task 8) |
|
||||||
|
| 각 `*.test.{js,jsx}` | hook/Item/Filters 단위 테스트 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: `agentActivity` API 헬퍼
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/api.js` (기존 `getActivityFeed` 줄 근처, 596라인 부근)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 헬퍼 추가**
|
||||||
|
|
||||||
|
`src/api.js`에서 기존 줄
|
||||||
|
```js
|
||||||
|
export const getActivityFeed = (limit=50, offset=0) => apiGet(`/api/agent-office/activity?limit=${limit}&offset=${offset}`);
|
||||||
|
```
|
||||||
|
바로 아래에 추가:
|
||||||
|
```js
|
||||||
|
// 횡단 오버사이트 타임라인용 — 빈 값은 쿼리에서 제외(백엔드 브랜치 선택).
|
||||||
|
export const agentActivity = ({ agent_id, type, status, days, limit = 30, offset = 0 } = {}) => {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (agent_id) p.set('agent_id', agent_id);
|
||||||
|
if (type) p.set('type', type);
|
||||||
|
if (status) p.set('status', status);
|
||||||
|
if (days) p.set('days', String(days));
|
||||||
|
p.set('limit', String(limit));
|
||||||
|
p.set('offset', String(offset));
|
||||||
|
return apiGet(`/api/agent-office/activity?${p.toString()}`);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: lint 통과 확인**
|
||||||
|
|
||||||
|
Run: `npm run lint`
|
||||||
|
Expected: 에러 없음 (no-unused-vars 등)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/api.js
|
||||||
|
git commit -m "feat(agent-office): agentActivity API 헬퍼 추가"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `useActivityFeed` 훅 (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/pages/agent-office/hooks/useActivityFeed.js`
|
||||||
|
- Test: `src/pages/agent-office/hooks/useActivityFeed.test.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 실패하는 테스트 작성**
|
||||||
|
|
||||||
|
`src/pages/agent-office/hooks/useActivityFeed.test.js`:
|
||||||
|
```js
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||||
|
import { useActivityFeed } from './useActivityFeed.js';
|
||||||
|
|
||||||
|
const mockAgentActivity = vi.fn();
|
||||||
|
vi.mock('../../../api', () => ({
|
||||||
|
agentActivity: (...args) => mockAgentActivity(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => mockAgentActivity.mockReset());
|
||||||
|
|
||||||
|
const item = (over = {}) => ({ type: 'task', task_id: 'a', agent_id: 'stock', created_at: 't1', ...over });
|
||||||
|
|
||||||
|
describe('useActivityFeed', () => {
|
||||||
|
it('마운트 시 offset=0으로 첫 페이지를 불러온다', async () => {
|
||||||
|
mockAgentActivity.mockResolvedValueOnce({ items: [item()], total: 1 });
|
||||||
|
const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0));
|
||||||
|
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||||
|
expect(mockAgentActivity).toHaveBeenCalledWith(expect.objectContaining({ days: 7, offset: 0 }));
|
||||||
|
expect(result.current.total).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadMore는 다음 offset으로 append한다', async () => {
|
||||||
|
mockAgentActivity
|
||||||
|
.mockResolvedValueOnce({ items: [item({ task_id: 'a' })], total: 2 })
|
||||||
|
.mockResolvedValueOnce({ items: [item({ task_id: 'b', agent_id: 'lotto', created_at: 't2' })], total: 2 });
|
||||||
|
const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0));
|
||||||
|
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||||
|
await act(async () => { result.current.loadMore(); });
|
||||||
|
await waitFor(() => expect(result.current.items).toHaveLength(2));
|
||||||
|
expect(mockAgentActivity).toHaveBeenLastCalledWith(expect.objectContaining({ offset: 1 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('필터 변경 시 offset 리셋 + items 교체', async () => {
|
||||||
|
mockAgentActivity
|
||||||
|
.mockResolvedValueOnce({ items: [item({ task_id: 'a' })], total: 1 })
|
||||||
|
.mockResolvedValueOnce({ items: [item({ type: 'log', task_id: 'c', agent_id: 'insta', created_at: 't3', level: 'info' })], total: 1 });
|
||||||
|
const { result, rerender } = renderHook(({ f }) => useActivityFeed(f, 0), { initialProps: { f: { days: 7 } } });
|
||||||
|
await waitFor(() => expect(result.current.items[0].task_id).toBe('a'));
|
||||||
|
rerender({ f: { days: 7, agent_id: 'insta' } });
|
||||||
|
await waitFor(() => expect(result.current.items[0].task_id).toBe('c'));
|
||||||
|
expect(result.current.items).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshTrigger 변경 시 첫 페이지 재조회', async () => {
|
||||||
|
mockAgentActivity.mockResolvedValue({ items: [item()], total: 1 });
|
||||||
|
const { rerender } = renderHook(({ rt }) => useActivityFeed({ days: 7 }, rt), { initialProps: { rt: 0 } });
|
||||||
|
await waitFor(() => expect(mockAgentActivity).toHaveBeenCalledTimes(1));
|
||||||
|
rerender({ rt: 1 });
|
||||||
|
await waitFor(() => expect(mockAgentActivity).toHaveBeenCalledTimes(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasMore는 items.length < total', async () => {
|
||||||
|
mockAgentActivity.mockResolvedValueOnce({ items: [item()], total: 5 });
|
||||||
|
const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0));
|
||||||
|
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||||
|
expect(result.current.hasMore).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 실패 확인**
|
||||||
|
|
||||||
|
Run: `npm run test:run -- src/pages/agent-office/hooks/useActivityFeed.test.js`
|
||||||
|
Expected: FAIL — "Failed to resolve import './useActivityFeed.js'" 또는 useActivityFeed undefined
|
||||||
|
|
||||||
|
- [ ] **Step 3: 훅 구현**
|
||||||
|
|
||||||
|
`src/pages/agent-office/hooks/useActivityFeed.js`:
|
||||||
|
```js
|
||||||
|
// src/pages/agent-office/hooks/useActivityFeed.js
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { agentActivity } from '../../../api';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 30;
|
||||||
|
|
||||||
|
export function useActivityFeed(filters, refreshTrigger = 0) {
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const offsetRef = useRef(0);
|
||||||
|
const loadingRef = useRef(false);
|
||||||
|
const filtersRef = useRef(filters);
|
||||||
|
filtersRef.current = filters;
|
||||||
|
|
||||||
|
const filterKey = JSON.stringify(filters);
|
||||||
|
|
||||||
|
const fetchPage = useCallback(async (offset, replace) => {
|
||||||
|
if (loadingRef.current) return;
|
||||||
|
loadingRef.current = true;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await agentActivity({ ...filtersRef.current, limit: PAGE_SIZE, offset });
|
||||||
|
const newItems = Array.isArray(data?.items) ? data.items : [];
|
||||||
|
setTotal(data?.total || 0);
|
||||||
|
setItems(prev => (replace ? newItems : [...prev, ...newItems]));
|
||||||
|
offsetRef.current = offset + newItems.length;
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || '불러오기 실패');
|
||||||
|
} finally {
|
||||||
|
loadingRef.current = false;
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
offsetRef.current = 0;
|
||||||
|
fetchPage(0, true);
|
||||||
|
}, [filterKey, refreshTrigger, fetchPage]);
|
||||||
|
|
||||||
|
const loadMore = useCallback(() => {
|
||||||
|
if (loadingRef.current) return;
|
||||||
|
if (offsetRef.current >= total) return;
|
||||||
|
fetchPage(offsetRef.current, false);
|
||||||
|
}, [fetchPage, total]);
|
||||||
|
|
||||||
|
const retry = useCallback(() => {
|
||||||
|
offsetRef.current = 0;
|
||||||
|
fetchPage(0, true);
|
||||||
|
}, [fetchPage]);
|
||||||
|
|
||||||
|
const hasMore = items.length < total;
|
||||||
|
return { items, total, loading, error, hasMore, loadMore, retry };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `npm run test:run -- src/pages/agent-office/hooks/useActivityFeed.test.js`
|
||||||
|
Expected: PASS (5 tests)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/pages/agent-office/hooks/useActivityFeed.js src/pages/agent-office/hooks/useActivityFeed.test.js
|
||||||
|
git commit -m "feat(agent-office): useActivityFeed 훅 (페이지네이션·필터·refresh)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: `ActivityItem` 컴포넌트 (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/pages/agent-office/components/ActivityItem.jsx`
|
||||||
|
- Test: `src/pages/agent-office/components/ActivityItem.test.jsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 실패하는 테스트 작성**
|
||||||
|
|
||||||
|
`src/pages/agent-office/components/ActivityItem.test.jsx`:
|
||||||
|
```jsx
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import ActivityItem from './ActivityItem.jsx';
|
||||||
|
|
||||||
|
describe('ActivityItem', () => {
|
||||||
|
it('task 항목은 상태 뱃지와 duration을 렌더한다', () => {
|
||||||
|
render(<ActivityItem item={{ type: 'task', agent_id: 'stock', task_id: 'a', message: 'holdings_brief', created_at: '2026-06-10T23:30:00Z', status: 'succeeded', duration_seconds: 2 }} onSelectAgent={() => {}} />);
|
||||||
|
expect(screen.getByText('holdings_brief')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/완료/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('2s')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('log 항목은 level 아이콘을 렌더한다', () => {
|
||||||
|
render(<ActivityItem item={{ type: 'log', agent_id: 'lotto', task_id: 'b', message: 'signal_check', created_at: '2026-06-10T23:15:00Z', level: 'error' }} onSelectAgent={() => {}} />);
|
||||||
|
expect(screen.getByText('signal_check')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('❌')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('클릭 시 onSelectAgent(agent_id)를 호출한다', () => {
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
render(<ActivityItem item={{ type: 'task', agent_id: 'insta', task_id: 'c', message: '발급', created_at: '2026-06-10T23:00:00Z', status: 'pending' }} onSelectAgent={onSelect} />);
|
||||||
|
fireEvent.click(screen.getByText('발급').closest('.ao-activity-item'));
|
||||||
|
expect(onSelect).toHaveBeenCalledWith('insta');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('미지정 agent_id는 id를 그대로 표시한다', () => {
|
||||||
|
render(<ActivityItem item={{ type: 'log', agent_id: 'unknown', task_id: 'd', message: 'x', created_at: '2026-06-10T23:00:00Z', level: 'info' }} onSelectAgent={() => {}} />);
|
||||||
|
expect(screen.getByText('unknown')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 실패 확인**
|
||||||
|
|
||||||
|
Run: `npm run test:run -- src/pages/agent-office/components/ActivityItem.test.jsx`
|
||||||
|
Expected: FAIL — import 해결 실패
|
||||||
|
|
||||||
|
- [ ] **Step 3: 컴포넌트 구현**
|
||||||
|
|
||||||
|
`src/pages/agent-office/components/ActivityItem.jsx`:
|
||||||
|
```jsx
|
||||||
|
// src/pages/agent-office/components/ActivityItem.jsx
|
||||||
|
import { AGENT_META } from '../constants.js';
|
||||||
|
|
||||||
|
const STATUS_STYLE = {
|
||||||
|
succeeded: { bg: '#065f46', fg: '#34d399', label: '✓ 완료' },
|
||||||
|
failed: { bg: '#7f1d1d', fg: '#fca5a5', label: '✗ 실패' },
|
||||||
|
working: { bg: '#1e3a5f', fg: '#60a5fa', label: '⏳ 진행' },
|
||||||
|
pending: { bg: '#92400e', fg: '#fbbf24', label: '⏳ 대기' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEVEL_STYLE = {
|
||||||
|
error: { icon: '❌', cls: 'level-error' },
|
||||||
|
warning: { icon: '⚠️', cls: 'level-warning' },
|
||||||
|
info: { icon: '·', cls: 'level-info' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTime(ts) {
|
||||||
|
if (!ts) return '';
|
||||||
|
const d = new Date(ts);
|
||||||
|
const now = new Date();
|
||||||
|
const isToday = d.toDateString() === now.toDateString();
|
||||||
|
const time = d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
return isToday ? time : `${d.getMonth() + 1}/${d.getDate()} ${time}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActivityItem({ item, onSelectAgent }) {
|
||||||
|
const meta = AGENT_META[item.agent_id];
|
||||||
|
const color = meta?.color || '#6b7280';
|
||||||
|
const name = meta?.displayName || item.agent_id;
|
||||||
|
const isTask = item.type === 'task';
|
||||||
|
const status = STATUS_STYLE[item.status] || STATUS_STYLE.pending;
|
||||||
|
const level = LEVEL_STYLE[item.level] || LEVEL_STYLE.info;
|
||||||
|
const highlight = isTask && (item.status === 'pending' || item.status === 'working');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`ao-activity-item ${isTask ? 'is-task' : 'is-log'} ${highlight ? 'is-highlight' : ''}`}
|
||||||
|
onClick={() => onSelectAgent(item.agent_id)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<span className="ao-activity-dot" style={{ background: color }} aria-hidden="true" />
|
||||||
|
<div className="ao-activity-body">
|
||||||
|
<div className="ao-activity-line">
|
||||||
|
<span className="ao-activity-agent" style={{ color }}>{name}</span>
|
||||||
|
{isTask
|
||||||
|
? <span className="ao-activity-badge" style={{ background: status.bg, color: status.fg }}>{status.label}</span>
|
||||||
|
: <span className={`ao-activity-level ${level.cls}`}>{level.icon}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="ao-activity-msg">{item.message}</div>
|
||||||
|
</div>
|
||||||
|
<div className="ao-activity-meta">
|
||||||
|
<span className="ao-activity-time">{formatTime(item.created_at)}</span>
|
||||||
|
{isTask && item.duration_seconds != null && (
|
||||||
|
<span className="ao-activity-dur">{item.duration_seconds}s</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `npm run test:run -- src/pages/agent-office/components/ActivityItem.test.jsx`
|
||||||
|
Expected: PASS (4 tests)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/pages/agent-office/components/ActivityItem.jsx src/pages/agent-office/components/ActivityItem.test.jsx
|
||||||
|
git commit -m "feat(agent-office): ActivityItem (task/log 행 + 상태 뱃지)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: `ActivityFilters` 컴포넌트 (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/pages/agent-office/components/ActivityFilters.jsx`
|
||||||
|
- Test: `src/pages/agent-office/components/ActivityFilters.test.jsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 실패하는 테스트 작성**
|
||||||
|
|
||||||
|
`src/pages/agent-office/components/ActivityFilters.test.jsx`:
|
||||||
|
```jsx
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import ActivityFilters from './ActivityFilters.jsx';
|
||||||
|
|
||||||
|
const base = { agent_id: '', type: '', status: '', days: 7 };
|
||||||
|
|
||||||
|
describe('ActivityFilters', () => {
|
||||||
|
it('type=log이면 상태 필터가 비활성화된다', () => {
|
||||||
|
render(<ActivityFilters filters={{ ...base, type: 'log' }} onChange={() => {}} />);
|
||||||
|
expect(screen.getByLabelText('상태 필터')).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('기간 변경 시 onChange가 days와 함께 호출된다', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<ActivityFilters filters={base} onChange={onChange} />);
|
||||||
|
fireEvent.change(screen.getByLabelText('기간 필터'), { target: { value: '30' } });
|
||||||
|
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ days: 30 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('type을 log로 바꾸면 status를 비운다', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<ActivityFilters filters={{ ...base, status: 'succeeded' }} onChange={onChange} />);
|
||||||
|
fireEvent.change(screen.getByLabelText('타입 필터'), { target: { value: 'log' } });
|
||||||
|
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ type: 'log', status: '' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 실패 확인**
|
||||||
|
|
||||||
|
Run: `npm run test:run -- src/pages/agent-office/components/ActivityFilters.test.jsx`
|
||||||
|
Expected: FAIL — import 해결 실패
|
||||||
|
|
||||||
|
- [ ] **Step 3: 컴포넌트 구현**
|
||||||
|
|
||||||
|
`src/pages/agent-office/components/ActivityFilters.jsx`:
|
||||||
|
```jsx
|
||||||
|
// src/pages/agent-office/components/ActivityFilters.jsx
|
||||||
|
import { ACTIVE_AGENT_IDS, AGENT_META } from '../constants.js';
|
||||||
|
|
||||||
|
const TYPE_OPTIONS = [
|
||||||
|
{ value: '', label: '전체' },
|
||||||
|
{ value: 'task', label: 'Task' },
|
||||||
|
{ value: 'log', label: 'Log' },
|
||||||
|
];
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: '', label: '전체' },
|
||||||
|
{ value: 'succeeded', label: '완료' },
|
||||||
|
{ value: 'failed', label: '실패' },
|
||||||
|
{ value: 'pending', label: '대기' },
|
||||||
|
];
|
||||||
|
const DAYS_OPTIONS = [
|
||||||
|
{ value: 1, label: '1일' },
|
||||||
|
{ value: 7, label: '7일' },
|
||||||
|
{ value: 30, label: '30일' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ActivityFilters({ filters, onChange }) {
|
||||||
|
const set = (patch) => onChange({ ...filters, ...patch });
|
||||||
|
const statusDisabled = filters.type === 'log';
|
||||||
|
return (
|
||||||
|
<div className="ao-activity-filters">
|
||||||
|
<select
|
||||||
|
className="ao-activity-select"
|
||||||
|
aria-label="에이전트 필터"
|
||||||
|
value={filters.agent_id || ''}
|
||||||
|
onChange={e => set({ agent_id: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">모든 에이전트</option>
|
||||||
|
{ACTIVE_AGENT_IDS.map(id => (
|
||||||
|
<option key={id} value={id}>{AGENT_META[id]?.displayName || id}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="ao-activity-select"
|
||||||
|
aria-label="타입 필터"
|
||||||
|
value={filters.type || ''}
|
||||||
|
onChange={e => set(e.target.value === 'log' ? { type: 'log', status: '' } : { type: e.target.value })}
|
||||||
|
>
|
||||||
|
{TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="ao-activity-select"
|
||||||
|
aria-label="상태 필터"
|
||||||
|
value={filters.status || ''}
|
||||||
|
disabled={statusDisabled}
|
||||||
|
onChange={e => set({ status: e.target.value })}
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="ao-activity-select"
|
||||||
|
aria-label="기간 필터"
|
||||||
|
value={filters.days}
|
||||||
|
onChange={e => set({ days: Number(e.target.value) })}
|
||||||
|
>
|
||||||
|
{DAYS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `npm run test:run -- src/pages/agent-office/components/ActivityFilters.test.jsx`
|
||||||
|
Expected: PASS (3 tests)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/pages/agent-office/components/ActivityFilters.jsx src/pages/agent-office/components/ActivityFilters.test.jsx
|
||||||
|
git commit -m "feat(agent-office): ActivityFilters (agent/type/status/days)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: `ActivityTimeline` 컨테이너 (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/pages/agent-office/components/ActivityTimeline.jsx`
|
||||||
|
- Test: `src/pages/agent-office/components/ActivityTimeline.test.jsx`
|
||||||
|
|
||||||
|
> 참고: jsdom에는 IntersectionObserver가 없으므로 테스트 setup에서 stub이 필요하다. Step 1에서 테스트 파일 상단에 직접 stub을 둔다(전역 test-setup 수정 없이 국소 처리).
|
||||||
|
|
||||||
|
- [ ] **Step 1: 실패하는 테스트 작성**
|
||||||
|
|
||||||
|
`src/pages/agent-office/components/ActivityTimeline.test.jsx`:
|
||||||
|
```jsx
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
|
import ActivityTimeline from './ActivityTimeline.jsx';
|
||||||
|
|
||||||
|
// jsdom IntersectionObserver stub
|
||||||
|
beforeEach(() => {
|
||||||
|
global.IntersectionObserver = class {
|
||||||
|
observe() {} unobserve() {} disconnect() {}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockAgentActivity = vi.fn();
|
||||||
|
vi.mock('../../../api', () => ({
|
||||||
|
agentActivity: (...args) => mockAgentActivity(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ActivityTimeline', () => {
|
||||||
|
it('항목을 렌더하고 헤더에 total을 표시한다', async () => {
|
||||||
|
mockAgentActivity.mockResolvedValueOnce({
|
||||||
|
items: [{ type: 'task', agent_id: 'stock', task_id: 'a', message: 'holdings_brief', created_at: '2026-06-10T23:30:00Z', status: 'succeeded', duration_seconds: 2 }],
|
||||||
|
total: 1,
|
||||||
|
});
|
||||||
|
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={() => {}} />);
|
||||||
|
await waitFor(() => expect(screen.getByText('holdings_brief')).toBeInTheDocument());
|
||||||
|
expect(screen.getByText(/팀 활동/)).toHaveTextContent('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('빈 결과면 안내 문구를 표시한다', async () => {
|
||||||
|
mockAgentActivity.mockResolvedValueOnce({ items: [], total: 0 });
|
||||||
|
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={() => {}} />);
|
||||||
|
await waitFor(() => expect(screen.getByText(/활동 없음/)).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('항목 클릭 시 onSelectAgent가 호출된다', async () => {
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
mockAgentActivity.mockResolvedValueOnce({
|
||||||
|
items: [{ type: 'task', agent_id: 'lotto', task_id: 'b', message: 'signal_check', created_at: '2026-06-10T23:15:00Z', status: 'succeeded' }],
|
||||||
|
total: 1,
|
||||||
|
});
|
||||||
|
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={onSelect} />);
|
||||||
|
const row = await screen.findByText('signal_check');
|
||||||
|
fireEvent.click(row.closest('.ao-activity-item'));
|
||||||
|
expect(onSelect).toHaveBeenCalledWith('lotto');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 실패 확인**
|
||||||
|
|
||||||
|
Run: `npm run test:run -- src/pages/agent-office/components/ActivityTimeline.test.jsx`
|
||||||
|
Expected: FAIL — import 해결 실패
|
||||||
|
|
||||||
|
- [ ] **Step 3: 컴포넌트 구현**
|
||||||
|
|
||||||
|
`src/pages/agent-office/components/ActivityTimeline.jsx`:
|
||||||
|
```jsx
|
||||||
|
// src/pages/agent-office/components/ActivityTimeline.jsx
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { useActivityFeed } from '../hooks/useActivityFeed.js';
|
||||||
|
import ActivityFilters from './ActivityFilters.jsx';
|
||||||
|
import ActivityItem from './ActivityItem.jsx';
|
||||||
|
|
||||||
|
const DEFAULT_FILTERS = { agent_id: '', type: '', status: '', days: 7 };
|
||||||
|
|
||||||
|
export default function ActivityTimeline({ refreshTrigger, onSelectAgent }) {
|
||||||
|
const [filters, setFilters] = useState(DEFAULT_FILTERS);
|
||||||
|
const { items, total, loading, error, hasMore, loadMore, retry } = useActivityFeed(filters, refreshTrigger);
|
||||||
|
|
||||||
|
const sentinelRef = useRef(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const el = sentinelRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const io = new IntersectionObserver(entries => {
|
||||||
|
if (entries[0].isIntersecting) loadMore();
|
||||||
|
}, { rootMargin: '120px' });
|
||||||
|
io.observe(el);
|
||||||
|
return () => io.disconnect();
|
||||||
|
}, [loadMore, items.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ao-sidepanel ao-activity">
|
||||||
|
<div className="ao-sidepanel-header ao-activity-header">
|
||||||
|
<div className="ao-sidepanel-name">팀 활동 ({total})</div>
|
||||||
|
</div>
|
||||||
|
<ActivityFilters filters={filters} onChange={setFilters} />
|
||||||
|
<div className="ao-sidepanel-content ao-activity-content">
|
||||||
|
{error && (
|
||||||
|
<div className="ao-activity-error">
|
||||||
|
불러오기 실패: {error}
|
||||||
|
<button type="button" onClick={retry}>재시도</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!error && items.length === 0 && !loading && (
|
||||||
|
<div className="ao-empty">최근 {filters.days}일 활동 없음</div>
|
||||||
|
)}
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<ActivityItem
|
||||||
|
key={`${item.type}-${item.task_id}-${item.created_at}-${i}`}
|
||||||
|
item={item}
|
||||||
|
onSelectAgent={onSelectAgent}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{loading && <div className="ao-activity-loading">불러오는 중…</div>}
|
||||||
|
{hasMore && !loading && <div ref={sentinelRef} className="ao-activity-sentinel" />}
|
||||||
|
{!hasMore && items.length > 0 && <div className="ao-activity-end">더 이상 활동 없음</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `npm run test:run -- src/pages/agent-office/components/ActivityTimeline.test.jsx`
|
||||||
|
Expected: PASS (3 tests)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/pages/agent-office/components/ActivityTimeline.jsx src/pages/agent-office/components/ActivityTimeline.test.jsx
|
||||||
|
git commit -m "feat(agent-office): ActivityTimeline 컨테이너 (필터+무한스크롤)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: AgentOffice 우측 패널 배선
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/pages/agent-office/AgentOffice.jsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: import 추가**
|
||||||
|
|
||||||
|
`src/pages/agent-office/AgentOffice.jsx`에서
|
||||||
|
```js
|
||||||
|
import EmptyDetailPanel from './components/EmptyDetailPanel.jsx';
|
||||||
|
```
|
||||||
|
바로 아래에 추가:
|
||||||
|
```js
|
||||||
|
import ActivityTimeline from './components/ActivityTimeline.jsx';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: null 분기 교체**
|
||||||
|
|
||||||
|
같은 파일에서
|
||||||
|
```js
|
||||||
|
if (selectedAgent === null) {
|
||||||
|
rightPanel = <EmptyDetailPanel variant="initial" />;
|
||||||
|
} else if (selectedAgent.startsWith('placeholder-')) {
|
||||||
|
```
|
||||||
|
를 아래로 변경:
|
||||||
|
```js
|
||||||
|
if (selectedAgent === null) {
|
||||||
|
rightPanel = (
|
||||||
|
<ActivityTimeline
|
||||||
|
refreshTrigger={refreshTrigger}
|
||||||
|
onSelectAgent={handleSelectAgent}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (selectedAgent.startsWith('placeholder-')) {
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 전체 테스트 통과 확인 (회귀 없음)**
|
||||||
|
|
||||||
|
Run: `npm run test:run`
|
||||||
|
Expected: PASS — 신규 테스트 포함 전부 통과, 기존 테스트 회귀 없음
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/pages/agent-office/AgentOffice.jsx
|
||||||
|
git commit -m "feat(agent-office): 우측 기본 패널을 횡단 타임라인으로 교체"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: baseline CSS
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/pages/agent-office/AgentOffice.css` (파일 끝에 append)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 스타일 추가**
|
||||||
|
|
||||||
|
`src/pages/agent-office/AgentOffice.css` 맨 끝에 추가:
|
||||||
|
```css
|
||||||
|
/* ── 횡단 오버사이트 타임라인 ── */
|
||||||
|
.ao-activity { display: flex; flex-direction: column; min-height: 0; }
|
||||||
|
.ao-activity-header { display: flex; align-items: center; }
|
||||||
|
|
||||||
|
.ao-activity-filters {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 6px;
|
||||||
|
padding: 8px 12px; border-bottom: 1px solid #1f2937;
|
||||||
|
}
|
||||||
|
.ao-activity-select {
|
||||||
|
background: #111827; color: #e5e7eb;
|
||||||
|
border: 1px solid #374151; border-radius: 6px;
|
||||||
|
padding: 4px 8px; font-size: 12px;
|
||||||
|
}
|
||||||
|
.ao-activity-select:disabled { opacity: .4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.ao-activity-content { flex: 1; overflow-y: auto; min-height: 0; }
|
||||||
|
|
||||||
|
.ao-activity-item {
|
||||||
|
display: flex; align-items: flex-start; gap: 10px;
|
||||||
|
padding: 10px 12px; border-bottom: 1px solid #161b25;
|
||||||
|
cursor: pointer; transition: background .12s;
|
||||||
|
}
|
||||||
|
.ao-activity-item:hover { background: #161b25; }
|
||||||
|
.ao-activity-item.is-highlight { background: rgba(245, 158, 11, .08); }
|
||||||
|
.ao-activity-dot { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; margin-top: 5px; }
|
||||||
|
.ao-activity-body { flex: 1; min-width: 0; }
|
||||||
|
.ao-activity-line { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.ao-activity-agent { font-size: 12px; font-weight: 600; }
|
||||||
|
.ao-activity-badge { font-size: 11px; padding: 1px 7px; border-radius: 10px; white-space: nowrap; }
|
||||||
|
.ao-activity-level { font-size: 12px; }
|
||||||
|
.ao-activity-msg {
|
||||||
|
font-size: 13px; color: #cbd5e1; margin-top: 2px;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ao-activity-meta { flex: 0 0 auto; display: flex; flex-direction: column; align-items: flex-end; gap: 2px; }
|
||||||
|
.ao-activity-time { font-size: 11px; color: #6b7280; }
|
||||||
|
.ao-activity-dur { font-size: 10px; color: #475569; }
|
||||||
|
|
||||||
|
.ao-activity-loading,
|
||||||
|
.ao-activity-end { text-align: center; padding: 12px; font-size: 12px; color: #6b7280; }
|
||||||
|
.ao-activity-sentinel { height: 1px; }
|
||||||
|
.ao-activity-error { padding: 12px; font-size: 13px; color: #fca5a5; }
|
||||||
|
.ao-activity-error button {
|
||||||
|
margin-left: 8px; background: #374151; color: #e5e7eb;
|
||||||
|
border: none; border-radius: 6px; padding: 2px 10px; cursor: pointer;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 개발 서버에서 시각 확인**
|
||||||
|
|
||||||
|
Run: `npm run dev` 후 브라우저에서 `http://localhost:3007/agent-office` 접속 → 우측 패널에 타임라인/필터/항목이 보이는지 확인 (에이전트 미선택 상태).
|
||||||
|
Expected: 필터 4종 + 활동 항목 리스트 표시, 항목 클릭 시 SidePanel 전환
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/pages/agent-office/AgentOffice.css
|
||||||
|
git commit -m "style(agent-office): 횡단 타임라인 baseline 스타일"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: designer 스킬 비주얼 마감 + 최종 검증
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/pages/agent-office/AgentOffice.css` (+ 필요 시 컴포넌트 className 미세 조정)
|
||||||
|
|
||||||
|
- [ ] **Step 1: designer 스킬 적용**
|
||||||
|
|
||||||
|
`designer` 스킬을 invoke하여 AgentOffice 다크 미감과 일관된 타임라인 비주얼로 마감 (에이전트 색 강조, 상태 뱃지 가독성, 펄스 애니메이션, 밀도/여백). 기능/마크업 구조는 유지하고 스타일만 개선.
|
||||||
|
|
||||||
|
- [ ] **Step 2: lint + 전체 테스트 + 빌드 검증**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
npm run test:run
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
Expected: lint 0 error, 전체 테스트 PASS, build 성공
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "style(agent-office): designer 마감 — 횡단 오버사이트 타임라인"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review 체크리스트 (작성자 검증 완료)
|
||||||
|
|
||||||
|
- **Spec coverage:** agentActivity 헬퍼(T1) ✓ / useActivityFeed 필터·페이지네이션·refreshTrigger(T2) ✓ / 상태·level 뱃지 + agent 색 + 클릭(T3) ✓ / 필터 4종 + log시 status 비활성(T4) ✓ / 무한스크롤·empty·error·end(T5) ✓ / AgentOffice 배선(T6) ✓ / 비주얼(T7·T8) ✓ — spec 전 항목 커버.
|
||||||
|
- **Placeholder scan:** 모든 step에 실제 코드/명령/기대출력 포함, TBD 없음.
|
||||||
|
- **Type consistency:** `useActivityFeed(filters, refreshTrigger)` 반환 `{items,total,loading,error,hasMore,loadMore,retry}` — T5에서 동일 사용. `onSelectAgent(agent_id)` 시그니처 T3/T5/T6 일치. `AGENT_META`/`ACTIVE_AGENT_IDS` import 경로 `../constants.js` 일치. `agentActivity({...})` 객체 인자 T1 정의 ↔ T2 호출 일치.
|
||||||
|
- **Known caveat:** jsdom IntersectionObserver 없음 → T5 테스트 상단 stub으로 처리(전역 setup 미수정).
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
# Agent Office 그리드 재설계 — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-05-17
|
||||||
|
**Author:** CEO (with Claude)
|
||||||
|
**Target:** `web-ui` `/agent-office` 페이지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 배경 & 목적
|
||||||
|
|
||||||
|
현재 `/agent-office` 페이지는 픽셀 사무실 Canvas 위에서 5명의 에이전트 캐릭터가 무의미하게 걸어다니는 형태다. 시각적 즐거움은 있으나 정보 밀도가 낮고, 각 에이전트가 무슨 일을 하는지 한눈에 파악하기 어렵다.
|
||||||
|
|
||||||
|
이를 **3x3 그리드** 기반의 정보 중심 UI로 재설계한다. 왼편에 9개의 에이전트 이미지 카드를 배치하고, 카드 클릭 시 오른편 패널에서 해당 에이전트의 명령·태스크·토큰·로그를 확인한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 범위 (Scope)
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
- `src/pages/agent-office/AgentOffice.jsx` 전면 재작성 (Canvas → Grid)
|
||||||
|
- 그리드 카드 컴포넌트 신규 작성
|
||||||
|
- `SidePanel.jsx` 헤더 부분 수정 (emoji → 이미지)
|
||||||
|
- `SidePanel.jsx`의 `AGENT_META`에서 `blog` 제거, `insta` 추가
|
||||||
|
- TopBar 단순화 (theme/zoom 컨트롤 제거)
|
||||||
|
- Canvas 관련 파일/디렉토리 전체 삭제
|
||||||
|
- 이미지 에셋 디렉토리 신설
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
- 백엔드 변경 (현재 백엔드의 `insta` 에이전트는 이미 등록 완료, 추가 작업 불필요)
|
||||||
|
- 새 에이전트 추가 (4개 placeholder는 "준비 중" 표시만)
|
||||||
|
- 4탭 컨텐츠 (Commands/Tasks/Tokens/Logs) 로직 수정
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 에이전트 구성
|
||||||
|
|
||||||
|
### 실제 작동 5명
|
||||||
|
|
||||||
|
| ID | 표시명 | 색상 | 역할 요약 |
|
||||||
|
|----|--------|------|-----------|
|
||||||
|
| `stock` | 주식 트레이더 | `#4488cc` | 주식 매매·뉴스 분석·포트폴리오 |
|
||||||
|
| `music` | 음악 프로듀서 | `#44aa88` | AI 음악 생성 |
|
||||||
|
| `insta` | 인스타 큐레이터 | `#d97706` | 매일 09:30 뉴스 수집 → 키워드 추출 → AI 카드 10장 생성 → 텔레그램 푸시 |
|
||||||
|
| `realestate` | 청약 애널리스트 | `#c026d3` | 부동산 청약 매칭·자치구 5티어 분석 |
|
||||||
|
| `lotto` | 로또 큐레이터 | `#ef4444` | 로또 번호 추천·브리핑 |
|
||||||
|
|
||||||
|
> `blog`는 `insta`로 대체됨. 기존 `SidePanel.jsx`의 `AGENT_META`에서 `blog` 항목 삭제 + `insta` 추가.
|
||||||
|
|
||||||
|
### Placeholder 4개
|
||||||
|
|
||||||
|
- ID 없음 (그리드 슬롯 인덱스 6/7/8/9로만 식별)
|
||||||
|
- 모두 동일하게 `agent_undetermined.png` + "준비 중" 라벨
|
||||||
|
- 클릭 시 정적 안내 패널 노출
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 디렉토리 & 파일 구조
|
||||||
|
|
||||||
|
### 신설 디렉토리
|
||||||
|
|
||||||
|
```
|
||||||
|
src/pages/agent-office/assets/agents/
|
||||||
|
├── agent_stock.png (사용자 제공)
|
||||||
|
├── agent_music.png (사용자 제공)
|
||||||
|
├── agent_insta.png (사용자 제공)
|
||||||
|
├── agent_realestate.png (사용자 제공)
|
||||||
|
├── agent_lotto.png (사용자 제공)
|
||||||
|
└── agent_undetermined.png (사용자 제공, 4 placeholder 공유)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 파일명 규칙
|
||||||
|
`agent_{id}.png` 형식. `{id}`는 백엔드의 agent_id와 일치 (소문자, underscore).
|
||||||
|
|
||||||
|
### 권장 이미지 사양
|
||||||
|
- 정사각형 (예: 512x512)
|
||||||
|
- PNG (투명 배경 허용)
|
||||||
|
- 카드 표시 시 `object-fit: cover`로 정사각 크롭
|
||||||
|
|
||||||
|
### 삭제 대상
|
||||||
|
|
||||||
|
```
|
||||||
|
src/pages/agent-office/
|
||||||
|
├── canvas/ ← 전체 삭제
|
||||||
|
│ ├── themes.js
|
||||||
|
│ ├── FurnitureRenderer.js
|
||||||
|
│ ├── ProceduralSprite.js
|
||||||
|
│ ├── AgentSprite.js
|
||||||
|
│ ├── SpriteLoader.js
|
||||||
|
│ ├── OverlayRenderer.js
|
||||||
|
│ ├── Pathfinder.js
|
||||||
|
│ ├── OfficeRenderer.js
|
||||||
|
│ └── TileMap.js
|
||||||
|
├── hooks/
|
||||||
|
│ └── useOfficeCanvas.js ← 삭제
|
||||||
|
└── assets/
|
||||||
|
└── office-map.json ← 삭제
|
||||||
|
```
|
||||||
|
|
||||||
|
### 유지 대상
|
||||||
|
|
||||||
|
```
|
||||||
|
src/pages/agent-office/
|
||||||
|
├── AgentOffice.jsx ← 재작성
|
||||||
|
├── AgentOffice.css ← 재작성
|
||||||
|
├── hooks/
|
||||||
|
│ └── useAgentManager.js ← 그대로 (WebSocket 로직)
|
||||||
|
└── components/
|
||||||
|
├── TopBar.jsx ← 단순화 (theme/zoom 제거)
|
||||||
|
├── SidePanel.jsx ← 헤더 수정 + AGENT_META 갱신
|
||||||
|
├── CommandTab.jsx ← 그대로
|
||||||
|
├── TaskTab.jsx ← 그대로
|
||||||
|
├── TokenTab.jsx ← 그대로
|
||||||
|
└── LogTab.jsx ← 그대로
|
||||||
|
```
|
||||||
|
|
||||||
|
### 신규 컴포넌트
|
||||||
|
|
||||||
|
```
|
||||||
|
src/pages/agent-office/components/
|
||||||
|
├── AgentGrid.jsx ← 3x3 그리드 래퍼
|
||||||
|
├── AgentCard.jsx ← 카드 1개 (image + state dot + badge + name)
|
||||||
|
├── PlaceholderCard.jsx ← "준비 중" 카드
|
||||||
|
└── EmptyDetailPanel.jsx ← 초기 안내 / placeholder 클릭 시 안내
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 레이아웃
|
||||||
|
|
||||||
|
### 전체 화면 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ TopBar (connected status only) │
|
||||||
|
├──────────────────────────────────┬──────────────────────────┤
|
||||||
|
│ │ │
|
||||||
|
│ AgentGrid (3x3) │ Right Panel │
|
||||||
|
│ ┌──────┬──────┬──────┐ │ │
|
||||||
|
│ │stock │music │insta │ │ ┌─ active 선택 시 ─┐ │
|
||||||
|
│ ├──────┼──────┼──────┤ │ │ SidePanel │ │
|
||||||
|
│ │realE │lotto │ ?? │ │ │ - 헤더(이미지+이름)│ │
|
||||||
|
│ ├──────┼──────┼──────┤ │ │ - 4 tabs │ │
|
||||||
|
│ │ ?? │ ?? │ ?? │ │ └──────────────────┘ │
|
||||||
|
│ └──────┴──────┴──────┘ │ │
|
||||||
|
│ │ ┌─ placeholder 선택 ─┐ │
|
||||||
|
│ │ │ "준비 중인 에이전트"│ │
|
||||||
|
│ │ └────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ │ ┌─ 초기(미선택) ──────┐ │
|
||||||
|
│ │ │ "에이전트를 선택…" │ │
|
||||||
|
│ │ └────────────────────┘ │
|
||||||
|
└──────────────────────────────────┴──────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 그리드 슬롯 순서 (좌→우, 위→아래)
|
||||||
|
|
||||||
|
| Index | Slot |
|
||||||
|
|-------|------|
|
||||||
|
| 1 (행1·열1) | `stock` |
|
||||||
|
| 2 (행1·열2) | `music` |
|
||||||
|
| 3 (행1·열3) | `insta` |
|
||||||
|
| 4 (행2·열1) | `realestate` |
|
||||||
|
| 5 (행2·열2) | `lotto` |
|
||||||
|
| 6 (행2·열3) | placeholder |
|
||||||
|
| 7 (행3·열1) | placeholder |
|
||||||
|
| 8 (행3·열2) | placeholder |
|
||||||
|
| 9 (행3·열3) | placeholder |
|
||||||
|
|
||||||
|
### AgentCard 시각 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ ● state [③] │ ← 상태 dot(좌상, image 약간 위) + 알림 뱃지(우상)
|
||||||
|
│ ┌───────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ agent_xxx │ │ ← 정사각 이미지 (object-fit: cover)
|
||||||
|
│ │ .png │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └───────────────┘ │
|
||||||
|
│ 주식 트레이더 │ ← display_name
|
||||||
|
└─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 상태 dot
|
||||||
|
|
||||||
|
| state | color | 동작 |
|
||||||
|
|-------|-------|------|
|
||||||
|
| `idle` | `#6b7280` (회색) | 정적 |
|
||||||
|
| `working` | `#22c55e` (초록) | pulse 애니메이션 |
|
||||||
|
| `error` | `#ef4444` (빨강) | 정적 |
|
||||||
|
| `waiting_approval` | `#f59e0b` (주황) | pulse |
|
||||||
|
| `break` | `#94a3b8` (밝은 회색) | 정적 |
|
||||||
|
|
||||||
|
상태 dot은 카드의 좌상단, 이미지보다 약간 위쪽에 위치 (이미지 영역 바깥 또는 모서리 살짝 걸침).
|
||||||
|
|
||||||
|
#### 알림 뱃지
|
||||||
|
|
||||||
|
- `notifications[agentId] > 0`일 때만 우상단에 표시
|
||||||
|
- 빨강 배경에 흰 숫자 (count > 9면 "9+")
|
||||||
|
- 카드 클릭 시 자동으로 0으로 리셋 (`clearNotifications` 호출 — 기존 로직 재사용)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 데이터 플로우
|
||||||
|
|
||||||
|
```
|
||||||
|
useAgentManager (그대로 유지)
|
||||||
|
├── WebSocket /api/agent-office/ws
|
||||||
|
├── agents: { [id]: { state, detail, task_id } }
|
||||||
|
├── notifications: { [id]: count }
|
||||||
|
├── pendingTasks: [...]
|
||||||
|
├── connected: bool
|
||||||
|
└── refreshTrigger: number
|
||||||
|
|
||||||
|
AgentOffice.jsx
|
||||||
|
├── agents → AgentGrid에 전달 → 각 AgentCard가 state로 dot 색상 결정
|
||||||
|
├── notifications → 각 AgentCard가 badge 표시
|
||||||
|
├── selectedAgent (local state): string | null | "placeholder"
|
||||||
|
└── 카드 클릭 시 setSelectedAgent + clearNotifications
|
||||||
|
|
||||||
|
Right Panel 분기
|
||||||
|
├── selectedAgent === null → EmptyDetailPanel (초기 안내)
|
||||||
|
├── selectedAgent === "placeholder"→ EmptyDetailPanel ("준비 중" 메시지)
|
||||||
|
└── selectedAgent ∈ active 5명 → SidePanel (4탭, 기존 로직)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. SidePanel 수정 사항
|
||||||
|
|
||||||
|
### AGENT_META 갱신
|
||||||
|
|
||||||
|
```js
|
||||||
|
// src/pages/agent-office/components/SidePanel.jsx
|
||||||
|
import stockImg from '../assets/agents/agent_stock.png';
|
||||||
|
import musicImg from '../assets/agents/agent_music.png';
|
||||||
|
import instaImg from '../assets/agents/agent_insta.png';
|
||||||
|
import realestateImg from '../assets/agents/agent_realestate.png';
|
||||||
|
import lottoImg from '../assets/agents/agent_lotto.png';
|
||||||
|
|
||||||
|
const AGENT_META = {
|
||||||
|
stock: { displayName: '주식 트레이더', image: stockImg, color: '#4488cc' },
|
||||||
|
music: { displayName: '음악 프로듀서', image: musicImg, color: '#44aa88' },
|
||||||
|
insta: { displayName: '인스타 큐레이터', image: instaImg, color: '#d97706' },
|
||||||
|
realestate: { displayName: '청약 애널리스트', image: realestateImg, color: '#c026d3' },
|
||||||
|
lotto: { displayName: '로또 큐레이터', image: lottoImg, color: '#ef4444' }
|
||||||
|
};
|
||||||
|
// blog 항목 삭제
|
||||||
|
```
|
||||||
|
|
||||||
|
### 헤더 시각 변경
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// 변경 전: emoji icon
|
||||||
|
<div className="ao-sidepanel-icon" style={{ background: meta.color }}>
|
||||||
|
{meta.emoji}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// 변경 후: 이미지
|
||||||
|
<div className="ao-sidepanel-icon" style={{ borderColor: meta.color }}>
|
||||||
|
<img src={meta.image} alt={meta.displayName} />
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
4탭(Commands/Tasks/Tokens/Logs) 본체 로직은 손대지 않음.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. CSS 토큰 (제안)
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--ao-bg: #0f172a;
|
||||||
|
--ao-card-bg: #1e293b;
|
||||||
|
--ao-card-border: #334155;
|
||||||
|
--ao-card-border-active: #60a5fa;
|
||||||
|
--ao-text: #e2e8f0;
|
||||||
|
--ao-text-muted: #94a3b8;
|
||||||
|
--ao-grid-gap: 16px;
|
||||||
|
--ao-card-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--ao-grid-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-card {
|
||||||
|
aspect-ratio: 1 / 1.15; /* 이미지 정사각 + 이름줄 */
|
||||||
|
background: var(--ao-card-bg);
|
||||||
|
border: 1px solid var(--ao-card-border);
|
||||||
|
border-radius: var(--ao-card-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 120ms ease, border-color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-card:hover { transform: translateY(-2px); }
|
||||||
|
.ao-card.active { border-color: var(--ao-card-border-active); }
|
||||||
|
.ao-card.placeholder { opacity: 0.55; cursor: pointer; }
|
||||||
|
```
|
||||||
|
|
||||||
|
반응형: 모바일에서는 `grid-template-columns: repeat(2, 1fr)` 또는 `repeat(1, 1fr)`로 축소.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 에러 처리 / Edge Cases
|
||||||
|
|
||||||
|
| 상황 | 동작 |
|
||||||
|
|------|------|
|
||||||
|
| 이미지 로드 실패 | `<img onError>`로 단색 배경 + 첫 글자 fallback |
|
||||||
|
| WebSocket 끊김 | TopBar에 disconnected 표시. 카드는 마지막 상태 유지 (회색 처리 안 함 — 기존 동작 유지) |
|
||||||
|
| `agents[id]` 미존재 | dot 회색(`idle`), 정상 표시 |
|
||||||
|
| placeholder 클릭 | 우측 패널만 변경, WebSocket 호출/clearNotifications 호출 없음 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 테스트 계획
|
||||||
|
|
||||||
|
- [ ] 6개 이미지 파일이 디렉토리에 존재할 때 그리드 정상 렌더링
|
||||||
|
- [ ] 이미지 누락 시 fallback 표시
|
||||||
|
- [ ] WebSocket으로 `agent_state` 수신 시 dot 색상 변경
|
||||||
|
- [ ] `notification` 수신 시 뱃지 표시, 카드 클릭 시 0으로 리셋
|
||||||
|
- [ ] active 5명 클릭 → SidePanel 4탭 표시 (기존 동작 유지)
|
||||||
|
- [ ] placeholder 4슬롯 클릭 → "준비 중" 패널
|
||||||
|
- [ ] TopBar의 connected/disconnected 표시 정상
|
||||||
|
- [ ] Canvas 잔재(파일 import 누락 등) 없음 — `npm run build` 통과
|
||||||
|
- [ ] 모바일 뷰(<768px) 그리드 축소 정상
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 이행 절차 (사용자 작업 포함)
|
||||||
|
|
||||||
|
1. **사용자**: `src/pages/agent-office/assets/agents/` 디렉토리에 6개 PNG 파일 배치
|
||||||
|
2. **Claude (구현 단계)**: writing-plans 스킬로 단계별 작업 계획 작성
|
||||||
|
3. 구현·삭제·테스트 후 commit
|
||||||
|
4. NAS 배포는 별도 (`npm run release:nas`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 향후 확장
|
||||||
|
|
||||||
|
- 9번째 active 에이전트 채용 시: 이미지 추가 + `AGENT_META` 갱신 + 슬롯 인덱스 매핑 변경
|
||||||
|
- 그리드 자동 정렬(상태별/우선순위별 sort) — 현재는 정적 배치
|
||||||
|
- 카드 hover 시 미니 프리뷰 (최근 활동 1줄 요약) — 추후 검토
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# 에이전트 횡단 오버사이트 타임라인 — 설계
|
||||||
|
|
||||||
|
작성일: 2026-06-11
|
||||||
|
대상 repo: `web-ui` (프론트엔드)
|
||||||
|
연관 백엔드: ✅ 완료 (`GET /api/agent-office/activity` 필터 지원, main `2c2828c`)
|
||||||
|
|
||||||
|
## 배경 / 목적
|
||||||
|
|
||||||
|
3개 자율 에이전트(stock 보유종목·insta 발급·lotto 진화)가 모두 도는 상태에서
|
||||||
|
"팀이 무엇을·언제·왜 했나"를 **한 화면에서** 보는 에이전트 횡단 오버사이트(CEO 가시화) 기능.
|
||||||
|
|
||||||
|
현재 web-ui에는 `/lotto/evolver` 탭의 lotto 전용 `LottoActivityTimeline`만 존재.
|
||||||
|
통합 `/activity`(전 에이전트 대상)를 소비하는 횡단 뷰가 없다.
|
||||||
|
|
||||||
|
## 백엔드 응답 shape (라이브 검증 완료)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/agent-office/activity?agent_id=&type=task|log&status=&days=&limit=&offset=
|
||||||
|
→ { items: [...], total: N }
|
||||||
|
```
|
||||||
|
|
||||||
|
- **task item**: `{ type:'task', agent_id, task_id, message, created_at, task_type, status, completed_at, duration_seconds }`
|
||||||
|
- **log item**: `{ type:'log', agent_id, task_id, message, created_at, level }`
|
||||||
|
- `status`는 task 전용(`type=log`에 주면 무시). injection 안전(? 바인딩 + 브랜치 선택).
|
||||||
|
|
||||||
|
검증 메모:
|
||||||
|
- 무필터 `total`이 65,599건 → **기본 `days=7` 필터 필수**(task 기준 110건으로 감소).
|
||||||
|
- `requires_approval` 필드는 **존재하지 않음** → `status:'pending'`을 진행/대기 강조로 처리.
|
||||||
|
- `agent_id` 값이 `AGENT_META` 키(stock/music/insta/realestate/lotto)와 일치 → 색상/이미지 재사용.
|
||||||
|
|
||||||
|
## 아키텍처
|
||||||
|
|
||||||
|
AgentOffice는 단일 화면(TopBar + 3×3 AgentGrid + 우측 패널) 구조.
|
||||||
|
우측 패널은 `selectedAgent` 상태로 분기:
|
||||||
|
- `null` → (기존) `EmptyDetailPanel variant="initial"` → **`ActivityTimeline`으로 교체**
|
||||||
|
- `placeholder-N` → `EmptyDetailPanel variant="placeholder"` (유지)
|
||||||
|
- active agent id → `SidePanel` (유지)
|
||||||
|
|
||||||
|
즉 **에이전트 미선택 시 기본 우측 패널이 횡단 타임라인**이 되고, 그리드와 항상 동시 노출.
|
||||||
|
항목/그리드 클릭으로 해당 에이전트 SidePanel로 전환.
|
||||||
|
|
||||||
|
## 신규/변경 파일
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `src/api.js` | `agentActivity({agent_id,type,status,days,limit,offset})` 추가 — 빈 값 제외 쿼리스트링 빌드 + GET `/api/agent-office/activity` |
|
||||||
|
| `src/pages/agent-office/AgentOffice.jsx` | `selectedAgent===null` 분기를 `EmptyDetailPanel` → `ActivityTimeline`(props: `refreshTrigger`, `onSelectAgent`)로 교체 |
|
||||||
|
| `src/pages/agent-office/hooks/useActivityFeed.js` | items/offset/total/hasMore/loading/error/filters 상태 관리 |
|
||||||
|
| `src/pages/agent-office/components/ActivityTimeline.jsx` | 컨테이너: 헤더 + `ActivityFilters` + 리스트 + 무한스크롤 sentinel + 상태(loading/empty/error/end) |
|
||||||
|
| `src/pages/agent-office/components/ActivityFilters.jsx` | 필터 4종(agent 색칩 / type / status / days). `type==='log'`일 때 status 비활성 |
|
||||||
|
| `src/pages/agent-office/components/ActivityItem.jsx` | 한 행: agent 색·이미지 + message + 상태/level 뱃지 + 상대시간 + duration. 클릭 → `onSelectAgent(agent_id)` |
|
||||||
|
| `src/pages/agent-office/AgentOffice.css` | 타임라인/필터/항목 스타일 (designer 스킬로 마감) |
|
||||||
|
|
||||||
|
## 데이터 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
AgentOffice (selectedAgent===null)
|
||||||
|
└─ <ActivityTimeline refreshTrigger={refreshTrigger} onSelectAgent={handleSelectAgent} />
|
||||||
|
└─ useActivityFeed(filters)
|
||||||
|
• mount / 필터 변경 → offset=0 fetch → items 교체
|
||||||
|
• loadMore (sentinel 교차) → offset += limit → items append
|
||||||
|
• refreshTrigger 변경 → offset=0 재조회 → items 교체 (WS 실시간 연동)
|
||||||
|
└─ ActivityItem onClick → onSelectAgent(agent_id) → SidePanel로 전환
|
||||||
|
```
|
||||||
|
|
||||||
|
`handleSelectAgent`는 기존 콜백 재사용(선택 + `clearNotifications`).
|
||||||
|
|
||||||
|
## 필터 기본값
|
||||||
|
|
||||||
|
`days=7`, `type=all`, `status=all`, `agent=all`, `limit=30`(페이지당).
|
||||||
|
|
||||||
|
## 상태 / 비주얼 매핑
|
||||||
|
|
||||||
|
- task `status`: `succeeded` → 초록 ✓ / `failed` → 빨강 ✗ / `pending`·`working` → 앰버 펄스 ⏳(강조)
|
||||||
|
- log `level`: `error` → ❌ / `warning` → ⚠️ / `info` → ·
|
||||||
|
- agent 색상: `AGENT_META[agent_id].color`, 미지정 agent → 회색 `#6b7280`
|
||||||
|
- `offset >= total` → "더 이상 활동 없음" / 무한스크롤은 IntersectionObserver
|
||||||
|
|
||||||
|
## 상태 처리(엣지)
|
||||||
|
|
||||||
|
- 첫 페이지 로딩 → 스피너/스켈레톤
|
||||||
|
- 빈 결과 → "최근 N일 활동 없음"
|
||||||
|
- fetch 실패 → 인라인 에러 + 재시도 버튼
|
||||||
|
- 리스트 끝 → end-of-list 표시, sentinel 관찰 중단
|
||||||
|
|
||||||
|
## 테스트 (TDD, vitest + RTL — 기존 패턴 따름)
|
||||||
|
|
||||||
|
- `useActivityFeed`: 필터 변경 시 offset 리셋 + items 교체 / loadMore append / refreshTrigger 재조회 / `hasMore = items.length < total` 계산 (api mock)
|
||||||
|
- `ActivityItem`: task vs log 렌더 분기, status/level 뱃지 클래스, 클릭 시 `onSelectAgent(agent_id)` 호출
|
||||||
|
- `ActivityFilters`: `type==='log'`일 때 status select 비활성, 필터 변경 시 onChange 호출
|
||||||
|
|
||||||
|
## 비범위 (YAGNI)
|
||||||
|
|
||||||
|
- 별도 라우트(`/agent-office/activity`) 미생성 — 기본 우측 패널 통합으로 충분
|
||||||
|
- 기존 `getActivityFeed(limit, offset)` 헬퍼는 lotto evolver 등에서 사용 여부 확인 후 유지(신규 `agentActivity`와 공존, 무리한 통합 안 함)
|
||||||
|
- `LottoActivityTimeline`(`kind/ts/payload` shape)은 다른 엔드포인트 소비 → 건드리지 않음
|
||||||
|
- CSV/export, 검색어 필터 등 부가기능 제외
|
||||||
|
|
||||||
|
## 구현 순서
|
||||||
|
|
||||||
|
1. `agentActivity` api 헬퍼 추가
|
||||||
|
2. `useActivityFeed` 훅 (TDD)
|
||||||
|
3. `ActivityItem` / `ActivityFilters` (TDD)
|
||||||
|
4. `ActivityTimeline` 컨테이너 조립
|
||||||
|
5. `AgentOffice.jsx` 분기 교체
|
||||||
|
6. designer 스킬로 CSS 마감
|
||||||
|
7. lint + 테스트 + 빌드 검증
|
||||||
@@ -5,6 +5,13 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/main_logo.png" />
|
<link rel="icon" type="image/svg+xml" href="/main_logo.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<title>가후습 개인기록</title>
|
<title>가후습 개인기록</title>
|
||||||
|
<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" />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Nanum+Myeongjo:wght@400;700;800&family=Nanum+Gothic:wght@400;700;800&family=Gowun+Batang:wght@400;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
BIN
public/images/saju/horyung/background.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/images/saju/horyung/horyung-bust.png
Normal file
|
After Width: | Height: | Size: 409 KiB |
BIN
public/images/saju/horyung/horyung-front.png
Normal file
|
After Width: | Height: | Size: 554 KiB |
BIN
public/images/saju/horyung/horyung-greeting.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
public/images/saju/horyung/horyung-happy.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
public/images/saju/horyung/horyung-head.png
Normal file
|
After Width: | Height: | Size: 558 KiB |
BIN
public/images/saju/horyung/horyung-main.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
public/images/saju/horyung/horyung-pointing.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
public/images/saju/horyung/horyung-thinking.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
public/images/saju/horyung/horyung-upper.png
Normal file
|
After Width: | Height: | Size: 929 KiB |
BIN
public/images/tarot/card_back.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
29
public/images/tarot/card_back.svg
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 300" width="200" height="300">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#1a0d2e"/>
|
||||||
|
<stop offset="100%" stop-color="#0a0420"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="goldFrame" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#d4af37"/>
|
||||||
|
<stop offset="100%" stop-color="#8b6914"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="200" height="300" rx="14" fill="url(#bg)"/>
|
||||||
|
<rect x="8" y="8" width="184" height="284" rx="10" fill="none"
|
||||||
|
stroke="url(#goldFrame)" stroke-width="2"/>
|
||||||
|
<g transform="translate(100 150)" fill="#d4af37" font-family="serif" text-anchor="middle">
|
||||||
|
<circle r="38" fill="none" stroke="#d4af37" stroke-width="1.5"/>
|
||||||
|
<text font-size="48" dy="14" font-style="italic">A</text>
|
||||||
|
<g opacity=".5">
|
||||||
|
<circle cx="-60" cy="-90" r="1.5"/>
|
||||||
|
<circle cx="55" cy="-100" r="1"/>
|
||||||
|
<circle cx="-50" cy="80" r="1.2"/>
|
||||||
|
<circle cx="65" cy="90" r="1"/>
|
||||||
|
<circle cx="0" cy="-110" r="1.6"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<text x="100" y="280" fill="#d4af37" font-family="serif" font-size="10"
|
||||||
|
text-anchor="middle" letter-spacing="2">ARCANA TAROT</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
public/images/tarot/card_bunch.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
public/images/tarot/cards/ace-of-cups.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/ace-of-pentacles.png
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
public/images/tarot/cards/ace-of-swords.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/ace-of-wands.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/death.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/eight-of-cups.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/eight-of-pentacles.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/eight-of-swords.png
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
public/images/tarot/cards/eight-of-wands.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/five-of-cups.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/five-of-pentacles.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
public/images/tarot/cards/five-of-swords.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/five-of-wands.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/four-of-cups.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
public/images/tarot/cards/four-of-pentacles.png
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
public/images/tarot/cards/four-of-swords.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/four-of-wands.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
public/images/tarot/cards/judgement.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/justice.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/king-of-cups.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
public/images/tarot/cards/king-of-pentacles.png
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
public/images/tarot/cards/king-of-swords.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
public/images/tarot/cards/king-of-wands.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/knight-of-cups.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/knight-of-pentacles.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/knight-of-swords.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
public/images/tarot/cards/knight-of-wands.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/nine-of-cups.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/nine-of-pentacles.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
public/images/tarot/cards/nine-of-swords.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/nine-of-wands.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/page-of-cups.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/page-of-pentacles.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/page-of-swords.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/page-of-wands.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/queen-of-cups.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/queen-of-pentacles.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/queen-of-swords.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/queen-of-wands.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/seven-of-cups.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/seven-of-pentacles.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/seven-of-swords.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/seven-of-wands.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/six-of-cups.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/six-of-pentacles.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/six-of-swords.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/six-of-wands.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/strength.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/temperance.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
public/images/tarot/cards/ten-of-cups.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/ten-of-pentacles.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
public/images/tarot/cards/ten-of-swords.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/ten-of-wands.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/the-chariot.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/the-devil.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/the-emperor.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/the-empress.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/the-fool.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/the-hanged-man.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/the-hermit.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
public/images/tarot/cards/the-hierophant.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
public/images/tarot/cards/the-high-priestess.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/the-lovers.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/the-magician.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/the-moon.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/the-star.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/the-sun.png
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
public/images/tarot/cards/the-tower.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/the-world.png
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
public/images/tarot/cards/three-of-cups.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/three-of-pentacles.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
public/images/tarot/cards/three-of-swords.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/three-of-wands.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/two-of-cups.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/two-of-pentacles.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |