Compare commits
207 Commits
feat/agent
...
b0eda14982
| Author | SHA1 | Date | |
|---|---|---|---|
| b0eda14982 | |||
| 1f55d24ce6 | |||
| 6eb4ab1204 | |||
| 78b77e2691 | |||
| 1813db761f | |||
| 01d9b2f872 | |||
| b9dabd07e0 | |||
| a8e411ec22 | |||
| f261a80d52 | |||
| 42e9c8df27 | |||
| c84c6b5bac | |||
| 094366a162 | |||
| 3bf7ce446f | |||
| 8391919b90 | |||
| ed7e927dc1 | |||
| 309bedadeb | |||
| ebdfcd758b | |||
| cefaeca449 | |||
| cdfa31b0c1 | |||
| ec3ca5fcfa | |||
| 7ebeba2f3d | |||
| 5e66d96c61 | |||
| fde63d757b | |||
| 4b64761800 | |||
| 1449342f96 | |||
| 2effc47593 | |||
| f8574f1b45 | |||
| 2da7255c03 | |||
| b4ad0b1abf | |||
| 4e134eb59a | |||
| b1a1bb22f9 | |||
| f10fa062e9 | |||
| 40e3e2cf39 | |||
| 1505518ca6 | |||
| 2fd2ea33c7 | |||
| c60c32b7f2 | |||
| 5f95f55271 | |||
| d73ad9b851 | |||
| fdf5ef6ce8 | |||
| ca248891c2 | |||
| 55d2adeaf5 | |||
| 6fd70dd802 | |||
| 9f4363cdbb | |||
| 295972e0cb | |||
| e6659a416a | |||
| 3abd46c0fd | |||
| c42d3fe8d4 | |||
| 1e8542f6c7 | |||
| a11475db57 | |||
| bc2c020f71 | |||
| cd6072727f | |||
| 42ebd5a87c | |||
| 3b66a47316 | |||
| f7323a5b72 | |||
| ccf6d4e551 | |||
| a20315ce34 | |||
| 3fa4dbda3c | |||
| baf34dd7aa | |||
| 4ef76f6cce | |||
| 0bf1233e96 | |||
| ff7ac48c6b | |||
| 329141c732 | |||
| cd3c538eb7 | |||
| 9d2dfad512 | |||
| 42073a5bf3 | |||
| 6b2fcda2af | |||
| acac2cd20e | |||
| 95edc9d232 | |||
| ec22321d56 | |||
| a80b869878 | |||
| 93d5f49cdb | |||
| 3f5cd32c77 | |||
| 120c39a3ef | |||
| 08fce2d4f6 | |||
| 9c12de4593 | |||
| 53e9938903 | |||
| 522b7695aa | |||
| 9ffd7889e7 | |||
| 5bba880c23 | |||
| 4498124514 | |||
| b6748ecd27 | |||
| 397257cf3b | |||
| d38ee553c3 | |||
| 4acdc451c0 | |||
| f3b0b2c109 | |||
| 4281c1873f | |||
| 8a7b5e8a38 | |||
| 08981a292a | |||
| ed95f6678f | |||
| 1847771ad2 | |||
| 0f0ca8610d | |||
| 3f2fdb095c | |||
| 3e54b2c98d | |||
| 16b8cc59ae | |||
| a89de57b79 | |||
| 413dccb655 | |||
| d1526af32c | |||
| abd8762b5c | |||
| 8514232775 | |||
| 6c1f19e690 | |||
| 35ce362d20 | |||
| 11e4f00ae6 | |||
| b11d1c421d | |||
| f6d95264c3 | |||
| 7cbdbe6e8b | |||
| 573c0364bb | |||
| 7f42ff3594 | |||
| 1c331f209a | |||
| c87e764063 | |||
| 80fcb07fc0 | |||
| a9a6808005 | |||
| 0a0ab05e41 | |||
| f6e78ac0ca | |||
| 60f17ff3e0 | |||
| 344caace3a | |||
| 9e5521d784 | |||
| 3b3e4a1ee1 | |||
| a9d9540f61 | |||
| c68cee502a | |||
| 1bd680e47f | |||
| 60655f8ba9 | |||
| a50c6c8be2 | |||
| b88ae331d7 | |||
| a56923a6b3 | |||
| a6dd2ef747 | |||
| bebd55874c | |||
| 6cbdf95596 | |||
| 3e4f2e0934 | |||
| 31fc2dfb0d | |||
| 403046c4d0 | |||
| b03f438935 | |||
| 22a37cf6d9 | |||
| 6bd6cbd635 | |||
| 4c930c2cf8 | |||
| efeecadbef | |||
| a712a2f43b | |||
| ce245609f9 | |||
| 43904d033a | |||
| 379ad41e32 | |||
| f3de315272 | |||
| 71fe91cc85 | |||
| 7dd2cc9793 | |||
| f01a432329 | |||
| d4279f2e3b | |||
| 8207205418 | |||
| 95b3f2b37c | |||
| eab8ef295b | |||
| f11f9c529e | |||
| d24c04f9fa | |||
| b7ee9fe3fd | |||
| b8eb290e4d | |||
| fba101500e | |||
| 9b8daeffa4 | |||
| 59bb05ba22 | |||
| 093ca6635a | |||
| 047e15cad3 | |||
| d6ace70bff | |||
| 27dca3df69 | |||
| 439844cd14 | |||
| 085481e104 | |||
| f9495f0c30 | |||
| 4655e9ab3b | |||
| 5efb9525d5 | |||
| 201601dc95 | |||
| 1072a5eb21 | |||
| c9df3e0e88 | |||
| 6ef687378d | |||
| ca9929faac | |||
| 0198fec43c | |||
| 901cfd7e1b | |||
| c7cad9da61 | |||
| 28a80b5bd7 | |||
| 00f8e00436 | |||
| 326d54c73f | |||
| 5c10952e39 | |||
| 2b826ed700 | |||
| d5ef77ad17 | |||
| 033b89f87d | |||
| e7427ff1d5 | |||
| fd13f65faa | |||
| 2c2011659a | |||
| 0922261c74 | |||
| d53108f1c9 | |||
| 80921563be | |||
| 6875a28e92 | |||
| 2db0c1b3eb | |||
| bce5ae9fac | |||
| a053cf2d71 | |||
| 08efaa722a | |||
| 2cdecd918e | |||
| 1e60524cfc | |||
| 75d1558508 | |||
| 188a714372 | |||
| 064c983ca1 | |||
| bf1c23e66a | |||
| a922dd12c0 | |||
| 1344967118 | |||
| 2840ad7df6 | |||
| ad0a123d0f | |||
| 18d2cd5a51 | |||
| 104a34912f | |||
| be46da0a1f | |||
| 6728b2269e | |||
| cfc45fc43f | |||
| a165d6271f | |||
| deb285695a | |||
| 25715a2198 |
34
.claude/settings.json
Normal file
34
.claude/settings.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(git status:*)",
|
||||||
|
"Bash(git diff:*)",
|
||||||
|
"Bash(git log:*)",
|
||||||
|
"Bash(git show:*)",
|
||||||
|
"Bash(git branch:*)",
|
||||||
|
"Bash(git stash list:*)",
|
||||||
|
"Bash(git remote -v)",
|
||||||
|
"Bash(npm run:*)",
|
||||||
|
"Bash(npm test:*)",
|
||||||
|
"Bash(npm install:*)",
|
||||||
|
"Bash(npm ci:*)",
|
||||||
|
"Bash(npm list:*)",
|
||||||
|
"Bash(npm view:*)",
|
||||||
|
"Bash(npm outdated:*)",
|
||||||
|
"Bash(npx eslint:*)",
|
||||||
|
"Bash(npx vite:*)",
|
||||||
|
"Bash(node -v)",
|
||||||
|
"Bash(ls:*)"
|
||||||
|
],
|
||||||
|
"deny": [
|
||||||
|
"Read(.env)",
|
||||||
|
"Read(.env.*)",
|
||||||
|
"Read(**/.env)",
|
||||||
|
"Read(**/.env.*)",
|
||||||
|
"Read(**/credentials*)",
|
||||||
|
"Read(**/secrets*)",
|
||||||
|
"Read(**/*.pem)",
|
||||||
|
"Read(**/*.key)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -22,3 +22,7 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Superpowers visual companion (mockup files)
|
||||||
|
.superpowers/
|
||||||
|
|||||||
111
CLAUDE.md
111
CLAUDE.md
@@ -17,7 +17,8 @@
|
|||||||
| `/lotto` | `Lotto` | 로또 추천/통계 |
|
| `/lotto` | `Lotto` | 로또 추천/통계 |
|
||||||
| `/stock` | `Stock` | 주식 뉴스/지수 |
|
| `/stock` | `Stock` | 주식 뉴스/지수 |
|
||||||
| `/stock/trade` | `StockTrade` | 주식 트레이딩 |
|
| `/stock/trade` | `StockTrade` | 주식 트레이딩 |
|
||||||
| `/realestate` | `Subscription` | 청약 자격·일정 관리 |
|
| `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (폼 ↔ n8n 스타일 캔버스 모드 토글, 점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) |
|
||||||
|
| `/realestate` | `Subscription` | 청약 자격·일정 관리<br>• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글<br>• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시<br>• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격) |
|
||||||
| `/realestate/property` | `RealEstate` | 관심 단지 정보 |
|
| `/realestate/property` | `RealEstate` | 관심 단지 정보 |
|
||||||
| `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) |
|
| `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) |
|
||||||
| `/lab` | `EffectLab` | UI/UX 실험 허브 |
|
| `/lab` | `EffectLab` | UI/UX 실험 허브 |
|
||||||
@@ -25,6 +26,9 @@
|
|||||||
| `/lab/day-calc` | `DayCalc` | 날짜 계산기 |
|
| `/lab/day-calc` | `DayCalc` | 날짜 계산기 |
|
||||||
| `/lab/music` | `MusicStudio` | AI 음악 생성 스튜디오 (Sonic Forge) |
|
| `/lab/music` | `MusicStudio` | AI 음악 생성 스튜디오 (Sonic Forge) |
|
||||||
| `/todo` | `Todo` | 태스크 보드 |
|
| `/todo` | `Todo` | 태스크 보드 |
|
||||||
|
| `/insta` | `InstaCards` | 뉴스 키워드 발굴 → AI 카드 10장 자동 생성 → 인스타 업로드 |
|
||||||
|
| `/agent-office` | `AgentOffice` | AI 에이전트 가상 오피스 (WebSocket + 채팅) |
|
||||||
|
| `/portfolio` | `Portfolio` | 개인 포트폴리오 (프로필·경력·프로젝트·자기소개) |
|
||||||
|
|
||||||
라우트 정의: `src/routes.jsx` / 라우터 설정: `src/Router.jsx`
|
라우트 정의: `src/routes.jsx` / 라우터 설정: `src/Router.jsx`
|
||||||
|
|
||||||
@@ -61,7 +65,7 @@ proxy: {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `/api/*` → NAS 백엔드
|
- `/api/*` → NAS 백엔드 (nginx가 서비스별 라우팅: lotto, personal, stock, music-lab 등)
|
||||||
- `/media/*` → NAS 미디어 파일 (여행 사진 `/media/travel/`, 음악 `/media/music/`)
|
- `/media/*` → NAS 미디어 파일 (여행 사진 `/media/travel/`, 음악 `/media/music/`)
|
||||||
- 개발 서버 포트: **3007**
|
- 개발 서버 포트: **3007**
|
||||||
|
|
||||||
@@ -82,6 +86,12 @@ proxy: {
|
|||||||
| 주식 | GET | `/api/stock/news`, `/api/stock/indices` |
|
| 주식 | GET | `/api/stock/news`, `/api/stock/indices` |
|
||||||
| 트레이딩 | GET | `/api/trade/balance` |
|
| 트레이딩 | GET | `/api/trade/balance` |
|
||||||
| 트레이딩 | POST | `/api/trade/order` |
|
| 트레이딩 | POST | `/api/trade/order` |
|
||||||
|
| 스크리너 | GET | `/api/stock/screener/nodes` |
|
||||||
|
| 스크리너 | GET/PUT | `/api/stock/screener/settings` |
|
||||||
|
| 스크리너 | POST | `/api/stock/screener/run` — body: `{ mode, asof?, weights?, ... }` |
|
||||||
|
| 스크리너 | POST | `/api/stock/screener/snapshot/refresh` |
|
||||||
|
| 스크리너 | GET | `/api/stock/screener/runs?limit=N` |
|
||||||
|
| 스크리너 | GET | `/api/stock/screener/runs/:id` |
|
||||||
| 포트폴리오 | GET/POST | `/api/portfolio` |
|
| 포트폴리오 | GET/POST | `/api/portfolio` |
|
||||||
| 포트폴리오 | PUT/DELETE | `/api/portfolio/:id` |
|
| 포트폴리오 | PUT/DELETE | `/api/portfolio/:id` |
|
||||||
| 예수금 | PUT | `/api/portfolio/cash` — body: `{ broker, cash }` |
|
| 예수금 | PUT | `/api/portfolio/cash` — body: `{ broker, cash }` |
|
||||||
@@ -91,14 +101,33 @@ proxy: {
|
|||||||
| 실현손익 | GET | `/api/portfolio/sell-history?broker=X&days=N` — response: `{ records: [...] }` |
|
| 실현손익 | GET | `/api/portfolio/sell-history?broker=X&days=N` — response: `{ records: [...] }` |
|
||||||
| 실현손익 | POST/PUT | `/api/portfolio/sell-history`, `/api/portfolio/sell-history/:id` |
|
| 실현손익 | POST/PUT | `/api/portfolio/sell-history`, `/api/portfolio/sell-history/:id` |
|
||||||
| 실현손익 | DELETE | `/api/portfolio/sell-history/:id` |
|
| 실현손익 | DELETE | `/api/portfolio/sell-history/:id` |
|
||||||
| TODO | GET/POST | `/api/todos` |
|
| TODO | GET/POST | `/api/todos` — personal 서비스 |
|
||||||
| TODO | PUT/DELETE | `/api/todos/:id`, `/api/todos/done` |
|
| TODO | PUT/DELETE | `/api/todos/:id`, `/api/todos/done` — personal 서비스 |
|
||||||
| 블로그 | GET/POST | `/api/blog/posts` |
|
| 블로그 | GET/POST | `/api/blog/posts` — personal 서비스 |
|
||||||
| 블로그 | PUT/DELETE | `/api/blog/posts/:id` |
|
| 블로그 | PUT/DELETE | `/api/blog/posts/:id` — personal 서비스 |
|
||||||
| AI 음악 | POST | `/api/music/generate` — body: `{ title, genre, moods, instruments, duration_sec, bpm, key, scale, prompt }` → `{ task_id }` |
|
| AI 음악 | POST | `/api/music/generate` — body: `{ title, genre, moods, instruments, duration_sec, bpm, key, scale, prompt }` → `{ task_id }` |
|
||||||
| AI 음악 | GET | `/api/music/status/:task_id` → `{ status, progress, message, audio_url?, error?, track? }` |
|
| AI 음악 | GET | `/api/music/status/:task_id` → `{ status, progress, message, audio_url?, error?, track? }` |
|
||||||
| AI 음악 라이브러리 | GET/POST | `/api/music/library` — response: `{ tracks: [...] }` |
|
| AI 음악 라이브러리 | GET/POST | `/api/music/library` — response: `{ tracks: [...] }` |
|
||||||
| AI 음악 라이브러리 | DELETE | `/api/music/library/:id` |
|
| AI 음악 라이브러리 | DELETE | `/api/music/library/:id` |
|
||||||
|
| 여행 | GET | `/api/travel/regions`, `/api/travel/albums`, `/api/travel/photos` |
|
||||||
|
| 여행 | POST | `/api/travel/sync` |
|
||||||
|
| 여행 | PUT | `/api/travel/albums/:album/cover`, `/api/travel/albums/:album/region` |
|
||||||
|
| 여행 | PUT | `/api/travel/regions/:id` |
|
||||||
|
| 인스타 | GET | `/api/insta/status`, `/api/insta/news/articles`, `/api/insta/keywords`, `/api/insta/slates`, `/api/insta/slates/:id` |
|
||||||
|
| 인스타 | POST | `/api/insta/news/collect`, `/api/insta/keywords/extract`, `/api/insta/slates`, `/api/insta/slates/:id/render` |
|
||||||
|
| 인스타 | DELETE | `/api/insta/slates/:id` |
|
||||||
|
| 인스타 | GET/PUT | `/api/insta/templates/prompts/:name` |
|
||||||
|
| 인스타 | GET | `/api/insta/tasks/:task_id` |
|
||||||
|
| 에이전트 | GET | `/api/agent-office/agents`, `/api/agent-office/states` |
|
||||||
|
| 에이전트 | POST | `/api/agent-office/command`, `/api/agent-office/approve` |
|
||||||
|
| 에이전트 | WS | `/api/agent-office/ws` |
|
||||||
|
| 부동산 | GET | `/api/realestate/announcements`, `/api/realestate/matches` |
|
||||||
|
| 부동산 | GET | `/api/realestate/profile` — 프로필 조회 |
|
||||||
|
| 부동산 | PUT | `/api/realestate/profile` — body: `{ preferred_districts: { "S": [...], "A": [...], "B": [...], "C": [...], "D": [...] }, min_match_score: int, notify_enabled: bool, ... }` |
|
||||||
|
| AI 큐레이터 | GET | `/api/lotto/briefing/latest`, `/api/lotto/curator/usage` |
|
||||||
|
| 포트폴리오 | GET | `/api/profile/public` — personal 서비스 |
|
||||||
|
| 포트폴리오 | POST | `/api/profile/auth` — personal 서비스 |
|
||||||
|
| 포트폴리오 | CRUD | `/api/profile/careers`, `/api/profile/projects`, `/api/profile/skills`, `/api/profile/introductions` — personal 서비스 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -222,7 +251,32 @@ handleGenerate()
|
|||||||
|
|
||||||
## Lotto 고도화 (`/lotto`)
|
## Lotto 고도화 (`/lotto`)
|
||||||
|
|
||||||
`src/pages/lotto/Functions.jsx`에 4개 신규 섹션 추가:
|
`src/pages/lotto/Functions.jsx`는 3탭 구조 (`브리핑 / 분석·통계 / 구매·성과`)로 리팩토링되었습니다.
|
||||||
|
|
||||||
|
| 탭 | 파일 | 설명 |
|
||||||
|
|----|------|------|
|
||||||
|
| 이번 주 브리핑 | `tabs/BriefingTab.jsx` | AI 큐레이터 브리핑 표시 (`components/briefing/` 하위 컴포넌트) |
|
||||||
|
| 분석·통계 | `tabs/AnalysisTab.jsx` | 시뮬레이션 추천·통계·ReportPanel·수동 추천 |
|
||||||
|
| 구매·성과 | `tabs/PurchaseTab.jsx` | 구매 내역 CRUD + 성과 통계 |
|
||||||
|
|
||||||
|
### 브리핑 전용 컴포넌트 (`components/briefing/`)
|
||||||
|
|
||||||
|
| 컴포넌트 | 설명 |
|
||||||
|
|----------|------|
|
||||||
|
| `BriefingTab.jsx` | 탭 루트, 브리핑 로드 + 트리거 |
|
||||||
|
| `BriefingHeader.jsx` | 회차·생성일시 헤더 |
|
||||||
|
| `BriefingSummary.jsx` | 내러티브 요약 표시 |
|
||||||
|
| `PickSetCard.jsx` | 번호 세트 1장 카드 |
|
||||||
|
| `BriefingEmpty.jsx` | 브리핑 없을 때 빈 상태 |
|
||||||
|
| `CuratorUsageFooter.jsx` | 토큰·비용 집계 푸터 |
|
||||||
|
|
||||||
|
### 신규 api.js 헬퍼
|
||||||
|
|
||||||
|
- `getLatestBriefing()` — `GET /api/lotto/briefing/latest`
|
||||||
|
- `getCuratorUsage(days)` — `GET /api/lotto/curator/usage?days=N`
|
||||||
|
- `triggerLottoCurate()` — `POST /api/agent-office/command` (lotto_agent curate 명령)
|
||||||
|
|
||||||
|
### 기존 섹션 (AnalysisTab 내)
|
||||||
|
|
||||||
| 섹션 | API | 설명 |
|
| 섹션 | API | 설명 |
|
||||||
|------|-----|------|
|
|------|-----|------|
|
||||||
@@ -235,9 +289,46 @@ handleGenerate()
|
|||||||
|
|
||||||
## Travel 갤러리 (`/travel`)
|
## Travel 갤러리 (`/travel`)
|
||||||
|
|
||||||
- 테마: "Dark Room" (배경 `#0f0c09`, 서체 Cormorant Garamond + Space Mono)
|
테마: "Dark Room" (배경 `#0f0c09`, 서체 Cormorant Garamond + Space Mono)
|
||||||
- 사진 URL: `/media/travel/...` 형식 → `vite.config.js` `/media` 프록시로 처리
|
|
||||||
- 프로덕션 nginx에도 `location /media/` 프록시 블록 필요
|
### 파일 구조
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `src/pages/travel/Travel.jsx` | 메인 페이지 — 앨범 카드 목록 + MiniMap |
|
||||||
|
| `src/pages/travel/AlbumCard.jsx` | 앨범 썸네일 카드 (커버 이미지 + 사진 수) |
|
||||||
|
| `src/pages/travel/AlbumDetail.jsx` | 앨범 상세 오버레이 — 사진/영상 탭 + 지역 편집 |
|
||||||
|
| `src/pages/travel/MasonryGrid.jsx` | CSS columns 기반 Masonry 레이아웃 + 무한 스크롤 |
|
||||||
|
| `src/pages/travel/HeroLightbox.jsx` | 전체화면 사진 뷰어 — 스와이프/키보드 네비게이션 |
|
||||||
|
| `src/pages/travel/MiniMap.jsx` | 접이식 Leaflet 지도 — GeoJSON 지역 + 핀 마커 |
|
||||||
|
| `src/pages/travel/RegionPinPicker.jsx` | 지도 핀 위치 지정 모달 (Leaflet 클릭 → 좌표 저장) |
|
||||||
|
| `src/pages/travel/VideoTab.jsx` | 영상 탭 (준비 중) |
|
||||||
|
|
||||||
|
### 핵심 기능
|
||||||
|
|
||||||
|
- **지역 관리**: GeoJSON 기반 지역 선택 → 앨범 필터링 + 지역 변경 + 핀 좌표 지정
|
||||||
|
- **앨범 카드**: 커버 사진, 지역 라벨, 사진 수 표시, 접근성 accent 색상
|
||||||
|
- **Masonry 그리드**: 40장 단위 청크 로딩, IntersectionObserver 기반 무한 스크롤
|
||||||
|
- **Lightbox**: 앨범 커버 지정, 스와이프/키보드 네비게이션, 추가 로딩 지원
|
||||||
|
- **MiniMap**: Polygon(기존 지역) + CircleMarker(커스텀 핀) 이중 렌더링
|
||||||
|
- **지역 편집**: AlbumDetail에서 인라인 편집 + 자동완성 + "위치 지정" 버튼
|
||||||
|
|
||||||
|
### API 연동
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/travel/regions` | GeoJSON (커스텀 지역 포함) |
|
||||||
|
| GET | `/api/travel/photos?region=X&page=N&size=40` | 사진 페이지네이션 |
|
||||||
|
| GET | `/api/travel/albums` | 앨범 목록 + cover + region |
|
||||||
|
| POST | `/api/travel/sync` | 폴더 동기화 |
|
||||||
|
| PUT | `/api/travel/albums/{album}/cover` | 커버 지정 |
|
||||||
|
| PUT | `/api/travel/albums/{album}/region` | 지역 변경 |
|
||||||
|
| PUT | `/api/travel/regions/{id}` | 핀 좌표 저장 |
|
||||||
|
|
||||||
|
### 미디어 URL
|
||||||
|
- 사진: `/media/travel/{album}/{filename}`
|
||||||
|
- 썸네일: `/media/travel/.thumb/{album}/{filename}`
|
||||||
|
- `vite.config.js` `/media` 프록시로 처리, 프로덕션 nginx에서 직접 서빙
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
138
README.md
138
README.md
@@ -1,6 +1,6 @@
|
|||||||
# Web UI
|
# Web UI
|
||||||
|
|
||||||
개인 대시보드 — 블로그, 로또, 주식, 부동산, 여행, 할 일 등을 한 곳에서 관리하는 개인 웹 UI입니다.
|
개인 대시보드 — 블로그, 로또, 주식, 부동산, 여행, AI 음악, AI 에이전트, 할 일 등을 한 곳에서 관리하는 개인 웹 UI입니다.
|
||||||
|
|
||||||
## 기술 스택
|
## 기술 스택
|
||||||
|
|
||||||
@@ -11,12 +11,13 @@
|
|||||||
| 지도 | react-leaflet + Leaflet |
|
| 지도 | react-leaflet + Leaflet |
|
||||||
| 차트 | Recharts |
|
| 차트 | Recharts |
|
||||||
| 3D | Three.js |
|
| 3D | Three.js |
|
||||||
|
| 제스처 | react-swipeable |
|
||||||
| 스타일 | 커스텀 CSS (CSS 변수 기반 사이버펑크 다크 테마) |
|
| 스타일 | 커스텀 CSS (CSS 변수 기반 사이버펑크 다크 테마) |
|
||||||
| 배포 | Synology NAS (Docker + nginx 리버스 프록시) |
|
| 배포 | Synology NAS (Docker + nginx 리버스 프록시) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 페이지 구성
|
## 페이지 구성 (13개 라우트)
|
||||||
|
|
||||||
### Home (`/`)
|
### Home (`/`)
|
||||||
|
|
||||||
@@ -39,15 +40,18 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Lotto (`/lotto`)
|
### Lotto (`/lotto`) — 14 컴포넌트
|
||||||
|
|
||||||
로또 번호 추천 및 통계 실험실.
|
로또 번호 추천 및 통계 실험실.
|
||||||
|
|
||||||
- 최신 로또 당첨 결과 조회
|
- **3탭 구조**: 이번 주 브리핑 / 분석·통계 / 구매·성과
|
||||||
|
- AI 큐레이터 브리핑 (5세트 + 내러티브 + 토큰·비용 집계)
|
||||||
- 가중치·최근 회차·회피 수 파라미터 기반 번호 추천
|
- 가중치·최근 회차·회피 수 파라미터 기반 번호 추천
|
||||||
- 프리셋으로 빠른 추천 생성
|
- 몬테카를로 시뮬레이션 최적 번호 표시
|
||||||
- 추천 히스토리 목록 확인 및 삭제
|
- 전략 진화 (EMA+Softmax) 기반 메타 추천
|
||||||
- 번호 원클릭 복사
|
- 주간 리포트 + ConfidenceRing 시각화
|
||||||
|
- 구매 이력 CRUD + 성과 통계 (수익률·당첨 현황)
|
||||||
|
- 프리셋으로 빠른 추천 생성, 번호 원클릭 복사
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -60,65 +64,93 @@
|
|||||||
- 미국 10년물 금리, WTI/Brent 유가 등 매크로 지표
|
- 미국 10년물 금리, WTI/Brent 유가 등 매크로 지표
|
||||||
- 국내 / 해외 주식 뉴스 탭 분류 및 필터링
|
- 국내 / 해외 주식 뉴스 탭 분류 및 필터링
|
||||||
|
|
||||||
### Stock Trade (`/stock/trade`)
|
### Stock Trade (`/stock/trade`) — 7 컴포넌트
|
||||||
|
|
||||||
포트폴리오 관리 및 트레이딩 데스크.
|
포트폴리오 관리 및 트레이딩 데스크.
|
||||||
|
|
||||||
- **포트폴리오 탭**: 보유 종목 수익률, 자산 구성 차트 (파이/바 차트)
|
- **포트폴리오 탭**: 보유 종목 수익률, 자산 구성 차트 (파이/바 차트)
|
||||||
- **AI 탭**: AI 시장 분석 요약 및 투자 인사이트
|
- **AI 탭**: AI 시장 분석 요약 및 투자 인사이트
|
||||||
- **리포트 탭**: 자산 스냅샷 히스토리 및 수익 추이
|
- **리포트 탭**: 자산 스냅샷 히스토리 및 수익 추이
|
||||||
- 종목 추가/편집/삭제 CRUD
|
- **어드바이저 탭**: 투자 조언 및 리밸런싱 제안
|
||||||
- 현금 잔고(예수금) 관리, 브로커별 분리
|
- 종목 추가/편집/삭제 CRUD, 현금 잔고(예수금) 관리
|
||||||
|
- 매도 히스토리 드로어 (실현손익 추적)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Realestate (`/realestate`)
|
### Realestate (`/realestate`) — 2 섹션
|
||||||
|
|
||||||
부동산 청약 통합 관리 — 청약 대시보드와 관심 단지 정보 두 화면으로 구성.
|
부동산 청약 통합 관리.
|
||||||
|
|
||||||
#### 청약 대시보드 (`/realestate`)
|
#### 청약 대시보드 (`/realestate`)
|
||||||
|
|
||||||
- **청약 목록 탭**: 관심 청약 카드 목록 + 상세 패널 (요건/일정/자금 섹션)
|
- 관심 청약 카드 목록 + 상세 패널 (요건/일정/자금 섹션)
|
||||||
- **일정 탭**: 전체 청약 일정 타임라인 (청약 → 계약 → 중도금 → 잔금)
|
- 전체 청약 일정 타임라인 (청약 → 계약 → 중도금 → 잔금)
|
||||||
- **자금 탭**: 단지별 자금 계획 및 총합 분석
|
- 가점 계산 엔진 (무주택 32점 + 부양가족 35점 + 통장 17점 = 84점 만점)
|
||||||
- 가점 계산 엔진 (무주택기간 최대 32점, 부양가족 최대 35점, 통장기간 최대 17점 = 84점 만점)
|
|
||||||
- 내 청약 조건 프로필 입력 및 단지별 요건 충족 여부 자동 비교
|
|
||||||
- 청약 유형 분류: 줍줍 / 특공 / 일반
|
- 청약 유형 분류: 줍줍 / 특공 / 일반
|
||||||
- API 미구현 시 localStorage fallback으로 데이터 유지
|
|
||||||
|
|
||||||
#### 부동산 정보 (`/realestate/property`)
|
#### 부동산 정보 (`/realestate/property`)
|
||||||
|
|
||||||
- 관심 아파트 단지 카드 그리드 + 지도 통합 뷰 (react-leaflet)
|
- 관심 아파트 단지 카드 그리드 + Leaflet 지도 통합 뷰
|
||||||
- 단지별 상태 마커: 청약예정 / 청약중 / 결과발표 / 완료
|
- D-day 카운트다운, 평당가 비교 바 차트 (Recharts)
|
||||||
- D-day 카운트다운 및 우선순위 배지
|
- 모달 기반 단지 추가/편집, 네이버 부동산 바로가기 연동
|
||||||
- 평당가 비교 바 차트 (Recharts)
|
|
||||||
- 일정 탭: 전체 단지 청약 일정 타임라인
|
|
||||||
- 분석 탭: 단지별 평당가 비교표
|
|
||||||
- 모달 기반 단지 추가/편집 (단지명, 주소, 좌표, 평형, 분양가, 네이버 부동산 URL)
|
|
||||||
- 네이버 부동산 바로가기 링크 연동
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Travel (`/travel`)
|
### Travel (`/travel`) — 8 컴포넌트
|
||||||
|
|
||||||
여행 사진 갤러리.
|
여행 사진 갤러리 (Dark Room 테마).
|
||||||
|
|
||||||
- 지도 기반 지역 선택 (GeoJSON)
|
- **MiniMap**: GeoJSON 기반 접이식 세계 지도 — Polygon(기존 지역) + CircleMarker(핀)
|
||||||
- 선택 지역의 사진 목록 로딩 및 캐시
|
- **AlbumCard**: 앨범 썸네일 카드 그리드 (커버 이미지 + 지역 라벨 + 사진 수)
|
||||||
- 스크롤 기반 이미지 추가 로딩 (chunked lazy load)
|
- **AlbumDetail**: 앨범 상세 오버레이 — 사진/영상 탭 + 지역 인라인 편집
|
||||||
- 썸네일 / 모달 뷰, 키보드 및 스와이프 네비게이션
|
- **MasonryGrid**: CSS columns Masonry 레이아웃 + IntersectionObserver 무한 스크롤
|
||||||
- 앨범 및 파일 메타 정보 표시
|
- **HeroLightbox**: 전체화면 사진 뷰어 — 스와이프/키보드 네비 + 앨범 커버 지정
|
||||||
|
- **RegionPinPicker**: 커스텀 지역 좌표 지정 모달 (Leaflet 클릭 → 핀 저장)
|
||||||
|
- 40장 단위 청크 로딩, PullToRefresh 지원
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Lab (`/lab`)
|
### Music — Sonic Forge (`/lab/music`) — 8 컴포넌트
|
||||||
|
|
||||||
|
AI 음악 생성 스튜디오.
|
||||||
|
|
||||||
|
- 듀얼 프로바이더: Suno (보컬/가사) + 로컬 MusicGen (인스트루멘탈)
|
||||||
|
- 장르/무드/악기/BPM/키/스케일 설정, 스타일 부스트
|
||||||
|
- 생성 진행 폴링 (3초 간격), 라이브러리 자동 등록
|
||||||
|
- 가사 관리 + 타임스탬프 동기 재생 (가라오케)
|
||||||
|
- 커버 이미지 생성, WAV 변환, 12스템 분리
|
||||||
|
- SonicRadar 시각 효과 + WaveformCanvas 오실로스코프
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Blog Marketing (`/blog-lab`)
|
||||||
|
|
||||||
|
AI 블로그 마케팅 자동화 대시보드.
|
||||||
|
|
||||||
|
- 키워드 리서치 (네이버 검색 + 상위 블로그 크롤링)
|
||||||
|
- AI 글 생성 → 마케팅 강화 → 품질 리뷰 (6기준 x 10점)
|
||||||
|
- 발행 관리 + 브랜드커넥트 링크 + 수익 추적
|
||||||
|
- 비동기 작업 상태 폴링
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Agent Office (`/agent-office`) — 5 컴포넌트
|
||||||
|
|
||||||
|
AI 에이전트 가상 오피스.
|
||||||
|
|
||||||
|
- 2D 픽셀아트 사무실에서 4명의 에이전트가 실제 작업 수행
|
||||||
|
- WebSocket 실시간 상태 동기화 (에이전트 FSM: idle → working → reporting)
|
||||||
|
- 에이전트별 명령 전송 + 작업 승인/거부
|
||||||
|
- 채팅 패널 + 문서 패널
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Lab (`/lab`) — 3 컴포넌트
|
||||||
|
|
||||||
실험적 UI/UX 효과 테스트 공간.
|
실험적 UI/UX 효과 테스트 공간.
|
||||||
|
|
||||||
- Three.js 기반 실시간 3D 파티클 애니메이션 (1,500개 오브젝트)
|
- **SwordStream**: Three.js 1,500개 파티클 3D 애니메이션 (호버/오빗 모드)
|
||||||
- 호버 모드: 마우스 추적 및 자연스러운 흐름
|
- **DayCalc**: 날짜 계산 유틸리티
|
||||||
- 오빗 모드: 클릭 시 나선형 궤도 회전
|
|
||||||
- 동적 스케일, 조명 효과
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -129,7 +161,23 @@
|
|||||||
- 칸반 레이아웃: 할 일 → 진행 중 → 완료
|
- 칸반 레이아웃: 할 일 → 진행 중 → 완료
|
||||||
- 드래그 앤 드롭으로 상태 변경
|
- 드래그 앤 드롭으로 상태 변경
|
||||||
- 태스크 추가/삭제, 완료 항목 일괄 정리
|
- 태스크 추가/삭제, 완료 항목 일괄 정리
|
||||||
- 상태별 카운트 및 타임스탬프 표시
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 공통 컴포넌트 (`src/components/`)
|
||||||
|
|
||||||
|
| 컴포넌트 | 설명 |
|
||||||
|
|----------|------|
|
||||||
|
| `Navbar` | 상단 네비게이션 바 |
|
||||||
|
| `BottomNav` | 모바일 하단 네비게이션 |
|
||||||
|
| `PageHeader` | 페이지 헤더 + 브레드크럼 |
|
||||||
|
| `SwipeableView` | 스와이프 탭 컨테이너 |
|
||||||
|
| `PullToRefresh` | 풀투리프레시 제스처 |
|
||||||
|
| `MobileSheet` | 모바일 바텀시트 모달 |
|
||||||
|
| `FAB` | 플로팅 액션 버튼 |
|
||||||
|
| `FearGreedGauge` | 공포·탐욕 게이지 |
|
||||||
|
| `Loading` | 로딩 스피너 |
|
||||||
|
| `Icons` | SVG 아이콘 라이브러리 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -161,5 +209,15 @@ NAS_SSH_TARGET=user@gahusb.synology.me NAS_SSH_PORT=22 npm run release:nas
|
|||||||
import { apiGet, apiPost, apiPut, apiDelete } from './api';
|
import { apiGet, apiPost, apiPut, apiDelete } from './api';
|
||||||
|
|
||||||
apiGet('/api/stock/indices');
|
apiGet('/api/stock/indices');
|
||||||
apiPost('/api/subscription/items', { ... });
|
apiPost('/api/travel/sync');
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 프로젝트 통계
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|-----|
|
||||||
|
| 페이지 라우트 | 13개 |
|
||||||
|
| JSX 컴포넌트 | 62+ |
|
||||||
|
| 공통 컴포넌트 | 10개 |
|
||||||
|
| API 헬퍼 함수 | 65+ |
|
||||||
|
| 외부 라이브러리 | React, Router, Leaflet, Recharts, Three.js, react-swipeable |
|
||||||
|
|||||||
126
STATUS.md
Normal file
126
STATUS.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# web-ui — 구현 현황 & 로드맵
|
||||||
|
|
||||||
|
> 최종 갱신: 2026-05-07
|
||||||
|
> 자세한 페이지·API 표는 [CLAUDE.md](./CLAUDE.md) 참조.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 구현 완료
|
||||||
|
|
||||||
|
### 1-1. 메인 페이지
|
||||||
|
|
||||||
|
| 경로 | 상태 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/` Home | ✅ | 메인 허브 |
|
||||||
|
| `/blog` Blog | ✅ | 마크다운 기반 |
|
||||||
|
| `/portfolio` Portfolio | ✅ | 프로필·경력·프로젝트·자기소개 |
|
||||||
|
| `/todo` Todo | ✅ | 태스크 보드 |
|
||||||
|
|
||||||
|
### 1-2. 로또 (`/lotto`)
|
||||||
|
|
||||||
|
| 영역 | 상태 |
|
||||||
|
|------|------|
|
||||||
|
| 3탭 구조 (브리핑 / 분석·통계 / 구매·성과) | ✅ |
|
||||||
|
| AI 큐레이터 브리핑 탭 | ✅ |
|
||||||
|
| 성과 배너 + ReportPanel + ConfidenceRing | ✅ |
|
||||||
|
| 개인 분석 패널 | ✅ |
|
||||||
|
| 구매 내역 CRUD + 성과 통계 | ✅ |
|
||||||
|
|
||||||
|
### 1-3. 주식 (`/stock`, `/stock/trade`)
|
||||||
|
|
||||||
|
| 영역 | 상태 |
|
||||||
|
|------|------|
|
||||||
|
| 뉴스·지수 | ✅ |
|
||||||
|
| 트레이딩 + 잔고 | ✅ |
|
||||||
|
| 포트폴리오 (수동 입력 종목 + 예수금 + 자산 추이) | ✅ |
|
||||||
|
| 자산 스냅샷 + 7/30/90일 차트 | ✅ |
|
||||||
|
| 실현손익(매도이력) Drawer | ✅ |
|
||||||
|
| 포트폴리오 카드 모바일 금액 줄바꿈 대응 | ✅ (2026-05-06) |
|
||||||
|
|
||||||
|
### 1-4. 청약 (`/realestate`, `/realestate/property`)
|
||||||
|
|
||||||
|
| 영역 | 상태 |
|
||||||
|
|------|------|
|
||||||
|
| 자치구 5티어 (S/A/B/C/D) 드래그&드롭 + 슬라이더 + 토글 | ✅ |
|
||||||
|
| 카드/매칭 결과에 district 뱃지 + 5티어 뱃지 | ✅ |
|
||||||
|
| AnnouncementDetail 매칭 분석 섹션 | ✅ |
|
||||||
|
| 5축 점수 breakdown 시각화 + 알림 대상 카운트 | ✅ |
|
||||||
|
| 청약 일정 캘린더 뷰 | ✅ |
|
||||||
|
| 프로필 완성도 힌트 배너 + 소득 기준 힌트 | ✅ |
|
||||||
|
|
||||||
|
### 1-5. 여행 (`/travel`)
|
||||||
|
|
||||||
|
| 영역 | 상태 |
|
||||||
|
|------|------|
|
||||||
|
| Dark Room 테마 갤러리 | ✅ |
|
||||||
|
| 앨범 카드 + Masonry + Lightbox + MiniMap | ✅ |
|
||||||
|
| 지역 변경 + 핀 좌표 지정 | ✅ |
|
||||||
|
| 영상(VideoTab) | 🚧 준비 중 |
|
||||||
|
|
||||||
|
### 1-6. 음악 스튜디오 (`/lab/music` — Sonic Forge)
|
||||||
|
|
||||||
|
| 영역 | 상태 |
|
||||||
|
|------|------|
|
||||||
|
| Create 탭 (장르/무드/악기/BPM/Key) + 트랙 제목 직접 입력 | ✅ |
|
||||||
|
| Library 탭 + 트랙 카드 + 삭제/재생 | ✅ |
|
||||||
|
| YouTube 탭 (서브탭 4개: VideoProjects / Trends / Revenue / Compile) | ✅ (2026-05-01~05-06) |
|
||||||
|
| 다중 트랙 컴파일 (FFmpeg concat → MP4) | ✅ |
|
||||||
|
| 시장 트렌드 리포트 (장르/추천수/이력) | ✅ |
|
||||||
|
|
||||||
|
### 1-7. 기타 Lab
|
||||||
|
|
||||||
|
| 경로 | 상태 |
|
||||||
|
|------|------|
|
||||||
|
| `/lab/sword-stream` Three.js 파티클 | ✅ |
|
||||||
|
| `/lab/day-calc` 날짜 계산기 | ✅ |
|
||||||
|
| `/agent-office` 에이전트 가상 오피스 (WebSocket) | ✅ |
|
||||||
|
| `/blog-lab` 블로그 마케팅 수익화 대시보드 | ✅ |
|
||||||
|
|
||||||
|
### 1-8. 인프라 / DX
|
||||||
|
|
||||||
|
| 항목 | 상태 |
|
||||||
|
|------|------|
|
||||||
|
| Vite 개발 서버 프록시 (`/api`, `/media`, `/ext`) | ✅ |
|
||||||
|
| Windows robocopy + macOS SSH/SMB 배포 (`scripts/deploy-nas.cjs`) | ✅ |
|
||||||
|
| Mac SSH 배포 + tar\|ssh 전환 (Synology rsync 우회) | ✅ |
|
||||||
|
| 반응형 웹 디자인 패스 | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 진행 중 / 향후 계획
|
||||||
|
|
||||||
|
### 2-1. Travel 영상 탭 완성
|
||||||
|
- 현재 "준비 중" 플레이스홀더 → 실제 영상 업로드/재생 UI 구현
|
||||||
|
- 백엔드 `travel-proxy`에 영상 메타·썸네일 API 필요
|
||||||
|
|
||||||
|
### 2-2. 로또 프리미엄 구독 UI (백엔드 Phase 3 연동)
|
||||||
|
- 회원 가입/로그인 UI (JWT)
|
||||||
|
- 구독 플랜 선택 + Toss/Stripe 결제 플로우
|
||||||
|
- 구독자 전용 리포트·알림 영역
|
||||||
|
- 백엔드 로드맵: `web-backend/docs/lotto-premium-roadmap.md`
|
||||||
|
|
||||||
|
### 2-3. Music YouTube 탭 후속
|
||||||
|
- VideoProjects 실제 렌더링 진행률 시각화 강화
|
||||||
|
- Compile 탭에 트랙 트림/페이드 옵션
|
||||||
|
- Revenue 대시보드 차트 강화
|
||||||
|
|
||||||
|
### 2-4. 청약 후속
|
||||||
|
- 알림 dry-run 미리보기 UI (어떤 공고가 매칭됐을지 사전 확인)
|
||||||
|
- 모바일 5티어 편집 모드 (현재 PC 전용)
|
||||||
|
|
||||||
|
### 2-5. 포트폴리오/주식 후속
|
||||||
|
- 종목별 평균 매입가 분할 입력 UI
|
||||||
|
- 매도 시뮬레이터 (수익률 시나리오 비교)
|
||||||
|
|
||||||
|
### 2-6. 일반
|
||||||
|
- 다크/라이트 테마 토글 (현재 다크 단일)
|
||||||
|
- PWA 설치 + 홈화면 단축 (모바일 사용 빈도 증가)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 참고 문서
|
||||||
|
|
||||||
|
- 페이지·라우트·API 전체 표: [CLAUDE.md](./CLAUDE.md)
|
||||||
|
- 워크스페이스 통합 가이드: `../CLAUDE.md`
|
||||||
|
- 백엔드 상태: `../web-backend/STATUS.md`
|
||||||
|
- 백엔드 Spec/Plan 디렉토리: `../web-backend/docs/superpowers/`
|
||||||
2837
docs/superpowers/plans/2026-05-11-lotto-curator-evolution-plan.md
Normal file
2837
docs/superpowers/plans/2026-05-11-lotto-curator-evolution-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
4353
docs/superpowers/plans/2026-05-12-stock-screener-board.md
Normal file
4353
docs/superpowers/plans/2026-05-12-stock-screener-board.md
Normal file
File diff suppressed because it is too large
Load Diff
1795
docs/superpowers/plans/2026-05-13-ai-news-sentiment-node.md
Normal file
1795
docs/superpowers/plans/2026-05-13-ai-news-sentiment-node.md
Normal file
File diff suppressed because it is too large
Load Diff
1826
docs/superpowers/plans/2026-05-13-screener-node-canvas.md
Normal file
1826
docs/superpowers/plans/2026-05-13-screener-node-canvas.md
Normal file
File diff suppressed because it is too large
Load Diff
999
docs/superpowers/plans/2026-05-14-ai-news-articles-source.md
Normal file
999
docs/superpowers/plans/2026-05-14-ai-news-articles-source.md
Normal file
@@ -0,0 +1,999 @@
|
|||||||
|
# AI News Phase 1 — articles Source 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_news 파이프라인의 데이터 소스를 Naver 스크래퍼에서 기존 `articles` 테이블로 교체. 종목명 substring 매핑으로 시총 상위 100 ticker 의 뉴스 sentiment 산출. `news_sentiment.source` 컬럼 추가로 Phase 2 비교 baseline 확보.
|
||||||
|
|
||||||
|
**Architecture:** 신규 `articles_source.py` 모듈이 `articles` 테이블 + `krx_master.name` substring 매핑으로 ticker별 뉴스 dict 반환. `pipeline.py`는 scraper 호출 대신 articles_source 사용. `analyzer.py` 가 LLM prompt 에 `summary` 포함. 텔레그램 메시지에 매핑 hit-rate 라인 추가. legacy `scraper.py` 는 deprecate 주석만 추가하고 보존.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.11 / SQLite (WAL + busy_timeout) / anthropic AsyncClient / FastAPI / pytest + pytest-asyncio.
|
||||||
|
|
||||||
|
**선행 spec**: `web-ui/docs/superpowers/specs/2026-05-14-ai-news-articles-source-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 구조
|
||||||
|
|
||||||
|
신규 파일 (backend):
|
||||||
|
```
|
||||||
|
web-backend/stock-lab/app/screener/ai_news/
|
||||||
|
articles_source.py ← DB articles 조회 + 종목명 매핑
|
||||||
|
|
||||||
|
web-backend/stock-lab/tests/
|
||||||
|
test_ai_news_articles_source.py ← 6 tests
|
||||||
|
```
|
||||||
|
|
||||||
|
수정 파일 (backend):
|
||||||
|
```
|
||||||
|
web-backend/stock-lab/app/screener/
|
||||||
|
schema.py ← news_sentiment.source 컬럼 + migration
|
||||||
|
ai_news/pipeline.py ← articles_source 사용, _make_http 제거
|
||||||
|
ai_news/analyzer.py ← prompt에 summary/pub_date 포함
|
||||||
|
ai_news/telegram.py ← build_message 에 mapping 라인
|
||||||
|
ai_news/scraper.py ← deprecate 주석만 추가
|
||||||
|
router.py ← post_refresh_news_sentiment 에 mapping 전달
|
||||||
|
|
||||||
|
web-backend/stock-lab/tests/
|
||||||
|
test_ai_news_pipeline.py ← articles_source mock 으로 갱신
|
||||||
|
test_ai_news_analyzer.py ← summary 케이스 추가
|
||||||
|
test_ai_news_telegram.py ← mapping 인자 케이스 추가
|
||||||
|
test_ai_news_router.py ← mapping 응답 필드 검증
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: schema.py — `news_sentiment.source` 컬럼 + migration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-backend/stock-lab/app/screener/schema.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: DDL 본문에 `source` 컬럼 정의 추가**
|
||||||
|
|
||||||
|
`schema.py` 의 `DDL` 문자열 안 `news_sentiment` 테이블 정의에 `source` 컬럼을 `model` 컬럼 다음에 추가:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS news_sentiment (
|
||||||
|
ticker TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
score_raw REAL NOT NULL,
|
||||||
|
reason TEXT NOT NULL DEFAULT '',
|
||||||
|
news_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tokens_input INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tokens_output INTEGER NOT NULL DEFAULT 0,
|
||||||
|
model TEXT NOT NULL DEFAULT 'claude-haiku-4-5-20251001',
|
||||||
|
source TEXT NOT NULL DEFAULT 'articles',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||||
|
PRIMARY KEY (ticker, date)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: `ensure_screener_schema()` 함수에 1회성 migration 블록 추가**
|
||||||
|
|
||||||
|
기존 ai_news weight migration 블록 (라인 ~142-156 근처) 직전 또는 직후에 다음을 추가:
|
||||||
|
```python
|
||||||
|
# news_sentiment.source 컬럼 1회 추가 (기존 운영 환경)
|
||||||
|
cols = {r[1] for r in conn.execute(
|
||||||
|
"PRAGMA table_info(news_sentiment)"
|
||||||
|
).fetchall()}
|
||||||
|
if "source" not in cols:
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE news_sentiment "
|
||||||
|
"ADD COLUMN source TEXT NOT NULL DEFAULT 'articles'"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
위치는 `executescript(DDL)` 직후, 기존 ai_news weight migration block 안이 자연스러움.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 기존 schema 테스트 회귀**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-backend\stock-lab
|
||||||
|
python -m pytest app/test_screener_schema.py -v
|
||||||
|
```
|
||||||
|
Expected: PASS — 3 tests passed (migration 추가에도 idempotency 유지).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/screener/schema.py
|
||||||
|
git commit -m "feat(ai_news): add news_sentiment.source column with migration"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: `articles_source.py` — DB 매핑 모듈 + 6 tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web-backend/stock-lab/app/screener/ai_news/articles_source.py`
|
||||||
|
- Test: `web-backend/stock-lab/tests/test_ai_news_articles_source.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 실패하는 테스트 작성**
|
||||||
|
|
||||||
|
`tests/test_ai_news_articles_source.py`:
|
||||||
|
```python
|
||||||
|
import datetime as dt
|
||||||
|
import sqlite3
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.screener.ai_news import articles_source
|
||||||
|
from app.screener.schema import ensure_screener_schema
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def conn():
|
||||||
|
c = sqlite3.connect(":memory:")
|
||||||
|
c.row_factory = sqlite3.Row
|
||||||
|
ensure_screener_schema(c)
|
||||||
|
# krx_master + articles 시드 helper 는 각 테스트에서 진행
|
||||||
|
yield c
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_master(conn, ticker, name):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO krx_master (ticker, name, market, market_cap, updated_at) "
|
||||||
|
"VALUES (?, ?, 'KOSPI', 1_000_000_000, datetime('now'))",
|
||||||
|
(ticker, name),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_article(conn, title, summary="", crawled_at="2026-05-14T07:30:00"):
|
||||||
|
import hashlib
|
||||||
|
h = hashlib.md5(f"{title}|x".encode()).hexdigest()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO articles (hash, title, summary, link, press, pub_date, crawled_at) "
|
||||||
|
"VALUES (?, ?, ?, '', '', '2026-05-14', ?)",
|
||||||
|
(h, title, summary, crawled_at),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ASOF = dt.date(2026, 5, 14)
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_ticker_match_in_title(conn):
|
||||||
|
_seed_master(conn, "005930", "삼성전자")
|
||||||
|
_seed_article(conn, "삼성전자, HBM 양산 가시화")
|
||||||
|
conn.commit()
|
||||||
|
out, stats = articles_source.gather_articles_for_tickers(
|
||||||
|
conn, ["005930"], ASOF, window_days=1, max_per_ticker=5,
|
||||||
|
)
|
||||||
|
assert len(out["005930"]) == 1
|
||||||
|
assert out["005930"][0]["title"] == "삼성전자, HBM 양산 가시화"
|
||||||
|
assert stats["matched_pairs"] == 1
|
||||||
|
assert stats["hit_tickers"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_ticker_match_in_summary(conn):
|
||||||
|
_seed_master(conn, "005930", "삼성전자")
|
||||||
|
_seed_article(conn, "메모리 시장 회복세", summary="삼성전자가 1분기 어닝 서프라이즈")
|
||||||
|
conn.commit()
|
||||||
|
out, _ = articles_source.gather_articles_for_tickers(
|
||||||
|
conn, ["005930"], ASOF, window_days=1, max_per_ticker=5,
|
||||||
|
)
|
||||||
|
assert len(out["005930"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_ticker_match(conn):
|
||||||
|
_seed_master(conn, "005930", "삼성전자")
|
||||||
|
_seed_master(conn, "000660", "SK하이닉스")
|
||||||
|
_seed_article(conn, "삼성전자와 SK하이닉스, 메모리 양산 경쟁")
|
||||||
|
conn.commit()
|
||||||
|
out, stats = articles_source.gather_articles_for_tickers(
|
||||||
|
conn, ["005930", "000660"], ASOF, window_days=1, max_per_ticker=5,
|
||||||
|
)
|
||||||
|
assert len(out["005930"]) == 1
|
||||||
|
assert len(out["000660"]) == 1
|
||||||
|
assert stats["matched_pairs"] == 2
|
||||||
|
assert stats["hit_tickers"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_match_returns_empty_list(conn):
|
||||||
|
_seed_master(conn, "005930", "삼성전자")
|
||||||
|
_seed_article(conn, "엔비디아 실적 발표", summary="AI 칩 수요 견조")
|
||||||
|
conn.commit()
|
||||||
|
out, stats = articles_source.gather_articles_for_tickers(
|
||||||
|
conn, ["005930"], ASOF, window_days=1, max_per_ticker=5,
|
||||||
|
)
|
||||||
|
assert out["005930"] == []
|
||||||
|
assert stats["matched_pairs"] == 0
|
||||||
|
assert stats["hit_tickers"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_max_per_ticker_caps_results(conn):
|
||||||
|
_seed_master(conn, "005930", "삼성전자")
|
||||||
|
for i in range(6):
|
||||||
|
_seed_article(conn, f"삼성전자 뉴스 #{i}", crawled_at=f"2026-05-14T0{i}:00:00")
|
||||||
|
conn.commit()
|
||||||
|
out, _ = articles_source.gather_articles_for_tickers(
|
||||||
|
conn, ["005930"], ASOF, window_days=1, max_per_ticker=5,
|
||||||
|
)
|
||||||
|
assert len(out["005930"]) == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_window_days_filters_old_articles(conn):
|
||||||
|
_seed_master(conn, "005930", "삼성전자")
|
||||||
|
_seed_article(conn, "삼성전자 최신 뉴스", crawled_at="2026-05-14T07:00:00")
|
||||||
|
_seed_article(conn, "삼성전자 오래된 뉴스", crawled_at="2026-05-01T07:00:00")
|
||||||
|
conn.commit()
|
||||||
|
out, _ = articles_source.gather_articles_for_tickers(
|
||||||
|
conn, ["005930"], ASOF, window_days=1, max_per_ticker=5,
|
||||||
|
)
|
||||||
|
assert len(out["005930"]) == 1
|
||||||
|
assert "최신" in out["005930"][0]["title"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 실패 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/test_ai_news_articles_source.py -v
|
||||||
|
```
|
||||||
|
Expected: FAIL — "No module named 'app.screener.ai_news.articles_source'".
|
||||||
|
|
||||||
|
- [ ] **Step 3: `articles_source.py` 구현** — 정확히:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""기존 articles 테이블에서 종목별 뉴스 매핑."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
from typing import Any, Dict, List, Tuple
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def gather_articles_for_tickers(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
tickers: List[str],
|
||||||
|
asof: dt.date,
|
||||||
|
*,
|
||||||
|
window_days: int = 1,
|
||||||
|
max_per_ticker: int = 5,
|
||||||
|
) -> Tuple[Dict[str, List[Dict[str, Any]]], Dict[str, int]]:
|
||||||
|
"""articles 에서 ticker.name substring 매칭으로 종목별 뉴스 dict 반환.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(
|
||||||
|
{ticker: [{"title": str, "summary": str, "press": str, "pub_date": str}, ...]},
|
||||||
|
{"total_articles": int, "matched_pairs": int, "hit_tickers": int},
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
out: Dict[str, List[Dict[str, Any]]] = {t: [] for t in tickers}
|
||||||
|
stats = {"total_articles": 0, "matched_pairs": 0, "hit_tickers": 0}
|
||||||
|
|
||||||
|
if not tickers:
|
||||||
|
return out, stats
|
||||||
|
|
||||||
|
cutoff = (asof - dt.timedelta(days=window_days)).isoformat()
|
||||||
|
|
||||||
|
placeholders = ",".join("?" * len(tickers))
|
||||||
|
name_rows = conn.execute(
|
||||||
|
f"SELECT ticker, name FROM krx_master WHERE ticker IN ({placeholders})",
|
||||||
|
tickers,
|
||||||
|
).fetchall()
|
||||||
|
# 2글자 미만 회사명은 false positive 위험으로 제외
|
||||||
|
name_map = {r[0]: r[1] for r in name_rows if r[1] and len(r[1]) >= 2}
|
||||||
|
|
||||||
|
articles = conn.execute(
|
||||||
|
"SELECT title, summary, press, pub_date, crawled_at "
|
||||||
|
"FROM articles WHERE crawled_at >= ? ORDER BY crawled_at DESC",
|
||||||
|
(cutoff,),
|
||||||
|
).fetchall()
|
||||||
|
stats["total_articles"] = len(articles)
|
||||||
|
|
||||||
|
for a in articles:
|
||||||
|
title = (a[0] or "").strip()
|
||||||
|
summary = (a[1] or "").strip()
|
||||||
|
haystack = title + " " + summary
|
||||||
|
for ticker, name in name_map.items():
|
||||||
|
if name not in haystack:
|
||||||
|
continue
|
||||||
|
if len(out[ticker]) >= max_per_ticker:
|
||||||
|
continue
|
||||||
|
out[ticker].append({
|
||||||
|
"title": title,
|
||||||
|
"summary": summary,
|
||||||
|
"press": a[2] or "",
|
||||||
|
"pub_date": a[3] or "",
|
||||||
|
})
|
||||||
|
stats["matched_pairs"] += 1
|
||||||
|
|
||||||
|
stats["hit_tickers"] = sum(1 for arts in out.values() if arts)
|
||||||
|
return out, stats
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 테스트 통과 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/test_ai_news_articles_source.py -v
|
||||||
|
```
|
||||||
|
Expected: PASS — 6 tests passed.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/screener/ai_news/articles_source.py tests/test_ai_news_articles_source.py
|
||||||
|
git commit -m "feat(ai_news): articles_source module (substring ticker matching)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: `analyzer.py` — prompt 에 summary/pub_date 포함
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-backend/stock-lab/app/screener/ai_news/analyzer.py`
|
||||||
|
- Modify: `web-backend/stock-lab/tests/test_ai_news_analyzer.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 테스트 갱신 (실패 유도)**
|
||||||
|
|
||||||
|
`tests/test_ai_news_analyzer.py` 의 `NEWS` 상수와 `test_score_sentiment_success_parses_json` 테스트를 다음으로 교체/보강:
|
||||||
|
```python
|
||||||
|
NEWS = [
|
||||||
|
{"title": "삼성전자, HBM 양산", "summary": "1분기 영업이익 사상 최대", "pub_date": "2026-05-14"},
|
||||||
|
{"title": "메모리 가격 반등", "summary": "", "pub_date": "2026-05-14"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_score_sentiment_includes_summary_in_prompt():
|
||||||
|
"""summary 가 있으면 prompt 에 포함, 없으면 title 만."""
|
||||||
|
llm = _mk_llm(json.dumps({"score": 5.0, "reason": "ok"}))
|
||||||
|
await analyzer.score_sentiment(llm, "005930", NEWS, name="삼성전자")
|
||||||
|
# mock 의 messages.create 호출 인자 확인
|
||||||
|
call = llm.messages.create.call_args
|
||||||
|
user_msg = call.kwargs["messages"][0]["content"]
|
||||||
|
assert "1분기 영업이익 사상 최대" in user_msg # summary 포함
|
||||||
|
assert "삼성전자, HBM 양산" in user_msg # title 포함
|
||||||
|
assert "2026-05-14" in user_msg # pub_date 포함
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 실행으로 실패 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/test_ai_news_analyzer.py::test_score_sentiment_includes_summary_in_prompt -v
|
||||||
|
```
|
||||||
|
Expected: FAIL — `1분기 영업이익 사상 최대` 가 prompt 에 없음.
|
||||||
|
|
||||||
|
- [ ] **Step 3: `analyzer.py` 의 news_block 빌더 분리 + summary 포함**
|
||||||
|
|
||||||
|
기존 prompt 빌드 부분 수정. `score_sentiment` 함수의 prompt build 직전에 helper 함수 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _format_news_block(news: List[Dict[str, Any]]) -> str:
|
||||||
|
"""news dict 리스트 → prompt 에 들어가는 텍스트 블록.
|
||||||
|
|
||||||
|
summary 가 있으면 title 다음 줄에 indent 해서 포함 (최대 200자).
|
||||||
|
pub_date 가 있으면 title 앞에 표시.
|
||||||
|
"""
|
||||||
|
lines: List[str] = []
|
||||||
|
for n in news:
|
||||||
|
date = (n.get("pub_date") or "").strip()
|
||||||
|
title = (n.get("title") or "").strip()
|
||||||
|
summary = (n.get("summary") or "").strip()
|
||||||
|
prefix = f"[{date}] " if date else ""
|
||||||
|
if summary:
|
||||||
|
lines.append(f"- {prefix}{title}\n {summary[:200]}")
|
||||||
|
else:
|
||||||
|
lines.append(f"- {prefix}{title}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
```
|
||||||
|
|
||||||
|
그리고 `score_sentiment` 안 `news_block` 계산 라인을 다음으로 교체:
|
||||||
|
```python
|
||||||
|
news_block = _format_news_block(news)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 테스트 통과 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/test_ai_news_analyzer.py -v
|
||||||
|
```
|
||||||
|
Expected: PASS — 5 tests (기존 4 + 신규 1) 모두 통과.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/screener/ai_news/analyzer.py tests/test_ai_news_analyzer.py
|
||||||
|
git commit -m "feat(ai_news): include summary + pub_date in LLM prompt"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: `pipeline.py` — articles_source 사용으로 교체
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-backend/stock-lab/app/screener/ai_news/pipeline.py`
|
||||||
|
- Modify: `web-backend/stock-lab/tests/test_ai_news_pipeline.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 테스트 갱신 (실패 유도)**
|
||||||
|
|
||||||
|
`tests/test_ai_news_pipeline.py` 의 `test_refresh_daily_happy_path` 를 다음으로 교체:
|
||||||
|
```python
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_daily_happy_path(conn):
|
||||||
|
"""3종목 mini integration — articles_source mock + analyzer mock.
|
||||||
|
|
||||||
|
각 종목에 매핑되는 articles 1개씩 있다고 가정.
|
||||||
|
"""
|
||||||
|
asof = dt.date(2026, 5, 13)
|
||||||
|
|
||||||
|
fake_articles_by_ticker = {
|
||||||
|
"005930": [{"title": "삼성 뉴스", "summary": "", "press": "", "pub_date": ""}],
|
||||||
|
"000660": [{"title": "SK 뉴스", "summary": "", "press": "", "pub_date": ""}],
|
||||||
|
"373220": [{"title": "LG 뉴스", "summary": "", "press": "", "pub_date": ""}],
|
||||||
|
}
|
||||||
|
fake_stats = {"total_articles": 3, "matched_pairs": 3, "hit_tickers": 3}
|
||||||
|
|
||||||
|
scores_by_ticker = {
|
||||||
|
"005930": 7.5, "000660": 4.0, "373220": -6.0,
|
||||||
|
}
|
||||||
|
async def fake_score(llm, ticker, news, *, name=None, model="m"):
|
||||||
|
return {
|
||||||
|
"ticker": ticker, "score_raw": scores_by_ticker[ticker],
|
||||||
|
"reason": f"r{ticker}", "news_count": 1,
|
||||||
|
"tokens_input": 100, "tokens_output": 20, "model": model,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(pipeline, "articles_source") as mas, \
|
||||||
|
patch.object(pipeline, "_analyzer") as ma, \
|
||||||
|
patch.object(pipeline, "_make_llm") as ml:
|
||||||
|
mas.gather_articles_for_tickers = MagicMock(
|
||||||
|
return_value=(fake_articles_by_ticker, fake_stats)
|
||||||
|
)
|
||||||
|
ma.score_sentiment = fake_score
|
||||||
|
ml.return_value.__aenter__.return_value = AsyncMock()
|
||||||
|
ml.return_value.__aexit__.return_value = None
|
||||||
|
result = await pipeline.refresh_daily(conn, asof, concurrency=3)
|
||||||
|
|
||||||
|
assert result["asof"] == "2026-05-13"
|
||||||
|
assert result["updated"] == 3
|
||||||
|
assert result["failures"] == []
|
||||||
|
assert result["top_pos"][0]["ticker"] == "005930"
|
||||||
|
assert result["top_neg"][0]["ticker"] == "373220"
|
||||||
|
assert result["mapping"] == fake_stats
|
||||||
|
|
||||||
|
rows = conn.execute("SELECT ticker, score_raw, source FROM news_sentiment "
|
||||||
|
"WHERE date=?", ("2026-05-13",)).fetchall()
|
||||||
|
assert len(rows) == 3
|
||||||
|
assert all(r["source"] == "articles" for r in rows)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_daily_no_match_ticker_skipped(conn):
|
||||||
|
"""매핑 0인 ticker 는 LLM 호출 skip + news_sentiment 행 미생성."""
|
||||||
|
asof = dt.date(2026, 5, 13)
|
||||||
|
|
||||||
|
fake_articles_by_ticker = {
|
||||||
|
"005930": [{"title": "삼성", "summary": "", "press": "", "pub_date": ""}],
|
||||||
|
"000660": [], # 매핑 없음
|
||||||
|
"373220": [], # 매핑 없음
|
||||||
|
}
|
||||||
|
fake_stats = {"total_articles": 1, "matched_pairs": 1, "hit_tickers": 1}
|
||||||
|
|
||||||
|
async def fake_score(llm, ticker, news, *, name=None, model="m"):
|
||||||
|
return {
|
||||||
|
"ticker": ticker, "score_raw": 5.0, "reason": "r",
|
||||||
|
"news_count": 1, "tokens_input": 100, "tokens_output": 20,
|
||||||
|
"model": model,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(pipeline, "articles_source") as mas, \
|
||||||
|
patch.object(pipeline, "_analyzer") as ma, \
|
||||||
|
patch.object(pipeline, "_make_llm") as ml:
|
||||||
|
mas.gather_articles_for_tickers = MagicMock(
|
||||||
|
return_value=(fake_articles_by_ticker, fake_stats)
|
||||||
|
)
|
||||||
|
ma.score_sentiment = fake_score
|
||||||
|
ml.return_value.__aenter__.return_value = AsyncMock()
|
||||||
|
ml.return_value.__aexit__.return_value = None
|
||||||
|
result = await pipeline.refresh_daily(conn, asof, concurrency=3)
|
||||||
|
|
||||||
|
assert result["updated"] == 1
|
||||||
|
rows = conn.execute("SELECT ticker FROM news_sentiment "
|
||||||
|
"WHERE date=?", ("2026-05-13",)).fetchall()
|
||||||
|
assert {r["ticker"] for r in rows} == {"005930"}
|
||||||
|
```
|
||||||
|
|
||||||
|
기존 `test_refresh_daily_failures_isolated` 는 articles_source 매핑 데이터를 추가해야 함:
|
||||||
|
```python
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_daily_failures_isolated(conn):
|
||||||
|
asof = dt.date(2026, 5, 13)
|
||||||
|
|
||||||
|
fake_articles_by_ticker = {
|
||||||
|
"005930": [{"title": "h", "summary": "", "press": "", "pub_date": ""}],
|
||||||
|
"000660": [{"title": "h", "summary": "", "press": "", "pub_date": ""}],
|
||||||
|
"373220": [{"title": "h", "summary": "", "press": "", "pub_date": ""}],
|
||||||
|
}
|
||||||
|
fake_stats = {"total_articles": 3, "matched_pairs": 3, "hit_tickers": 3}
|
||||||
|
|
||||||
|
async def fake_score(llm, ticker, news, *, name=None, model="m"):
|
||||||
|
if ticker == "000660":
|
||||||
|
raise RuntimeError("llm exploded")
|
||||||
|
return {
|
||||||
|
"ticker": ticker, "score_raw": 5.0, "reason": "r", "news_count": 1,
|
||||||
|
"tokens_input": 100, "tokens_output": 20, "model": model,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(pipeline, "articles_source") as mas, \
|
||||||
|
patch.object(pipeline, "_analyzer") as ma, \
|
||||||
|
patch.object(pipeline, "_make_llm") as ml:
|
||||||
|
mas.gather_articles_for_tickers = MagicMock(
|
||||||
|
return_value=(fake_articles_by_ticker, fake_stats)
|
||||||
|
)
|
||||||
|
ma.score_sentiment = fake_score
|
||||||
|
ml.return_value.__aenter__.return_value = AsyncMock()
|
||||||
|
ml.return_value.__aexit__.return_value = None
|
||||||
|
result = await pipeline.refresh_daily(conn, asof, concurrency=3)
|
||||||
|
|
||||||
|
assert result["updated"] == 2
|
||||||
|
assert len(result["failures"]) == 1
|
||||||
|
```
|
||||||
|
|
||||||
|
상단 import 에 `MagicMock` 추가 확인:
|
||||||
|
```python
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 실패 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/test_ai_news_pipeline.py -v
|
||||||
|
```
|
||||||
|
Expected: FAIL — pipeline 이 articles_source 를 아직 사용 안 함.
|
||||||
|
|
||||||
|
- [ ] **Step 3: `pipeline.py` 본문 교체**
|
||||||
|
|
||||||
|
`pipeline.py` 의 다음을 변경:
|
||||||
|
|
||||||
|
(1) 상단 import 에 articles_source 추가:
|
||||||
|
```python
|
||||||
|
from . import scraper as _scraper # legacy, kept for backward import
|
||||||
|
from . import analyzer as _analyzer
|
||||||
|
from . import articles_source # 신규
|
||||||
|
```
|
||||||
|
|
||||||
|
(2) `_make_http()` 함수와 `DEFAULT_RATE_LIMIT_SEC` 상수는 제거 (또는 deprecate). 더 이상 사용 안 함.
|
||||||
|
|
||||||
|
(3) `_process_one()` 함수를 다음으로 교체:
|
||||||
|
```python
|
||||||
|
async def _process_one(
|
||||||
|
ticker: str, name: str, articles: List[Dict[str, Any]],
|
||||||
|
sem: asyncio.Semaphore, llm, model: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
async with sem:
|
||||||
|
return await _analyzer.score_sentiment(
|
||||||
|
llm, ticker, articles, name=name, model=model,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
(4) `refresh_daily()` 시그니처 + 본문 교체:
|
||||||
|
```python
|
||||||
|
async def refresh_daily(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
asof: dt.date,
|
||||||
|
*,
|
||||||
|
top_n: int = DEFAULT_TOP_N,
|
||||||
|
concurrency: int = DEFAULT_CONCURRENCY,
|
||||||
|
max_news_per_ticker: int = DEFAULT_NEWS_PER_TICKER,
|
||||||
|
window_days: int = 1,
|
||||||
|
model: str = _analyzer.DEFAULT_MODEL,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
started = time.time()
|
||||||
|
tickers = _top_market_cap_tickers(conn, n=top_n)
|
||||||
|
name_map = {
|
||||||
|
r[0]: r[1] for r in conn.execute(
|
||||||
|
f"SELECT ticker, name FROM krx_master WHERE ticker IN "
|
||||||
|
f"({','.join('?' * len(tickers))})", tickers,
|
||||||
|
).fetchall()
|
||||||
|
} if tickers else {}
|
||||||
|
|
||||||
|
articles_by_ticker, mapping_stats = articles_source.gather_articles_for_tickers(
|
||||||
|
conn, tickers, asof,
|
||||||
|
window_days=window_days,
|
||||||
|
max_per_ticker=max_news_per_ticker,
|
||||||
|
)
|
||||||
|
|
||||||
|
sem = asyncio.Semaphore(concurrency)
|
||||||
|
async with _make_llm() as llm:
|
||||||
|
tasks = []
|
||||||
|
for t in tickers:
|
||||||
|
arts = articles_by_ticker.get(t, [])
|
||||||
|
if not arts:
|
||||||
|
continue # 매핑 0 — score 미생성
|
||||||
|
tasks.append(_process_one(t, name_map.get(t, t), arts, sem, llm, model))
|
||||||
|
raw_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
successes: List[Dict[str, Any]] = []
|
||||||
|
failures: List[str] = []
|
||||||
|
for r in raw_results:
|
||||||
|
if isinstance(r, BaseException):
|
||||||
|
failures.append(repr(r))
|
||||||
|
elif isinstance(r, dict):
|
||||||
|
successes.append(r)
|
||||||
|
|
||||||
|
if successes:
|
||||||
|
_upsert_news_sentiment(conn, asof, successes, source="articles")
|
||||||
|
|
||||||
|
top_pos = sorted(successes, key=lambda r: -r["score_raw"])[:5]
|
||||||
|
top_neg = sorted(successes, key=lambda r: r["score_raw"])[:5]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"asof": asof.isoformat(),
|
||||||
|
"updated": len(successes),
|
||||||
|
"failures": failures,
|
||||||
|
"duration_sec": round(time.time() - started, 2),
|
||||||
|
"tokens_input": sum(r["tokens_input"] for r in successes),
|
||||||
|
"tokens_output": sum(r["tokens_output"] for r in successes),
|
||||||
|
"top_pos": top_pos,
|
||||||
|
"top_neg": top_neg,
|
||||||
|
"model": model,
|
||||||
|
"mapping": mapping_stats,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(5) `_upsert_news_sentiment()` 함수에 `source` 인자 추가 + INSERT 에 컬럼 포함:
|
||||||
|
```python
|
||||||
|
def _upsert_news_sentiment(
|
||||||
|
conn: sqlite3.Connection, asof: dt.date,
|
||||||
|
rows: List[Dict[str, Any]], *, source: str = "articles",
|
||||||
|
) -> None:
|
||||||
|
iso = asof.isoformat()
|
||||||
|
data = [
|
||||||
|
(
|
||||||
|
r["ticker"], iso, r["score_raw"], r["reason"], r["news_count"],
|
||||||
|
r["tokens_input"], r["tokens_output"], r["model"], source,
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
conn.executemany(
|
||||||
|
"""INSERT INTO news_sentiment
|
||||||
|
(ticker, date, score_raw, reason, news_count,
|
||||||
|
tokens_input, tokens_output, model, source)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(ticker, date) DO UPDATE SET
|
||||||
|
score_raw=excluded.score_raw,
|
||||||
|
reason=excluded.reason,
|
||||||
|
news_count=excluded.news_count,
|
||||||
|
tokens_input=excluded.tokens_input,
|
||||||
|
tokens_output=excluded.tokens_output,
|
||||||
|
model=excluded.model,
|
||||||
|
source=excluded.source
|
||||||
|
""",
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 테스트 통과 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/test_ai_news_pipeline.py -v
|
||||||
|
```
|
||||||
|
Expected: PASS — `test_refresh_daily_happy_path`, `test_refresh_daily_failures_isolated`, `test_refresh_daily_no_match_ticker_skipped`, `test_top_market_cap_tickers` 모두 통과 (4 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/screener/ai_news/pipeline.py tests/test_ai_news_pipeline.py
|
||||||
|
git commit -m "feat(ai_news): pipeline uses articles_source (replaces Naver scraper)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: `telegram.py` — 매핑 라인 추가
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-backend/stock-lab/app/screener/ai_news/telegram.py`
|
||||||
|
- Modify: `web-backend/stock-lab/tests/test_ai_news_telegram.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 테스트 갱신 (실패 유도)**
|
||||||
|
|
||||||
|
`tests/test_ai_news_telegram.py` 끝에 새 테스트 추가:
|
||||||
|
```python
|
||||||
|
def test_build_message_includes_mapping_line():
|
||||||
|
msg = tg.build_message(
|
||||||
|
asof="2026-05-14",
|
||||||
|
top_pos=[_row("005930", 8.5, "HBM 호재")],
|
||||||
|
top_neg=[],
|
||||||
|
tokens_input=1000, tokens_output=200,
|
||||||
|
mapping={"total_articles": 35, "matched_pairs": 50, "hit_tickers": 42},
|
||||||
|
)
|
||||||
|
assert "매핑" in msg
|
||||||
|
assert "42" in msg
|
||||||
|
assert "50" in msg
|
||||||
|
assert "35" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_message_without_mapping_omits_line():
|
||||||
|
msg = tg.build_message(
|
||||||
|
asof="2026-05-14",
|
||||||
|
top_pos=[],
|
||||||
|
top_neg=[],
|
||||||
|
tokens_input=1000, tokens_output=200,
|
||||||
|
)
|
||||||
|
assert "매핑" not in msg
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 실패 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/test_ai_news_telegram.py -v
|
||||||
|
```
|
||||||
|
Expected: FAIL — `mapping` 인자 미지원.
|
||||||
|
|
||||||
|
- [ ] **Step 3: `telegram.py` 의 `build_message` 시그니처 + footer 갱신**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def build_message(
|
||||||
|
*,
|
||||||
|
asof: str,
|
||||||
|
top_pos: List[Dict[str, Any]],
|
||||||
|
top_neg: List[Dict[str, Any]],
|
||||||
|
tokens_input: int,
|
||||||
|
tokens_output: int,
|
||||||
|
mapping: Dict[str, int] | None = None,
|
||||||
|
) -> str:
|
||||||
|
lines: List[str] = [
|
||||||
|
f"🌅 *AI 뉴스 분석* \\({_escape(asof)} 08:00\\)",
|
||||||
|
"",
|
||||||
|
"📈 *호재 Top 5*",
|
||||||
|
]
|
||||||
|
if top_pos:
|
||||||
|
for i, r in enumerate(top_pos, 1):
|
||||||
|
lines.append(_row_line(i, r))
|
||||||
|
else:
|
||||||
|
lines.append(_escape("- (없음)"))
|
||||||
|
|
||||||
|
lines += ["", "📉 *악재 Top 5*"]
|
||||||
|
if top_neg:
|
||||||
|
for i, r in enumerate(top_neg, 1):
|
||||||
|
lines.append(_row_line(i, r))
|
||||||
|
else:
|
||||||
|
lines.append(_escape("- (없음)"))
|
||||||
|
|
||||||
|
cost = _cost_won(tokens_input, tokens_output)
|
||||||
|
mapping_part = ""
|
||||||
|
if mapping:
|
||||||
|
mapping_part = (
|
||||||
|
f"매핑 {mapping['hit_tickers']}/100 ticker "
|
||||||
|
f"\\({mapping['matched_pairs']}쌍 / articles {mapping['total_articles']}건\\) · "
|
||||||
|
)
|
||||||
|
lines += [
|
||||||
|
"",
|
||||||
|
f"_분석: 시총 상위 100종목 · {mapping_part}"
|
||||||
|
f"토큰 {tokens_input:,} in / {tokens_output:,} out · "
|
||||||
|
f"약 ₩{cost:,}_",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 테스트 통과 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/test_ai_news_telegram.py -v
|
||||||
|
```
|
||||||
|
Expected: PASS — 6 tests (기존 4 + 신규 2) 모두 통과.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/screener/ai_news/telegram.py tests/test_ai_news_telegram.py
|
||||||
|
git commit -m "feat(ai_news): telegram includes article mapping stats line"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: `router.py` — mapping 응답 필드 전달
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-backend/stock-lab/app/screener/router.py`
|
||||||
|
- Modify: `web-backend/stock-lab/tests/test_ai_news_router.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 테스트 갱신**
|
||||||
|
|
||||||
|
`tests/test_ai_news_router.py` 의 `test_refresh_news_sentiment_weekday_invokes_pipeline` 보강:
|
||||||
|
```python
|
||||||
|
def test_refresh_news_sentiment_weekday_invokes_pipeline():
|
||||||
|
fake_summary = {
|
||||||
|
"asof": "2026-05-13", "updated": 3, "failures": [],
|
||||||
|
"duration_sec": 1.0, "tokens_input": 100, "tokens_output": 20,
|
||||||
|
"top_pos": [], "top_neg": [], "model": "m",
|
||||||
|
"mapping": {"total_articles": 5, "matched_pairs": 8, "hit_tickers": 3},
|
||||||
|
}
|
||||||
|
with patch("app.screener.router._ai_pipeline") as mp, \
|
||||||
|
patch("app.screener.router._ai_telegram") as mt:
|
||||||
|
mp.refresh_daily = AsyncMock(return_value=fake_summary)
|
||||||
|
mt.build_message = lambda **kw: f"TEXT_with_mapping={kw.get('mapping')}"
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/stock/screener/snapshot/refresh-news-sentiment?asof=2026-05-13"
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["mapping"]["hit_tickers"] == 3
|
||||||
|
assert "mapping=" in body["telegram_text"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 실패 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/test_ai_news_router.py -v
|
||||||
|
```
|
||||||
|
Expected: FAIL — `mapping` 이 build_message 호출에 전달되지 않음.
|
||||||
|
|
||||||
|
- [ ] **Step 3: `router.py` 의 `post_refresh_news_sentiment` 의 telegram_text 빌드 갱신**
|
||||||
|
|
||||||
|
기존:
|
||||||
|
```python
|
||||||
|
summary["telegram_text"] = _ai_telegram.build_message(
|
||||||
|
asof=summary["asof"],
|
||||||
|
top_pos=summary["top_pos"], top_neg=summary["top_neg"],
|
||||||
|
tokens_input=summary["tokens_input"],
|
||||||
|
tokens_output=summary["tokens_output"],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
다음으로 교체:
|
||||||
|
```python
|
||||||
|
summary["telegram_text"] = _ai_telegram.build_message(
|
||||||
|
asof=summary["asof"],
|
||||||
|
top_pos=summary["top_pos"], top_neg=summary["top_neg"],
|
||||||
|
tokens_input=summary["tokens_input"],
|
||||||
|
tokens_output=summary["tokens_output"],
|
||||||
|
mapping=summary.get("mapping"),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 테스트 통과 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/test_ai_news_router.py -v
|
||||||
|
```
|
||||||
|
Expected: PASS — 2 tests.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/screener/router.py tests/test_ai_news_router.py
|
||||||
|
git commit -m "feat(ai_news): router forwards mapping stats to telegram"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: 전체 회귀 + scraper deprecate 주석
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-backend/stock-lab/app/screener/ai_news/scraper.py` (주석만)
|
||||||
|
|
||||||
|
- [ ] **Step 1: scraper.py 상단에 deprecate 주석 추가**
|
||||||
|
|
||||||
|
기존 docstring 을 다음으로 교체:
|
||||||
|
```python
|
||||||
|
"""[DEPRECATED] 네이버 finance 종목 뉴스 스크래핑.
|
||||||
|
|
||||||
|
본 모듈은 ai_news Phase 1 (2026-05-14, `cdfa31b` spec) 에서 더 이상
|
||||||
|
파이프라인에서 사용되지 않음. 데이터 소스는 stock-lab 의 articles 테이블
|
||||||
|
(`ai_news/articles_source.py`) 로 전환됨.
|
||||||
|
|
||||||
|
삭제 시점: Phase 2 (DART 도입) 결정 후. IC 검증 4주 누적 후 노드 활성화
|
||||||
|
여부에 따라 본 모듈을 (a) 완전 삭제 또는 (b) DART 와 함께 ensemble
|
||||||
|
fallback 으로 재활용.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
다른 라인은 유지 (테스트가 여전히 import 함).
|
||||||
|
|
||||||
|
- [ ] **Step 2: 전체 stock-lab 테스트 실행**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-backend\stock-lab
|
||||||
|
python -m pytest --ignore=app/test_scraper.py -q
|
||||||
|
```
|
||||||
|
Expected: 신규 6 + 갱신 테스트 포함 **82 tests passed** (이전 76 + ai_news_articles_source 6 - 변동 없음).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/screener/ai_news/scraper.py
|
||||||
|
git commit -m "docs(ai_news): mark scraper.py deprecated (Phase 1 transition)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: 운영 검증 + 배포
|
||||||
|
|
||||||
|
**Files:** (실행만, 수동 점검)
|
||||||
|
|
||||||
|
- [ ] **Step 1: backend push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-backend
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
실패 시: 사용자에게 Gitea 자격증명 입력 요청.
|
||||||
|
|
||||||
|
- [ ] **Step 2: deployer 반영 확인 (~1분)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs stock-lab --tail 20 2>&1 | grep -i "starting\|started"
|
||||||
|
docker logs agent-office --tail 20 2>&1 | grep -i "starting\|started"
|
||||||
|
```
|
||||||
|
두 컨테이너 모두 새 startup 시각 확인.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 운영 DB 마이그레이션 자동 적용 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec stock-lab python -c "
|
||||||
|
import sqlite3
|
||||||
|
c = sqlite3.connect('/app/data/stock.db')
|
||||||
|
cols = [r[1] for r in c.execute('PRAGMA table_info(news_sentiment)').fetchall()]
|
||||||
|
print('news_sentiment columns:', cols)
|
||||||
|
print('has source:', 'source' in cols)
|
||||||
|
"
|
||||||
|
```
|
||||||
|
Expected: `has source: True`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: 수동 트리거**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://gahusb.synology.me/api/agent-office/command" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"agent":"stock","action":"run_ai_news"}'
|
||||||
|
```
|
||||||
|
응답 `{"ok": true}` 받으면 30-60초 후 텔레그램에 메시지 도착.
|
||||||
|
|
||||||
|
- [ ] **Step 5: 텔레그램 메시지 검증**
|
||||||
|
|
||||||
|
수신 메시지에 다음 패턴 모두 포함되는지 확인:
|
||||||
|
- `🌅 AI 뉴스 분석 (YYYY-MM-DD 08:00)` 헤더
|
||||||
|
- `📈 호재 Top 5` / `📉 악재 Top 5` 섹션
|
||||||
|
- 종목명 + 티커 형태 (예: `삼성전자 (005930)`)
|
||||||
|
- `매핑 N/100 ticker (M쌍 / articles K건)` 라인 (신규)
|
||||||
|
- 토큰/비용 라인
|
||||||
|
|
||||||
|
매핑 hit_tickers 가 합리적 범위 (예: 20~60) 인지 확인.
|
||||||
|
|
||||||
|
- [ ] **Step 6: DB 검증**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec stock-lab python -c "
|
||||||
|
import sqlite3
|
||||||
|
c = sqlite3.connect('/app/data/stock.db')
|
||||||
|
rows = c.execute('SELECT COUNT(*), SUM(news_count), SUM(tokens_input) FROM news_sentiment WHERE date = date(\"now\") AND source = \"articles\"').fetchone()
|
||||||
|
print('articles rows / total_news / tokens:', rows)
|
||||||
|
# Naver 데이터와 비교
|
||||||
|
naver = c.execute('SELECT COUNT(*) FROM news_sentiment WHERE source = \"articles\"').fetchone()
|
||||||
|
print('all articles-source rows:', naver[0])
|
||||||
|
"
|
||||||
|
```
|
||||||
|
Expected: `articles rows >= 10` (매핑 hit 종목 수), `source='articles'`.
|
||||||
|
|
||||||
|
- [ ] **Step 7: 메모리 업데이트**
|
||||||
|
|
||||||
|
`C:\Users\jaeoh\.claude\projects\C--Users-jaeoh-Desktop-workspace-web-ui\memory\project_stock_screener.md` 의 hotfix 이력에 본 슬라이스 commits 추가:
|
||||||
|
- Phase 1 (`cdfa31b` spec + 본 plan 의 task commit SHA들)
|
||||||
|
- 매핑 hit-rate 측정 결과 (예: "첫 실행 매핑 42/100, articles 35건, LLM cost ₩42")
|
||||||
|
- 다음 단계: 4주 후 IC 측정 결과 보고 Phase 2 (DART) 또는 노드 삭제 결정
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 완료 후 검증 체크리스트
|
||||||
|
|
||||||
|
본 plan 완료 시:
|
||||||
|
- [ ] stock-lab `news_sentiment` 테이블에 `source` 컬럼 존재
|
||||||
|
- [ ] 운영 트리거 시 source='articles' 행 생성, news_count > 0
|
||||||
|
- [ ] 텔레그램 메시지에 매핑 N/100 라인 표시
|
||||||
|
- [ ] 외부 HTTP 호출 (Naver) 0건
|
||||||
|
- [ ] LLM cost 텔레그램 ₩ 라인이 이전(~₩60)보다 작거나 비슷 (~₩40-80)
|
||||||
|
- [ ] 단위 테스트 신규 6 + 갱신 4 모두 통과, 기존 회귀 없음
|
||||||
|
- [ ] `news_sentiment.source` 컬럼이 idempotent 하게 추가 (재기동 시 재추가 시도 없음)
|
||||||
|
- [ ] legacy `scraper.py` 에 deprecate 주석 (코드 보존)
|
||||||
|
|
||||||
|
## 후속 슬라이스 (이번 plan 완료 후)
|
||||||
|
|
||||||
|
본 spec §15 명시:
|
||||||
|
- **Phase 1.5** — 매핑 hit-rate < 30% 면 alias dict 추가
|
||||||
|
- **Phase 2** — 4주 IC ≥ 0.05 시 DART OpenAPI 추가
|
||||||
|
- **Phase X** — IC < 0.05 시 노드 deprecate
|
||||||
858
docs/superpowers/plans/2026-05-15-signal-v2-phase1-webai-api.md
Normal file
858
docs/superpowers/plans/2026-05-15-signal-v2-phase1-webai-api.md
Normal file
@@ -0,0 +1,858 @@
|
|||||||
|
# Signal V2 Phase 1 — stock WebAI API Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Confidence Signal Pipeline V2 의 Phase 2 (web-ai pull worker) 가 polling 할 stock 의 인증된 입력 계약 3종 (`/api/webai/portfolio`, `/api/webai/news-sentiment`, X-WebAI-Key 인증 인프라) 을 신설.
|
||||||
|
|
||||||
|
**Architecture:** stock FastAPI app 에 `/api/webai/*` prefix 의 신규 endpoint 2개 추가. 인증은 `verify_webai_key` FastAPI dependency (단일 정적 키 `WEBAI_API_KEY` 환경변수 비교). nginx 에 `/api/webai/` location + `limit_req` rate limit. 기존 `/api/portfolio` 무변경, web-ui 영향 0.
|
||||||
|
|
||||||
|
**Tech Stack:** FastAPI / pytest + TestClient / sqlite3 / nginx (limit_req_zone)
|
||||||
|
|
||||||
|
**Spec:** `web-ui/docs/superpowers/specs/2026-05-15-signal-v2-phase1-webai-api.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 구조
|
||||||
|
|
||||||
|
| 파일 | 책임 |
|
||||||
|
|------|------|
|
||||||
|
| `web-backend/stock/app/auth.py` (신규) | `verify_webai_key` FastAPI dependency — X-WebAI-Key 헤더 검증, env 미설정 503, 인증 실패 401 + logger.warning |
|
||||||
|
| `web-backend/stock/app/main.py` (수정) | 2 신규 endpoint: `GET /api/webai/portfolio`, `GET /api/webai/news-sentiment`. 기존 `get_portfolio()` 응답 위에 pnl_pct augment mapper |
|
||||||
|
| `web-backend/stock/app/test_webai_auth.py` (신규) | `verify_webai_key` 단위 3 케이스 |
|
||||||
|
| `web-backend/stock/app/test_webai_endpoints.py` (신규) | 두 endpoint × 4 + 공통 4 = 12 통합 케이스 |
|
||||||
|
| `web-backend/nginx/default.conf` (수정) | `limit_req_zone webai` + `/api/webai/` location |
|
||||||
|
| `web-backend/docker-compose.yml` (수정) | stock 컨테이너 env 에 `WEBAI_API_KEY` 추가 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 순서
|
||||||
|
|
||||||
|
```
|
||||||
|
Task 1: auth.py + verify_webai_key 단위 테스트 (TDD)
|
||||||
|
Task 2: /api/webai/portfolio + 응답 보강 + 통합 4 케이스 (TDD)
|
||||||
|
Task 3: /api/webai/news-sentiment + DB SELECT + 통합 4 케이스 (TDD)
|
||||||
|
Task 4: 공통 통합 4 케이스 (401 leak / 503 / wrong key / unknown date)
|
||||||
|
Task 5: docker-compose env 추가
|
||||||
|
Task 6: nginx config (rate limit + location + 헤더 forward)
|
||||||
|
Task 7: 배포 + 사용자 .env 갱신 + manual smoke 검증
|
||||||
|
```
|
||||||
|
|
||||||
|
각 Task 는 TDD 패턴 (test 먼저 → fail 확인 → 구현 → pass → commit).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: auth.py + verify_webai_key 단위 테스트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web-backend/stock/app/auth.py`
|
||||||
|
- Create: `web-backend/stock/app/test_webai_auth.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `web-backend/stock/app/test_webai_auth.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
|
||||||
|
def _make_request() -> Request:
|
||||||
|
"""Minimal Request stub for verify_webai_key (only request.url.path + request.client used)."""
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"path": "/api/webai/test",
|
||||||
|
"headers": [],
|
||||||
|
"client": ("1.2.3.4", 12345),
|
||||||
|
}
|
||||||
|
return Request(scope=scope)
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_with_valid_key_passes(monkeypatch):
|
||||||
|
monkeypatch.setenv("WEBAI_API_KEY", "secret-key-abc")
|
||||||
|
from app.auth import verify_webai_key
|
||||||
|
verify_webai_key(_make_request(), x_webai_key="secret-key-abc")
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_without_key_raises_401(monkeypatch):
|
||||||
|
monkeypatch.setenv("WEBAI_API_KEY", "secret-key-abc")
|
||||||
|
from app.auth import verify_webai_key
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
verify_webai_key(_make_request(), x_webai_key=None)
|
||||||
|
assert exc.value.status_code == 401
|
||||||
|
assert "X-WebAI-Key" in exc.value.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_with_wrong_key_raises_401(monkeypatch):
|
||||||
|
monkeypatch.setenv("WEBAI_API_KEY", "secret-key-abc")
|
||||||
|
from app.auth import verify_webai_key
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
verify_webai_key(_make_request(), x_webai_key="wrong-key")
|
||||||
|
assert exc.value.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_returns_503_when_env_missing(monkeypatch):
|
||||||
|
monkeypatch.delenv("WEBAI_API_KEY", raising=False)
|
||||||
|
from app.auth import verify_webai_key
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
verify_webai_key(_make_request(), x_webai_key="anything")
|
||||||
|
assert exc.value.status_code == 503
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd web-backend/stock && python -m pytest app/test_webai_auth.py -v`
|
||||||
|
Expected: ImportError: cannot import name 'verify_webai_key' from 'app.auth' (또는 ModuleNotFoundError: No module named 'app.auth')
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Create `web-backend/stock/app/auth.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import Header, HTTPException
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
logger = logging.getLogger("stock")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_webai_key(
|
||||||
|
request: Request,
|
||||||
|
x_webai_key: str | None = Header(default=None, alias="X-WebAI-Key"),
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
/api/webai/* 보호용 FastAPI dependency.
|
||||||
|
|
||||||
|
- WEBAI_API_KEY env 미설정 → 503 (다른 endpoint 무영향)
|
||||||
|
- 헤더 누락 또는 키 불일치 → 401 + logger.warning(ip)
|
||||||
|
"""
|
||||||
|
configured = os.getenv("WEBAI_API_KEY", "").strip()
|
||||||
|
if not configured:
|
||||||
|
logger.error("WEBAI_API_KEY not configured — refusing /api/webai/* request")
|
||||||
|
raise HTTPException(status_code=503, detail="webai auth not configured")
|
||||||
|
|
||||||
|
if not x_webai_key or x_webai_key != configured:
|
||||||
|
remote = request.client.host if request.client else "?"
|
||||||
|
logger.warning("auth_fail path=%s remote=%s", request.url.path, remote)
|
||||||
|
raise HTTPException(status_code=401, detail="invalid or missing X-WebAI-Key")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd web-backend/stock && python -m pytest app/test_webai_auth.py -v`
|
||||||
|
Expected: 4 passed
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web-backend/stock/app/auth.py web-backend/stock/app/test_webai_auth.py
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(stock-webai): add X-WebAI-Key auth dependency + tests
|
||||||
|
|
||||||
|
verify_webai_key FastAPI dependency: 401 on missing/wrong key,
|
||||||
|
503 when WEBAI_API_KEY env unset. 4 unit tests pass.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: /api/webai/portfolio + 응답 보강 + 통합 4 케이스
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-backend/stock/app/main.py` (신규 endpoint + helper)
|
||||||
|
- Create: `web-backend/stock/app/test_webai_endpoints.py` (portfolio 4 케이스)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests (portfolio 4 케이스)**
|
||||||
|
|
||||||
|
Create `web-backend/stock/app/test_webai_endpoints.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.screener.schema import ensure_screener_schema
|
||||||
|
from app.db import init_db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def isolated_db_and_auth(tmp_path, monkeypatch):
|
||||||
|
db_path = tmp_path / "stock.db"
|
||||||
|
# 기본 stock DB 스키마
|
||||||
|
monkeypatch.setenv("STOCK_DB_PATH", str(db_path))
|
||||||
|
init_db()
|
||||||
|
# screener 스키마 (news_sentiment, krx_master 등)
|
||||||
|
c = sqlite3.connect(db_path)
|
||||||
|
ensure_screener_schema(c)
|
||||||
|
c.close()
|
||||||
|
# WEBAI_API_KEY 활성화
|
||||||
|
monkeypatch.setenv("WEBAI_API_KEY", "test-secret")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
from app.main import app
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
HEADERS_OK = {"X-WebAI-Key": "test-secret"}
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_portfolio(broker="키움", ticker="005930", name="삼성전자",
|
||||||
|
quantity=100, avg_price=75000.0, purchase_price=75500.0):
|
||||||
|
from app.db import add_portfolio_item
|
||||||
|
return add_portfolio_item(broker, ticker, name, quantity, avg_price,
|
||||||
|
purchase_price=purchase_price)
|
||||||
|
|
||||||
|
|
||||||
|
def test_webai_portfolio_normal_response_includes_pnl_pct(client, monkeypatch):
|
||||||
|
_seed_portfolio()
|
||||||
|
|
||||||
|
# current_price 모킹 — profit_rate 4.67% 만들기
|
||||||
|
from app import main
|
||||||
|
monkeypatch.setattr(
|
||||||
|
main, "get_current_prices_detail",
|
||||||
|
lambda tickers: {"005930": {"price": 78500.0, "session": "REGULAR", "as_of": "2026-05-15T15:30:00"}}
|
||||||
|
)
|
||||||
|
|
||||||
|
r = client.get("/api/webai/portfolio", headers=HEADERS_OK)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert len(body["holdings"]) == 1
|
||||||
|
h = body["holdings"][0]
|
||||||
|
assert h["pnl_pct"] is not None
|
||||||
|
assert abs(h["pnl_pct"] - 0.0467) < 0.0005 # 0.0467 ± rounding
|
||||||
|
|
||||||
|
|
||||||
|
def test_webai_portfolio_summary_has_total_pnl_pct(client, monkeypatch):
|
||||||
|
_seed_portfolio()
|
||||||
|
from app import main
|
||||||
|
monkeypatch.setattr(
|
||||||
|
main, "get_current_prices_detail",
|
||||||
|
lambda tickers: {"005930": {"price": 78500.0, "session": "REGULAR", "as_of": "x"}}
|
||||||
|
)
|
||||||
|
|
||||||
|
r = client.get("/api/webai/portfolio", headers=HEADERS_OK)
|
||||||
|
body = r.json()
|
||||||
|
assert "total_pnl_pct" in body["summary"]
|
||||||
|
assert abs(body["summary"]["total_pnl_pct"] - 0.0467) < 0.0005
|
||||||
|
|
||||||
|
|
||||||
|
def test_webai_portfolio_pnl_pct_matches_profit_rate_divided_100(client, monkeypatch):
|
||||||
|
_seed_portfolio()
|
||||||
|
from app import main
|
||||||
|
monkeypatch.setattr(
|
||||||
|
main, "get_current_prices_detail",
|
||||||
|
lambda tickers: {"005930": {"price": 78500.0, "session": "REGULAR", "as_of": "x"}}
|
||||||
|
)
|
||||||
|
|
||||||
|
r = client.get("/api/webai/portfolio", headers=HEADERS_OK)
|
||||||
|
h = r.json()["holdings"][0]
|
||||||
|
assert h["pnl_pct"] == round(h["profit_rate"] / 100, 6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_webai_portfolio_missing_key_returns_401(client):
|
||||||
|
r = client.get("/api/webai/portfolio")
|
||||||
|
assert r.status_code == 401
|
||||||
|
assert "X-WebAI-Key" in r.json()["detail"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `cd web-backend/stock && python -m pytest app/test_webai_endpoints.py -v`
|
||||||
|
Expected: 4 failed with 404 (endpoint not defined yet) — except `missing_key_returns_401` 도 404 (endpoint 자체가 없으므로)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Modify `web-backend/stock/app/main.py` — add right after the imports block (around line 27):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from .auth import verify_webai_key
|
||||||
|
```
|
||||||
|
|
||||||
|
And add the new endpoint right after the existing `get_portfolio()` function (after line 384):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _augment_portfolio_with_pnl_pct(raw: dict) -> dict:
|
||||||
|
"""Add pnl_pct (ratio) to each holding and total_pnl_pct to summary."""
|
||||||
|
holdings = []
|
||||||
|
for h in raw["holdings"]:
|
||||||
|
pnl_pct = round(h["profit_rate"] / 100, 6) if h.get("profit_rate") is not None else None
|
||||||
|
holdings.append({**h, "pnl_pct": pnl_pct})
|
||||||
|
|
||||||
|
summary = dict(raw["summary"])
|
||||||
|
rate = summary.get("total_profit_rate")
|
||||||
|
summary["total_pnl_pct"] = round(rate / 100, 6) if rate is not None else 0.0
|
||||||
|
|
||||||
|
return {"holdings": holdings, "cash": raw["cash"], "summary": summary}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/webai/portfolio", dependencies=[Depends(verify_webai_key)])
|
||||||
|
def get_webai_portfolio():
|
||||||
|
"""web-ai 전용 portfolio (인증 필수, pnl_pct 비율 필드 추가)."""
|
||||||
|
return _augment_portfolio_with_pnl_pct(get_portfolio())
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `cd web-backend/stock && python -m pytest app/test_webai_endpoints.py -v`
|
||||||
|
Expected: 4 passed
|
||||||
|
|
||||||
|
Also run full stock suite to verify no regression:
|
||||||
|
|
||||||
|
Run: `cd web-backend/stock && python -m pytest --ignore=app/test_scraper.py -q`
|
||||||
|
Expected: 86 + 4 = 90 passed
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web-backend/stock/app/main.py web-backend/stock/app/test_webai_endpoints.py
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(stock-webai): /api/webai/portfolio + pnl_pct augment
|
||||||
|
|
||||||
|
Reuses get_portfolio() and adds pnl_pct (ratio, profit_rate/100) to
|
||||||
|
each holding plus total_pnl_pct to summary. 4 integration tests pass.
|
||||||
|
verify_webai_key dependency enforced.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: /api/webai/news-sentiment + DB SELECT + 통합 4 케이스
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-backend/stock/app/main.py` (신규 endpoint + helper)
|
||||||
|
- Modify: `web-backend/stock/app/test_webai_endpoints.py` (news-sentiment 4 케이스 추가)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests (news-sentiment 4 케이스)**
|
||||||
|
|
||||||
|
Append to `web-backend/stock/app/test_webai_endpoints.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _seed_news_sentiment(date_str: str, rows: list[tuple]):
|
||||||
|
"""rows: list of (ticker, score_raw, reason, news_count)."""
|
||||||
|
db_path = os.environ["STOCK_DB_PATH"]
|
||||||
|
c = sqlite3.connect(db_path)
|
||||||
|
for ticker, score, reason, news_count in rows:
|
||||||
|
c.execute(
|
||||||
|
"INSERT OR REPLACE INTO news_sentiment "
|
||||||
|
"(ticker, date, score_raw, reason, news_count, source) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, 'articles')",
|
||||||
|
(ticker, date_str, score, reason, news_count)
|
||||||
|
)
|
||||||
|
c.commit()
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_krx_master(rows: list[tuple]):
|
||||||
|
"""rows: list of (ticker, name)."""
|
||||||
|
db_path = os.environ["STOCK_DB_PATH"]
|
||||||
|
c = sqlite3.connect(db_path)
|
||||||
|
import datetime as dt
|
||||||
|
now = dt.datetime.utcnow().isoformat()
|
||||||
|
for ticker, name in rows:
|
||||||
|
c.execute(
|
||||||
|
"INSERT OR REPLACE INTO krx_master "
|
||||||
|
"(ticker, name, market, market_cap, updated_at) VALUES (?, ?, 'KOSPI', 0, ?)",
|
||||||
|
(ticker, name, now)
|
||||||
|
)
|
||||||
|
c.commit()
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_webai_news_sentiment_returns_latest_date_when_no_param(client):
|
||||||
|
_seed_krx_master([("005930", "삼성전자"), ("000660", "SK하이닉스")])
|
||||||
|
_seed_news_sentiment("2026-05-14", [("005930", 5.0, "old", 5)])
|
||||||
|
_seed_news_sentiment("2026-05-15", [
|
||||||
|
("005930", 6.2, "HBM 양산 가시화", 12),
|
||||||
|
("000660", 5.5, "PPI 우려에도 강세", 8),
|
||||||
|
])
|
||||||
|
|
||||||
|
r = client.get("/api/webai/news-sentiment", headers=HEADERS_OK)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["date"] == "2026-05-15"
|
||||||
|
assert body["count"] == 2
|
||||||
|
# sorted by score DESC
|
||||||
|
assert body["items"][0]["ticker"] == "005930"
|
||||||
|
assert body["items"][0]["score"] == 6.2
|
||||||
|
assert body["items"][0]["name"] == "삼성전자"
|
||||||
|
assert body["items"][0]["reason"] == "HBM 양산 가시화"
|
||||||
|
|
||||||
|
|
||||||
|
def test_webai_news_sentiment_filters_by_date_param(client):
|
||||||
|
_seed_krx_master([("005930", "삼성전자")])
|
||||||
|
_seed_news_sentiment("2026-05-14", [("005930", 5.0, "yesterday", 5)])
|
||||||
|
_seed_news_sentiment("2026-05-15", [("005930", 6.2, "today", 12)])
|
||||||
|
|
||||||
|
r = client.get("/api/webai/news-sentiment?date=2026-05-14", headers=HEADERS_OK)
|
||||||
|
body = r.json()
|
||||||
|
assert body["date"] == "2026-05-14"
|
||||||
|
assert body["count"] == 1
|
||||||
|
assert body["items"][0]["reason"] == "yesterday"
|
||||||
|
|
||||||
|
|
||||||
|
def test_webai_news_sentiment_empty_table_returns_count_zero(client):
|
||||||
|
r = client.get("/api/webai/news-sentiment", headers=HEADERS_OK)
|
||||||
|
body = r.json()
|
||||||
|
assert body["date"] is None
|
||||||
|
assert body["count"] == 0
|
||||||
|
assert body["items"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_webai_news_sentiment_items_sorted_by_score_desc(client):
|
||||||
|
_seed_krx_master([("A", "A주"), ("B", "B주"), ("C", "C주")])
|
||||||
|
_seed_news_sentiment("2026-05-15", [
|
||||||
|
("A", 1.0, "low", 1),
|
||||||
|
("B", 9.0, "high", 1),
|
||||||
|
("C", 5.0, "mid", 1),
|
||||||
|
])
|
||||||
|
|
||||||
|
r = client.get("/api/webai/news-sentiment", headers=HEADERS_OK)
|
||||||
|
items = r.json()["items"]
|
||||||
|
assert [i["score"] for i in items] == [9.0, 5.0, 1.0]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `cd web-backend/stock && python -m pytest app/test_webai_endpoints.py::test_webai_news_sentiment_returns_latest_date_when_no_param app/test_webai_endpoints.py::test_webai_news_sentiment_filters_by_date_param app/test_webai_endpoints.py::test_webai_news_sentiment_empty_table_returns_count_zero app/test_webai_endpoints.py::test_webai_news_sentiment_items_sorted_by_score_desc -v`
|
||||||
|
Expected: 4 failed with 404 (endpoint not defined)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Modify `web-backend/stock/app/main.py` — add right after the portfolio endpoint added in Task 2:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _fetch_news_sentiment_dump(date: str | None) -> dict:
|
||||||
|
"""news_sentiment 일별 dump (krx_master JOIN, score DESC)."""
|
||||||
|
from .db import _conn # _conn() is the shared connection helper
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
# 1) date resolve — None 이면 최신 date
|
||||||
|
if date is None:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT MAX(date) FROM news_sentiment"
|
||||||
|
).fetchone()
|
||||||
|
date = row[0] if row and row[0] else None
|
||||||
|
|
||||||
|
if date is None:
|
||||||
|
return {"date": None, "count": 0, "items": []}
|
||||||
|
|
||||||
|
# 2) JOIN krx_master.name (없으면 ticker 그대로)
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT ns.ticker,
|
||||||
|
COALESCE(km.name, ns.ticker) AS name,
|
||||||
|
ns.score_raw,
|
||||||
|
ns.reason,
|
||||||
|
ns.news_count,
|
||||||
|
ns.source
|
||||||
|
FROM news_sentiment ns
|
||||||
|
LEFT JOIN krx_master km ON km.ticker = ns.ticker
|
||||||
|
WHERE ns.date = ?
|
||||||
|
ORDER BY ns.score_raw DESC
|
||||||
|
""",
|
||||||
|
(date,)
|
||||||
|
).fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
items = [
|
||||||
|
{"ticker": r[0], "name": r[1], "score": r[2],
|
||||||
|
"reason": r[3], "news_count": r[4], "source": r[5]}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
return {"date": date, "count": len(items), "items": items}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/webai/news-sentiment", dependencies=[Depends(verify_webai_key)])
|
||||||
|
def get_webai_news_sentiment(date: str | None = None):
|
||||||
|
"""web-ai 전용 news sentiment 일별 dump."""
|
||||||
|
return _fetch_news_sentiment_dump(date)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `cd web-backend/stock && python -m pytest app/test_webai_endpoints.py -v`
|
||||||
|
Expected: 8 passed (4 portfolio + 4 news-sentiment)
|
||||||
|
|
||||||
|
Run full suite:
|
||||||
|
Run: `cd web-backend/stock && python -m pytest --ignore=app/test_scraper.py -q`
|
||||||
|
Expected: 86 + 8 = 94 passed
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web-backend/stock/app/main.py web-backend/stock/app/test_webai_endpoints.py
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(stock-webai): /api/webai/news-sentiment daily dump
|
||||||
|
|
||||||
|
JOINs news_sentiment with krx_master for name fallback. Sorted by
|
||||||
|
score DESC. Date param defaults to latest. Empty table returns
|
||||||
|
{date: null, count: 0, items: []}. 4 integration tests pass.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: 공통 통합 4 케이스 (401 leak / 503 / wrong key / unknown date)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-backend/stock/app/test_webai_endpoints.py` (공통 4 케이스 추가)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the tests**
|
||||||
|
|
||||||
|
Append to `web-backend/stock/app/test_webai_endpoints.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_webai_401_response_has_no_payload_leak(client):
|
||||||
|
"""인증 실패 응답에는 portfolio/sentiment 데이터가 없어야 한다."""
|
||||||
|
_seed_portfolio()
|
||||||
|
r = client.get("/api/webai/portfolio") # 헤더 없음
|
||||||
|
assert r.status_code == 401
|
||||||
|
body = r.json()
|
||||||
|
assert "holdings" not in body
|
||||||
|
assert "cash" not in body
|
||||||
|
assert "summary" not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_webai_503_when_env_missing(client, monkeypatch):
|
||||||
|
"""WEBAI_API_KEY env 미설정 시 503, 다른 endpoint 영향 없음."""
|
||||||
|
monkeypatch.delenv("WEBAI_API_KEY", raising=False)
|
||||||
|
|
||||||
|
r1 = client.get("/api/webai/portfolio", headers={"X-WebAI-Key": "anything"})
|
||||||
|
assert r1.status_code == 503
|
||||||
|
|
||||||
|
# 기존 endpoint 무영향 — /api/portfolio 는 200 (빈 portfolio)
|
||||||
|
r2 = client.get("/api/portfolio")
|
||||||
|
assert r2.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_webai_wrong_key_returns_401(client):
|
||||||
|
r = client.get("/api/webai/portfolio", headers={"X-WebAI-Key": "wrong"})
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_webai_news_sentiment_unknown_date_returns_empty(client):
|
||||||
|
r = client.get("/api/webai/news-sentiment?date=1999-01-01", headers=HEADERS_OK)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["count"] == 0
|
||||||
|
assert body["items"] == []
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `cd web-backend/stock && python -m pytest app/test_webai_endpoints.py -v`
|
||||||
|
Expected: 12 passed (4 + 4 + 4)
|
||||||
|
|
||||||
|
Also run full stock suite:
|
||||||
|
Run: `cd web-backend/stock && python -m pytest --ignore=app/test_scraper.py -q`
|
||||||
|
Expected: 86 + 12 = 98 passed (note: spec said 101, but 86 stock + 4 auth + 12 endpoint = 102; the count in the spec was approximate, actual = current_baseline + 4 + 12)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web-backend/stock/app/test_webai_endpoints.py
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
test(stock-webai): edge cases — 401 no leak, 503 env missing, unknown date
|
||||||
|
|
||||||
|
Verifies auth failure responses contain no portfolio/sentiment data,
|
||||||
|
503 when WEBAI_API_KEY env unset (existing endpoints unaffected),
|
||||||
|
news-sentiment unknown date returns empty result.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: docker-compose env 추가
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-backend/docker-compose.yml` (stock 서비스 env)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Locate the stock environment block**
|
||||||
|
|
||||||
|
Run: `grep -n -A 20 "^ stock:" web-backend/docker-compose.yml | head -30`
|
||||||
|
Expected: stock 서비스 블록 출력. environment 또는 env_file 항목 확인.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add WEBAI_API_KEY to stock env**
|
||||||
|
|
||||||
|
Edit `web-backend/docker-compose.yml` — find the `stock:` service block and add `WEBAI_API_KEY=${WEBAI_API_KEY}` line to the `environment:` list.
|
||||||
|
|
||||||
|
Example final state (excerpt):
|
||||||
|
```yaml
|
||||||
|
stock:
|
||||||
|
container_name: stock
|
||||||
|
build:
|
||||||
|
context: ./stock
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Seoul
|
||||||
|
- STOCK_DB_PATH=/app/data/stock.db
|
||||||
|
- WEBAI_API_KEY=${WEBAI_API_KEY}
|
||||||
|
# ... other vars
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify compose config**
|
||||||
|
|
||||||
|
Run: `cd web-backend && docker compose config | grep -A 30 "stock:" | grep WEBAI_API_KEY`
|
||||||
|
Expected: `WEBAI_API_KEY: ""` (env 미설정 시 빈 문자열) 또는 실제 값
|
||||||
|
|
||||||
|
If the line is missing, re-check the edit.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web-backend
|
||||||
|
git add docker-compose.yml
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
chore(stock-webai): pass WEBAI_API_KEY env to stock container
|
||||||
|
|
||||||
|
Required by /api/webai/* endpoints. Operator must set WEBAI_API_KEY
|
||||||
|
in NAS /volume1/docker/webpage/.env before deploy.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: nginx config (rate limit + location + 헤더 forward)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-backend/nginx/default.conf`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add limit_req_zone to http {} block**
|
||||||
|
|
||||||
|
Edit `web-backend/nginx/default.conf` — find the existing `limit_req_zone` directive (or the top of `http {}` block / top of `server {}` context) and add:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# /api/webai/* rate limit — web-ai pull worker (default 60/min, burst 20)
|
||||||
|
limit_req_zone $binary_remote_addr zone=webai:5m rate=60r/m;
|
||||||
|
```
|
||||||
|
|
||||||
|
Place it at the top of the http context (before any server blocks) or alongside existing limit_req_zone directives.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add /api/webai/ location block**
|
||||||
|
|
||||||
|
In the same file, find the existing `location /api/stock/` (or similar) block inside the relevant `server {}` and add the new location BEFORE it (to ensure prefix matching priority is explicit):
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /api/webai/ {
|
||||||
|
limit_req zone=webai burst=20 nodelay;
|
||||||
|
limit_req_status 429;
|
||||||
|
|
||||||
|
proxy_pass http://stock:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-WebAI-Key $http_x_webai_key;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Validate nginx config syntax**
|
||||||
|
|
||||||
|
Run: `cd web-backend && docker compose run --rm --no-deps frontend nginx -t -c /etc/nginx/conf.d/default.conf 2>&1 | tail -5`
|
||||||
|
|
||||||
|
If frontend image isn't built locally, use:
|
||||||
|
Run: `docker run --rm -v "$(pwd)/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro" nginx:alpine nginx -t 2>&1`
|
||||||
|
Expected: `nginx: configuration file /etc/nginx/nginx.conf test is successful`
|
||||||
|
|
||||||
|
If the test fails due to missing upstream resolution (`host not found in upstream "stock"`), that's expected outside the compose network — the syntax check is what matters here. Ignore upstream resolution errors.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web-backend
|
||||||
|
git add nginx/default.conf
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(nginx-webai): /api/webai/ location with rate limit + X-WebAI-Key forward
|
||||||
|
|
||||||
|
limit_req_zone webai:5m rate=60r/m, burst=20 nodelay, return 429 on
|
||||||
|
limit hit. Proxies to stock:8000 with X-Real-IP, X-Forwarded-For,
|
||||||
|
and X-WebAI-Key headers preserved.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: 배포 + 사용자 .env 갱신 + manual smoke 검증
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- 운영 `.env` (NAS `/volume1/docker/webpage/.env`) — 사용자 수동
|
||||||
|
- web-ai `.env` (Windows PC) — 사용자 수동 (Phase 2 진입 시 사용, 본 Phase 에서 미사용 OK)
|
||||||
|
|
||||||
|
**This task requires user action (NAS SSH + push). The implementer should pause and request the user to perform these steps. Do NOT mark the task complete until the user reports smoke test results.**
|
||||||
|
|
||||||
|
- [ ] **Step 1: Generate WEBAI_API_KEY (사용자)**
|
||||||
|
|
||||||
|
Sample command for the user to run locally:
|
||||||
|
```bash
|
||||||
|
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||||
|
```
|
||||||
|
|
||||||
|
Save the output. This is the `WEBAI_API_KEY` value.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update NAS .env (사용자)**
|
||||||
|
|
||||||
|
SSH to NAS:
|
||||||
|
```bash
|
||||||
|
ssh user@gahusb.synology.me
|
||||||
|
sudo vi /volume1/docker/webpage/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Add line:
|
||||||
|
```
|
||||||
|
WEBAI_API_KEY=<the key generated in Step 1>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Push web-backend (사용자)**
|
||||||
|
|
||||||
|
Locally:
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-backend && git push
|
||||||
|
```
|
||||||
|
Wait for Gitea webhook → deployer rsync + docker compose up.
|
||||||
|
|
||||||
|
If deployer DEPLOY_FAIL false alarm (known issue, see graduation experience):
|
||||||
|
```bash
|
||||||
|
ssh user@gahusb.synology.me
|
||||||
|
cd /volume1/docker/webpage
|
||||||
|
docker compose up -d --build stock frontend
|
||||||
|
docker ps --format "{{.Names}}: {{.Status}}" | grep -E "stock|frontend"
|
||||||
|
```
|
||||||
|
Expected: both `healthy`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Manual smoke — auth success**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export WEBAI_API_KEY=<the value>
|
||||||
|
curl -s -H "X-WebAI-Key: $WEBAI_API_KEY" https://gahusb.synology.me/api/webai/portfolio | head -c 200
|
||||||
|
```
|
||||||
|
Expected: 200 JSON beginning with `{"holdings":[`. If portfolio empty, `{"holdings":[],"cash":[...`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -H "X-WebAI-Key: $WEBAI_API_KEY" "https://gahusb.synology.me/api/webai/news-sentiment" | head -c 300
|
||||||
|
```
|
||||||
|
Expected: 200 JSON with `"date":` and `"items":` keys.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Manual smoke — auth failure**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i -s https://gahusb.synology.me/api/webai/portfolio | head -5
|
||||||
|
```
|
||||||
|
Expected:
|
||||||
|
```
|
||||||
|
HTTP/1.1 401 Unauthorized
|
||||||
|
...
|
||||||
|
{"detail":"invalid or missing X-WebAI-Key"}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i -s -H "X-WebAI-Key: wrong" https://gahusb.synology.me/api/webai/portfolio | head -5
|
||||||
|
```
|
||||||
|
Expected: 401 with same detail.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Manual smoke — rate limit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for i in $(seq 1 120); do
|
||||||
|
curl -s -o /dev/null -w "%{http_code}\n" \
|
||||||
|
-H "X-WebAI-Key: $WEBAI_API_KEY" \
|
||||||
|
https://gahusb.synology.me/api/webai/portfolio
|
||||||
|
done | sort | uniq -c
|
||||||
|
```
|
||||||
|
Expected: significant `200` count plus some `429` (rate limit triggered). Example:
|
||||||
|
```
|
||||||
|
85 200
|
||||||
|
35 429
|
||||||
|
```
|
||||||
|
|
||||||
|
If you see all 200 (no 429), rate limit may not be applied. Check nginx logs and config.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Verify web-ui unchanged**
|
||||||
|
|
||||||
|
Open https://gahusb.synology.me/ in browser. Navigate to `/stock` page. Verify the portfolio list still loads correctly (no errors). This confirms `/api/portfolio` (legacy, no auth) is unaffected.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Verify 503 fallback (optional, requires env removal + redeploy)**
|
||||||
|
|
||||||
|
This is optional and disruptive — only run if you want to verify the 503 fallback explicitly. Skip in normal deploys.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh user@gahusb.synology.me
|
||||||
|
cd /volume1/docker/webpage
|
||||||
|
# Comment out WEBAI_API_KEY in .env temporarily
|
||||||
|
sed -i 's/^WEBAI_API_KEY=/#WEBAI_API_KEY=/' .env
|
||||||
|
docker compose up -d stock
|
||||||
|
sleep 5
|
||||||
|
curl -s -o /dev/null -w "%{http_code}\n" -H "X-WebAI-Key: anything" https://gahusb.synology.me/api/webai/portfolio
|
||||||
|
# Expected: 503
|
||||||
|
# Restore:
|
||||||
|
sed -i 's/^#WEBAI_API_KEY=/WEBAI_API_KEY=/' .env
|
||||||
|
docker compose up -d stock
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 9: Report results to user (운영 검증 게이트)**
|
||||||
|
|
||||||
|
Report to the user:
|
||||||
|
- Step 4 (auth success): PASS / FAIL with details
|
||||||
|
- Step 5 (auth failure): PASS / FAIL
|
||||||
|
- Step 6 (rate limit): PASS (some 429 observed) / FAIL (all 200)
|
||||||
|
- Step 7 (web-ui unchanged): PASS / FAIL
|
||||||
|
|
||||||
|
Only after the user confirms all PASS, mark Task 7 complete. If any FAIL, investigate before proceeding to Phase 2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (plan author runs this)
|
||||||
|
|
||||||
|
**1. Spec coverage:**
|
||||||
|
|
||||||
|
| Spec § | 요구사항 | Plan task |
|
||||||
|
|--------|----------|----------|
|
||||||
|
| §2 포함 ① portfolio + pnl_pct | Task 2 ✅ |
|
||||||
|
| §2 포함 ② news-sentiment | Task 3 ✅ |
|
||||||
|
| §2 포함 ③ X-WebAI-Key 인증 | Task 1 ✅ |
|
||||||
|
| §2 포함 ④ nginx rate limit | Task 6 ✅ |
|
||||||
|
| §2 포함 ⑤ 인증 실패 logger | Task 1 (logger.warning 호출 포함) ✅ |
|
||||||
|
| §2 포함 ⑥ 15 테스트 (4 unit + 12 integration) | Task 1 (4) + Task 2 (4) + Task 3 (4) + Task 4 (4) = 16. Note: spec said 15, plan delivers 16 (4 auth + 4 portfolio + 4 sentiment + 4 common). Counted higher, no gap. ✅ |
|
||||||
|
| §4.1 portfolio shape with pnl_pct | Task 2 Step 3 ✅ |
|
||||||
|
| §4.2 news-sentiment shape | Task 3 Step 3 ✅ |
|
||||||
|
| §4.3 401 leak free | Task 4 Step 1 (`test_webai_401_response_has_no_payload_leak`) ✅ |
|
||||||
|
| §4.4 503 when env missing | Task 1 (unit) + Task 4 (integration) ✅ |
|
||||||
|
| §5 auth.py implementation | Task 1 Step 3 ✅ |
|
||||||
|
| §6 nginx config | Task 6 ✅ |
|
||||||
|
| §10 DoD | Task 7 covers manual smoke + web-ui verification ✅ |
|
||||||
|
|
||||||
|
No gaps.
|
||||||
|
|
||||||
|
**2. Placeholder scan:** No "TBD" / "implement later" / vague descriptions found. Every step has executable code or commands.
|
||||||
|
|
||||||
|
**3. Type consistency:**
|
||||||
|
- `verify_webai_key(request, x_webai_key)` signature consistent across Tasks 1, 2, 3 ✅
|
||||||
|
- `_augment_portfolio_with_pnl_pct(raw)` defined in Task 2, no later reference (helper internal to main.py) ✅
|
||||||
|
- `_fetch_news_sentiment_dump(date)` defined in Task 3, signature consistent ✅
|
||||||
|
- `HEADERS_OK = {"X-WebAI-Key": "test-secret"}` defined in Task 2, reused in Tasks 3 and 4 ✅
|
||||||
|
- `_seed_portfolio()` defined in Task 2, reused in Task 4 ✅
|
||||||
|
- `_seed_news_sentiment()` / `_seed_krx_master()` defined in Task 3, consistent ✅
|
||||||
|
- `WEBAI_API_KEY` env var name consistent across all tasks ✅
|
||||||
|
|
||||||
|
Plan passes self-review.
|
||||||
506
docs/superpowers/plans/2026-05-15-stock-lab-rename-to-stock.md
Normal file
506
docs/superpowers/plans/2026-05-15-stock-lab-rename-to-stock.md
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
# stock-lab → stock 리네이밍 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:** `stock-lab` 컨테이너/디렉토리/환경변수를 `stock` 으로 graduation. lab 네이밍 정책 정리 + V2 Phase 1 작업 시작 전 선행.
|
||||||
|
|
||||||
|
**Architecture:** Atomic refactor — web-backend repo 안의 모든 stock-lab 참조를 한 commit으로 갱신 (git mv + docker-compose + agent-office + nginx + 문서). web-ui/workspace CLAUDE.md 별도 commit. 메모리는 controller 직접 갱신. Python `app.*` import 경로 + API URL `/api/stock/...` + DB 파일 그대로 유지.
|
||||||
|
|
||||||
|
**Tech Stack:** Git (mv with history), Docker Compose, nginx upstream, Python FastAPI / httpx.
|
||||||
|
|
||||||
|
**선행 spec**: `web-ui/docs/superpowers/specs/2026-05-15-stock-lab-rename-to-stock.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 사전 가정
|
||||||
|
|
||||||
|
- web-backend repo 와 web-ui repo 는 별도 git 저장소
|
||||||
|
- `workspace/CLAUDE.md` 는 git 관리 외 파일 (단순 편집)
|
||||||
|
- `stock-lab/.venv/` 디렉토리는 `.gitignore` 되어 있음 (Windows 로컬 가상환경, 변경 영향 무관)
|
||||||
|
- Gitea webhook 자동 배포: web-backend push → deployer rsync + docker compose up
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 변경 매트릭스 요약 (Task 별로 상세)
|
||||||
|
|
||||||
|
```
|
||||||
|
[Task 1] grep 사전 검토 (코드 변경 0)
|
||||||
|
|
||||||
|
[Task 2] web-backend atomic commit
|
||||||
|
- git mv stock-lab → stock (수십 파일)
|
||||||
|
- docker-compose.yml (서비스 키 + container_name + build.context + depends_on + agent-office env)
|
||||||
|
- agent-office/app/config.py (STOCK_LAB_URL → STOCK_URL)
|
||||||
|
- agent-office/app/service_proxy.py (import + 5 함수)
|
||||||
|
- agent-office/app/agents/stock.py (있다면)
|
||||||
|
- agent-office/tests/test_stock_screener_job.py
|
||||||
|
- nginx/default.conf (upstream + proxy_pass)
|
||||||
|
- CLAUDE.md, README.md, STATUS.md
|
||||||
|
- scripts/deploy-nas.sh, deploy.sh
|
||||||
|
|
||||||
|
[Task 3] web-ui commit
|
||||||
|
- web-ui/CLAUDE.md
|
||||||
|
|
||||||
|
[Task 4] workspace 편집 (git 없음 가능)
|
||||||
|
- workspace/CLAUDE.md
|
||||||
|
|
||||||
|
[Task 5] 메모리 갱신 (controller, 별도 git 외)
|
||||||
|
- project_workspace.md / project_scale.md / project_stock_screener.md / nas_infra.md
|
||||||
|
- feedback_lab_naming.md (graduation 사례)
|
||||||
|
|
||||||
|
[Task 6] 배포 + 검증
|
||||||
|
- 사용자 push (Gitea 자격증명) + NAS 검증
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: 사전 검토 — 모든 stock-lab 참조 위치 확인
|
||||||
|
|
||||||
|
**Files:** (검증만, 변경 없음)
|
||||||
|
|
||||||
|
- [ ] **Step 1: web-backend stock-lab 참조 전체 grep (docs / .venv / __pycache__ 제외)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-backend
|
||||||
|
grep -rln "stock-lab\|STOCK_LAB" . \
|
||||||
|
--exclude-dir=.venv --exclude-dir=__pycache__ --exclude-dir=.git --exclude-dir=docs \
|
||||||
|
2>&1 | sort
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output (예상): 다음 파일들이 등장해야 함:
|
||||||
|
- `./agent-office/app/agents/stock.py`
|
||||||
|
- `./agent-office/app/config.py`
|
||||||
|
- `./agent-office/app/service_proxy.py`
|
||||||
|
- `./agent-office/tests/test_stock_screener_job.py`
|
||||||
|
- `./CLAUDE.md`
|
||||||
|
- `./docker-compose.yml`
|
||||||
|
- `./nginx/default.conf`
|
||||||
|
- `./README.md`
|
||||||
|
- `./scripts/deploy-nas.sh`
|
||||||
|
- `./scripts/deploy.sh`
|
||||||
|
- `./STATUS.md`
|
||||||
|
- `./stock-lab/...` (stock-lab 내부 파일들 — `app/main.py`, 테스트 등 내부 참조는 디렉토리 rename 으로 자연 해소)
|
||||||
|
|
||||||
|
- [ ] **Step 2: web-ui stock-lab 참조 grep**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ui
|
||||||
|
grep -rln "stock-lab" . \
|
||||||
|
--exclude-dir=node_modules --exclude-dir=.git --exclude-dir=docs \
|
||||||
|
2>&1 | sort
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `./CLAUDE.md` 만.
|
||||||
|
|
||||||
|
- [ ] **Step 3: nginx/default.conf 정확한 변경 라인 식별**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -nE "stock-lab|upstream stock" /c/Users/jaeoh/Desktop/workspace/web-backend/nginx/default.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `upstream stock-lab { ... }` 블록 정의 + `proxy_pass http://stock-lab` 호출 라인 (1-3 곳).
|
||||||
|
|
||||||
|
- [ ] **Step 4: web-backend stock-lab 내부의 자기 참조 확인 (디렉토리 rename 후 영향)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rln "stock-lab" /c/Users/jaeoh/Desktop/workspace/web-backend/stock-lab/ \
|
||||||
|
--exclude-dir=.venv --exclude-dir=__pycache__ 2>&1 | sort
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `app/main.py` 의 헬스체크 메시지 + 일부 CLAUDE.md/README.md 문구. Python `app.*` import 는 stock-lab 문자열 없으므로 0건. 발견된 매칭은 Task 2 의 7단계 (디렉토리 내부 문서) 에서 처리.
|
||||||
|
|
||||||
|
- [ ] **Step 5: 사용자에게 `.venv` 삭제 요청 (선택사항이지만 git mv 안전성 향상)**
|
||||||
|
|
||||||
|
사용자에게 다음 메시지:
|
||||||
|
> "git mv stock-lab → stock 직전에 `web-backend/stock-lab/.venv/` 디렉토리 삭제 권장 (Windows local 가상환경, .gitignore 되어있어 영향 없음. 사용 시 재생성 필요). 삭제 완료 후 Task 2 진행."
|
||||||
|
|
||||||
|
Step 5 는 사용자 직접 실행:
|
||||||
|
```bash
|
||||||
|
rm -rf /c/Users/jaeoh/Desktop/workspace/web-backend/stock-lab/.venv
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Step 1-4 결과 기록 (commit 없음, Task 2 의 cross-check 자료)**
|
||||||
|
|
||||||
|
기록할 항목:
|
||||||
|
- 변경 대상 파일 N개 (Step 1 출력)
|
||||||
|
- nginx config 의 정확한 변경 라인 (예: 라인 12, 18, 25 등)
|
||||||
|
- 사용자가 `.venv` 삭제 완료했는지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: web-backend repo atomic commit
|
||||||
|
|
||||||
|
**Files:** (web-backend repo)
|
||||||
|
- Rename: `stock-lab/` → `stock/`
|
||||||
|
- Modify: `docker-compose.yml`
|
||||||
|
- Modify: `agent-office/app/config.py`
|
||||||
|
- Modify: `agent-office/app/service_proxy.py`
|
||||||
|
- Modify: `agent-office/app/agents/stock.py` (해당 시)
|
||||||
|
- Modify: `agent-office/tests/test_stock_screener_job.py`
|
||||||
|
- Modify: `nginx/default.conf`
|
||||||
|
- Modify: `CLAUDE.md`
|
||||||
|
- Modify: `README.md`
|
||||||
|
- Modify: `STATUS.md`
|
||||||
|
- Modify: `scripts/deploy-nas.sh`
|
||||||
|
- Modify: `scripts/deploy.sh`
|
||||||
|
|
||||||
|
- [ ] **Step 1: git mv 디렉토리 rename**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-backend
|
||||||
|
git mv stock-lab stock
|
||||||
|
git status --short | head -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: git status 에 `R stock-lab/... -> stock/...` 라인 다수. .venv 가 사용자에 의해 사전 삭제되었다면 무관, 살아있어도 .gitignore 로 untracked.
|
||||||
|
|
||||||
|
- [ ] **Step 2: docker-compose.yml 갱신**
|
||||||
|
|
||||||
|
`docker-compose.yml` 안 4 곳 변경:
|
||||||
|
1. `services:` 아래 `stock-lab:` 키 → `stock:`
|
||||||
|
2. `container_name: stock-lab` → `container_name: stock`
|
||||||
|
3. `build:` 의 `context: ./stock-lab` → `context: ./stock`
|
||||||
|
4. `frontend:` 의 `depends_on:` 항목 중 `- stock-lab` → `- stock`
|
||||||
|
5. `agent-office:` 의 `environment:` 안 `STOCK_LAB_URL=http://stock-lab:8000` → `STOCK_URL=http://stock:8000`
|
||||||
|
|
||||||
|
수정 명령 (Edit tool 로 안전하게):
|
||||||
|
- `stock-lab:` 단일 occurrence → `stock:`
|
||||||
|
- `container_name: stock-lab` → `container_name: stock`
|
||||||
|
- `context: ./stock-lab` → `context: ./stock`
|
||||||
|
- `- stock-lab` (frontend.depends_on 항목) → `- stock`
|
||||||
|
- `STOCK_LAB_URL=http://stock-lab:8000` → `STOCK_URL=http://stock:8000`
|
||||||
|
|
||||||
|
- [ ] **Step 3: agent-office/app/config.py 갱신**
|
||||||
|
|
||||||
|
`STOCK_LAB_URL = os.getenv("STOCK_LAB_URL", "http://stock-lab:8000")` 형태의 라인을:
|
||||||
|
```python
|
||||||
|
STOCK_URL = os.getenv("STOCK_URL", "http://stock:8000")
|
||||||
|
```
|
||||||
|
으로 교체. 다른 lab URL (MUSIC_LAB_URL 등) 은 그대로 유지.
|
||||||
|
|
||||||
|
- [ ] **Step 4: agent-office/app/service_proxy.py 갱신**
|
||||||
|
|
||||||
|
상단 import:
|
||||||
|
```python
|
||||||
|
from .config import STOCK_LAB_URL, MUSIC_LAB_URL, BLOG_LAB_URL, REALESTATE_LAB_URL
|
||||||
|
```
|
||||||
|
을:
|
||||||
|
```python
|
||||||
|
from .config import STOCK_URL, MUSIC_LAB_URL, BLOG_LAB_URL, REALESTATE_LAB_URL
|
||||||
|
```
|
||||||
|
으로 변경.
|
||||||
|
|
||||||
|
함수 본문의 `STOCK_LAB_URL` 사용 5개 (fetch_stock_news / fetch_stock_indices / summarize_stock_news / refresh_screener_snapshot / run_stock_screener) 모두 `STOCK_URL` 로 변경. 또한 본 spec 이후 추가된 `refresh_ai_news_sentiment` 함수도 STOCK_URL 사용.
|
||||||
|
|
||||||
|
가장 단순한 방법: 파일 안 모든 `STOCK_LAB_URL` → `STOCK_URL` 치환 (replace_all).
|
||||||
|
|
||||||
|
- [ ] **Step 5: agent-office/app/agents/stock.py 갱신**
|
||||||
|
|
||||||
|
다음 패턴 grep:
|
||||||
|
```bash
|
||||||
|
grep -n "stock-lab\|STOCK_LAB" /c/Users/jaeoh/Desktop/workspace/web-backend/agent-office/app/agents/stock.py
|
||||||
|
```
|
||||||
|
|
||||||
|
매칭이 있으면 (`stock-lab` 호스트 URL 또는 환경변수명 직접 참조) 갱신. 없으면 skip.
|
||||||
|
|
||||||
|
- [ ] **Step 6: agent-office/tests/test_stock_screener_job.py 갱신**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "stock-lab\|STOCK_LAB" /c/Users/jaeoh/Desktop/workspace/web-backend/agent-office/tests/test_stock_screener_job.py
|
||||||
|
```
|
||||||
|
|
||||||
|
mock URL 또는 환경변수 참조 갱신. `STOCK_LAB_URL` → `STOCK_URL`, `http://stock-lab:` → `http://stock:`.
|
||||||
|
|
||||||
|
- [ ] **Step 7: nginx/default.conf 갱신**
|
||||||
|
|
||||||
|
Task 1 Step 3 에서 식별된 라인 모두 변경:
|
||||||
|
- `upstream stock-lab` → `upstream stock`
|
||||||
|
- `server stock-lab:8000;` → `server stock:8000;`
|
||||||
|
- `proxy_pass http://stock-lab` → `proxy_pass http://stock`
|
||||||
|
|
||||||
|
- [ ] **Step 8: 운영 문서 갱신 (CLAUDE.md / README.md / STATUS.md / scripts/)**
|
||||||
|
|
||||||
|
각 파일 grep 후 모든 stock-lab 언급을 stock 으로 교체:
|
||||||
|
- `web-backend/CLAUDE.md` — 디렉토리 표 + 서비스 표 + 환경변수 표
|
||||||
|
- `web-backend/README.md` — 동일
|
||||||
|
- `web-backend/STATUS.md` — 동일
|
||||||
|
- `web-backend/scripts/deploy-nas.sh` — stock-lab 호출/경로 갱신
|
||||||
|
- `web-backend/scripts/deploy.sh` — 동일
|
||||||
|
|
||||||
|
수정 방법: 각 파일에 대해 grep → Edit tool replace_all (단, 의도적 보존 항목 — 예: 과거 변경 이력 등 — 있는지 검토).
|
||||||
|
|
||||||
|
- [ ] **Step 9: stock 디렉토리 내부 문서 갱신**
|
||||||
|
|
||||||
|
Task 1 Step 4 에서 발견된 stock-lab 내부 자기 참조 (예: `stock/CLAUDE.md`, `stock/app/main.py` 헬스체크 문구) 모두 갱신.
|
||||||
|
|
||||||
|
- [ ] **Step 10: agent-office 테스트 회귀 검증**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-backend/agent-office
|
||||||
|
python -m pytest tests/test_stock_screener_job.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS — `STOCK_LAB_URL` 참조 없이 새 `STOCK_URL` 환경변수 기반으로 mock 통과.
|
||||||
|
|
||||||
|
- [ ] **Step 11: stock pytest 회귀**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-backend/stock
|
||||||
|
python -m pytest --ignore=app/test_scraper.py -q 2>&1 | tail -5
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 80+ tests passed (이전 76 + Phase 1 작업 전 검증). 디렉토리 이름만 변경, 코드 무변. 회귀 0건.
|
||||||
|
|
||||||
|
- [ ] **Step 12: 최종 grep 검증 — stock-lab 잔여 0건**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-backend
|
||||||
|
grep -rln "stock-lab\|STOCK_LAB" . \
|
||||||
|
--exclude-dir=.venv --exclude-dir=__pycache__ --exclude-dir=.git --exclude-dir=docs \
|
||||||
|
2>&1 | sort
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: **0 lines** (의도적 보존된 docs/ 제외).
|
||||||
|
|
||||||
|
만약 0건이 아니면 빠진 위치 찾아서 추가 갱신 후 재검증.
|
||||||
|
|
||||||
|
- [ ] **Step 13: web-backend atomic commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-backend
|
||||||
|
git add -A
|
||||||
|
git status --short | head -20
|
||||||
|
git commit -m "refactor: rename stock-lab → stock (graduation)
|
||||||
|
|
||||||
|
- git mv stock-lab/ → stock/
|
||||||
|
- docker-compose.yml: 서비스 키 + container_name + build.context +
|
||||||
|
frontend.depends_on + agent-office STOCK_LAB_URL → STOCK_URL
|
||||||
|
- agent-office/app: config.py, service_proxy.py STOCK_LAB_URL → STOCK_URL
|
||||||
|
- nginx/default.conf: upstream + proxy_pass 갱신
|
||||||
|
- CLAUDE.md / README.md / STATUS.md / scripts/ 문구 갱신
|
||||||
|
|
||||||
|
lab 네이밍 정책 (feedback_lab_naming.md) 에 따라 정식 graduation.
|
||||||
|
API URL / Python import / DB 파일명은 변경 없음."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: web-ui CLAUDE.md 갱신
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-ui/CLAUDE.md` (web-ui repo)
|
||||||
|
|
||||||
|
- [ ] **Step 1: stock-lab 언급 grep**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "stock-lab" /c/Users/jaeoh/Desktop/workspace/web-ui/CLAUDE.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 디렉토리 경로 / 라우팅 설명 / API 표 등에서 다수 매칭.
|
||||||
|
|
||||||
|
- [ ] **Step 2: 모두 stock 으로 교체**
|
||||||
|
|
||||||
|
Edit tool 의 `replace_all=true` 로 `stock-lab` → `stock` 일괄 치환. 단, "stock screener" 같은 단어는 영향 없음 (정확한 `stock-lab` 문자열만 매칭).
|
||||||
|
|
||||||
|
- [ ] **Step 3: commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ui
|
||||||
|
git add CLAUDE.md
|
||||||
|
git commit -m "docs: rename stock-lab → stock in CLAUDE.md"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: workspace/CLAUDE.md 갱신 (git 외 가능)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/c/Users/jaeoh/Desktop/workspace/CLAUDE.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: git 관리 여부 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls /c/Users/jaeoh/Desktop/workspace/.git 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `No such file or directory` — workspace 자체는 git repo 아님. 단순 파일 편집.
|
||||||
|
|
||||||
|
(만약 git 관리 중이라면 별도 commit 진행)
|
||||||
|
|
||||||
|
- [ ] **Step 2: stock-lab 언급 grep + 교체**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "stock-lab" /c/Users/jaeoh/Desktop/workspace/CLAUDE.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit tool 로 `stock-lab` → `stock` 일괄 치환.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 변경 사항 사용자에게 알림 (commit 없음, 단순 파일)**
|
||||||
|
|
||||||
|
workspace/CLAUDE.md 는 단순 파일 — 자동 syncing 없음. 사용자에게 다음 메시지 전달:
|
||||||
|
> "workspace/CLAUDE.md 갱신 완료. git 관리 외 파일이라 commit 없음. 다음 세션부터 자동 적용."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: 메모리 갱신 (controller 직접)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `C:\Users\jaeoh\.claude\projects\C--Users-jaeoh-Desktop-workspace-web-ui\memory\project_workspace.md`
|
||||||
|
- Modify: `...\memory\project_scale.md`
|
||||||
|
- Modify: `...\memory\project_stock_screener.md`
|
||||||
|
- Modify: `...\memory\nas_infra.md`
|
||||||
|
- Modify: `...\memory\feedback_lab_naming.md` (graduation 사례 추가)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 메모리 폴더 grep**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rln "stock-lab\|STOCK_LAB" /c/Users/jaeoh/.claude/projects/C--Users-jaeoh-Desktop-workspace-web-ui/memory/ 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
매칭 파일 모두 확인.
|
||||||
|
|
||||||
|
- [ ] **Step 2: 각 메모리에서 stock-lab → stock 갱신**
|
||||||
|
|
||||||
|
다음 표를 보고 각 파일에서 Edit:
|
||||||
|
|
||||||
|
| 파일 | 주요 갱신 |
|
||||||
|
|------|----------|
|
||||||
|
| `project_workspace.md` | "stock-lab/" → "stock/" (디렉토리 경로) |
|
||||||
|
| `project_scale.md` | 백엔드 서비스 표의 stock-lab 행 → stock |
|
||||||
|
| `project_stock_screener.md` | 백엔드 위치 / 컨테이너 이름 모두 |
|
||||||
|
| `nas_infra.md` | Docker 서비스 포트 표 + nginx 라우팅 |
|
||||||
|
|
||||||
|
- [ ] **Step 3: feedback_lab_naming.md 에 graduation 사례 등재**
|
||||||
|
|
||||||
|
기존 메모리 본문 끝에 다음 추가:
|
||||||
|
```markdown
|
||||||
|
|
||||||
|
## Graduation 이력
|
||||||
|
- **2026-05-15**: `stock-lab` → `stock` graduation. 8 노드 screener + 캔버스 UI + AI 뉴스 Phase 1 + V2 시그널 파이프라인 중심 = 정식 서비스 단계. 디렉토리/컨테이너/환경변수 (`STOCK_LAB_URL` → `STOCK_URL`) 갱신. API URL `/api/stock/*` + Python import / DB 파일명 그대로.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: MEMORY.md 인덱스의 stock_screener 행에 영향 있나 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "stock-lab" /c/Users/jaeoh/.claude/projects/C--Users-jaeoh-Desktop-workspace-web-ui/memory/MEMORY.md
|
||||||
|
```
|
||||||
|
|
||||||
|
매칭 있으면 갱신, 없으면 skip.
|
||||||
|
|
||||||
|
- [ ] **Step 5: 메모리 폴더 잔여 grep 검증 (0건)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rln "stock-lab\|STOCK_LAB" /c/Users/jaeoh/.claude/projects/C--Users-jaeoh-Desktop-workspace-web-ui/memory/ 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 0 lines (feedback_lab_naming.md의 graduation 본문 안에 의도적으로 "stock-lab" 언급은 가능 — 정책 사례 명시).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: 배포 + 운영 검증
|
||||||
|
|
||||||
|
**Files:** (실행만, 변경 없음)
|
||||||
|
|
||||||
|
- [ ] **Step 1: web-backend push (사용자 수동, Gitea 자격증명 필요)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-backend
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
자격증명 prompt 시 사용자가 입력. push 성공 시 Gitea webhook → deployer rsync + docker compose up 자동.
|
||||||
|
|
||||||
|
- [ ] **Step 2: web-ui push (사용자 수동)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ui
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
자격증명 prompt. 본 push 는 CLAUDE.md 한 줄 변경만이라 deployer 영향 없음.
|
||||||
|
|
||||||
|
- [ ] **Step 3: NAS 컨테이너 상태 확인 (사용자가 NAS SSH)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker ps --format "{{.Names}}: {{.Status}}" | grep -E "stock|agent-office"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- `stock: Up (healthy)` 라인 존재 (옛 stock-lab 컨테이너는 사라짐)
|
||||||
|
- `agent-office: Up (healthy)`
|
||||||
|
|
||||||
|
- [ ] **Step 4: stock 컨테이너 로그 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs stock --tail 30
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FastAPI startup 로그, init_db 완료, 어떤 stock-lab 잔여 참조나 에러 없음.
|
||||||
|
|
||||||
|
- [ ] **Step 5: agent-office 환경변수 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec agent-office env | grep -E "STOCK|stock"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- `STOCK_URL=http://stock:8000` (새 변수)
|
||||||
|
- (옛 `STOCK_LAB_URL` 잔여가 없어야 — `.env` 파일에 남아있으면 사용자가 수동 삭제)
|
||||||
|
|
||||||
|
- [ ] **Step 6: API curl 검증**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://gahusb.synology.me/api/stock/news | python -m json.tool | head -10
|
||||||
|
curl -s https://gahusb.synology.me/api/stock/screener/runs | python -m json.tool | head -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 200 응답, JSON 파싱 정상.
|
||||||
|
|
||||||
|
- [ ] **Step 7: agent-office 수동 트리거 (테스트)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://gahusb.synology.me/api/agent-office/command" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"agent":"stock","action":"run_ai_news"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `{"ok": true, "message": "AI 뉴스 분석 트리거 완료"}`. 30-60초 후 텔레그램 메시지 도착 = stock 호스트 라우팅 정상.
|
||||||
|
|
||||||
|
- [ ] **Step 8: web-ui 페이지 회귀 (브라우저)**
|
||||||
|
|
||||||
|
`https://gahusb.synology.me/stock/screener` 접속:
|
||||||
|
- 캔버스 모드 진입 정상
|
||||||
|
- 슬라이더 조작 → settings PUT 정상 (X-WebAI-Key 미사용 상태에서도 통과 — 인증은 Phase 1 작업)
|
||||||
|
- 노드 변경 즉시 반영
|
||||||
|
|
||||||
|
`https://gahusb.synology.me/portfolio` 접속:
|
||||||
|
- portfolio 페이지 정상 (current_price/PnL 표시 — Phase 1 작업 전이므로 raw 값만 표시)
|
||||||
|
|
||||||
|
- [ ] **Step 9: 운영 .env 파일 정리 안내 (사용자 수동)**
|
||||||
|
|
||||||
|
NAS의 `/volume1/docker/webpage/.env` 파일에서:
|
||||||
|
- `STOCK_LAB_URL=...` 라인 삭제 (또는 `STOCK_URL=...` 로 갱신)
|
||||||
|
- agent-office 컨테이너 재기동 필요 시: `docker restart agent-office`
|
||||||
|
|
||||||
|
사용자에게 알림:
|
||||||
|
> "NAS의 .env 파일에서 옛 STOCK_LAB_URL 라인 제거 권장. agent-office 의 default fallback (`http://stock:8000`) 으로 동작 가능하지만, 명시적 STOCK_URL 등재가 깔끔."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 완료 후 검증 체크리스트
|
||||||
|
|
||||||
|
- [ ] `web-backend/stock-lab/` 사라지고 `stock/` 존재 (`ls web-backend/stock` 확인)
|
||||||
|
- [ ] `grep -rln "stock-lab\|STOCK_LAB" web-backend --exclude-dir=docs --exclude-dir=.venv --exclude-dir=__pycache__ --exclude-dir=.git` → 0 lines
|
||||||
|
- [ ] web-ui/CLAUDE.md stock-lab 0건
|
||||||
|
- [ ] workspace/CLAUDE.md stock-lab 0건
|
||||||
|
- [ ] 메모리 폴더 stock-lab 0건 (feedback_lab_naming.md graduation 본문 외)
|
||||||
|
- [ ] docker ps 에 `stock` 컨테이너 healthy
|
||||||
|
- [ ] curl `/api/stock/news` 200
|
||||||
|
- [ ] agent-office `run_ai_news` 수동 트리거 + 텔레그램 도착
|
||||||
|
- [ ] stock pytest 76+ tests passed (회귀 0)
|
||||||
|
- [ ] agent-office tests 통과
|
||||||
|
- [ ] web-ui 페이지 (portfolio + screener) 정상
|
||||||
|
|
||||||
|
## 본 plan 완료 후 다음 단계
|
||||||
|
|
||||||
|
- **Confidence Signal Pipeline V2 Phase 1 brainstorming 시작** (이전 발표 디자인 그대로, 새 이름 `stock` 기준)
|
||||||
|
- spec → plan → 실행 (1주 작업 예상)
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,402 @@
|
|||||||
|
# web-ai V1 → signal_v1 Rename Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** `web-ai/` 루트의 모든 V1 자산 (main_server.py + modules/ + data/ + tests/ + 진입점 스크립트 + 문서 + 로그) 을 `web-ai/signal_v1/` 안으로 atomic mv 하고, web-ai 루트에 신규 가이드 (`CLAUDE.md`, `start.bat`) 추가. V2 (`signal_v2/`) 추가 전 신/구 격리.
|
||||||
|
|
||||||
|
**Architecture:** 단일 atomic commit (stock-lab → stock graduation 과 동일 패턴). `git mv` 로 history 보존, `load_dotenv()` 호출만 경로 명시. cwd 기반 V1 코드라 import 변경 0. Phase 6 deprecation 시 `rm -rf signal_v1/` 단순화.
|
||||||
|
|
||||||
|
**Tech Stack:** git mv / Python load_dotenv path 갱신 / pytest 회귀 확인
|
||||||
|
|
||||||
|
**Spec:** `web-ui/docs/superpowers/specs/2026-05-16-web-ai-v1-rename-to-signal-v1.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 구조 (Task 2 후)
|
||||||
|
|
||||||
|
```
|
||||||
|
web-ai/
|
||||||
|
├── .env ← 그대로 (V1 + V2 공유)
|
||||||
|
├── .gitignore ← 그대로
|
||||||
|
├── CLAUDE.md ← 신규 (web-ai 레벨 가이드)
|
||||||
|
├── start.bat ← 신규 (signal_v1 진입 wrapper)
|
||||||
|
├── signal_v1/ ← 신규 디렉토리
|
||||||
|
│ ├── CLAUDE.md ← 기존 V1 가이드 (mv)
|
||||||
|
│ ├── KIS_SETUP.md
|
||||||
|
│ ├── README.md
|
||||||
|
│ ├── main_server.py ← load_dotenv 경로 명시 갱신
|
||||||
|
│ ├── warmup_and_restart.py ← load_dotenv 경로 명시 갱신
|
||||||
|
│ ├── watchlist_manager.py
|
||||||
|
│ ├── backtester.py
|
||||||
|
│ ├── backtest_runner.py
|
||||||
|
│ ├── theme_manager.py
|
||||||
|
│ ├── start.bat ← 사용 안 함 (cleanup 안 함, 향후)
|
||||||
|
│ ├── modules/ ← 전체
|
||||||
|
│ ├── data/ ← 전체 (runtime data 보존)
|
||||||
|
│ ├── tests/ ← 전체
|
||||||
|
│ └── (log/json 파일들)
|
||||||
|
└── (signal_v2/ 는 Phase 2 spec)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Atomic refactor (사전 점검 + git mv + 신규 파일 + 검증 + commit)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Source repo: `C:\Users\jaeoh\Desktop\workspace\web-ai` (별도 Gitea repo: `ai-trade.git`, branch `main`)
|
||||||
|
- Create: `web-ai/signal_v1/` (디렉토리)
|
||||||
|
- Create: `web-ai/CLAUDE.md` (신규)
|
||||||
|
- Create: `web-ai/start.bat` (신규)
|
||||||
|
- Move (git mv): web-ai 루트의 모든 V1 자산 → signal_v1/
|
||||||
|
- Modify: `web-ai/signal_v1/main_server.py` (load_dotenv 명시 경로)
|
||||||
|
- Modify: `web-ai/signal_v1/warmup_and_restart.py` (load_dotenv 명시 경로)
|
||||||
|
- (필요 시) Modify: `signal_v1/modules/config.py` 또는 다른 load_dotenv 위치
|
||||||
|
|
||||||
|
- [ ] **Step 1: 사전 — 자동매매 봇 정지 확인 + git status clean**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ai
|
||||||
|
git status
|
||||||
|
```
|
||||||
|
Expected: `nothing to commit, working tree clean`. 만약 dirty 면 implementer 는 BLOCKED 보고. 사용자가 stash 또는 commit 처리.
|
||||||
|
|
||||||
|
또한: V1 자동매매 봇이 실행 중이면 mv 도중 파일 잠금 위험. PowerShell:
|
||||||
|
```powershell
|
||||||
|
Get-Process python -ErrorAction SilentlyContinue | Select-Object Id, ProcessName, StartTime
|
||||||
|
```
|
||||||
|
실행 중 Python 프로세스 발견 시 사용자에게 종료 요청. (장외 시간대에 작업 가정.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: 사전 grep — load_dotenv 호출 위치 파악**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ai
|
||||||
|
grep -rn "load_dotenv" --include="*.py" .
|
||||||
|
```
|
||||||
|
Expected: 1~3개 hit. 각 hit 의 파일 경로 기록 (Step 6 에서 갱신). 일반적으로 main_server.py, warmup_and_restart.py, modules/config.py 중 1~2곳에 있음.
|
||||||
|
|
||||||
|
만약 hit 0 이면 V1 이 `.env` 를 다른 방식 (예: pydantic-settings) 으로 로드. 코드 경로 추가 grep:
|
||||||
|
```bash
|
||||||
|
grep -rn "BaseSettings\|env_file\|\.env" --include="*.py" .
|
||||||
|
```
|
||||||
|
어느 방식이든 cwd 가 signal_v1/ 으로 바뀌면 `.env` 가 parent (`web-ai/.env`) 에 있다는 사실을 코드가 알아야 함.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 사전 baseline — 현 pytest 통과 개수 측정**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ai
|
||||||
|
python -m pytest tests/unit -q 2>&1 | tail -3
|
||||||
|
```
|
||||||
|
Expected output 형태: `N passed in Xs` (또는 `N passed, M warnings ...`). N 값을 baseline 으로 기록 (Step 13 에서 비교).
|
||||||
|
|
||||||
|
만약 baseline 자체가 실패면 implementer 는 DONE_WITH_CONCERNS 보고 — 사용자 결정 (pre-existing failure 라면 무시하고 진행 가능).
|
||||||
|
|
||||||
|
- [ ] **Step 4: signal_v1 디렉토리 생성**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ai
|
||||||
|
mkdir signal_v1
|
||||||
|
```
|
||||||
|
Verify:
|
||||||
|
```bash
|
||||||
|
ls -d signal_v1
|
||||||
|
```
|
||||||
|
Expected: `signal_v1`
|
||||||
|
|
||||||
|
- [ ] **Step 5: git mv 실행 (V1 자산 모두)**
|
||||||
|
|
||||||
|
다음 항목을 모두 `signal_v1/` 안으로 이동. `git mv` 사용 (history 보존):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ai
|
||||||
|
|
||||||
|
# 진입점 + 스크립트
|
||||||
|
git mv main_server.py signal_v1/
|
||||||
|
git mv warmup_and_restart.py signal_v1/
|
||||||
|
git mv watchlist_manager.py signal_v1/
|
||||||
|
git mv backtester.py signal_v1/
|
||||||
|
git mv backtest_runner.py signal_v1/
|
||||||
|
git mv theme_manager.py signal_v1/
|
||||||
|
git mv start.bat signal_v1/
|
||||||
|
|
||||||
|
# 문서 (현 V1 가이드)
|
||||||
|
git mv CLAUDE.md signal_v1/
|
||||||
|
git mv KIS_SETUP.md signal_v1/
|
||||||
|
git mv README.md signal_v1/
|
||||||
|
|
||||||
|
# 디렉토리
|
||||||
|
git mv modules signal_v1/
|
||||||
|
git mv data signal_v1/
|
||||||
|
git mv tests signal_v1/
|
||||||
|
|
||||||
|
# 로그 / IPC / 캐시
|
||||||
|
git mv bot_ipc.json signal_v1/ 2>/dev/null || true
|
||||||
|
git mv bot_output.log signal_v1/ 2>/dev/null || true
|
||||||
|
git mv daily_launcher.log signal_v1/ 2>/dev/null || true
|
||||||
|
git mv server.log signal_v1/ 2>/dev/null || true
|
||||||
|
git mv telegram_bot.log signal_v1/ 2>/dev/null || true
|
||||||
|
git mv warmup.log signal_v1/ 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
`__pycache__/` 는 gitignore 이므로 git mv 불가능. 단순 mv:
|
||||||
|
```bash
|
||||||
|
mv __pycache__ signal_v1/ 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
```bash
|
||||||
|
git status --short | head -30
|
||||||
|
ls signal_v1/
|
||||||
|
ls
|
||||||
|
```
|
||||||
|
Expected: `signal_v1/` 안에 모든 V1 자산이 있고, web-ai 루트에는 `.env`, `.gitignore`, `signal_v1/` 만 (still untracked: none yet for new files).
|
||||||
|
|
||||||
|
- [ ] **Step 6: load_dotenv 경로 명시 갱신**
|
||||||
|
|
||||||
|
Step 2 에서 식별한 각 `load_dotenv()` 호출을 명시 경로로 변경. 가장 빈도 높은 패턴 (main_server.py 의 시작 부분):
|
||||||
|
|
||||||
|
기존 (cwd 기준):
|
||||||
|
```python
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
```
|
||||||
|
|
||||||
|
신규 (명시 경로, signal_v1 의 parent = web-ai 루트):
|
||||||
|
```python
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# web-ai/.env (signal_v1/ 의 parent) 명시 로드
|
||||||
|
load_dotenv(Path(__file__).parent.parent / ".env")
|
||||||
|
```
|
||||||
|
|
||||||
|
Step 2 에서 식별한 모든 위치에 동일 패턴 적용. 만약 V1 이 `BaseSettings` (pydantic) 사용 시:
|
||||||
|
```python
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
class Config:
|
||||||
|
env_file = str(Path(__file__).parent.parent / ".env")
|
||||||
|
```
|
||||||
|
|
||||||
|
만약 V1 이 그냥 `os.getenv(...)` 만 쓰고 어딘가에서 명시적으로 load 하지 않는다면 (uvicorn 이 시작 시 cwd 의 .env 를 자동 로드 시) — 시작 wrapper (`web-ai/start.bat`) 가 `cd signal_v1` 후 실행하면 cwd=signal_v1 → `.env` 못 찾음. 해결: Step 7 의 `start.bat` 에서 명시적으로 `cd /d "%~dp0"` (= web-ai 루트) 후 `python signal_v1/main_server.py` 실행.
|
||||||
|
|
||||||
|
근데 그러면 main_server.py 안의 다른 상대 경로 (`data/kis_token.json` 등) 가 cwd=web-ai 일 때 `web-ai/data/kis_token.json` 을 찾음 → 잘못된 경로.
|
||||||
|
|
||||||
|
**결정**: cwd 는 `signal_v1/` 으로 두고 `load_dotenv(Path(__file__).parent.parent / ".env")` 명시. 다른 상대 경로는 cwd=signal_v1 기준이라 `data/...` 그대로 작동.
|
||||||
|
|
||||||
|
각 갱신 후 git status:
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ai
|
||||||
|
git status --short | head -10
|
||||||
|
```
|
||||||
|
Expected: signal_v1/main_server.py 등 modified 표시.
|
||||||
|
|
||||||
|
- [ ] **Step 7: 신규 파일 — web-ai/CLAUDE.md**
|
||||||
|
|
||||||
|
Create `web-ai/CLAUDE.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# web-ai — Workspace 가이드
|
||||||
|
|
||||||
|
Windows AI 머신 (AMD 9800X3D + RTX 5070 Ti) 의 두 시그널 파이프라인 컨테이너.
|
||||||
|
|
||||||
|
## 디렉토리 구조
|
||||||
|
|
||||||
|
| 경로 | 역할 | 상태 |
|
||||||
|
|------|------|------|
|
||||||
|
| `signal_v1/` | V1 자체 자동매매 시스템 (main_server.py + Trading Bot + Telegram Bot + LSTM + Ollama + KIS 자동주문) | 운영 중. Confidence Signal Pipeline V2 Phase 6 에서 deprecation 예정 |
|
||||||
|
| `signal_v2/` | V2 신호 파이프라인 (stock pull worker + Chronos-2 + signal API client) | Phase 2 에서 신설 |
|
||||||
|
| `.env` | V1 + V2 환경변수 공유 | KIS_*, TELEGRAM_*, STOCK_API_URL, WEBAI_API_KEY 등 |
|
||||||
|
| `start.bat` | V1 진입 (signal_v1 디렉토리 안 main_server.py 실행) | V2 별도 start 스크립트는 signal_v2/start.bat |
|
||||||
|
|
||||||
|
## 운영 가이드
|
||||||
|
|
||||||
|
- V1 시작: `start.bat` 또는 `cd signal_v1 && python main_server.py`
|
||||||
|
- V2 시작 (Phase 2 이후): `cd signal_v2 && python -m uvicorn main:app --port 8001`
|
||||||
|
- 둘 다 동시 실행 가능 (포트 분리: V1=8000, V2=8001)
|
||||||
|
|
||||||
|
## Phase 진행 상태 (Confidence Signal Pipeline V2)
|
||||||
|
|
||||||
|
`web-ui/docs/superpowers/specs/2026-05-15-confidence-signal-pipeline-v2-architecture.md` 참조.
|
||||||
|
|
||||||
|
자세한 V1 가이드는 `signal_v1/CLAUDE.md` 참조.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: 신규 파일 — web-ai/start.bat**
|
||||||
|
|
||||||
|
Create `web-ai/start.bat`:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
@echo off
|
||||||
|
cd /d "%~dp0\signal_v1"
|
||||||
|
python main_server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 9: git add 신규 파일**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ai
|
||||||
|
git add CLAUDE.md start.bat
|
||||||
|
git add signal_v1/main_server.py signal_v1/warmup_and_restart.py # load_dotenv 갱신
|
||||||
|
# 추가로 갱신한 다른 .py 파일이 있으면 모두 add
|
||||||
|
```
|
||||||
|
|
||||||
|
git status 점검:
|
||||||
|
```bash
|
||||||
|
git status
|
||||||
|
```
|
||||||
|
Expected: 모든 git mv + 신규 + modify 변경이 staged 상태.
|
||||||
|
|
||||||
|
- [ ] **Step 10: 잔여 grep — `from web-ai` 같은 잘못된 import 0건 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ai
|
||||||
|
grep -rn "from web-ai\|import web-ai" --include="*.py" signal_v1/
|
||||||
|
```
|
||||||
|
Expected: 0 lines.
|
||||||
|
|
||||||
|
또한 V1 코드 안에 hardcoded 절대 경로 (예: `C:\Users\jaeoh\Desktop\workspace\web-ai\data\...`) 검사:
|
||||||
|
```bash
|
||||||
|
grep -rn "web-ai.data\|web-ai/data\|web-ai\\\\data" --include="*.py" signal_v1/
|
||||||
|
```
|
||||||
|
Expected: 0 lines.
|
||||||
|
|
||||||
|
만약 hit 있으면 implementer 는 DONE_WITH_CONCERNS 보고, 사용자가 조정.
|
||||||
|
|
||||||
|
- [ ] **Step 11: signal_v1 안에서 pytest 자동 검증**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ai/signal_v1
|
||||||
|
python -m pytest tests/unit -q 2>&1 | tail -5
|
||||||
|
```
|
||||||
|
Expected: Step 3 의 baseline 과 동일한 PASS 개수 (회귀 없음).
|
||||||
|
|
||||||
|
만약 import 오류 (`ModuleNotFoundError: No module named 'modules'`) 가 발생하면 conftest.py 가 sys.path 를 수정하지 않을 가능성. 확인:
|
||||||
|
```bash
|
||||||
|
cat tests/unit/conftest.py | head -20
|
||||||
|
```
|
||||||
|
필요 시 `sys.path.insert(0, str(Path(__file__).parent.parent.parent))` 추가. 단, 기존 conftest 가 cwd 기반이면 cwd=signal_v1 에서 작동해야 함.
|
||||||
|
|
||||||
|
만약 다른 failure 면 BLOCKED 보고 — 사용자 진단.
|
||||||
|
|
||||||
|
- [ ] **Step 12: 잠시 후 다시 git status — 추가 untracked 없는지 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ai
|
||||||
|
git status
|
||||||
|
```
|
||||||
|
Expected: 모든 변경이 staged. 만약 새 untracked (pytest cache 등) 있으면 .gitignore 패턴 또는 무시.
|
||||||
|
|
||||||
|
- [ ] **Step 13: 단일 commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ai
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
refactor: web-ai V1 assets → signal_v1/ (graduation prep)
|
||||||
|
|
||||||
|
Atomic mv of root V1 assets (main_server.py + modules/ + data/ +
|
||||||
|
tests/ + entry scripts + docs + logs) into signal_v1/ subdirectory.
|
||||||
|
load_dotenv() updated to load web-ai/.env explicitly via Path.
|
||||||
|
|
||||||
|
Adds web-ai/CLAUDE.md (workspace guide) and web-ai/start.bat
|
||||||
|
(signal_v1 entry wrapper). Prepares for signal_v2/ Phase 2.
|
||||||
|
|
||||||
|
Tests: signal_v1/tests/unit baseline preserved (no regression).
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
```bash
|
||||||
|
git log -1 --stat
|
||||||
|
```
|
||||||
|
Expected: 1 commit, 다수 rename + 2 신규 (CLAUDE.md / start.bat) + 1-3 modified (load_dotenv 위치).
|
||||||
|
|
||||||
|
## Reporting
|
||||||
|
|
||||||
|
When done, report:
|
||||||
|
- DONE: commit SHA, baseline test count (Step 3) + post-mv count (Step 11), 자동 grep 결과 (0 lines).
|
||||||
|
- DONE_WITH_CONCERNS: implementation 됐지만 hardcoded path / pre-existing test fail 등 발견 — 상세 보고.
|
||||||
|
- NEEDS_CONTEXT: load_dotenv 패턴이 spec 예상과 다름, 또는 conftest 추가 fix 필요 등.
|
||||||
|
- BLOCKED: working tree dirty / pytest baseline 자체 실패 / git mv 충돌.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: 사용자 수동 — 운영 검증 + push
|
||||||
|
|
||||||
|
**This task requires user action. Pause and request user to perform.**
|
||||||
|
|
||||||
|
- [ ] **Step 1: V1 자동매매 봇 정상 시작 검증**
|
||||||
|
|
||||||
|
사용자가 PowerShell 에서:
|
||||||
|
```powershell
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-ai
|
||||||
|
.\start.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
기대 출력 (수십 줄):
|
||||||
|
- `Config.validate()` 성공 (환경변수 누락 없음)
|
||||||
|
- KIS OAuth `access_token` 발급 (또는 cached token 로드)
|
||||||
|
- Telegram Bot started + `Conflict` 없음
|
||||||
|
- ProcessWatchdog 시작
|
||||||
|
- Uvicorn 0.0.0.0:8000 listening
|
||||||
|
- 봇 사이클 (장중이면) 또는 idle (장외)
|
||||||
|
|
||||||
|
만약 `FileNotFoundError: .env` 또는 KIS auth 실패 시 — load_dotenv 경로 오류. Task 1 으로 돌아가 Step 6 조정.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Telegram /status 명령 응답 검증**
|
||||||
|
|
||||||
|
사용자가 텔레그램에서 `/status` 명령. 봇이 정상 응답하면 IPC + SharedMemory + Telegram Bot 모두 정상.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 30분 관측**
|
||||||
|
|
||||||
|
콘솔 또는 telegram_bot.log 에 에러 없음 + Watchdog 30초 간격 health check PASS 확인.
|
||||||
|
|
||||||
|
만약 자식 프로세스 (Trading Bot / Telegram Bot) 가 자동 종료 → restart loop → 재실패 시 Task 1 으로 돌아가 진단.
|
||||||
|
|
||||||
|
- [ ] **Step 4: git push (사용자, Gitea 자격증명)**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-ai
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
만약 자격증명 실패 시 사용자가 수동으로 처리 (메모리 `feedback_nas_deploy_paths.md` 의 Gitea 자격증명 패턴).
|
||||||
|
|
||||||
|
- [ ] **Step 5: 결과 보고 (사용자 → 컨트롤러)**
|
||||||
|
|
||||||
|
- Step 1 (start.bat 시작): PASS / FAIL — 첫 에러 메시지 공유
|
||||||
|
- Step 2 (/status 응답): PASS / FAIL
|
||||||
|
- Step 3 (30분 관측): PASS (no errors) / FAIL — 관측된 에러
|
||||||
|
- Step 4 (push): PASS / FAIL
|
||||||
|
|
||||||
|
전부 PASS 시 Task 2 완료 → Phase 2 brainstorming 재개 (이미 6 결정 + 디자인 섹션 1-2 OK).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**1. Spec coverage:**
|
||||||
|
|
||||||
|
| Spec § | 요구사항 | Plan task |
|
||||||
|
|--------|----------|----------|
|
||||||
|
| §2 포함 (V1 자산 mv) | Task 1 Step 5 ✅ |
|
||||||
|
| §2 포함 (web-ai/CLAUDE.md 신규) | Task 1 Step 7 ✅ |
|
||||||
|
| §2 포함 (web-ai/start.bat 신규) | Task 1 Step 8 ✅ |
|
||||||
|
| §2 범위 외 (Python import 변경 없음) | Task 1 Step 10 의 grep 으로 검증 ✅ |
|
||||||
|
| §3.3 web-ai/CLAUDE.md 정확한 내용 | Task 1 Step 7 — 동일 markdown 본문 포함 ✅ |
|
||||||
|
| §3.3 web-ai/start.bat 정확한 내용 | Task 1 Step 8 — 동일 bat 본문 포함 ✅ |
|
||||||
|
| §3.4 load_dotenv 경로 갱신 | Task 1 Step 2 (grep) + Step 6 (갱신) ✅ |
|
||||||
|
| §4 작업 순서 (사전 검토 → mv → 검증 → push → 사용자 검증) | Task 1 Step 1-13 + Task 2 ✅ |
|
||||||
|
| §5 위험 (.env 로드 실패, 자동매매 중단 등) | Task 2 Step 1 의 first-start verification + load_dotenv 명시 ✅ |
|
||||||
|
| §6.1 자동 검증 (pytest + grep) | Task 1 Step 3 (baseline) + Step 11 (post-mv) + Step 10 (grep) ✅ |
|
||||||
|
| §6.2 수동 검증 (start.bat + /status + 30분 관측) | Task 2 Step 1-3 ✅ |
|
||||||
|
| §8 DoD 8 항목 | 전체 (Task 1 + Task 2 합) ✅ |
|
||||||
|
|
||||||
|
No gaps.
|
||||||
|
|
||||||
|
**2. Placeholder scan:** No "TBD" / "implement later". load_dotenv 갱신은 Step 2 grep 결과에 의존하지만, Step 6 에 정확한 갱신 패턴 (2 코드 예시) 포함 — placeholder 아님.
|
||||||
|
|
||||||
|
**3. Type consistency:** N/A (refactor only, 새 함수/타입 0). 모든 step 의 명령어와 파일 경로 일관 — `signal_v1/` 표기 + `web-ai/` 표기 통일.
|
||||||
|
|
||||||
|
Plan passes self-review.
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
# Lotto Curator Evolution — Design Spec
|
||||||
|
|
||||||
|
- 일자: 2026-05-11
|
||||||
|
- 범위: `web-ui` (브리핑 탭 재구성), `web-backend/lotto` (스키마·잡), `web-backend/agent-office` (큐레이터·텔레그램)
|
||||||
|
- 컨셉 한 줄: **매주 같은 시간에 큐레이터가 한 번 더 똑똑해진다**
|
||||||
|
|
||||||
|
## 1. 동기와 문제
|
||||||
|
|
||||||
|
현재 `/lotto`는 3탭(브리핑·분석·구매)으로 구성되어 정보가 풍부하지만, 사용자가 5천~1만원 어치를 즐기며 구매하기에 다음 페인이 있다.
|
||||||
|
|
||||||
|
- 분석·통계·브리핑이 모두 *결정용 화면*처럼 노출되어 정보 과다.
|
||||||
|
- 큐레이터가 매주 5세트를 추천하지만, 5세트의 *역할*과 *왜 이 분배인지*가 와닿지 않는다.
|
||||||
|
- 큐레이터·시스템에 시간축이 없다. 매주 동일 알고리즘을 새로 도는 느낌.
|
||||||
|
- 1만원어치 구매 시 5세트로는 부족하다. 추가 게임에 대한 설계가 없다.
|
||||||
|
|
||||||
|
## 2. 컨셉
|
||||||
|
|
||||||
|
다음 두 축으로 강화한다.
|
||||||
|
|
||||||
|
- **서사적 진화**: 큐레이터가 매주 *지난 주를 회고*하고 이번 주 전략으로 이어간다. 자기 추천 결과 + 사용자 실제 구매 결과를 둘 다 회고 데이터로 사용한다.
|
||||||
|
- **포트폴리오 명료성**: 5게임이 단순 5장이 아니라 안정/균형/공격 분배가 그 주 데이터에 따라 동적으로 바뀌고, 그 이유가 한 줄로 와닿는다. 5~20세트로 위계적으로 확장된다.
|
||||||
|
|
||||||
|
## 3. 주간 사이클
|
||||||
|
|
||||||
|
```
|
||||||
|
토 20:35 추첨
|
||||||
|
│
|
||||||
|
일 03:00 추첨결과 sync (기존)
|
||||||
|
↓
|
||||||
|
채점 잡 (신규) → weekly_review INSERT
|
||||||
|
lotto_purchase auto_graded UPDATE
|
||||||
|
│
|
||||||
|
월 09:00 큐레이션 트리거 (lotto_agent.on_schedule)
|
||||||
|
├─ build_retrospective(target_draw)
|
||||||
|
├─ collect_candidates(N=30)
|
||||||
|
├─ build_context (+retrospective)
|
||||||
|
├─ Claude 호출 (회고+계층 규칙)
|
||||||
|
└─ briefings INSERT (4계층 picks)
|
||||||
|
│
|
||||||
|
월 09:05 텔레그램 헤드라인 푸시
|
||||||
|
│
|
||||||
|
월~토 사용자: 사이트 결정 카드 → 모드 선택(5/10/15/20) → 1탭 구매 기록
|
||||||
|
│
|
||||||
|
토 20:35 추첨 → 다음 사이클
|
||||||
|
```
|
||||||
|
|
||||||
|
cron 시간(일 03:00 / 월 09:00)은 운영하며 조정 가능한 기본값.
|
||||||
|
|
||||||
|
## 4. 결정 카드 (브리핑 탭 메인)
|
||||||
|
|
||||||
|
브리핑 탭을 단일 `DecisionCard`로 재구성한다. 정보 위계는 위→아래로:
|
||||||
|
|
||||||
|
1. **헤더** — 회차 + 한 줄 헤드라인 + 신뢰도(0~100, 큐레이터 자기 평가)
|
||||||
|
2. **회고 박스** (▸ 보라색 라벨) — 지난 주 너 + 큐레이터 한 줄 회고. *시간축*의 핵심.
|
||||||
|
3. **헤드라인 + 3줄** — 이번 주 전망 + 근거 3줄(기존 narrative 유지).
|
||||||
|
4. **분배 칩** — 선택 모드까지의 안정/균형/공격 합산 + "왜 이 분배인지" 한 줄.
|
||||||
|
5. **모드 토글** — 4단계 칩(코어 5 / +보너스 5 / +확장 5 / +풀 5).
|
||||||
|
6. **계층 섹션 × 4** — 각 계층마다 타이틀 + 사유 한 줄 + 5장 PickCard. 코어는 항상 펼침, 그 외는 모드에 따라.
|
||||||
|
7. **하단 액션** — "이대로 N세트 구매했음" 한 클릭 → 자동 기록.
|
||||||
|
|
||||||
|
### 4계층 위계
|
||||||
|
|
||||||
|
| 계층 | 누적 게임 | 비용 | 큐레이터의 의도 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 코어(필수) | 5 | 5천 | 안정 2 / 균형 2 / 공격 1, 그 주 주축 |
|
||||||
|
| + 보너스 | 10 | 1만 | 코어 분배의 공백 보완 |
|
||||||
|
| + 확장 | 15 | 1.5만 | 코어·보너스에 없던 시각(합계 극단·콜드 누적·4주 미등장) |
|
||||||
|
| + 풀 | 20 | 2만 | 한 번도 누르지 않은 패턴(연속·동끝·5수 균등) |
|
||||||
|
|
||||||
|
각 5세트는 *큐레이터가 의도한 한 묶음*이며, 늘어날수록 *서사가 더해지는 구조*. 마지막 모드 선택은 브라우저 `localStorage` 에 `lotto.tier_mode` 키로 저장하여 다음 주 진입 시 디폴트로 사용한다(서버 저장 X — 사용자 디바이스 단위 기억).
|
||||||
|
|
||||||
|
### 분석 탭은 "Deep Dive" 자료실로 강등
|
||||||
|
|
||||||
|
- 라벨 변경: `📊 분석·통계` → `📚 자료실 / Deep Dive`
|
||||||
|
- 첫 진입 시 모든 패널 접힘
|
||||||
|
- 기존 패널 모두 보존 (CombinedRecommendPanel, ReportPanel, 시뮬레이션, 통계, 빈도, PersonalAnalysisPanel, 수동 추천, 히스토리)
|
||||||
|
- PerformanceBanner는 결정 카드 헤더와 역할 중복 없도록 자료실에만 둠
|
||||||
|
|
||||||
|
## 5. 데이터 모델
|
||||||
|
|
||||||
|
### 신규 테이블 — `weekly_review`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE weekly_review (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
draw_no INTEGER NOT NULL UNIQUE,
|
||||||
|
|
||||||
|
-- 큐레이터 자기 평가 (briefings.picks vs 추첨)
|
||||||
|
curator_avg_match REAL,
|
||||||
|
curator_best_tier TEXT, -- 안정 | 균형 | 공격
|
||||||
|
curator_best_match INTEGER,
|
||||||
|
curator_5plus_prizes INTEGER, -- 3개↑ 일치 카운트(5등 이상)
|
||||||
|
|
||||||
|
-- 사용자 구매 평가 (lotto_purchase vs 추첨)
|
||||||
|
user_avg_match REAL,
|
||||||
|
user_best_match INTEGER,
|
||||||
|
user_5plus_prizes INTEGER,
|
||||||
|
|
||||||
|
-- 패턴 갭 (서사 재료)
|
||||||
|
user_pattern_summary TEXT,
|
||||||
|
draw_pattern_summary TEXT,
|
||||||
|
pattern_delta TEXT, -- "너 저번호 편향 +1.2 / 합계 -18"
|
||||||
|
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `lotto_purchase` 컬럼 추가
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE lotto_purchase ADD COLUMN numbers TEXT; -- JSON [3,11,17,25,33,41]
|
||||||
|
ALTER TABLE lotto_purchase ADD COLUMN match_count INTEGER;
|
||||||
|
ALTER TABLE lotto_purchase ADD COLUMN auto_graded INTEGER DEFAULT 0;
|
||||||
|
ALTER TABLE lotto_purchase ADD COLUMN curator_tier TEXT; -- core | bonus | extended | pool
|
||||||
|
ALTER TABLE lotto_purchase ADD COLUMN curator_role TEXT; -- 안정 | 균형 | 공격
|
||||||
|
```
|
||||||
|
|
||||||
|
### `briefings.picks` 구조 변경
|
||||||
|
|
||||||
|
JSON 컬럼을 4계층 구조로 마이그레이션:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"core": [/* 5세트 */],
|
||||||
|
"bonus": [/* 5세트 */],
|
||||||
|
"extended": [/* 5세트 */],
|
||||||
|
"pool": [/* 5세트 */]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
기존 단일 배열 데이터는 `core` 키에만 매핑하고 나머지 키는 빈 배열로 채우는 1회 마이그레이션 스크립트.
|
||||||
|
|
||||||
|
## 6. 큐레이터 변경
|
||||||
|
|
||||||
|
### 출력 스키마 (`agent-office/curator/schema.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
class CuratorOutput(BaseModel):
|
||||||
|
core_picks: List[Pick] = Field(min_length=5, max_length=5)
|
||||||
|
bonus_picks: List[Pick] = Field(min_length=5, max_length=5)
|
||||||
|
extended_picks: List[Pick] = Field(min_length=5, max_length=5)
|
||||||
|
pool_picks: List[Pick] = Field(min_length=5, max_length=5)
|
||||||
|
tier_rationale: TierRationale # bonus / extended / pool 각 30자 이내
|
||||||
|
narrative: Narrative # retrospective(60자 이내) 필드 추가
|
||||||
|
confidence: int # 0~100
|
||||||
|
```
|
||||||
|
|
||||||
|
### SYSTEM_PROMPT 추가 규칙
|
||||||
|
|
||||||
|
```
|
||||||
|
회고 규칙:
|
||||||
|
- context.retrospective 가 있으면 narrative.retrospective 에 한 줄(60자 이내).
|
||||||
|
- 큐레이터 자기 결과(curator_avg, best_tier) + 사용자 결과(user_avg, pattern_delta) 둘 다 짚을 것.
|
||||||
|
- 이번 주 코어 분배는 회고에 근거해 조정. 사유는 narrative.headline 에 한 줄로.
|
||||||
|
|
||||||
|
계층별 큐레이션 규칙:
|
||||||
|
- core_picks (5): 안정 2 / 균형 2 / 공격 1. 그 주 주축.
|
||||||
|
- bonus_picks (5): 코어 분배의 공백을 메움. 코어와 상보적.
|
||||||
|
- extended_picks (5): 코어·보너스에 없는 시각(합계 극단 / 콜드 누적 / 4주 미등장).
|
||||||
|
- pool_picks (5): 이번 주 한 번도 누르지 않은 패턴(연속·동끝·5수 균등).
|
||||||
|
- tier_rationale 의 3개 키(bonus·extended·pool)에 각각 30자 이내 사유.
|
||||||
|
- 후보에 없는 번호 조합은 절대 사용 금지(기존 규칙 유지).
|
||||||
|
```
|
||||||
|
|
||||||
|
### 회고 컨텍스트 — `agent-office/curator/retrospective.py` (신규)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def build_retrospective(target_draw_no: int) -> dict | None:
|
||||||
|
last = lotto_get_review(target_draw_no - 1)
|
||||||
|
prev3 = lotto_get_reviews(target_draw_no - 4, target_draw_no - 2)
|
||||||
|
if not last:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"last_draw": {
|
||||||
|
"draw_no": last["draw_no"],
|
||||||
|
"curator_avg": last["curator_avg_match"],
|
||||||
|
"curator_best_tier": last["curator_best_tier"],
|
||||||
|
"user_avg": last["user_avg_match"],
|
||||||
|
"user_5plus": last["user_5plus_prizes"],
|
||||||
|
"pattern_delta": last["pattern_delta"],
|
||||||
|
},
|
||||||
|
"trend_4w": {
|
||||||
|
"curator_avg_4w": mean(curator_avg_match for r in [last, *prev3]),
|
||||||
|
"user_avg_4w": mean(user_avg_match for r in [last, *prev3] if user_avg_match is not None),
|
||||||
|
"user_persistent_bias": _detect_bias([last, *prev3]), # 3주↑ 유지된 패턴 편향(예: "저번호 편향")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 후보 풀 N=30
|
||||||
|
|
||||||
|
`collect_candidates(n=30)` — 20세트 선별 + 다양성 여유. 기존 4개 소스(simulation/heatmap/statistics/meta) 추출량을 비례 확대.
|
||||||
|
|
||||||
|
## 7. 자동 채점 잡 — `lotto/app/jobs/grade_weekly_review.py`
|
||||||
|
|
||||||
|
```
|
||||||
|
실행: 매주 일요일 03:00 KST (cron)
|
||||||
|
입력: 가장 최근 sync된 추첨 회차
|
||||||
|
처리:
|
||||||
|
1) briefings 에서 해당 회차의 4계층 picks 로드 (없으면 curator_* NULL)
|
||||||
|
2) lotto_purchase 에서 해당 회차의 사용자 구매 로드 (없으면 user_* NULL)
|
||||||
|
3) 각 세트별 일치 수 계산 → 큐레이터/사용자 집계
|
||||||
|
4) 패턴 요약(저번호·홀짝·합계 평균) → user/draw_pattern_summary
|
||||||
|
5) 패턴 갭 한 줄(가장 큰 격차 1~2개) → pattern_delta
|
||||||
|
6) weekly_review UPSERT (draw_no 유니크)
|
||||||
|
7) lotto_purchase 채점:
|
||||||
|
- 일치 3개 → prize=5000, auto_graded=1
|
||||||
|
- 일치 4개 → prize=NULL, note 에 "4등 가능성 — 동행복권 확인" 플래그
|
||||||
|
- 일치 5+ → prize=NULL, note 에 "🚨 큰 당첨 가능성 — 즉시 확인" 플래그
|
||||||
|
+ agent-office HTTP webhook(`POST /api/agent-office/notify/lotto-prize`)
|
||||||
|
호출하여 텔레그램 별도 알림 트리거
|
||||||
|
- numbers NULL 인 행은 스킵
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 텔레그램 알림 — `agent-office/notifiers/telegram_lotto.py` (신규)
|
||||||
|
|
||||||
|
큐레이션 성공 후 `lotto_agent` 가 호출. 발송 실패는 try/except 로 흡수(briefing 저장과 분리).
|
||||||
|
4등 이상 당첨 알림은 lotto-backend 채점 잡이 `POST /api/agent-office/notify/lotto-prize` webhook 으로 트리거(agent-office 측 라우터 신규 추가).
|
||||||
|
|
||||||
|
```
|
||||||
|
🎟 1154회 · 큐레이션 떴음
|
||||||
|
|
||||||
|
"이번 주는 안정 +1, 콜드 누적 보강."
|
||||||
|
신뢰도 72 · 분배 안정 3·균형 1·공격 1
|
||||||
|
|
||||||
|
▸ 회고: 너 2.0 / 나 1.8
|
||||||
|
너 저번호 편향 → 보너스 고번호 보강
|
||||||
|
|
||||||
|
👉 결정 카드 보러가기 (https://gahusb.synology.me/lotto)
|
||||||
|
```
|
||||||
|
|
||||||
|
회고 단락은 retrospective 가 있을 때만(첫 주 생략).
|
||||||
|
|
||||||
|
## 9. 프론트 변경
|
||||||
|
|
||||||
|
### 파일 변경 맵
|
||||||
|
|
||||||
|
| 파일 | 종류 | 내용 |
|
||||||
|
|------|------|------|
|
||||||
|
| `pages/lotto/Functions.jsx` | 수정 | 분석탭 라벨 변경 |
|
||||||
|
| `pages/lotto/tabs/BriefingTab.jsx` | 수정 | DecisionCard 단일로 재구성 |
|
||||||
|
| `pages/lotto/components/decision/DecisionCard.jsx` | 신규 | 결정 카드 메인 |
|
||||||
|
| `pages/lotto/components/decision/RetrospectiveBox.jsx` | 신규 | 회고 박스 |
|
||||||
|
| `pages/lotto/components/decision/TierModeToggle.jsx` | 신규 | 4단계 칩 토글 |
|
||||||
|
| `pages/lotto/components/decision/TierSection.jsx` | 신규 | 한 계층 영역(타이틀+사유+5장) |
|
||||||
|
| `pages/lotto/components/decision/PickCard.jsx` | 신규 | 한 세트 카드(역할+번호+사유) |
|
||||||
|
| `pages/lotto/components/decision/BulkPurchaseButton.jsx` | 신규 | 원클릭 구매 |
|
||||||
|
| `pages/lotto/components/briefing/*` | 삭제·이동 | DecisionCard 하위로 흡수, CuratorUsageFooter 는 자료실 이동 |
|
||||||
|
| `pages/lotto/components/PurchasePanel.jsx` | 수정 | auto_graded 표시 + 4등 이상 플래그 |
|
||||||
|
| `pages/lotto/components/PurchaseTrendChart.jsx` | 신규 | 4주 추세 라인(너 vs 큐레이터 평균 일치) |
|
||||||
|
| `pages/lotto/hooks/useBriefing.js` | 수정 | 4계층 + retrospective 수용 |
|
||||||
|
| `pages/lotto/hooks/useReview.js` | 신규 | weekly_review 로드 |
|
||||||
|
| `pages/lotto/hooks/usePurchases.js` | 수정 | bulkPurchase 추가 |
|
||||||
|
| `api.js` | 수정 | getLatestReview, getReviewHistory, bulkPurchase 헬퍼 |
|
||||||
|
|
||||||
|
### 컴포넌트 격리 원칙
|
||||||
|
|
||||||
|
- `DecisionCard` 는 `briefing` + `review` 두 객체만 props 로 받음(내부 hook 호출 X).
|
||||||
|
- `TierSection` 은 `tier`, `picks`, `rationale` 만 받아 4번 재사용.
|
||||||
|
- `BulkPurchaseButton` 은 `draw_no`, `tier_mode`, `sets`, `amount` 4개로 작동.
|
||||||
|
|
||||||
|
## 10. 백엔드 변경
|
||||||
|
|
||||||
|
### `web-backend/lotto/`
|
||||||
|
|
||||||
|
| 파일 | 종류 | 내용 |
|
||||||
|
|------|------|------|
|
||||||
|
| `app/db/migrations/00X_weekly_review.sql` | 신규 | 테이블 생성 |
|
||||||
|
| `app/db/migrations/00X_purchase_grading.sql` | 신규 | lotto_purchase 컬럼 추가 |
|
||||||
|
| `app/db/migrations/00X_briefings_tiers.sql` | 신규 | briefings.picks 4계층 마이그레이션 |
|
||||||
|
| `app/jobs/grade_weekly_review.py` | 신규 | 채점 잡 |
|
||||||
|
| `app/curator_helpers.py` | 수정 | collect_candidates(N=30) 기본값, build_context 에 retrospective 합치기 |
|
||||||
|
| `app/routers/briefing.py` | 수정 | BriefingRequest 4계층 + narrative.retrospective 수용 |
|
||||||
|
| `app/routers/review.py` | 신규 | GET /api/lotto/review/latest, GET /api/lotto/review/history?limit=N |
|
||||||
|
| `app/routers/purchase.py` | 수정 | POST /api/lotto/purchase/bulk |
|
||||||
|
| `app/cron.py` (또는 compose 스케줄러) | 수정 | 채점 잡 일 03:00 등록 |
|
||||||
|
|
||||||
|
### `web-backend/agent-office/`
|
||||||
|
|
||||||
|
| 파일 | 종류 | 내용 |
|
||||||
|
|------|------|------|
|
||||||
|
| `app/curator/retrospective.py` | 신규 | build_retrospective |
|
||||||
|
| `app/curator/schema.py` | 수정 | 4계층 + tier_rationale + narrative.retrospective |
|
||||||
|
| `app/curator/prompt.py` | 수정 | 회고·계층 규칙 추가 |
|
||||||
|
| `app/curator/pipeline.py` | 수정 | retrospective 빌드 호출, 4계층 직렬화 |
|
||||||
|
| `app/agents/lotto.py` | 수정 | on_schedule 월 09:00, 성공 시 텔레그램 호출 |
|
||||||
|
| `app/notifiers/telegram_lotto.py` | 신규 | 알림 포맷·발송(큐레이션 완료, 4등 이상 당첨 알림 둘 다) |
|
||||||
|
| `app/routers/notify.py` | 신규 | `POST /api/agent-office/notify/lotto-prize` — lotto-backend 채점 잡이 호출 |
|
||||||
|
| `app/service_proxy.py` | 수정 | review 헬퍼 추가 |
|
||||||
|
|
||||||
|
## 11. API 추가·변경
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/lotto/review/latest` | 최신 weekly_review 1건 |
|
||||||
|
| GET | `/api/lotto/review/history?limit=N` | 최근 N건 (4주 추세 차트용) |
|
||||||
|
| POST | `/api/lotto/purchase/bulk` | 결정 카드 원클릭 — body: `{ draw_no, tier_mode, sets, amount }` |
|
||||||
|
| POST | `/api/agent-office/notify/lotto-prize` | 4등 이상 당첨 시 lotto-backend 가 트리거 — body: `{ draw_no, match_count, numbers, purchase_id }` |
|
||||||
|
|
||||||
|
기존 엔드포인트는 그대로 유지(스키마 호환).
|
||||||
|
|
||||||
|
## 12. 에러 처리 / 격리
|
||||||
|
|
||||||
|
| 단계 | 실패 | 처리 |
|
||||||
|
|------|------|------|
|
||||||
|
| 추첨결과 sync | 동행복권 API down | 기존 정책(재시도). 채점 잡은 자동 지연만. |
|
||||||
|
| 채점 — 큐레이터 picks 없음 | 첫 주, 큐레이션 실패 회차 | curator_* NULL 로 INSERT |
|
||||||
|
| 채점 — 사용자 구매 없음 | 그 주 미구매 | user_* NULL |
|
||||||
|
| 채점 — numbers NULL 행 | 마이그레이션 이전 데이터 | 스킵, auto_graded=0 유지 |
|
||||||
|
| build_retrospective — review 없음 | 첫 주 | None 반환 → 프롬프트 분기 자연 처리 |
|
||||||
|
| Claude 스키마 실패 | 4계층 미준수 등 | 기존 1회 retry, 2회 실패 시 텔레그램 에러 알림 |
|
||||||
|
| 텔레그램 발송 실패 | 봇/네트워크 | try/except, 로그만. briefing 저장은 영향 없음 |
|
||||||
|
| bulk purchase — briefing 없음 | 큐레이션 실패 회차 | 400 + 토스트 |
|
||||||
|
| bulk purchase — 중복 호출 | 더블클릭 | (draw_no, tier_mode) 유니크 → idempotent |
|
||||||
|
| 자동채점 — 4등 이상 | 큰 당첨 | prize NULL + 메모 플래그 + 텔레그램 별도 알림 |
|
||||||
|
|
||||||
|
## 13. 테스트
|
||||||
|
|
||||||
|
### 백엔드 (`lotto/`)
|
||||||
|
|
||||||
|
- `grade_weekly_review`: (a) 정상 (b) user 구매 없음 (c) numbers NULL 스킵 (d) 일치 3개 → prize 5000 (e) 일치 4개 → 메모 플래그
|
||||||
|
- 마이그레이션: 빈 DB → 더미 → 잡 실행 → 행 정확
|
||||||
|
- briefings 마이그레이션: 구 단일 picks → core 매핑, 나머지 빈 배열
|
||||||
|
- `POST /purchase/bulk`: 정상 / 잘못된 tier_mode / briefing 없음 / 중복 호출
|
||||||
|
- `GET /review/latest`: 데이터 있음 / 빈 DB → 404
|
||||||
|
|
||||||
|
### 큐레이터 (`agent-office/curator/`)
|
||||||
|
|
||||||
|
- `build_retrospective`: review 1건 / 4건 / 0건
|
||||||
|
- `validate_response`: 정상 / 계층 누락 / 후보 외 번호 / tier_rationale 누락
|
||||||
|
- `curate_weekly` (Claude API mock): retrospective 있음·없음 / 1차 실패 → 2차 성공 / 2회 실패
|
||||||
|
- `telegram_lotto.format`: retrospective 있음·없음
|
||||||
|
|
||||||
|
### 프론트
|
||||||
|
|
||||||
|
- `DecisionCard` 수동: retrospective 있음·없음 / 모드 토글 5/10/15/20 / confidence 색
|
||||||
|
- `TierModeToggle` 단위: onChange 콜백 정확
|
||||||
|
- `BulkPurchaseButton` 수동 E2E: 클릭 → POST → 토스트 → 구매탭 갱신
|
||||||
|
- 자료실 탭 수동: 첫 진입 모두 접힘
|
||||||
|
- 모바일: DecisionCard 좁은 화면에서 깨짐 없음
|
||||||
|
|
||||||
|
## 14. 운영 점검 (배포 후 1주차)
|
||||||
|
|
||||||
|
수동으로 확인:
|
||||||
|
|
||||||
|
1. 일 03:00 채점 잡 1회 실행(`weekly_review` 1행 추가)
|
||||||
|
2. 월 09:00 큐레이션 실행(`briefings` 1행, 4계층 5×4=20개)
|
||||||
|
3. 텔레그램 알림 도착(회고 단락 정확 포함/생략)
|
||||||
|
4. 결정 카드 렌더링 정상(모바일 + PC)
|
||||||
|
5. 원클릭 구매 정확 N건 INSERT
|
||||||
|
6. cron 시간(03:00 / 09:00) 운영 패턴에 맞게 조정
|
||||||
|
|
||||||
|
## 15. Out of Scope
|
||||||
|
|
||||||
|
- 4등 이상 당첨금 자동 입력(회차별 변동, 사용자 PUT 으로 갱신)
|
||||||
|
- 큐레이터 호출 재무 비용 모니터링 강화(기존 `curator_usage` 그대로)
|
||||||
|
- 분석 탭 패널 자체의 리팩토링(라벨·디폴트 접힘만 변경)
|
||||||
|
- 1만원 외 임의 분량(7세트 등) 토글(4계층 5단위로 고정)
|
||||||
822
docs/superpowers/specs/2026-05-12-stock-screener-board-design.md
Normal file
822
docs/superpowers/specs/2026-05-12-stock-screener-board-design.md
Normal file
@@ -0,0 +1,822 @@
|
|||||||
|
# Stock Screener Board — 설계 문서 (MVP 슬라이스 1)
|
||||||
|
|
||||||
|
- **상태**: 설계 (Draft)
|
||||||
|
- **작성일**: 2026-05-12
|
||||||
|
- **대상 프로젝트**: `web-ui` (프론트엔드) + `web-backend/stock-lab` (백엔드) + `web-backend/agent-office` (스케줄러/텔레그램)
|
||||||
|
- **저자**: 개인 웹 플랫폼 CEO + Claude (brainstorming)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 배경 & 목표
|
||||||
|
|
||||||
|
현재 `/stock`은 뉴스·지수·공포탐욕, `/stock/trade`는 포트폴리오·매매·AI 코치까지 다룹니다. **시장 전체에서 강세주를 발굴하는 기능은 없습니다.**
|
||||||
|
|
||||||
|
이 작업은 KRX 전체 종목을 매일 분석해 강세주 후보를 점수화·순위화하고, 평일 장 마감 후 텔레그램으로 자동 전송하는 **노드 기반 분석 보드**를 만듭니다. 노드 인터페이스를 일관되게 정의해 후속 슬라이스에서 노드 캔버스 UI·AI 뉴스 노드·백테스트로 자연스럽게 확장 가능한 구조를 둡니다.
|
||||||
|
|
||||||
|
### 비전 (장기)
|
||||||
|
|
||||||
|
n8n 같은 노드 캔버스에서 시그널 노드를 연결·점수화하고, 결과를 표·텔레그램으로 받는 개인용 스크리닝/분석 워크벤치.
|
||||||
|
|
||||||
|
### 본 슬라이스 (MVP)
|
||||||
|
|
||||||
|
| 요소 | 범위 |
|
||||||
|
|------|------|
|
||||||
|
| 데이터 | pykrx로 매일 KRX 전종목 일봉 + 외국인/기관 수급 → SQLite 캐시 |
|
||||||
|
| 분석 노드 | 점수 7개 + 위생 게이트 1개 = 총 8개 |
|
||||||
|
| 결합 | 가중합 (게이트 통과군 내 백분위 정규화 기반) |
|
||||||
|
| 출력 | Top N(기본 20) 결과 표 + 진입가/손절/익절 + 텔레그램 |
|
||||||
|
| 실행 | 평일 16:30 KST 자동 + 사용자 수동 미리보기 |
|
||||||
|
| UI | `/stock/screener` 별도 페이지, 좌(설정)-중(표)-우(히스토리) |
|
||||||
|
| 자동 잡 | `agent-office`가 트리거, 텔레그램 전송 책임 |
|
||||||
|
|
||||||
|
### 비목표 (후속 슬라이스에 명시 예약)
|
||||||
|
|
||||||
|
1. AI 뉴스 호재/악재 노드
|
||||||
|
2. 노드 캔버스 UI (react-flow)
|
||||||
|
3. 주간 자가학습 (가중치 자동 조정 제안)
|
||||||
|
4. DART 공시·재무제표 노드
|
||||||
|
5. 분봉 기반 노드 (한투 API)
|
||||||
|
6. 진짜 미너비니 VCP (베이스 카운트·피벗 포인트)
|
||||||
|
7. 멀티 프리셋 ("공격형"/"안정형")
|
||||||
|
8. 백테스트 화면
|
||||||
|
9. KRX 호가단위 적용
|
||||||
|
10. 메트릭/대시보드 (Prometheus 등)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 전체 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
[agent-office 평일 16:30 KST] [사용자: Stock 스크리너 페이지]
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
POST /api/stock/screener/snapshot/refresh POST /api/stock/screener/run
|
||||||
|
POST /api/stock/screener/run {mode:"auto"} {mode:"preview"|"manual_save"}
|
||||||
|
│ │
|
||||||
|
└──────────► Screener.run() ◄──────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
ScreenContext.load(asof)
|
||||||
|
(KRX 마스터·일봉·수급 SQLite 캐시)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
HygieneGate.filter() ← Survivors ~500-800종
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[ScoreNode.compute() × 7 활성 노드]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
combine + rank Top N
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
position_sizer (entry/stop/target)
|
||||||
|
│
|
||||||
|
┌─────────────┴───────────────┐
|
||||||
|
▼ ▼
|
||||||
|
screener_runs + screener_results 응답 JSON (results, telegram_payload)
|
||||||
|
(mode='auto'·'manual_save') │
|
||||||
|
▼
|
||||||
|
agent-office가 telegram_payload 전송
|
||||||
|
(mode='auto')
|
||||||
|
```
|
||||||
|
|
||||||
|
데이터 신선도 가정: pykrx의 외국인/기관 수급은 KRX 마감 후 30-60분 뒤 갱신. **16:30 KST 트리거는 안전 마진**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 백엔드 컴포넌트 구조 (stock-lab)
|
||||||
|
|
||||||
|
### 3.1 디렉토리
|
||||||
|
|
||||||
|
```
|
||||||
|
web-backend/stock-lab/app/
|
||||||
|
├─ main.py # router.include_router(screener_router) 1줄 추가
|
||||||
|
├─ db.py
|
||||||
|
├─ price_fetcher.py
|
||||||
|
├─ scraper.py
|
||||||
|
├─ ai_summarizer.py
|
||||||
|
├─ holidays.json
|
||||||
|
├─ test_*.py # 기존
|
||||||
|
├─ test_screener_*.py # 신규 (각 노드/엔진/라우터)
|
||||||
|
└─ screener/ # ← NEW
|
||||||
|
├─ __init__.py
|
||||||
|
├─ router.py # FastAPI: /api/stock/screener/*
|
||||||
|
├─ schemas.py # Pydantic 요청/응답
|
||||||
|
├─ engine.py # Screener / ScreenContext / ScreenerResult / combine()
|
||||||
|
├─ snapshot.py # pykrx 일봉·수급 갱신
|
||||||
|
├─ position_sizer.py # ATR 기반 진입/손절/익절
|
||||||
|
├─ registry.py # NODE_REGISTRY, GATE_REGISTRY
|
||||||
|
├─ telegram.py # agent-office payload 빌더 (전송 책임은 agent-office)
|
||||||
|
├─ _test_fixtures.py # 합성 ScreenContext 헬퍼
|
||||||
|
└─ nodes/
|
||||||
|
├─ __init__.py
|
||||||
|
├─ base.py # ScoreNode, GateNode 추상
|
||||||
|
├─ hygiene.py
|
||||||
|
├─ foreign_buy.py
|
||||||
|
├─ volume_surge.py
|
||||||
|
├─ momentum.py
|
||||||
|
├─ high52w.py
|
||||||
|
├─ rs_rating.py
|
||||||
|
├─ ma_alignment.py
|
||||||
|
└─ vcp_lite.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 핵심 추상
|
||||||
|
|
||||||
|
```python
|
||||||
|
# nodes/base.py
|
||||||
|
class ScoreNode(ABC):
|
||||||
|
name: ClassVar[str] # "foreign_buy"
|
||||||
|
label: ClassVar[str] # "외국인 누적 순매수"
|
||||||
|
default_params: ClassVar[dict]
|
||||||
|
param_schema: ClassVar[dict] # 프론트 폼 자동 생성용 JSON Schema
|
||||||
|
@abstractmethod
|
||||||
|
def compute(self, ctx: "ScreenContext", params: dict) -> "pd.Series":
|
||||||
|
"""index=ticker, dtype=float, range 0..100."""
|
||||||
|
|
||||||
|
class GateNode(ABC):
|
||||||
|
name: ClassVar[str]
|
||||||
|
label: ClassVar[str]
|
||||||
|
default_params: ClassVar[dict]
|
||||||
|
param_schema: ClassVar[dict]
|
||||||
|
@abstractmethod
|
||||||
|
def filter(self, ctx: "ScreenContext", params: dict) -> "pd.Index":
|
||||||
|
"""returns surviving tickers."""
|
||||||
|
|
||||||
|
# engine.py
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ScreenContext:
|
||||||
|
prices: pd.DataFrame # long form: date·ticker·open·high·low·close·volume·value
|
||||||
|
flow: pd.DataFrame # date·ticker·foreign_net·institution_net
|
||||||
|
master: pd.DataFrame # ticker·name·market·market_cap·is_managed·listed_date·is_preferred·is_spac
|
||||||
|
kospi: pd.Series # date → close (시장 비교용)
|
||||||
|
asof: datetime.date
|
||||||
|
@classmethod
|
||||||
|
def load(cls, asof: datetime.date) -> "ScreenContext": ...
|
||||||
|
def restrict(self, tickers) -> "ScreenContext": ...
|
||||||
|
|
||||||
|
class Screener:
|
||||||
|
def __init__(self, gate: GateNode, score_nodes: list[ScoreNode], weights: dict[str, float],
|
||||||
|
node_params: dict[str, dict], gate_params: dict, top_n: int,
|
||||||
|
sizer_params: dict):
|
||||||
|
...
|
||||||
|
def run(self, ctx: ScreenContext) -> "ScreenerResult":
|
||||||
|
survivors = self.gate.filter(ctx, self.gate_params)
|
||||||
|
scoped = ctx.restrict(survivors)
|
||||||
|
active = [n for n in self.score_nodes if self.weights.get(n.name, 0) > 0]
|
||||||
|
scores = {n.name: n.compute(scoped, self.node_params.get(n.name, {})) for n in active}
|
||||||
|
total = combine(scores, self.weights)
|
||||||
|
ranked = total.sort_values(ascending=False).head(self.top_n)
|
||||||
|
rows = position_sizer.expand(ranked, scoped, self.sizer_params)
|
||||||
|
return ScreenerResult(rows=rows, scores=scores, weights=self.weights,
|
||||||
|
survivors_count=len(survivors), warnings=[...])
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 registry
|
||||||
|
|
||||||
|
```python
|
||||||
|
# registry.py
|
||||||
|
from .nodes import (foreign_buy, volume_surge, momentum, high52w,
|
||||||
|
rs_rating, ma_alignment, vcp_lite, hygiene)
|
||||||
|
|
||||||
|
NODE_REGISTRY: dict[str, type[ScoreNode]] = {
|
||||||
|
"foreign_buy": foreign_buy.ForeignBuy,
|
||||||
|
"volume_surge": volume_surge.VolumeSurge,
|
||||||
|
"momentum": momentum.Momentum20,
|
||||||
|
"high52w": high52w.High52WProximity,
|
||||||
|
"rs_rating": rs_rating.RsRating,
|
||||||
|
"ma_alignment": ma_alignment.MaAlignment,
|
||||||
|
"vcp_lite": vcp_lite.VcpLite,
|
||||||
|
}
|
||||||
|
GATE_REGISTRY: dict[str, type[GateNode]] = {
|
||||||
|
"hygiene": hygiene.HygieneGate,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 데이터 모델 (stock.db 신규 7테이블)
|
||||||
|
|
||||||
|
### 4.1 KRX 캐시 (3테이블)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS krx_master (
|
||||||
|
ticker TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
market TEXT NOT NULL, -- 'KOSPI'|'KOSDAQ'
|
||||||
|
market_cap INTEGER, -- 원, nullable (pykrx 누락 케이스)
|
||||||
|
is_managed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_preferred INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_spac INTEGER NOT NULL DEFAULT 0,
|
||||||
|
listed_date TEXT, -- 'YYYY-MM-DD'
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS krx_daily_prices (
|
||||||
|
ticker TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
open INTEGER, high INTEGER, low INTEGER, close INTEGER,
|
||||||
|
volume INTEGER,
|
||||||
|
value INTEGER, -- 거래대금(원)
|
||||||
|
PRIMARY KEY (ticker, date)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_prices_date ON krx_daily_prices(date);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS krx_flow (
|
||||||
|
ticker TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
foreign_net INTEGER, -- 원
|
||||||
|
institution_net INTEGER,
|
||||||
|
PRIMARY KEY (ticker, date)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flow_date ON krx_flow(date);
|
||||||
|
```
|
||||||
|
|
||||||
|
**용량**: KRX 2,700종목 × 252거래일 × 5년 ≈ 340만 행. SQLite 충분 (수십 MB).
|
||||||
|
**갱신**: 마스터는 매일 전체 재기록, 일봉·수급은 당일 행 upsert.
|
||||||
|
|
||||||
|
**초기 백필 (최초 배포 시 1회)**: 백분위 정규화·52주 신고가·RS Rating(1년 수익률)·MA200 계산을 위해 **최소 1년(252거래일), 권장 2년**의 일봉·수급을 시드 데이터로 백필. `snapshot.py`에 `backfill(start_date, end_date)` 함수를 두고 첫 배포·이전 캐시 손실 시 수동 호출. 자동 잡은 일일 증분만.
|
||||||
|
|
||||||
|
### 4.2 사용자 설정 (싱글톤 1테이블)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS screener_settings (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
weights_json TEXT NOT NULL, -- {"foreign_buy":1.0, ...}
|
||||||
|
node_params_json TEXT NOT NULL, -- {"foreign_buy":{"window_days":5}, ...}
|
||||||
|
gate_params_json TEXT NOT NULL, -- {"min_market_cap_won":50_000_000_000, ...}
|
||||||
|
top_n INTEGER NOT NULL DEFAULT 20,
|
||||||
|
rr_ratio REAL NOT NULL DEFAULT 2.0,
|
||||||
|
atr_window INTEGER NOT NULL DEFAULT 14,
|
||||||
|
atr_stop_mult REAL NOT NULL DEFAULT 2.0,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
`ensure_schema()` 시 초기 row 삽입 (디폴트 가중치 §6 참조).
|
||||||
|
|
||||||
|
### 4.3 실행 스냅샷 (2테이블)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS screener_runs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
asof TEXT NOT NULL,
|
||||||
|
mode TEXT NOT NULL, -- 'auto' | 'manual_save'
|
||||||
|
status TEXT NOT NULL, -- 'success' | 'failed' | 'skipped_holiday'
|
||||||
|
error TEXT,
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
finished_at TEXT,
|
||||||
|
weights_json TEXT NOT NULL,
|
||||||
|
node_params_json TEXT NOT NULL,
|
||||||
|
gate_params_json TEXT NOT NULL,
|
||||||
|
top_n INTEGER NOT NULL,
|
||||||
|
survivors_count INTEGER,
|
||||||
|
telegram_sent INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_runs_asof ON screener_runs(asof DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS screener_results (
|
||||||
|
run_id INTEGER NOT NULL,
|
||||||
|
rank INTEGER NOT NULL,
|
||||||
|
ticker TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
total_score REAL NOT NULL,
|
||||||
|
scores_json TEXT NOT NULL,
|
||||||
|
close INTEGER,
|
||||||
|
market_cap INTEGER,
|
||||||
|
entry_price INTEGER,
|
||||||
|
stop_price INTEGER,
|
||||||
|
target_price INTEGER,
|
||||||
|
atr14 REAL,
|
||||||
|
PRIMARY KEY (run_id, ticker),
|
||||||
|
FOREIGN KEY (run_id) REFERENCES screener_runs(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_results_run_rank ON screener_results(run_id, rank);
|
||||||
|
```
|
||||||
|
|
||||||
|
**`mode='preview'`는 저장하지 않습니다.** `auto`·`manual_save`만 행을 만듭니다.
|
||||||
|
보관 기간 정책 없음 (디스크 부담 미미). 후속에서 cleanup 잡 필요시 추가.
|
||||||
|
|
||||||
|
### 4.4 마이그레이션 방식
|
||||||
|
|
||||||
|
stock-lab의 기존 `db.py` 패턴(`CREATE TABLE IF NOT EXISTS`)을 그대로 따릅니다. `screener/snapshot.py`·`screener/engine.py` import 시점에 1회 `ensure_screener_schema()` 호출. 별도 alembic 도입은 본 작업 스코프 밖.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 노드 8개 알고리즘
|
||||||
|
|
||||||
|
모든 점수 노드는 0~100 정수로 정규화. 표준 정규화는 **게이트 통과군 내 백분위(percentile)**, 룰 기반이 더 자연스러운 노드(이평선·52주 근접도)는 룰을 사용.
|
||||||
|
|
||||||
|
### 5.1 위생 게이트 — `HygieneGate` (점수 ❌)
|
||||||
|
|
||||||
|
```text
|
||||||
|
params:
|
||||||
|
min_market_cap_won = 50_000_000_000 # 500억 이상
|
||||||
|
min_avg_value_won = 500_000_000 # 20일 평균 거래대금 5억 이상
|
||||||
|
min_listed_days = 60 # 신규 상장 60일 미만 제외
|
||||||
|
skip_managed = true
|
||||||
|
skip_preferred = true
|
||||||
|
skip_spac = true
|
||||||
|
skip_halted_days = 3 # 최근 3일 거래정지(close 또는 volume=0)
|
||||||
|
통과 조건: 위 AND market_cap NOT NULL AND close NOT NULL
|
||||||
|
출력: 통과 종목 Index (보통 500~800종)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 외국인 누적 순매수 — `ForeignBuy`
|
||||||
|
|
||||||
|
```text
|
||||||
|
params: window_days = 5
|
||||||
|
raw = sum(foreign_net[-5:]) / market_cap # 시총 대비 비율
|
||||||
|
score = percentile_rank(raw, 통과군) × 100
|
||||||
|
debug: foreign_net_sum, market_cap, raw_ratio_pct
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 거래량 급증 — `VolumeSurge`
|
||||||
|
|
||||||
|
```text
|
||||||
|
params: baseline_days = 20, eval_days = 3
|
||||||
|
baseline = mean(volume[-23:-3])
|
||||||
|
recent = mean(volume[-3:])
|
||||||
|
raw = log1p(recent / baseline) # 극값 평탄화
|
||||||
|
score = percentile_rank(raw, 통과군) × 100
|
||||||
|
debug: baseline, recent, ratio
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 20일 모멘텀 — `Momentum20`
|
||||||
|
|
||||||
|
```text
|
||||||
|
params: window_days = 20
|
||||||
|
raw = close[today] / close[today - 20] - 1
|
||||||
|
score = percentile_rank(raw, 통과군) × 100
|
||||||
|
debug: return_20d_pct
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 52주 신고가 근접도 — `High52WProximity` (룰 기반)
|
||||||
|
|
||||||
|
```text
|
||||||
|
params: window_days = 252
|
||||||
|
high_52w = max(high[-252:])
|
||||||
|
proximity = close / high_52w # 0..1
|
||||||
|
score = clip((proximity - 0.7) / 0.3, 0, 1) × 100
|
||||||
|
# 70% 미만 = 0, 100% 도달 = 100, 선형
|
||||||
|
debug: high_52w, proximity_pct
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.6 RS Rating — `RsRating`
|
||||||
|
|
||||||
|
```text
|
||||||
|
params: weights = {3m:2, 6m:1, 9m:1, 12m:1} # IBD 표준 가중
|
||||||
|
for k in [63, 126, 189, 252] 거래일:
|
||||||
|
r_stock = close[t]/close[t-k] - 1
|
||||||
|
r_kospi = kospi[t]/kospi[t-k] - 1
|
||||||
|
excess_k = r_stock - r_kospi
|
||||||
|
raw = Σ w_k × excess_k
|
||||||
|
score = percentile_rank(raw, 통과군) × 100 # IBD RS Rating 정의
|
||||||
|
debug: excess_1y, excess_3m, raw
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.7 이평선 정배열 — `MaAlignment` (룰 기반)
|
||||||
|
|
||||||
|
```text
|
||||||
|
params: ma_periods = [50, 150, 200]
|
||||||
|
5개 조건의 만족 개수 / 5 × 100:
|
||||||
|
① close > MA50
|
||||||
|
② MA50 > MA150
|
||||||
|
③ MA150 > MA200
|
||||||
|
④ close > MA200
|
||||||
|
⑤ close ≥ min(close[-252:]) × 1.25 # Stage 2 진입
|
||||||
|
debug: 각 조건 boolean
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.8 VCP-lite (변동성 수축률) — `VcpLite`
|
||||||
|
|
||||||
|
```text
|
||||||
|
params: short_window = 40, long_window = 252 # 8주 / 52주
|
||||||
|
daily_range_pct = (high - low) / close
|
||||||
|
short_vol = mean(daily_range_pct[-40:])
|
||||||
|
long_vol = mean(daily_range_pct[-252:])
|
||||||
|
raw = 1 - (short_vol / long_vol) # 양수면 수축
|
||||||
|
score = percentile_rank(raw, 통과군) × 100
|
||||||
|
debug: short_vol, long_vol, contraction_ratio
|
||||||
|
주: 진짜 미너비니 VCP(베이스 카운트·피벗 포인트)는 후속 슬라이스
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.9 결합 (`engine.combine`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
total = Σ(w[n] * scores[n]) / Σ(w[n]) # active 노드만
|
||||||
|
# 가중치 0 → 노드 실행 스킵. 모든 가중치 0이면 422 에러.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.10 디폴트 가중치
|
||||||
|
|
||||||
|
| 노드 | w | 근거 |
|
||||||
|
|------|----|------|
|
||||||
|
| foreign_buy | 1.0 | 한국 시장 강한 시그널 |
|
||||||
|
| volume_surge | 1.0 | 표준 |
|
||||||
|
| momentum | 1.0 | 표준 |
|
||||||
|
| high52w | **1.2** | 미너비니 SEPA 핵심 |
|
||||||
|
| rs_rating | **1.2** | 미너비니 + IBD 핵심 |
|
||||||
|
| ma_alignment | 1.0 | Stage 2 확인용 |
|
||||||
|
| vcp_lite | 0.8 | 단순 버전이라 보수적 가중 |
|
||||||
|
|
||||||
|
### 5.11 포지션 사이징 — `position_sizer.py`
|
||||||
|
|
||||||
|
```text
|
||||||
|
params (settings):
|
||||||
|
atr_window = 14
|
||||||
|
atr_stop_mult = 2.0 # 2 × ATR 손절
|
||||||
|
rr_ratio = 2.0 # 익절 = 진입가 + 2R
|
||||||
|
|
||||||
|
atr14 = ATR_Wilder(high, low, close, 14) # Wilder's smoothing (RMA), Pandas .ewm(alpha=1/14)
|
||||||
|
entry = round_won(close × 1.005) # 다음날 시초 0.5% 위
|
||||||
|
stop = round_won(close - 2.0 × atr14)
|
||||||
|
target = round_won(entry + 2.0 × (entry - stop))
|
||||||
|
r_pct = (entry - stop) / entry × 100 # 손실 위험 %
|
||||||
|
|
||||||
|
# round_won(x) = int(round(x)) — 1원 단위 반올림 (Python builtin)
|
||||||
|
```
|
||||||
|
|
||||||
|
ATR은 **Wilder's smoothing** (RMA). 일반 SMA보다 트레이딩 표준. MVP는 1원 단위 라운딩. KRX 호가단위(1·5·10·50·100·500·1000원)는 후속.
|
||||||
|
|
||||||
|
### 5.12 정규화 시 주의점
|
||||||
|
|
||||||
|
- 게이트 통과군이 100종목 미만이면 백분위 의미 ↓. 응답 `warnings`에 경고.
|
||||||
|
- 데이터 부족(상장 60일 미만 등)으로 NaN 발생 시 자동 0점 처리 (게이트가 이미 걸러줄 것).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. API 명세 (prefix `/api/stock/screener/*`)
|
||||||
|
|
||||||
|
### 6.1 엔드포인트 표
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 호출 주체 | 책임 |
|
||||||
|
|--------|------|----------|------|
|
||||||
|
| GET | `/nodes` | 프론트 | 노드 메타데이터 (label, default_params, param_schema) |
|
||||||
|
| GET | `/settings` | 프론트 | 현재 설정 조회 |
|
||||||
|
| PUT | `/settings` | 프론트 | 설정 업서트 (id=1 싱글톤) |
|
||||||
|
| POST | `/run` | 프론트 · agent-office | 분석 1회 실행. mode 매트릭스로 분기 |
|
||||||
|
| POST | `/snapshot/refresh` | agent-office | KRX 캐시 강제 갱신 |
|
||||||
|
| GET | `/runs?limit=30` | 프론트 | 최근 실행 메타 리스트 |
|
||||||
|
| GET | `/runs/{id}` | 프론트 | 특정 실행 결과 전체 |
|
||||||
|
|
||||||
|
### 6.2 `/run` 시맨틱
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// REQUEST
|
||||||
|
POST /api/stock/screener/run
|
||||||
|
{
|
||||||
|
"mode": "preview" | "manual_save" | "auto",
|
||||||
|
"asof": "2026-05-12", // 생략 시 직전 거래일
|
||||||
|
"weights": { ... }, // optional override
|
||||||
|
"node_params": { ... }, // optional override
|
||||||
|
"gate_params": { ... }, // optional override
|
||||||
|
"top_n": 20 // optional override
|
||||||
|
}
|
||||||
|
|
||||||
|
// RESPONSE
|
||||||
|
{
|
||||||
|
"asof": "2026-05-12",
|
||||||
|
"mode": "preview",
|
||||||
|
"status": "success",
|
||||||
|
"run_id": null, // manual_save·auto만
|
||||||
|
"survivors_count": 612,
|
||||||
|
"weights": { ... }, // 실제 사용된 값
|
||||||
|
"top_n": 20,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"rank": 1,
|
||||||
|
"ticker": "005930",
|
||||||
|
"name": "삼성전자",
|
||||||
|
"total_score": 84.3,
|
||||||
|
"scores": {
|
||||||
|
"foreign_buy": 92, "volume_surge": 78, "momentum": 73,
|
||||||
|
"high52w": 88, "rs_rating": 95, "ma_alignment": 80, "vcp_lite": 70
|
||||||
|
},
|
||||||
|
"close": 74500,
|
||||||
|
"market_cap": 444800000000000,
|
||||||
|
"entry_price": 74872,
|
||||||
|
"stop_price": 71200,
|
||||||
|
"target_price": 82216,
|
||||||
|
"atr14": 1835.5,
|
||||||
|
"r_pct": 4.9
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"telegram_payload": null, // auto · manual_save만
|
||||||
|
"warnings": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 mode 매트릭스
|
||||||
|
|
||||||
|
| mode | settings_override | DB 저장 | telegram_payload 반환 | telegram 실전송 |
|
||||||
|
|------|------------------|---------|----------------------|----------------|
|
||||||
|
| `preview` | 허용 (DB 미반영) | ❌ | ✅ (미리보기 표시용) | ❌ |
|
||||||
|
| `manual_save` | 허용 (DB 미반영) | ✅ | ✅ | ❌ |
|
||||||
|
| `auto` | 무시 (DB settings만) | ✅ | ✅ | ✅ (호출자=agent-office) |
|
||||||
|
|
||||||
|
`telegram_payload`는 `status='success'`일 때 항상 빌드해 반환 (페이로드 1회 생성 비용 매우 작음). **실전송은 mode='auto' 시 호출자(agent-office) 책임**. `status='failed'`·`'skipped_holiday'`이면 `null`.
|
||||||
|
|
||||||
|
### 6.4 `asof` 처리
|
||||||
|
|
||||||
|
- 요청에 `asof` 없으면: stock-lab이 `holidays.json` 참조해 **직전 거래일**로 자동 설정
|
||||||
|
- 요청한 `asof`가 공휴일·주말이거나 캐시에 없으면: 503 + message "no snapshot for {asof}"
|
||||||
|
- `agent-office` 자동 잡이 공휴일에 호출하는 경우 stock-lab은 status='skipped_holiday'로 success 응답 (텔레그램 전송 안 함)
|
||||||
|
|
||||||
|
### 6.5 에러 응답
|
||||||
|
|
||||||
|
응답 body의 `status` 필드와 HTTP status 코드의 매핑:
|
||||||
|
|
||||||
|
| HTTP | body.status | 발생 |
|
||||||
|
|------|-------------|------|
|
||||||
|
| 200 | `success` | 정상 분석 완료 |
|
||||||
|
| 200 | `skipped_holiday` | 공휴일·주말 asof로 자동 잡이 호출됨 |
|
||||||
|
| 422 | `failed` | 가중치 합 0, 게이트 통과 0, 잘못된 asof 형식 |
|
||||||
|
| 503 | `failed` | 캐시 미존재 (snapshot 미실행) |
|
||||||
|
| 500 | `failed` | 예기치 못한 예외 (응답 body는 일반 메시지) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 프론트엔드 구조 (web-ui)
|
||||||
|
|
||||||
|
### 7.1 라우팅 & 내비게이션
|
||||||
|
|
||||||
|
- `src/routes.jsx`: `/stock/screener` 등록, 라벨 "스크리너"
|
||||||
|
- `src/Router.jsx`: 라우트 추가
|
||||||
|
- Stock·StockTrade 페이지 상단에 "스크리너" 링크
|
||||||
|
- 홈(`/`) 허브 카드에 항목 추가
|
||||||
|
|
||||||
|
### 7.2 디렉토리
|
||||||
|
|
||||||
|
```
|
||||||
|
src/pages/stock/screener/
|
||||||
|
├─ Screener.jsx # 페이지 루트
|
||||||
|
├─ Screener.css
|
||||||
|
├─ components/
|
||||||
|
│ ├─ NodePanel.jsx # 점수 노드 7개 카드
|
||||||
|
│ ├─ NodeCard.jsx # param_schema 기반 자동 폼
|
||||||
|
│ ├─ GatePanel.jsx # 위생 게이트 1개
|
||||||
|
│ ├─ GlobalControls.jsx # Top N, ATR, RR, "지금 실행", "스냅샷 저장"
|
||||||
|
│ ├─ ResultTable.jsx
|
||||||
|
│ ├─ ScoreChips.jsx # 각 노드 점수 칩
|
||||||
|
│ ├─ RunHistoryList.jsx
|
||||||
|
│ └─ TelegramPreview.jsx
|
||||||
|
└─ hooks/
|
||||||
|
├─ useScreenerMeta.js
|
||||||
|
├─ useScreenerSettings.js
|
||||||
|
├─ useScreenerRun.js
|
||||||
|
└─ useScreenerHistory.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 `src/api.js` 신규 헬퍼
|
||||||
|
|
||||||
|
```js
|
||||||
|
export const getScreenerNodes = () => apiGet ('/api/stock/screener/nodes');
|
||||||
|
export const getScreenerSettings = () => apiGet ('/api/stock/screener/settings');
|
||||||
|
export const saveScreenerSettings = (body) => apiPut ('/api/stock/screener/settings', body);
|
||||||
|
export const runScreener = (body) => apiPost('/api/stock/screener/run', body);
|
||||||
|
export const refreshScreenerSnap = () => apiPost('/api/stock/screener/snapshot/refresh');
|
||||||
|
export const listScreenerRuns = (limit = 30) => apiGet (`/api/stock/screener/runs?limit=${limit}`);
|
||||||
|
export const getScreenerRun = (id) => apiGet (`/api/stock/screener/runs/${id}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 레이아웃
|
||||||
|
|
||||||
|
```
|
||||||
|
PC (≥1024px)
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 헤더 — 분석 기준일 · 직전 자동 잡 시각 · "스냅샷 저장" │
|
||||||
|
├──────────────┬──────────────────────────────┬───────────────┤
|
||||||
|
│ NodePanel │ ResultTable │ RunHistoryList │
|
||||||
|
│ + GlobalControls │ TelegramPreview │ │
|
||||||
|
│ [지금 실행] │ │ │
|
||||||
|
└──────────────┴──────────────────────────────┴───────────────┘
|
||||||
|
|
||||||
|
모바일 (<768px) — 세로 적층
|
||||||
|
[헤더] → [NodePanel 접기] → [GlobalControls+실행] → [ResultTable 가로 스크롤]
|
||||||
|
→ [TelegramPreview 접기] → [RunHistoryList]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.5 상태 관리 패턴
|
||||||
|
|
||||||
|
- `useScreenerMeta`: 마운트 시 1회, 정적
|
||||||
|
- `useScreenerSettings`: GET → 사용자 슬라이더 조작 시 로컬 dirty state. **명시적 "설정 저장" 버튼**에서만 PUT
|
||||||
|
- "지금 실행" → `runScreener({mode:'preview', ...override})`. **DB는 건드리지 않음**
|
||||||
|
- "스냅샷 저장" → 같은 override를 `mode:'manual_save'`로 재호출
|
||||||
|
- 히스토리 클릭 → `getScreenerRun(id)`로 결과 표 교체
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 텔레그램 메시지 포맷
|
||||||
|
|
||||||
|
자동 잡과 manual_save 모두 동일. **Top 20 중 본문 1-10**까지 표시, 11-20은 페이지 링크. MarkdownV2.
|
||||||
|
|
||||||
|
```
|
||||||
|
🎯 *KRX 강세주 스크리너* — 2026-05-12 (자동)
|
||||||
|
통과 612종 / Top 20 / 본문 1-10
|
||||||
|
|
||||||
|
1. *삼성전자* `005930` ⭐ 84.3
|
||||||
|
👤외 ⚡거 🚀모 🆙고 💪RS 📈MA
|
||||||
|
진입 74,872 손절 71,200 익절 82,216 (R 4.9%)
|
||||||
|
|
||||||
|
2. *NAVER* `035420` ⭐ 81.7
|
||||||
|
👤외 ⚡거 🆙고 💪RS 📈MA
|
||||||
|
진입 215,400 손절 207,800 익절 230,600 (R 3.5%)
|
||||||
|
|
||||||
|
⋯ (3-10)
|
||||||
|
|
||||||
|
🔗 전체 결과·11~20위:
|
||||||
|
https://gahusb.synology.me/stock/screener?run_id=42
|
||||||
|
```
|
||||||
|
|
||||||
|
### 노드 아이콘 (점수 ≥70인 노드만 표시)
|
||||||
|
|
||||||
|
| 노드 | 아이콘 |
|
||||||
|
|------|--------|
|
||||||
|
| foreign_buy | 👤외 |
|
||||||
|
| volume_surge | ⚡거 |
|
||||||
|
| momentum | 🚀모 |
|
||||||
|
| high52w | 🆙고 |
|
||||||
|
| rs_rating | 💪RS |
|
||||||
|
| ma_alignment | 📈MA |
|
||||||
|
| vcp_lite | 🌀VCP |
|
||||||
|
|
||||||
|
빌더(`screener/telegram.py`)는 payload만 반환:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"chat_target": "default",
|
||||||
|
"parse_mode": "MarkdownV2",
|
||||||
|
"text": "..." // 위 메시지
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
agent-office가 받아서 자체 텔레그램 채널로 발신. stock-lab은 텔레그램 SDK 의존성 없음.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. agent-office 통합
|
||||||
|
|
||||||
|
agent-office 측에 새 잡(또는 stock_agent 액션) 추가:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Trigger: 평일 16:30 KST (Asia/Seoul)
|
||||||
|
Steps:
|
||||||
|
1. POST /api/stock/screener/snapshot/refresh
|
||||||
|
실패해도 다음 단계 진행 (이전 캐시로 분석)
|
||||||
|
2. POST /api/stock/screener/run { "mode": "auto" }
|
||||||
|
3. 응답에서 status 확인:
|
||||||
|
- status == 'skipped_holiday': 종료, 텔레그램 미발신
|
||||||
|
- status == 'success': telegram_payload 추출 → 발신
|
||||||
|
- status == 'failed': agent-office 자체 알림(기존 패턴)으로 운영자에게
|
||||||
|
4. 텔레그램 발신은 agent-office의 기존 채널 사용
|
||||||
|
```
|
||||||
|
|
||||||
|
**공휴일 판정은 stock-lab 책임** (`holidays.json`이 stock-lab에 있으므로). agent-office는 매 평일 16:30에 호출하고 응답 status로 분기. agent-office에 공휴일 데이터를 복제할 필요 없음.
|
||||||
|
|
||||||
|
stock-lab은 agent-office의 인증을 신뢰 (내부 Docker 네트워크). MVP에서 헤더 토큰 검증 없음. 후속에서 필요해지면 시크릿 헤더 추가.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 에러 처리
|
||||||
|
|
||||||
|
| 발생 지점 | 정책 |
|
||||||
|
|----------|------|
|
||||||
|
| pykrx 종목 단위 실패 | retry ×3 → 실패해도 다음 종목 계속. 전체 실패율 >20%면 snapshot 작업 자체 실패 |
|
||||||
|
| 캐시 미존재 (`asof` 데이터 없음) | 503 + message "snapshot not available for {asof}" |
|
||||||
|
| 노드 1개 compute 실패 | 해당 노드 점수 0 처리, 다른 노드 정상. 응답 `warnings`에 사유 |
|
||||||
|
| 게이트 통과 종목 0 | 422 + message "no survivors after hygiene gate" |
|
||||||
|
| 모든 가중치 0 | 422 + message "no active score nodes" |
|
||||||
|
| 텔레그램 전송 실패 | `/run` 응답 status는 success. agent-office 측 로그·재시도 |
|
||||||
|
| 예기치 못한 예외 | 500. 스택트레이스는 stock-lab stdout 로그에만. 응답은 일반 메시지 |
|
||||||
|
|
||||||
|
`/run`의 `warnings` 필드는 치명적이지 않은 이상을 모음. 프론트는 결과 표 위에 노란 배너로 노출.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 테스트 전략
|
||||||
|
|
||||||
|
stock-lab의 평탄 pytest 컨벤션을 따름. `app/test_screener_*.py`로 통합.
|
||||||
|
|
||||||
|
### 11.1 단위 테스트 (노드별)
|
||||||
|
|
||||||
|
```
|
||||||
|
app/test_screener_nodes_foreign_buy.py
|
||||||
|
app/test_screener_nodes_volume_surge.py
|
||||||
|
app/test_screener_nodes_momentum.py
|
||||||
|
app/test_screener_nodes_high52w.py
|
||||||
|
app/test_screener_nodes_rs_rating.py
|
||||||
|
app/test_screener_nodes_ma_alignment.py
|
||||||
|
app/test_screener_nodes_vcp_lite.py
|
||||||
|
app/test_screener_nodes_hygiene.py
|
||||||
|
app/test_screener_position_sizer.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**공통 케이스**:
|
||||||
|
|
||||||
|
1. 알려진 입력 → 알려진 출력 (회귀 방지)
|
||||||
|
2. 데이터 부족(상장 30일짜리) → 게이트 탈락 또는 NaN 안전
|
||||||
|
3. 모든 종목 동일 값 → 백분위 정규화가 50점으로 평탄화
|
||||||
|
4. 극값 1개 → 다른 종목 점수가 무너지지 않음 (특히 volume_surge의 log1p)
|
||||||
|
|
||||||
|
### 11.2 통합 테스트
|
||||||
|
|
||||||
|
```
|
||||||
|
app/test_screener_engine.py # combine, Screener.run, ScreenContext.restrict
|
||||||
|
app/test_screener_router.py # /run mode 매트릭스, /settings round-trip, /nodes, /runs
|
||||||
|
app/test_screener_telegram.py # 메시지 텍스트 생성
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.3 픽스쳐
|
||||||
|
|
||||||
|
`app/screener/_test_fixtures.py`:
|
||||||
|
- 5종목 × 60거래일 합성 DataFrame 빌더
|
||||||
|
- 시나리오: "강세주 1종", "위생 게이트 탈락 1종(시총 부족)", "데이터 부족 1종", "약세주 2종"
|
||||||
|
- `StubScreenContext`: DB 거치지 않고 메모리 DataFrame 주입
|
||||||
|
|
||||||
|
### 11.4 수동 검증 (verification-before-completion)
|
||||||
|
|
||||||
|
- 실 KRX 데이터로 1회 돌려 Top 20이 합리적인 강세주 후보인지 사용자가 눈으로 확인
|
||||||
|
- 자동 잡 1회 실행 후 텔레그램에 메시지 도착 확인
|
||||||
|
- 모바일 화면에서 결과 표 가로 스크롤 OK 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 운영
|
||||||
|
|
||||||
|
- 로그: stock-lab stdout (Docker logs)
|
||||||
|
- 알림: agent-office가 `/run` failed 응답을 받으면 텔레그램 자체 알림
|
||||||
|
- 백업: stock.db는 NAS Synology 자체 백업 정책에 의존
|
||||||
|
- 메트릭 대시보드: MVP 범위 밖 (후속 슬라이스)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 양쪽 동시 수정 체크리스트 (workspace CLAUDE.md 규약)
|
||||||
|
|
||||||
|
- [ ] 백엔드: `web-backend/stock-lab/app/screener/` 패키지 신규
|
||||||
|
- [ ] 백엔드: `app/main.py`에 router include
|
||||||
|
- [ ] 백엔드: stock.db에 신규 테이블 7개 `ensure_*_schema()` 함수
|
||||||
|
- [ ] 백엔드: `requirements.txt`에 `pykrx` 추가
|
||||||
|
- [ ] 프론트: `src/api.js`에 7개 헬퍼 추가
|
||||||
|
- [ ] 프론트: `src/routes.jsx` + `src/Router.jsx`에 `/stock/screener` 등록
|
||||||
|
- [ ] 프론트: `src/pages/stock/screener/` 디렉토리 신규
|
||||||
|
- [ ] 프론트: `web-ui/CLAUDE.md` API 테이블에 7개 엔드포인트 추가
|
||||||
|
- [ ] agent-office: 평일 16:30 KST `stock_agent screener` 잡 추가
|
||||||
|
- [ ] 배포: `scripts/deploy.bat` 또는 개별
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 후속 슬라이스 예약
|
||||||
|
|
||||||
|
| # | 슬라이스 | 의존 |
|
||||||
|
|---|---------|------|
|
||||||
|
| 2 | AI 뉴스 호재/악재 노드 | agent-office LLM 사용량 설계 |
|
||||||
|
| 3 | 노드 캔버스 UI (react-flow) | MVP 노드 인터페이스 안정화 후 |
|
||||||
|
| 4 | 주간 자가학습 (가중치 자동 조정 제안) | screener_runs 누적 4주 이상 |
|
||||||
|
| 5 | DART 공시·재무제표 노드 | DART 수집 파이프라인 별도 spec |
|
||||||
|
| 6 | 분봉 기반 노드 | 한투 API 분봉 캐싱 |
|
||||||
|
| 7 | 진짜 미너비니 VCP | 베이스 카운트·피벗 포인트 정의 |
|
||||||
|
| 8 | 멀티 프리셋 | settings 테이블 확장 |
|
||||||
|
| 9 | 백테스트 화면 | screener_runs + krx_daily_prices join |
|
||||||
|
| 10 | KRX 호가단위 적용 | 포지션 사이저 후처리 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록 A — 노드 메타데이터 응답 예시 (`GET /nodes`)
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"score_nodes": [
|
||||||
|
{
|
||||||
|
"name": "foreign_buy",
|
||||||
|
"label": "외국인 누적 순매수",
|
||||||
|
"default_params": { "window_days": 5 },
|
||||||
|
"param_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"window_days": { "type": "integer", "minimum": 1, "maximum": 60, "default": 5 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// … 7개
|
||||||
|
],
|
||||||
|
"gate_nodes": [
|
||||||
|
{
|
||||||
|
"name": "hygiene",
|
||||||
|
"label": "위생 게이트",
|
||||||
|
"default_params": {
|
||||||
|
"min_market_cap_won": 50000000000,
|
||||||
|
"min_avg_value_won": 500000000,
|
||||||
|
"min_listed_days": 60,
|
||||||
|
"skip_managed": true,
|
||||||
|
"skip_preferred": true,
|
||||||
|
"skip_spac": true,
|
||||||
|
"skip_halted_days": 3
|
||||||
|
},
|
||||||
|
"param_schema": { ... }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
이 응답으로 프론트는 `NodeCard`를 자동 생성합니다. 새 노드 추가 시 백엔드 클래스 1개 + registry 등록 1줄만으로 UI에 자동 노출.
|
||||||
@@ -0,0 +1,558 @@
|
|||||||
|
# AI News Sentiment Node — Design
|
||||||
|
|
||||||
|
**작성일**: 2026-05-13
|
||||||
|
**작성자**: gahusb
|
||||||
|
**상태**: Approved for implementation
|
||||||
|
**선행 spec**: `2026-05-12-stock-screener-board-design.md` (§14 — AI 뉴스 호재/악재 노드 후속 슬라이스)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 목표
|
||||||
|
|
||||||
|
스크리너의 8번째 점수 노드 `AiNewsSentiment` 를 추가한다. 평일 **08:00 KST** 에 시총 상위 100종목의 네이버 종목 뉴스를 스크래핑하고 Claude Haiku로 호재/악재를 정량화하여, 그날의 sentiment를 (a) 텔레그램으로 호재/악재 Top 5 알림으로 전달하고, (b) 16:30 스크리너 자동 잡의 가중합에 percentile_rank 형태로 기여한다.
|
||||||
|
|
||||||
|
**Why**: 기존 7개 점수 노드는 모두 수치 기반(가격/거래량/외국인 수급)이며, 시장 정서(뉴스 호재/악재)는 반영되지 않는다. 트레이더 의사결정에 큰 영향을 주는 호재/악재 시그널을 LLM으로 정량화하면 정량 노드와 정성 노드를 한 점수 체계로 통합할 수 있다. 장 시작 전 알림으로 즉시 가치 전달.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 범위
|
||||||
|
|
||||||
|
**포함 (이번 슬라이스)**:
|
||||||
|
- 평일 08:00 KST agent-office cron → stock-lab `/snapshot/refresh-news-sentiment` 호출
|
||||||
|
- 시총 상위 100종목 × 네이버 종목 뉴스 (`/item/news_news.naver?code=XXX`) 스크래핑
|
||||||
|
- 종목당 Claude Haiku 1콜 (총 100콜 asyncio.gather 병렬, 동시성 10)
|
||||||
|
- `news_sentiment(ticker, date, score_raw, reason, news_count, tokens_input, tokens_output, model, created_at)` 테이블
|
||||||
|
- 8번째 ScoreNode `AiNewsSentiment` 등록 (registry, schema, ScreenContext, 가중합 통합)
|
||||||
|
- 호재 Top 5 + 악재 Top 5 텔레그램 메시지 (장 시작 전 발송)
|
||||||
|
- 프론트 캔버스 모드에 8번째 노드 추가 (`SCORE_KEYS` 한 줄 + `INITIAL_NODE_POSITIONS` 좌표 한 줄)
|
||||||
|
|
||||||
|
**범위 외 (NOT)**:
|
||||||
|
- 뉴스 URL 단위 캐싱 (비용이 충분히 낮음)
|
||||||
|
- 16:00 추가 cron (MVP 일 1회)
|
||||||
|
- 시장 전체 뉴스 종목 매핑 LLM (시총 상위 100 명시적 매핑)
|
||||||
|
- 백테스트 (sentiment 점수가 실수익에 미친 영향) — 별도 후속 슬라이스
|
||||||
|
- 가중치 자동 조정 — spec §14 별도 슬라이스
|
||||||
|
- 종목별 sentiment 트렌드 차트 — 데이터 누적 후 후속 슬라이스
|
||||||
|
- 종목 5-10위 외 sentiment 가시화 — Top 5 알림 외 별도 대시보드 없음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 아키텍처 개요
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
[08:00 KST 평일] │ agent-office cron │
|
||||||
|
│ on_ai_news_schedule() │
|
||||||
|
└──────────────┬──────────────┘
|
||||||
|
│ HTTP POST
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ stock-lab: /api/stock/screener/snapshot/ │
|
||||||
|
│ refresh-news-sentiment │
|
||||||
|
│ │
|
||||||
|
│ ai_news/pipeline.refresh_daily(asof): │
|
||||||
|
│ 1. krx_master 시총 상위 100 ticker 조회 │
|
||||||
|
│ 2. asyncio.gather(Semaphore=10) 100 종목 병렬: │
|
||||||
|
│ a. scraper.fetch_news(ticker) │
|
||||||
|
│ b. analyzer.score_sentiment(ticker, news[]) │
|
||||||
|
│ c. → {score: float, reason: str, ...} │
|
||||||
|
│ 3. news_sentiment 일괄 upsert │
|
||||||
|
│ 4. Top 5 호재/악재 추출 → 텔레그램 페이로드 빌드 │
|
||||||
|
│ 5. agent-office /telegram/send 호출 │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[16:30 KST 평일] ┌─────────────────────────────┐
|
||||||
|
│ agent-office on_screener_ │
|
||||||
|
│ schedule (변경 없음) │
|
||||||
|
└──────────────┬──────────────┘
|
||||||
|
│ HTTP POST
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ stock-lab: /api/stock/screener/run mode=auto │
|
||||||
|
│ │
|
||||||
|
│ Screener.run(ctx): │
|
||||||
|
│ ctx.news_sentiment = SELECT * FROM news_sentiment │
|
||||||
|
│ WHERE date = asof │
|
||||||
|
│ AiNewsSentiment.compute(ctx, params) │
|
||||||
|
│ → percentile_rank(score_raw) for 100 tickers │
|
||||||
|
│ → 가중합에 ai_news weight × percentile 점수 기여 │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**의존성 추가**: `anthropic` Python SDK (stock-lab requirements.txt). `ANTHROPIC_API_KEY` 는 docker-compose.yml에 이미 stock-lab 환경변수로 존재.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 컴포넌트 분해 (신규 파일)
|
||||||
|
|
||||||
|
### 4.1 stock-lab
|
||||||
|
|
||||||
|
```
|
||||||
|
web-backend/stock-lab/app/
|
||||||
|
screener/
|
||||||
|
ai_news/ ← 신규 모듈
|
||||||
|
__init__.py
|
||||||
|
scraper.py ← 네이버 finance 종목 뉴스 스크래핑
|
||||||
|
analyzer.py ← Claude Haiku 호재/악재 분석
|
||||||
|
pipeline.py ← refresh_daily() 메인 (스크래핑+병렬 LLM+DB upsert)
|
||||||
|
telegram.py ← Top 5/Top 5 메시지 빌더
|
||||||
|
nodes/
|
||||||
|
ai_news.py ← 8번째 ScoreNode 클래스
|
||||||
|
schema.py ← (수정) news_sentiment 테이블 DDL 추가
|
||||||
|
registry.py ← (수정) NODE_REGISTRY["ai_news"] 등록
|
||||||
|
engine.py ← (수정) ScreenContext에 news_sentiment 로딩
|
||||||
|
router.py ← (수정) POST /snapshot/refresh-news-sentiment 라우트 추가
|
||||||
|
requirements.txt ← (수정) anthropic 추가
|
||||||
|
tests/
|
||||||
|
test_ai_news_scraper.py ← 네이버 HTML mock 파싱
|
||||||
|
test_ai_news_analyzer.py ← anthropic mock 응답
|
||||||
|
test_ai_news_pipeline.py ← 5종목 mini integration
|
||||||
|
test_ai_news_node.py ← percentile_rank + min_news_count 필터
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 agent-office
|
||||||
|
|
||||||
|
```
|
||||||
|
web-backend/agent-office/app/
|
||||||
|
agents/stock.py ← (수정) on_ai_news_schedule 메서드 추가
|
||||||
|
scheduler.py ← (수정) cron mon-fri 08:00 등록
|
||||||
|
service_proxy.py ← (수정) refresh_ai_news_sentiment() helper 추가
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 frontend
|
||||||
|
|
||||||
|
```
|
||||||
|
web-ui/src/pages/stock/screener/
|
||||||
|
components/canvas/constants/
|
||||||
|
canvasLayout.js ← (수정) AI 노드 추가 (NODE_IDS / NAME_MAP / LABEL / POSITIONS / SCORE_KEYS)
|
||||||
|
canvasLayout.test.js ← (수정) 카운트 8 점수 노드, 18 엣지로 갱신
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. DB 스키마 (1개 신규 테이블)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS news_sentiment (
|
||||||
|
ticker TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL, -- YYYY-MM-DD
|
||||||
|
score_raw REAL NOT NULL, -- LLM 원점수 -10 ~ +10
|
||||||
|
reason TEXT NOT NULL DEFAULT '', -- LLM 한 줄 근거
|
||||||
|
news_count INTEGER NOT NULL DEFAULT 0, -- 분석에 사용된 뉴스 수
|
||||||
|
tokens_input INTEGER NOT NULL DEFAULT 0, -- 비용 모니터링
|
||||||
|
tokens_output INTEGER NOT NULL DEFAULT 0,
|
||||||
|
model TEXT NOT NULL DEFAULT 'claude-haiku-4-5-20251001',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||||
|
PRIMARY KEY (ticker, date)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_news_sentiment_date ON news_sentiment(date DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
`schema.py` 의 `ensure_screener_schema(conn)` 함수에 이 DDL 추가. WAL + busy_timeout 패턴은 stock-lab `_conn()` 표준화로 이미 적용됨.
|
||||||
|
|
||||||
|
**기본 가중치 시드**: `DEFAULT_WEIGHTS["ai_news"] = 0.5` 추가 (다른 7노드의 default와 동일). 기존 settings 행이 있는 환경에서는 마이그레이션 1회 — `ensure_screener_schema()` 가 settings의 weights_json에 ai_news 키 누락 시 0.5로 보충하는 1회성 patch 적용.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. ScoreNode 구현
|
||||||
|
|
||||||
|
```python
|
||||||
|
# stock-lab/app/screener/nodes/ai_news.py
|
||||||
|
import pandas as pd
|
||||||
|
from .base import ScoreNode, percentile_rank
|
||||||
|
|
||||||
|
class AiNewsSentiment(ScoreNode):
|
||||||
|
name = "ai_news"
|
||||||
|
label = "AI 뉴스 호재/악재"
|
||||||
|
default_params = {"min_news_count": 1}
|
||||||
|
param_schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"min_news_count": {
|
||||||
|
"type": "integer", "default": 1, "minimum": 0,
|
||||||
|
"description": "최소 분석 뉴스 수. 미만이면 NaN.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def compute(self, ctx, params):
|
||||||
|
df = getattr(ctx, "news_sentiment", None)
|
||||||
|
if df is None or df.empty:
|
||||||
|
return pd.Series(dtype=float)
|
||||||
|
df = df[df["news_count"] >= params["min_news_count"]]
|
||||||
|
if df.empty:
|
||||||
|
return pd.Series(dtype=float)
|
||||||
|
return percentile_rank(df.set_index("ticker")["score_raw"])
|
||||||
|
```
|
||||||
|
|
||||||
|
`ScreenContext` dataclass에 `news_sentiment: Optional[pd.DataFrame] = None` 필드 추가 (default None 으로 기존 호출자 호환성 유지). `ScreenContext.load(conn, asof)` 에 로딩 한 줄 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
news_sentiment = pd.read_sql_query(
|
||||||
|
"SELECT ticker, score_raw, news_count FROM news_sentiment WHERE date = ?",
|
||||||
|
conn, params=(asof.isoformat(),),
|
||||||
|
)
|
||||||
|
return ScreenContext(..., news_sentiment=news_sentiment)
|
||||||
|
```
|
||||||
|
|
||||||
|
기존 테스트 fixture에서 `ScreenContext(...)` 를 직접 생성하는 케이스는 default=None 으로 자동 호환. AiNewsSentiment.compute() 는 `getattr(ctx, "news_sentiment", None)` 로 안전 fallback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 파이프라인 (`ai_news/pipeline.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def refresh_daily(conn, asof, *, tickers=None, model=DEFAULT_MODEL,
|
||||||
|
concurrency=10, news_per_ticker=5):
|
||||||
|
"""
|
||||||
|
Returns:
|
||||||
|
{"asof": ..., "updated": N, "failures": [...], "duration_sec": ...,
|
||||||
|
"tokens_input": ..., "tokens_output": ..., "top_pos": [...], "top_neg": [...]}
|
||||||
|
"""
|
||||||
|
if tickers is None:
|
||||||
|
tickers = _top_market_cap_tickers(conn, n=100)
|
||||||
|
|
||||||
|
sem = asyncio.Semaphore(concurrency)
|
||||||
|
async with httpx.AsyncClient(...) as http_client, AsyncAnthropic(...) as llm:
|
||||||
|
tasks = [_process_ticker(t, sem, http_client, llm, news_per_ticker, model)
|
||||||
|
for t in tickers]
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
successes = [r for r in results if isinstance(r, dict)]
|
||||||
|
failures = [r for r in results if isinstance(r, BaseException)]
|
||||||
|
|
||||||
|
_upsert_news_sentiment(conn, asof, successes)
|
||||||
|
|
||||||
|
top_pos = sorted(successes, key=lambda r: -r["score_raw"])[:5]
|
||||||
|
top_neg = sorted(successes, key=lambda r: r["score_raw"])[:5]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"asof": asof.isoformat(),
|
||||||
|
"updated": len(successes),
|
||||||
|
"failures": [str(e) for e in failures],
|
||||||
|
"duration_sec": ...,
|
||||||
|
"tokens_input": sum(r["tokens_input"] for r in successes),
|
||||||
|
"tokens_output": sum(r["tokens_output"] for r in successes),
|
||||||
|
"top_pos": top_pos,
|
||||||
|
"top_neg": top_neg,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _process_ticker(ticker, sem, http_client, llm, news_per_ticker, model):
|
||||||
|
async with sem:
|
||||||
|
await asyncio.sleep(0.2) # rate limit
|
||||||
|
news = await scraper.fetch_news(http_client, ticker, n=news_per_ticker)
|
||||||
|
if not news:
|
||||||
|
return {"ticker": ticker, "score_raw": 0.0,
|
||||||
|
"reason": "no news", "news_count": 0,
|
||||||
|
"tokens_input": 0, "tokens_output": 0}
|
||||||
|
return await analyzer.score_sentiment(llm, ticker, news, model=model)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Scraper (`ai_news/scraper.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
NAVER_NEWS_URL = "https://finance.naver.com/item/news_news.naver"
|
||||||
|
|
||||||
|
async def fetch_news(client, ticker, n=5):
|
||||||
|
r = await client.get(NAVER_NEWS_URL, params={"code": ticker, "page": 1})
|
||||||
|
if r.status_code != 200:
|
||||||
|
return []
|
||||||
|
soup = BeautifulSoup(r.text, "lxml")
|
||||||
|
rows = soup.select("table.type5 tbody tr")[:n]
|
||||||
|
out = []
|
||||||
|
for row in rows:
|
||||||
|
title_el = row.select_one("td.title a")
|
||||||
|
date_el = row.select_one("td.date")
|
||||||
|
if not title_el or not date_el:
|
||||||
|
continue
|
||||||
|
out.append({
|
||||||
|
"title": title_el.get_text(strip=True),
|
||||||
|
"date": date_el.get_text(strip=True),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
```
|
||||||
|
|
||||||
|
Rate limit: pipeline 의 `Semaphore(10) + 0.2초 sleep` 으로 보호.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Analyzer (`ai_news/analyzer.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
DEFAULT_MODEL = os.getenv("AI_NEWS_MODEL", "claude-haiku-4-5-20251001")
|
||||||
|
|
||||||
|
PROMPT_TEMPLATE = """다음은 종목 {name}({ticker})에 대한 최근 뉴스 {n}개의 헤드라인입니다.
|
||||||
|
|
||||||
|
{news_block}
|
||||||
|
|
||||||
|
이 뉴스들이 종목에 호재인지 악재인지 평가하세요.
|
||||||
|
score: -10(매우 강한 악재) ~ +10(매우 강한 호재) 사이의 실수. 0은 중립.
|
||||||
|
reason: 30자 이내 한 줄 근거.
|
||||||
|
|
||||||
|
JSON으로만 응답하세요:
|
||||||
|
{{"score": <float>, "reason": "<string>"}}"""
|
||||||
|
|
||||||
|
async def score_sentiment(llm, ticker, news, *, model=DEFAULT_MODEL, name=None):
|
||||||
|
news_block = "\n".join(f"- {n['title']}" for n in news)
|
||||||
|
prompt = PROMPT_TEMPLATE.format(
|
||||||
|
name=name or ticker, ticker=ticker,
|
||||||
|
n=len(news), news_block=news_block,
|
||||||
|
)
|
||||||
|
resp = await llm.messages.create(
|
||||||
|
model=model, max_tokens=200,
|
||||||
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
text = resp.content[0].text
|
||||||
|
data = json.loads(text)
|
||||||
|
return {
|
||||||
|
"ticker": ticker,
|
||||||
|
"score_raw": float(data["score"]),
|
||||||
|
"reason": str(data["reason"])[:200],
|
||||||
|
"news_count": len(news),
|
||||||
|
"tokens_input": resp.usage.input_tokens,
|
||||||
|
"tokens_output": resp.usage.output_tokens,
|
||||||
|
}
|
||||||
|
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
||||||
|
log.warning("ai_news parse fail for %s: %s", ticker, e)
|
||||||
|
return {
|
||||||
|
"ticker": ticker, "score_raw": 0.0,
|
||||||
|
"reason": f"parse fail: {e!s}",
|
||||||
|
"news_count": len(news),
|
||||||
|
"tokens_input": resp.usage.input_tokens,
|
||||||
|
"tokens_output": resp.usage.output_tokens,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 텔레그램 메시지 (`ai_news/telegram.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def build_telegram_payload(*, asof, top_pos, top_neg,
|
||||||
|
tokens_input, tokens_output, model):
|
||||||
|
cost_won = int(tokens_input * 0.0013 + tokens_output * 0.0065) # ₩ 환산
|
||||||
|
lines = [
|
||||||
|
f"🌅 *AI 뉴스 분석* ({asof} 08:00)",
|
||||||
|
"",
|
||||||
|
"📈 *호재 Top 5*",
|
||||||
|
]
|
||||||
|
for i, r in enumerate(top_pos, 1):
|
||||||
|
lines.append(
|
||||||
|
f"{i}\\. {_escape(r['ticker'])} \\({r['score_raw']:+.1f}\\) — "
|
||||||
|
f"{_escape(r['reason'])}"
|
||||||
|
)
|
||||||
|
lines += ["", "📉 *악재 Top 5*"]
|
||||||
|
for i, r in enumerate(top_neg, 1):
|
||||||
|
lines.append(
|
||||||
|
f"{i}\\. {_escape(r['ticker'])} \\({r['score_raw']:+.1f}\\) — "
|
||||||
|
f"{_escape(r['reason'])}"
|
||||||
|
)
|
||||||
|
lines += [
|
||||||
|
"",
|
||||||
|
f"_분석: 시총 상위 100종목 · 토큰 {tokens_input:,} in / {tokens_output:,} out · "
|
||||||
|
f"약 ₩{cost_won:,}_",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
```
|
||||||
|
|
||||||
|
agent-office 가 텔레그램 발송 책임: stock-lab `/refresh-news-sentiment` 응답을 받아 `messaging.send_raw(text, parse_mode="MarkdownV2")` 호출.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. agent-office 통합
|
||||||
|
|
||||||
|
### 11.1 `agents/stock.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def on_ai_news_schedule(self):
|
||||||
|
"""평일 08:00 KST cron."""
|
||||||
|
try:
|
||||||
|
result = await service_proxy.refresh_ai_news_sentiment()
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
await self.telegram.send_raw(f"⚠️ AI 뉴스 분석 실패: {e!s}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if result.get("updated", 0) == 0:
|
||||||
|
await self.telegram.send_raw("⚠️ AI 뉴스: 0종목 분석됨 (스크래핑/LLM 전체 실패)")
|
||||||
|
return
|
||||||
|
|
||||||
|
failure_rate = len(result.get("failures", [])) / 100
|
||||||
|
if failure_rate > 0.3:
|
||||||
|
await self.telegram.send_raw(
|
||||||
|
f"⚠️ AI 뉴스 실패율 {failure_rate:.0%} — 어제 데이터 사용 가능성"
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = build_telegram_payload(
|
||||||
|
asof=result["asof"],
|
||||||
|
top_pos=result["top_pos"], top_neg=result["top_neg"],
|
||||||
|
tokens_input=result["tokens_input"],
|
||||||
|
tokens_output=result["tokens_output"],
|
||||||
|
model=DEFAULT_MODEL,
|
||||||
|
)
|
||||||
|
await self.telegram.send_raw(payload, parse_mode="MarkdownV2")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 `scheduler.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
scheduler.add_job(
|
||||||
|
stock_agent.on_ai_news_schedule,
|
||||||
|
"cron", day_of_week="mon-fri", hour=8, minute=0,
|
||||||
|
id="stock_ai_news_sentiment",
|
||||||
|
timezone="Asia/Seoul",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 에러 처리
|
||||||
|
|
||||||
|
| 상황 | 처리 |
|
||||||
|
|------|------|
|
||||||
|
| 네이버 뉴스 페이지 404/타임아웃 | 해당 종목 score_raw=0 + reason="no news", failures 별도 카운트 |
|
||||||
|
| BeautifulSoup 파싱 실패 (HTML 구조 변경) | 동일 처리 (failures 카운트) |
|
||||||
|
| LLM JSON 파싱 실패 | score_raw=0 + reason="parse fail", tokens는 그래도 누적 (실제 호출됨) |
|
||||||
|
| anthropic API 5xx | 자동 retry 1회 (SDK 기본), 실패 시 failures 카운트 |
|
||||||
|
| 전체 cron 실패 (네트워크 등) | agent-office 에러 텔레그램 + 16:30 잡은 어제 sentiment 데이터 사용 (date 비교로 자동) |
|
||||||
|
| 실패율 > 30% | 텔레그램 경고 알림. 단 부분 데이터는 그대로 DB 반영 |
|
||||||
|
| 16:30 시점 news_sentiment 비어 있음 | AiNewsSentiment.compute() 가 빈 Series 반환 → 가중합에서 이 노드 자동 제외 |
|
||||||
|
| LLM이 -10/+10 범위 벗어난 값 응답 | clamp `max(-10, min(10, score))` 적용 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 동시성 & rate limit
|
||||||
|
|
||||||
|
- `asyncio.Semaphore(10)` — 동시 10종목 처리 (네이버 차단 회피)
|
||||||
|
- 종목 처리 사이 0.2초 sleep (semaphore 안에서)
|
||||||
|
- 100종목 ÷ 10 동시 × 평균 3초/종목 = **~30-60초 총 소요**
|
||||||
|
- agent-office httpx timeout = 180초 (충분한 여유)
|
||||||
|
- stock-lab _conn() 의 WAL + busy_timeout=120s 로 16:30 잡과 동시 실행 시 lock 보호
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 비용 모니터링
|
||||||
|
|
||||||
|
- 종목당 평균: input ~500 tokens, output ~50 tokens
|
||||||
|
- 일 비용: 50K input × $1/M + 5K output × $5/M = **$0.075/일**
|
||||||
|
- 월 비용: **~$1.6** (텔레그램 메시지 하단에 매일 ₩72 형태로 표시)
|
||||||
|
- `news_sentiment.tokens_input/output` 컬럼으로 누적 추적 가능
|
||||||
|
- 환산: 1 USD ≈ ₩1,300, input $0.0013/K, output $0.0065/K (장기 평균)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 프론트엔드 변경
|
||||||
|
|
||||||
|
캔버스 모드에 8번째 점수 노드 추가. 아래 한 파일만 수정:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// canvasLayout.js
|
||||||
|
export const NODE_IDS = {
|
||||||
|
...,
|
||||||
|
AI_NEWS: 'score-ai-news', // 신규
|
||||||
|
...,
|
||||||
|
};
|
||||||
|
export const NODE_KIND_MAP = { ..., [NODE_IDS.AI_NEWS]: 'score', ... };
|
||||||
|
export const SCORE_NODE_NAME_MAP = { ..., [NODE_IDS.AI_NEWS]: 'ai_news' };
|
||||||
|
export const SCORE_NODE_LABEL = {
|
||||||
|
...,
|
||||||
|
[NODE_IDS.AI_NEWS]: { icon: '🤖', title: 'AI 뉴스' },
|
||||||
|
};
|
||||||
|
export const INITIAL_NODE_POSITIONS = {
|
||||||
|
...,
|
||||||
|
// 기존 7개 score y: 0,90,180,270,360,450,540 → 8개 y: 0,90,...,630
|
||||||
|
[NODE_IDS.AI_NEWS]: { x: 480, y: 630 },
|
||||||
|
};
|
||||||
|
const SCORE_KEYS = [..., 'AI_NEWS']; // 한 줄 추가
|
||||||
|
```
|
||||||
|
|
||||||
|
폼 모드 `NodePanel` 은 백엔드 `/api/stock/screener/nodes` 응답 기반이라 백엔드 등록만으로 자동 반영.
|
||||||
|
|
||||||
|
테스트 갱신:
|
||||||
|
- `canvasLayout.test.js`: 8 score 노드, 18 엣지 (1+8+8+1), Object.keys(SCORE_NODE_NAME_MAP) === 8
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 테스트 전략
|
||||||
|
|
||||||
|
### 16.1 backend 단위 테스트
|
||||||
|
|
||||||
|
| 파일 | 검증 |
|
||||||
|
|------|------|
|
||||||
|
| `test_ai_news_scraper.py` | 네이버 HTML mock 파싱 (3종목 fixture, 빈 HTML, 404 응답) |
|
||||||
|
| `test_ai_news_analyzer.py` | anthropic mock — success / JSON 파싱 실패 / score 범위 클램프 |
|
||||||
|
| `test_ai_news_pipeline.py` | 5종목 mini integration (scraper/analyzer monkeypatch) — top_pos/top_neg 정렬 검증, failures 격리 검증 |
|
||||||
|
| `test_ai_news_node.py` | AiNewsSentiment.compute() — percentile_rank 결과, min_news_count 필터, 빈 컨텍스트 |
|
||||||
|
| `test_screener_schema.py` | news_sentiment DDL 생성 확인 (기존 테스트 보강) |
|
||||||
|
| `test_screener_router.py` | POST /snapshot/refresh-news-sentiment 라우팅 검증 (mock pipeline) |
|
||||||
|
|
||||||
|
### 16.2 frontend 회귀 테스트
|
||||||
|
|
||||||
|
| 파일 | 검증 |
|
||||||
|
|------|------|
|
||||||
|
| `canvasLayout.test.js` (수정) | SCORE_NODE_NAME_MAP 8 entries, EDGES 18, AI_NEWS가 gate→score→combine 경로 가짐 |
|
||||||
|
|
||||||
|
### 16.3 수동 검증 체크리스트
|
||||||
|
|
||||||
|
배포 전 NAS에서:
|
||||||
|
- [ ] 08:00 cron 트리거 (수동 `agent-office.on_ai_news_schedule()`)
|
||||||
|
- [ ] news_sentiment 테이블에 100종목 행 생성 확인
|
||||||
|
- [ ] 텔레그램 메시지 호재/악재 Top 5 + 비용 라인 정상 표시
|
||||||
|
- [ ] 16:30 스크리너 잡이 ai_news 점수 가중합에 반영 (스크리너 결과의 scores.ai_news 컬럼 확인)
|
||||||
|
- [ ] 캔버스 모드에 🤖 AI 뉴스 노드 표시, 활성/비활성 토글 동작
|
||||||
|
- [ ] LLM 실패 시뮬레이션 (ANTHROPIC_API_KEY 잘못 설정 후 cron) → fail-soft 동작
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. 배포
|
||||||
|
|
||||||
|
- **백엔드**: stock-lab + agent-office 동시 변경 → git push → Gitea webhook → 자동 deployer rsync + docker compose build
|
||||||
|
- **DB 마이그레이션**: `ensure_screener_schema(conn)` 의 `CREATE TABLE IF NOT EXISTS` 로 자동 (기존 패턴)
|
||||||
|
- **환경변수**: stock-lab docker-compose.yml 에 `AI_NEWS_MODEL` (옵션) 추가 가능. 기본값 `claude-haiku-4-5-20251001`
|
||||||
|
- **프론트**: web-ui에서 `npm run release:nas` (캔버스 노드 1개 추가는 작은 변경)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. 후속 슬라이스 후보 (이번 슬라이스 NOT)
|
||||||
|
|
||||||
|
본 슬라이스 완료 후 자연스럽게 이어질 작업:
|
||||||
|
|
||||||
|
1. **URL 단위 캐싱** — 뉴스 분석 비용 ~70% 절감
|
||||||
|
2. **장중 16:00 추가 sentiment cron** — 16:30 스크리너에 더 신선한 데이터 공급
|
||||||
|
3. **종목별 sentiment 트렌드 차트** — 데이터 1-2주 누적 후 시각화
|
||||||
|
4. **시총 200~500 확장** — 중소형주 sentiment 커버리지
|
||||||
|
5. **백테스트** — sentiment 점수가 실수익에 미친 영향 회귀
|
||||||
|
6. **다국어/거시 뉴스 통합** — 글로벌 시장 영향 변수 추가
|
||||||
|
7. **알림 토글** — 운영 중 텔레그램 알림 일시 정지 옵션
|
||||||
|
8. **종목별 sentiment 페이지** — 상세 뉴스 + 점수 + LLM 근거 가시화
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. 리스크와 완화
|
||||||
|
|
||||||
|
| 리스크 | 완화 |
|
||||||
|
|--------|------|
|
||||||
|
| 네이버 finance HTML 구조 변경 | 단위 테스트로 빠른 감지. fail-soft (해당 종목 skip). 운영 알림 (실패율 > 30%) |
|
||||||
|
| LLM 응답이 JSON 깨짐 | 종목당 1콜 + JSON-mode prompt + 파싱 실패 시 단일 종목만 skip. lotto curator에서 검증된 패턴 |
|
||||||
|
| 네이버 차단 (429) | Semaphore(10) + 0.2초 sleep + httpx User-Agent. 향후 429 응답 시 exponential backoff 추가 |
|
||||||
|
| anthropic API 비용 폭증 | 일 1회 100종목 = $0.075 상한. 토큰 모니터링 컬럼 + 텔레그램 표시로 즉시 감지 |
|
||||||
|
| 08:00 cron이 16:30 잡과 lock 충돌 | _conn() WAL + busy_timeout=120s 로 흡수. 두 cron 시간 8.5시간 차이로 실질 충돌 없음 |
|
||||||
|
| 16:30 시점 news_sentiment 비어 있음 (cron 실패) | AiNewsSentiment.compute() 가 빈 Series → 가중합에서 자동 제외. 다른 7노드 점수만 사용 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 20. 완료 조건 (Definition of Done)
|
||||||
|
|
||||||
|
- [ ] 평일 08:00 KST agent-office cron 등록, 수동 트리거로 실행 검증
|
||||||
|
- [ ] news_sentiment 테이블에 100종목 데이터 일별 생성
|
||||||
|
- [ ] 텔레그램에 호재/악재 Top 5 + 비용 라인 표시
|
||||||
|
- [ ] 16:30 스크리너 잡에서 ai_news 점수가 가중합에 반영 (scores.ai_news 노출)
|
||||||
|
- [ ] 캔버스 모드에 8번째 노드 🤖 AI 뉴스 표시, 가중치/활성/파라미터 편집 동작
|
||||||
|
- [ ] 폼 모드 NodePanel에 AI 뉴스 자동 노출 (백엔드 메타 기반)
|
||||||
|
- [ ] 16.1 단위 테스트 모두 통과
|
||||||
|
- [ ] 16.3 수동 검증 체크리스트 모두 통과
|
||||||
|
- [ ] LLM 실패 시 fail-soft 동작 (전체 잡은 성공으로 끝나고, 실패 종목만 누락)
|
||||||
505
docs/superpowers/specs/2026-05-13-screener-node-canvas-design.md
Normal file
505
docs/superpowers/specs/2026-05-13-screener-node-canvas-design.md
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
# Stock Screener — Node Canvas Mode Design
|
||||||
|
|
||||||
|
**작성일**: 2026-05-13
|
||||||
|
**작성자**: gahusb
|
||||||
|
**상태**: Approved for implementation
|
||||||
|
**선행 spec**: `2026-05-12-stock-screener-board-design.md` (§14 — react-flow 노드 캔버스 후속 슬라이스)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 목표
|
||||||
|
|
||||||
|
`/stock/screener` 페이지에 **n8n 스타일 노드 캔버스 모드**를 추가한다. 폼 모드와 토글로 전환하며, 같은 settings state를 공유한다. 백엔드는 변경하지 않는다 — 캔버스는 시각화 + 편집 UI일 뿐, 결과적으로는 동일한 `weights / node_params / gate_params` 를 `/api/stock/screener/run` 에 전송한다.
|
||||||
|
|
||||||
|
**Why**: 사용자가 슬라이더만 들여다보는 폼 모드는 "어떤 노드가 어떤 단계에서 무엇을 하는지"의 파이프라인 감각이 약하다. n8n/Figma류 캔버스 시각화는 데이터 흐름을 한눈에 보여줘 강세주 분석 모델의 구조적 이해를 돕는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 범위
|
||||||
|
|
||||||
|
**포함 (이번 슬라이스)**:
|
||||||
|
- 헤더 토글 (`폼 ↔ 캔버스`) — 데스크탑 전용
|
||||||
|
- 11개 노드의 미니 파이프라인 시각화 (고정 토폴로지)
|
||||||
|
- 점수 노드 카드 위 가중치/활성/핵심 파라미터 인라인 편집 + 설명 표시
|
||||||
|
- floating 미니 툴바 (실행 / 저장 실행 / 설정 영구 저장 / 레이아웃 리셋)
|
||||||
|
- 노드 위치 localStorage 저장 + 초기화 버튼
|
||||||
|
- 모바일에서는 캔버스 토글 숨김, 폼 강제
|
||||||
|
|
||||||
|
**범위 외 (NOT)**:
|
||||||
|
- 노드 추가/삭제 UI (토폴로지 고정)
|
||||||
|
- 노드 간 연결선 사용자 편집
|
||||||
|
- 자유 그래프 모드 (별도 후속 슬라이스)
|
||||||
|
- 캔버스 안 결과 노드에 결과 표시 (외부 테이블에만 표시)
|
||||||
|
- 노드 캔버스 화면 자체에서의 대화형 백테스트
|
||||||
|
- dagre 등 자동 레이아웃 알고리즘
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 아키텍처 개요
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ Screener.jsx (entrypoint) │
|
||||||
|
│ - useScreenerMode (form|canvas) │
|
||||||
|
│ - useIsMobile() → 강제 form │
|
||||||
|
└────────────┬────────────────┘
|
||||||
|
│
|
||||||
|
┌────────────────┼────────────────┐
|
||||||
|
│ │ │
|
||||||
|
form mode canvas mode shared result area
|
||||||
|
(기존 그대로) (신규) (기존 그대로)
|
||||||
|
│ │ │
|
||||||
|
┌──────────┴──┐ ┌─────────┴──────┐ ┌────┴──────┐
|
||||||
|
│ GatePanel │ │ ScreenerCanvas │ │ ResultTable
|
||||||
|
│ NodePanel │ │ + CanvasToolbar│ │ TelegramPreview
|
||||||
|
│ GlobalControls│ │ + Node cards │ │ RunHistoryList
|
||||||
|
└──────────────┘ └─────────────────┘ └───────────┘
|
||||||
|
↑ ↑ ↑
|
||||||
|
└────────────────┴────────────────┘
|
||||||
|
공유 state: useScreenerSettings,
|
||||||
|
useScreenerRun, useScreenerHistory
|
||||||
|
```
|
||||||
|
|
||||||
|
**의존성 추가**: `@xyflow/react` (구 react-flow, MIT, ~50KB gzipped).
|
||||||
|
|
||||||
|
**백엔드 변경 없음**. 캔버스는 settings를 동일한 형태로 만들고, 동일한 `/run` 엔드포인트를 호출한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 화면 레이아웃
|
||||||
|
|
||||||
|
### 4.1 데스크탑 — 캔버스 모드
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────────────────────────────────────────────┐
|
||||||
|
│ Header: 스크리너 [폼] [캔버스] │
|
||||||
|
│ 최근 자동잡: 2026-05-13 · 분석 기준일: 2026-05-13│
|
||||||
|
├───────────────────────────────────────────────────────────┤
|
||||||
|
│ ╔═════════════════════════════════════════════════════╗ │
|
||||||
|
│ ║ ┌─ floating toolbar ──────────────────────────┐ ║ │
|
||||||
|
│ ║ │ ▶ 실행 💾 저장 실행 📌 설정 저장 🔄 ⛶ │ ║ │
|
||||||
|
│ ║ └──────────────────────────────────────────────┘ ║ │
|
||||||
|
│ ║ ║ │
|
||||||
|
│ ║ ┌─────┐ ┌──────┐ ┌───────┐ ║ │
|
||||||
|
│ ║ │📥KRX│→ │🛡️위생│ ┬→│외국인 │ ┐ ║ │
|
||||||
|
│ ║ │data │ │gate │ ├→│거래량 │ │ ┌─────────────┐ ║ │
|
||||||
|
│ ║ └─────┘ └──────┘ ├→│모멘텀 │ ┼→ │⚙️가중합+TopN │→ │📊│║│
|
||||||
|
│ ║ ├→│52w고가│ │ │ +ATR 사이저 │ ║ │
|
||||||
|
│ ║ ├→│RS │ │ └─────────────┘ ║ │
|
||||||
|
│ ║ ├→│이평선│ ┤ ║ │
|
||||||
|
│ ║ └→│VCP │ ┘ ║ │
|
||||||
|
│ ║ ║ │
|
||||||
|
│ ║ (캔버스 영역: 화면 높이의 약 60-65%) ║ │
|
||||||
|
│ ╚═══════════════════════════════════════════════════════╝ │
|
||||||
|
├───────────────────────────────────────────────────────────┤
|
||||||
|
│ ResultTable (기존 그대로) — 비교 모드 그대로 │
|
||||||
|
│ TelegramPreview (기존 그대로) │
|
||||||
|
│ RunHistoryList (기존 그대로 — 우측 사이드) │
|
||||||
|
└───────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**그리드 구성 (캔버스 모드)**:
|
||||||
|
|
||||||
|
- Row 1 — 헤더 (높이 자동)
|
||||||
|
- Row 2 — 캔버스 영역 (`min-height: 60vh`, `max-height: 70vh`)
|
||||||
|
- Row 3 — 2-column: 좌측 `ResultTable + TelegramPreview` (flex 1), 우측 `RunHistoryList` (width 300px)
|
||||||
|
|
||||||
|
폼 모드의 3-column 그리드(좌 사이드/센터/우 사이드)와 달리, 캔버스 모드는 캔버스가 가로 전체를 쓰고 결과 영역만 2-column으로 분리. `RunHistoryList` 의 위치는 두 모드 모두 "우측 결과 사이드"로 일관.
|
||||||
|
|
||||||
|
### 4.2 데스크탑 — 폼 모드
|
||||||
|
|
||||||
|
기존 layout 그대로. 헤더에 토글 [폼] [캔버스]만 추가.
|
||||||
|
|
||||||
|
### 4.3 모바일 (<768px)
|
||||||
|
|
||||||
|
기존 모바일 카드 layout 그대로. 헤더 토글 자체를 렌더하지 않음. localStorage에 `mode='canvas'`로 저장돼 있어도 무시.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 노드 종류
|
||||||
|
|
||||||
|
총 11개 노드, 4개 카테고리.
|
||||||
|
|
||||||
|
| 카테고리 | 노드 | 편집 | 색상 | 표시 정보 |
|
||||||
|
|----------|------|------|------|-----------|
|
||||||
|
| **데이터** | `📥 KRX 데이터` | 불가 | 회색 | "~2,800종목 · FDR" |
|
||||||
|
| **게이트** | `🛡️ 위생 게이트` | 가능 | 노랑 | 파라미터 (min_market_cap 등) + 활성/비활성 |
|
||||||
|
| **점수** | `📈 외국인` | 가능 | 컬러 | 가중치 + 핵심 파라미터 + 설명 |
|
||||||
|
| **점수** | `📊 거래량 급증` | 가능 | 컬러 | 동일 |
|
||||||
|
| **점수** | `🚀 모멘텀` | 가능 | 컬러 | 동일 |
|
||||||
|
| **점수** | `🔝 52w 고가` | 가능 | 컬러 | 동일 |
|
||||||
|
| **점수** | `💪 RS Rating` | 가능 | 컬러 | 동일 |
|
||||||
|
| **점수** | `📉 이평선 정렬` | 가능 | 컬러 | 동일 |
|
||||||
|
| **점수** | `🌀 VCP-lite` | 가능 | 컬러 | 동일 |
|
||||||
|
| **결합** | `⚙️ 가중합+TopN+ATR` | 불가 | 회색 | "TopN=10 · ATR×2" 등 현재 settings 요약 |
|
||||||
|
| **결과** | `📊 결과` | 불가 | 회색 | "마지막 실행: 2026-05-13 · 8종목 통과" |
|
||||||
|
|
||||||
|
점수 노드의 컬러는 기존 `NODE_META` 의 accent color 시스템과 동기화 — 폼 모드에서 쓰던 색상이 캔버스에서도 동일하게 적용.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 노드 카드 디자인
|
||||||
|
|
||||||
|
### 6.1 점수 노드 카드 (편집 가능)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ 📈 거래량 급증 ⓘ │ ← 호버 시 풀 설명 툴팁
|
||||||
|
│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ │
|
||||||
|
│ "20일 평균 대비 2배 이상" │ ← 항상 표시되는 한 줄 요약
|
||||||
|
│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ │
|
||||||
|
│ 가중치 [█████░░░░░] 0.5 │ ← 슬라이더 (0~1, step 0.05)
|
||||||
|
│ ☑ 활성 │ ← 체크박스. uncheck = weight 0
|
||||||
|
│ │
|
||||||
|
│ ▾ 파라미터 (펼치면) │
|
||||||
|
│ lookback_days: [ 20 ] 일 │
|
||||||
|
│ multiplier: [2.0 ] │
|
||||||
|
└──────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- 한 줄 요약: 기존 `NODE_META[name].summary` (없으면 `description` 첫 줄)
|
||||||
|
- 풀 설명 (호버 툴팁): 기존 `NODE_META[name].description`
|
||||||
|
- 파라미터 폼: `param_schema` 기반 자동 생성 (기존 `NodeCard.jsx` 와 동일 로직 재사용)
|
||||||
|
|
||||||
|
### 6.2 게이트 노드 카드 (편집 가능, 노랑)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ 🛡️ 위생 게이트 ⓘ │
|
||||||
|
│ "통과해야 점수 단계 진입" │
|
||||||
|
│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ │
|
||||||
|
│ ☑ 활성 │
|
||||||
|
│ ▾ 파라미터 │
|
||||||
|
│ min_market_cap: [50] 억원 │
|
||||||
|
│ exclude_spac: ☑ │
|
||||||
|
│ ... │
|
||||||
|
└──────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 고정 노드 카드 (정보 표시만, 회색)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────┐
|
||||||
|
│ 📥 KRX 데이터 │
|
||||||
|
│ ~2,800종목 · FDR │
|
||||||
|
└────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
결합 노드는 동적으로 현재 settings를 요약 표시:
|
||||||
|
```
|
||||||
|
┌────────────────────────────┐
|
||||||
|
│ ⚙️ 가중합 + TopN + ATR │
|
||||||
|
│ Top 10 · RR 2.0 · ATR×2 │ ← settings에서 계산해서 표시
|
||||||
|
└────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
결과 노드도 동적:
|
||||||
|
```
|
||||||
|
┌──────────────────────────┐
|
||||||
|
│ 📊 결과 │
|
||||||
|
│ 마지막 실행: 14:32 │
|
||||||
|
│ 8 / 12 종목 통과 │
|
||||||
|
└──────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 캔버스 인터랙션
|
||||||
|
|
||||||
|
| 동작 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| 노드 드래그 | 위치 변경 → 드래그 종료 시 `screener-canvas-layout-v1` localStorage에 저장 |
|
||||||
|
| 슬라이더 변경 | `useScreenerSettings.setLocal({...settings, weights: {...}})` → `dirty=true` |
|
||||||
|
| 체크박스 (활성) | weight 토글: uncheck 시 weight=0 저장, check 시 이전 값 복원 (default = 0.5) |
|
||||||
|
| 파라미터 ▾ 펼치기 | 카드 높이 동적 확장 |
|
||||||
|
| 마우스 휠 | 줌 (React Flow 기본) |
|
||||||
|
| 드래그 (빈 공간) | 팬 (React Flow 기본) |
|
||||||
|
| ⛶ fitView 버튼 | 전체 노드 화면 맞춤 |
|
||||||
|
| 🔄 레이아웃 리셋 | `INITIAL_NODE_POSITIONS` 로 복귀, localStorage 키 삭제 |
|
||||||
|
| ▶ 실행 | 기존 `runPreview(settings)` → 결과는 하단 ResultTable |
|
||||||
|
| 💾 저장 실행 | 기존 `runSave(settings)` → DB 영구화 |
|
||||||
|
| 📌 설정 저장 | 기존 `save()` (settings 영구화) |
|
||||||
|
|
||||||
|
엣지 연결선은 사용자가 편집할 수 없음 (고정). React Flow 인스턴스 prop `nodesConnectable={false}`, `edgesUpdatable={false}`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 컴포넌트 분해 (신규 파일)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/pages/stock/screener/
|
||||||
|
Screener.jsx ← 모드 토글 추가, canvas 모드 분기 렌더
|
||||||
|
hooks/
|
||||||
|
useScreenerMode.js ← 신규: 'form' | 'canvas' state + localStorage
|
||||||
|
useCanvasLayout.js ← 신규: 노드 위치 read/write/reset
|
||||||
|
(기존 hooks 그대로)
|
||||||
|
components/
|
||||||
|
ModeToggle.jsx ← 신규: [폼][캔버스] 세그먼트 컨트롤 (헤더용)
|
||||||
|
canvas/
|
||||||
|
CanvasLayout.jsx ← 신규: 캔버스 + 결과 영역 그리드 (4.1 그리드 구성)
|
||||||
|
ScreenerCanvas.jsx ← React Flow 루트 컨테이너
|
||||||
|
CanvasToolbar.jsx ← floating Panel (실행/저장/리셋/fitView)
|
||||||
|
nodes/
|
||||||
|
ScoreNodeCard.jsx ← 점수 노드 카드 (편집)
|
||||||
|
GateNodeCard.jsx ← 게이트 노드 카드 (편집)
|
||||||
|
FixedNodeCard.jsx ← 데이터/결합/결과 카드 (정보만)
|
||||||
|
constants/
|
||||||
|
canvasLayout.js ← INITIAL_NODE_POSITIONS / EDGES / NODE_KIND_MAP
|
||||||
|
(기존 components 그대로 — 폼 모드에서 계속 사용)
|
||||||
|
```
|
||||||
|
|
||||||
|
기존 컴포넌트(`GatePanel`, `NodePanel`, `GlobalControls`, `ResultTable`, `TelegramPreview`, `RunHistoryList`)는 **변경 없음**. 결과 영역은 모드와 무관하게 동일.
|
||||||
|
|
||||||
|
### 8.1 `Screener.jsx` 변경점
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const { mode, setMode } = useScreenerMode();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const effectiveMode = isMobile ? 'form' : mode;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="screener-page">
|
||||||
|
<header className="screener-header">
|
||||||
|
<h1>스크리너</h1>
|
||||||
|
{!isMobile && (
|
||||||
|
<ModeToggle value={mode} onChange={setMode} />
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{effectiveMode === 'form' ? (
|
||||||
|
<FormLayout {...sharedProps} /> /* 기존 grid layout */
|
||||||
|
) : (
|
||||||
|
<CanvasLayout {...sharedProps} /> /* 신규 — 캔버스 + 동일 결과 영역 */
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 데이터 / state 설계
|
||||||
|
|
||||||
|
### 9.1 localStorage 키
|
||||||
|
|
||||||
|
| 키 | shape | 설명 |
|
||||||
|
|----|-------|------|
|
||||||
|
| `screener-mode-v1` | `'form' \| 'canvas'` | 마지막 사용 모드 |
|
||||||
|
| `screener-canvas-layout-v1` | `{ [nodeId: string]: { x: number, y: number } }` | 노드별 좌표 |
|
||||||
|
|
||||||
|
### 9.2 `useScreenerMode`
|
||||||
|
|
||||||
|
```js
|
||||||
|
export function useScreenerMode() {
|
||||||
|
const [mode, setModeState] = useState(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem('screener-mode-v1') || 'form';
|
||||||
|
} catch { return 'form'; }
|
||||||
|
});
|
||||||
|
const setMode = (m) => {
|
||||||
|
setModeState(m);
|
||||||
|
try { localStorage.setItem('screener-mode-v1', m); } catch {}
|
||||||
|
};
|
||||||
|
return { mode, setMode };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 `useCanvasLayout`
|
||||||
|
|
||||||
|
```js
|
||||||
|
export function useCanvasLayout(initialPositions) {
|
||||||
|
const STORAGE_KEY = 'screener-canvas-layout-v1';
|
||||||
|
const [positions, setPositions] = useState(() => readOrInit(initialPositions));
|
||||||
|
|
||||||
|
const updateNodePosition = (nodeId, pos) => {
|
||||||
|
setPositions((prev) => {
|
||||||
|
const next = { ...prev, [nodeId]: pos };
|
||||||
|
writeSafe(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const reset = () => {
|
||||||
|
setPositions(initialPositions);
|
||||||
|
try { localStorage.removeItem(STORAGE_KEY); } catch {}
|
||||||
|
};
|
||||||
|
return { positions, updateNodePosition, reset };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`readOrInit` 은 JSON.parse 실패하거나 노드 ID가 누락된 경우 누락된 ID에 대해서만 `initialPositions` 값을 보충.
|
||||||
|
|
||||||
|
### 9.4 `canvasLayout.js` 상수
|
||||||
|
|
||||||
|
```js
|
||||||
|
export const NODE_IDS = {
|
||||||
|
DATA: 'data',
|
||||||
|
GATE: 'gate-hygiene',
|
||||||
|
FOREIGN: 'score-foreign-buy',
|
||||||
|
VOLUME: 'score-volume-surge',
|
||||||
|
MOMENTUM: 'score-momentum',
|
||||||
|
HIGH52W: 'score-high52w',
|
||||||
|
RS: 'score-rs-rating',
|
||||||
|
MA: 'score-ma-alignment',
|
||||||
|
VCP: 'score-vcp-lite',
|
||||||
|
COMBINE: 'combine',
|
||||||
|
RESULT: 'result',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const INITIAL_NODE_POSITIONS = {
|
||||||
|
[NODE_IDS.DATA]: { x: 40, y: 280 },
|
||||||
|
[NODE_IDS.GATE]: { x: 240, y: 280 },
|
||||||
|
[NODE_IDS.FOREIGN]: { x: 480, y: 0 },
|
||||||
|
[NODE_IDS.VOLUME]: { x: 480, y: 90 },
|
||||||
|
[NODE_IDS.MOMENTUM]: { x: 480, y: 180 },
|
||||||
|
[NODE_IDS.HIGH52W]: { x: 480, y: 270 },
|
||||||
|
[NODE_IDS.RS]: { x: 480, y: 360 },
|
||||||
|
[NODE_IDS.MA]: { x: 480, y: 450 },
|
||||||
|
[NODE_IDS.VCP]: { x: 480, y: 540 },
|
||||||
|
[NODE_IDS.COMBINE]: { x: 800, y: 280 },
|
||||||
|
[NODE_IDS.RESULT]: { x: 1080, y: 280 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EDGES = [
|
||||||
|
{ id: 'e-data-gate', source: NODE_IDS.DATA, target: NODE_IDS.GATE },
|
||||||
|
...['FOREIGN','VOLUME','MOMENTUM','HIGH52W','RS','MA','VCP'].map((k) => ({
|
||||||
|
id: `e-gate-${k.toLowerCase()}`, source: NODE_IDS.GATE, target: NODE_IDS[k],
|
||||||
|
})),
|
||||||
|
...['FOREIGN','VOLUME','MOMENTUM','HIGH52W','RS','MA','VCP'].map((k) => ({
|
||||||
|
id: `e-${k.toLowerCase()}-combine`, source: NODE_IDS[k], target: NODE_IDS.COMBINE,
|
||||||
|
})),
|
||||||
|
{ id: 'e-combine-result', source: NODE_IDS.COMBINE, target: NODE_IDS.RESULT },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
총 엣지 수: 1(data→gate) + 7(gate→점수) + 7(점수→combine) + 1(combine→result) = **16개**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 시각 디자인 디테일
|
||||||
|
|
||||||
|
| 요소 | 스타일 |
|
||||||
|
|------|--------|
|
||||||
|
| 캔버스 배경 | `bg-screener-canvas` (다크 그리드, 점선 `#1f2937`) |
|
||||||
|
| 고정 노드 카드 | 배경 `#1f2937`, 텍스트 `#9ca3af`, 200×64 |
|
||||||
|
| 게이트 카드 | accent `#facc15` (노랑) 좌측 4px stripe, 220×auto |
|
||||||
|
| 점수 카드 | accent = 기존 `NODE_META[name].color`, 240×auto |
|
||||||
|
| 비활성 점수 카드 | opacity 0.45 + grayscale 0.6 |
|
||||||
|
| 엣지 (active) | `#fbbf24` 1.5px, 약한 그라데이션 |
|
||||||
|
| 엣지 (해당 점수 노드 weight=0) | `#374151` 1px, 점선 |
|
||||||
|
| 미니맵 | **사용하지 않음** (캔버스 크기가 작아 불필요) |
|
||||||
|
| Controls (줌/리셋) | React Flow `<Controls />` 좌하단, 미니멀 |
|
||||||
|
| floating toolbar | 좌상단, `position: absolute`, `backdrop-filter: blur(8px)`, 반투명 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 모바일/엣지 케이스
|
||||||
|
|
||||||
|
| 케이스 | 처리 |
|
||||||
|
|--------|------|
|
||||||
|
| 모바일 진입 (≤768px) | 토글 미렌더, `effectiveMode = 'form'` 강제 |
|
||||||
|
| 데스크탑 → 모바일 리사이즈 중 | `useIsMobile` 가 자동 감지 → 폼으로 폴백 |
|
||||||
|
| localStorage 파싱 실패 | catch + reset → 초기 위치/모드로 복귀 |
|
||||||
|
| 노드 ID 누락 (마이그레이션) | 누락 노드만 `INITIAL_NODE_POSITIONS` 값 사용, 나머지는 저장값 유지 |
|
||||||
|
| 노드 ID 신규 추가 (후속) | 같은 누락 처리 로직으로 자동 흡수 |
|
||||||
|
| React Flow 초기 렌더 깜빡임 | `fitView` 초기 옵션 + `defaultViewport` 명시로 흡수 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 테스트 전략
|
||||||
|
|
||||||
|
캔버스는 시각화 위주라 E2E 테스트 비용이 크므로 **단위 테스트 중심**으로 간다.
|
||||||
|
|
||||||
|
### 12.1 단위 테스트 (web-ui)
|
||||||
|
|
||||||
|
| 파일 | 검증 |
|
||||||
|
|------|------|
|
||||||
|
| `useScreenerMode.test.js` | 초기값 'form', set 후 localStorage 반영, 손상 시 fallback |
|
||||||
|
| `useCanvasLayout.test.js` | 초기 positions 반환, updateNodePosition 후 localStorage 반영, reset 후 storage 삭제, 손상 시 initial 반환, 누락 ID 시 initial 보충 |
|
||||||
|
| `canvasLayout.test.js` | EDGES 정합성: 모든 점수 노드가 gate 입력과 combine 출력을 가짐, source/target ID가 NODE_IDS 안에 존재 |
|
||||||
|
| `ScoreNodeCard.test.jsx` | 슬라이더 onChange 호출, 비활성 체크박스 시 weight=0, 활성 복원 시 default 0.5 |
|
||||||
|
|
||||||
|
### 12.2 통합 (가볍게)
|
||||||
|
|
||||||
|
- `Screener.test.jsx` 회귀: 폼 모드 기본 렌더 후 토글로 캔버스 진입, 다시 폼으로 — settings state 유지 확인
|
||||||
|
|
||||||
|
### 12.3 수동 검증 체크리스트
|
||||||
|
|
||||||
|
배포 전 데스크탑 브라우저:
|
||||||
|
- [ ] 토글 폼↔캔버스 전환 시 가중치 동기화
|
||||||
|
- [ ] 캔버스에서 슬라이더 → `dirty` 표시 정상
|
||||||
|
- [ ] `▶ 실행` → 하단 ResultTable 갱신
|
||||||
|
- [ ] 노드 드래그 → 새로고침 후 위치 복원
|
||||||
|
- [ ] `🔄` 리셋 → 초기 위치로 복귀
|
||||||
|
- [ ] 모바일 (DevTools 360×640) → 토글 미표시, 폼 강제
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 성능
|
||||||
|
|
||||||
|
| 항목 | 평가 |
|
||||||
|
|------|------|
|
||||||
|
| 번들 사이즈 | `@xyflow/react` ~50KB gzipped + 노드 카드 컴포넌트 ~5KB. 전체 web-ui 번들 영향 미미 |
|
||||||
|
| 렌더 비용 | 11개 노드, 16개 엣지 — React Flow 권장 한계 대비 매우 작음 |
|
||||||
|
| localStorage I/O | 노드 드래그 종료(`onNodeDragStop`) 시점에만 write, 드래그 중 빈번한 write 없음 |
|
||||||
|
| 모바일 폴백 | useIsMobile 분기로 캔버스 컴포넌트 자체를 mount하지 않음 → 모바일 번들 부담 없음 (lazy import 검토 가치 있음) |
|
||||||
|
|
||||||
|
`@xyflow/react` 는 데스크탑 진입 시에만 필요하므로 **`React.lazy` + `Suspense` 로 분리 import** 권장 (Plan에서 task로 명시).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 후속 슬라이스 후보 (이번 슬라이스 NOT)
|
||||||
|
|
||||||
|
이번 캔버스 슬라이스가 완료된 이후 자연스럽게 이어질 수 있는 작업들:
|
||||||
|
|
||||||
|
1. **노드 추가/삭제 UI** — 캔버스 우클릭 메뉴로 점수 노드 추가/제거 (백엔드 registry 동적 등록 필요)
|
||||||
|
2. **자유 그래프 모드** — 토폴로지 자체를 사용자가 구성 (엔진 재설계 동반)
|
||||||
|
3. **캔버스 안 결과 노드 펼치기** — 결과 노드 클릭 시 in-canvas 결과 표
|
||||||
|
4. **캔버스 백테스트 시각화** — 노드별 기여도 히트맵 (후속 백테스트 슬라이스와 연동)
|
||||||
|
5. **노드 그룹화** — 점수 노드 7개를 묶어 접기/펼치기
|
||||||
|
6. **키보드 단축키** — Space=실행, Cmd+S=저장, R=리셋
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 리스크와 완화
|
||||||
|
|
||||||
|
| 리스크 | 완화 |
|
||||||
|
|--------|------|
|
||||||
|
| `@xyflow/react` API 변경 (v11 → v12 transition 중) | spec 작성 시점 안정 버전(`12.x`) 고정, package.json에 명시 |
|
||||||
|
| 캔버스 모드에서 폼 모드 settings와 동기화 깨짐 | 같은 hook 인스턴스 공유 + Screener.jsx 한 컴포넌트가 두 layout 분기 렌더 → 동일 state 자동 공유 |
|
||||||
|
| 노드 카드가 너무 커서 캔버스 빽빽 | spec 6장의 카드 폭(220~240px), 점수 노드 세로 90px 간격으로 사전 검증된 좌표 사용 |
|
||||||
|
| localStorage 무한 누적 | 키는 정해진 1개씩만 사용, 마이그레이션 시 키 명에 -v1 suffix |
|
||||||
|
| 모바일 사용자 혼란 | 토글 자체를 렌더하지 않음 → 캔버스 모드 존재 자체를 알지 못함 → 학습 부담 0 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. API/백엔드 영향
|
||||||
|
|
||||||
|
**없음**. 본 슬라이스는 프론트엔드 전용. 기존 API:
|
||||||
|
- `GET /api/stock/screener/nodes`
|
||||||
|
- `GET/PUT /api/stock/screener/settings`
|
||||||
|
- `POST /api/stock/screener/run`
|
||||||
|
|
||||||
|
를 그대로 사용한다. settings의 shape도 변경 없음.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. 배포
|
||||||
|
|
||||||
|
- 프론트만 변경 → `npm run release:nas` 또는 `scripts\deploy.bat --frontend`
|
||||||
|
- 백엔드 배포 불필요
|
||||||
|
- 마이그레이션 불필요 (DB 변경 없음, localStorage는 점진적 적용)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. 완료 조건 (Definition of Done)
|
||||||
|
|
||||||
|
- [ ] 데스크탑에서 헤더 [폼][캔버스] 토글이 보이고 정상 전환
|
||||||
|
- [ ] 캔버스 모드에 11개 노드, 16개 엣지가 사전 정의된 위치로 표시
|
||||||
|
- [ ] 점수 노드 카드에서 가중치 슬라이더/활성 체크박스/핵심 파라미터 편집 동작
|
||||||
|
- [ ] 카드 ⓘ 호버 시 설명 툴팁 표시, 한 줄 요약 항상 표시
|
||||||
|
- [ ] floating 툴바 4개 버튼 (실행/저장 실행/설정 저장/레이아웃 리셋) 모두 동작
|
||||||
|
- [ ] 노드 드래그 → localStorage 저장 → 새로고침 후 복원
|
||||||
|
- [ ] 🔄 리셋 → 초기 좌표 복귀 + localStorage 삭제
|
||||||
|
- [ ] 모바일 (≤768px)에서 토글 미렌더, 폼 강제
|
||||||
|
- [ ] 폼/캔버스 모드 전환해도 settings, 미리보기 히스토리, 결과 유지
|
||||||
|
- [ ] 12.1의 단위 테스트 모두 통과
|
||||||
|
- [ ] 12.3의 수동 검증 체크리스트 통과
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
# AI News Phase 1 — `articles` Source Integration Design
|
||||||
|
|
||||||
|
**작성일**: 2026-05-14
|
||||||
|
**작성자**: gahusb
|
||||||
|
**상태**: Approved for implementation
|
||||||
|
**선행 spec**: `2026-05-13-ai-news-sentiment-node-design.md`
|
||||||
|
**선행 review**: adversarial review (Claude general-purpose, codex CLI ENOENT fallback)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 목표
|
||||||
|
|
||||||
|
`ai_news` 파이프라인의 데이터 소스를 **Naver 종목 뉴스 스크래핑 → 기존 `articles` 테이블 재사용** 으로 교체한다. 인프라 중복 제거(이미 매일 cron으로 수집 중) + Naver 차단 회피 + LLM 입력 풍부화(summary 포함).
|
||||||
|
|
||||||
|
본 슬라이스는 **Phase 1** 전략의 일부. 4주 IC 측정 결과를 보고 (a) IC < 0.05 → 노드 폐기, (b) IC ≥ 0.05 → Phase 2 (DART OpenAPI 추가) 결정.
|
||||||
|
|
||||||
|
**Why**: adversarial review에서 가장 강한 비판이 **"이미 매일 수집 중인 `articles` 테이블을 무시하고 Naver를 100번 더 긁는 중복 인프라"**였음. weight=0 차단(이전 슬라이스 `943f676`)과 짝을 이루어 본 슬라이스로 인프라 중복 해소.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 범위
|
||||||
|
|
||||||
|
**포함 (Phase 1)**:
|
||||||
|
- 신규 모듈 `ai_news/articles_source.py` — 기존 articles 테이블 조회 + 종목명 substring 매핑
|
||||||
|
- `news_sentiment` 테이블에 `source TEXT NOT NULL DEFAULT 'articles'` 컬럼 추가
|
||||||
|
- `pipeline.py` 가 articles_source 사용 (Naver scraper 호출 제거)
|
||||||
|
- `analyzer.py` 가 LLM 입력에 `summary` 추가 (제목 + 요약)
|
||||||
|
- 텔레그램 메시지에 매핑 hit-rate 표시 (e.g., "matched 42/100")
|
||||||
|
- 단위 테스트 — articles_source 6개, pipeline 통합 회귀
|
||||||
|
|
||||||
|
**범위 외 (NOT)**:
|
||||||
|
- DART OpenAPI 통합 (Phase 2, IC 검증 후)
|
||||||
|
- alias dict / LLM ticker 추출 (Phase 1.5, hit-rate 낮을 시)
|
||||||
|
- failure taxonomy (별도 슬라이스)
|
||||||
|
- legacy `scraper.py` 삭제 (Phase 2 결정 후)
|
||||||
|
- 환경변수로 source 토글 fallback (YAGNI)
|
||||||
|
- weight 변경 (여전히 0.0 유지)
|
||||||
|
- 매핑 정확도 자동 alarm/threshold
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
[08:00 KST 평일] │ agent-office on_ai_news_ │
|
||||||
|
│ schedule (변경 없음) │
|
||||||
|
└──────────┬───────────────────┘
|
||||||
|
│ HTTP POST
|
||||||
|
▼
|
||||||
|
┌────────────────────────────────────────────────────────┐
|
||||||
|
│ stock-lab /snapshot/refresh-news-sentiment (변경 없음) │
|
||||||
|
│ │
|
||||||
|
│ ai_news/pipeline.refresh_daily(asof): │
|
||||||
|
│ 1. top-100 tickers by market_cap (그대로) │
|
||||||
|
│ 2. articles_source.gather_articles_for_tickers(...) │
|
||||||
|
│ - SELECT * FROM articles WHERE crawled_at >= asof-1d│
|
||||||
|
│ - 각 article (title+summary) ∋ ticker.name 매칭 │
|
||||||
|
│ - {ticker: [article_dict, ...]} 반환 │
|
||||||
|
│ 3. asyncio.gather (매핑된 ticker만): │
|
||||||
|
│ a. analyzer.score_sentiment(llm, ticker, articles) │
|
||||||
|
│ (Naver scraper 호출 없음 — articles 그대로 전달) │
|
||||||
|
│ 4. news_sentiment upsert with source='articles' │
|
||||||
|
│ 5. 텔레그램 페이로드: matched_count / total_count 추가 │
|
||||||
|
└────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**의존성 변경 없음**: anthropic SDK 유지, httpx/BeautifulSoup 제거하지 않음 (legacy scraper에서 import 유지).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 파일 변경
|
||||||
|
|
||||||
|
### 4.1 신규
|
||||||
|
```
|
||||||
|
web-backend/stock-lab/app/screener/ai_news/
|
||||||
|
articles_source.py ← DB articles 조회 + 종목 매핑
|
||||||
|
web-backend/stock-lab/tests/
|
||||||
|
test_ai_news_articles_source.py ← 6 tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 수정
|
||||||
|
```
|
||||||
|
web-backend/stock-lab/app/screener/
|
||||||
|
schema.py ← news_sentiment.source 컬럼 + migration
|
||||||
|
ai_news/pipeline.py ← scraper 호출 제거, articles_source 사용
|
||||||
|
ai_news/analyzer.py ← summary 활용
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 변경 없음
|
||||||
|
- `ai_news/scraper.py` (deprecate 주석만, 다음 슬라이스에서 삭제 결정)
|
||||||
|
- `ai_news/telegram.py` (매핑 통계는 router 에서 처리하거나 telegram 빌더에 인자 추가)
|
||||||
|
- `ai_news/validation.py` (IC 측정은 데이터 소스 무관)
|
||||||
|
- `nodes/ai_news.py`
|
||||||
|
- `engine.py`
|
||||||
|
- `router.py` (응답 구조는 동일, 새 통계 필드만 추가)
|
||||||
|
- agent-office 전체
|
||||||
|
- 프론트엔드
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. DB 스키마 변경
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE news_sentiment ADD COLUMN source TEXT NOT NULL DEFAULT 'articles';
|
||||||
|
```
|
||||||
|
|
||||||
|
`schema.py` 의 `ensure_screener_schema(conn)` 에 migration block:
|
||||||
|
```python
|
||||||
|
cols = {r[1] for r in conn.execute("PRAGMA table_info(news_sentiment)").fetchall()}
|
||||||
|
if "source" not in cols:
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE news_sentiment ADD COLUMN source TEXT NOT NULL DEFAULT 'articles'"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
기존 운영 row (Naver 출처)는 default `'articles'` 로 채워짐 — 이는 의미적으로 부정확하지만 다음 cron부터 실제 articles 출처로 upsert되어 덮어쓰여짐. 24시간 내 정확화. Phase 2 비교 시점(4주 후)에는 충분히 cleared.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. `articles_source.py` 구현
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""기존 articles 테이블에서 종목별 뉴스 매핑."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
from typing import Any, Dict, List, Tuple
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def gather_articles_for_tickers(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
tickers: List[str],
|
||||||
|
asof: dt.date,
|
||||||
|
*,
|
||||||
|
window_days: int = 1,
|
||||||
|
max_per_ticker: int = 5,
|
||||||
|
) -> Tuple[Dict[str, List[Dict[str, Any]]], Dict[str, int]]:
|
||||||
|
"""Returns ({ticker: [article, ...]}, stats)."""
|
||||||
|
cutoff = (asof - dt.timedelta(days=window_days)).isoformat()
|
||||||
|
|
||||||
|
# 1. tickers 의 회사명 조회
|
||||||
|
if not tickers:
|
||||||
|
return {}, {"total_articles": 0, "matched_pairs": 0, "hit_tickers": 0}
|
||||||
|
placeholders = ",".join("?" * len(tickers))
|
||||||
|
name_rows = conn.execute(
|
||||||
|
f"SELECT ticker, name FROM krx_master WHERE ticker IN ({placeholders})",
|
||||||
|
tickers,
|
||||||
|
).fetchall()
|
||||||
|
name_map = {r[0]: r[1] for r in name_rows if r[1]}
|
||||||
|
|
||||||
|
# 2. 최근 articles 조회
|
||||||
|
articles = conn.execute(
|
||||||
|
"SELECT title, summary, press, pub_date, crawled_at "
|
||||||
|
"FROM articles WHERE crawled_at >= ? ORDER BY crawled_at DESC",
|
||||||
|
(cutoff,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
# 3. 매핑
|
||||||
|
out: Dict[str, List[Dict[str, Any]]] = {t: [] for t in tickers}
|
||||||
|
matched_pairs = 0
|
||||||
|
for a in articles:
|
||||||
|
title = (a[0] or "").strip()
|
||||||
|
summary = (a[1] or "").strip()
|
||||||
|
haystack = title + " " + summary
|
||||||
|
for ticker, name in name_map.items():
|
||||||
|
if not name or len(name) < 2:
|
||||||
|
continue
|
||||||
|
if name in haystack:
|
||||||
|
if len(out[ticker]) >= max_per_ticker:
|
||||||
|
continue
|
||||||
|
out[ticker].append({
|
||||||
|
"title": title,
|
||||||
|
"summary": summary,
|
||||||
|
"press": a[2] or "",
|
||||||
|
"pub_date": a[3] or "",
|
||||||
|
})
|
||||||
|
matched_pairs += 1
|
||||||
|
|
||||||
|
hit_tickers = sum(1 for arts in out.values() if arts)
|
||||||
|
stats = {
|
||||||
|
"total_articles": len(articles),
|
||||||
|
"matched_pairs": matched_pairs,
|
||||||
|
"hit_tickers": hit_tickers,
|
||||||
|
}
|
||||||
|
return out, stats
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. `pipeline.py` 변경
|
||||||
|
|
||||||
|
`refresh_daily()` 의 `_make_http()` / `asyncio.Semaphore(rate_limit)` / scraper 호출 부분 교체:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def refresh_daily(conn, asof, *, top_n=100, concurrency=10,
|
||||||
|
max_news_per_ticker=5, model=_analyzer.DEFAULT_MODEL):
|
||||||
|
started = time.time()
|
||||||
|
tickers = _top_market_cap_tickers(conn, n=top_n)
|
||||||
|
name_map = {...} # 기존 그대로
|
||||||
|
|
||||||
|
# 새: articles 매핑
|
||||||
|
articles_by_ticker, mapping_stats = articles_source.gather_articles_for_tickers(
|
||||||
|
conn, tickers, asof, window_days=1, max_per_ticker=max_news_per_ticker,
|
||||||
|
)
|
||||||
|
|
||||||
|
sem = asyncio.Semaphore(concurrency)
|
||||||
|
async with _make_llm() as llm:
|
||||||
|
tasks = []
|
||||||
|
for t in tickers:
|
||||||
|
articles = articles_by_ticker.get(t, [])
|
||||||
|
if not articles:
|
||||||
|
continue # 매핑 0 — score 미생성
|
||||||
|
tasks.append(_process_one_articles(
|
||||||
|
t, name_map.get(t, t), articles, sem, llm, model
|
||||||
|
))
|
||||||
|
raw_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
successes, failures = _split_results(raw_results)
|
||||||
|
if successes:
|
||||||
|
_upsert_news_sentiment(conn, asof, successes, source="articles")
|
||||||
|
|
||||||
|
top_pos = sorted(successes, key=lambda r: -r["score_raw"])[:5]
|
||||||
|
top_neg = sorted(successes, key=lambda r: r["score_raw"])[:5]
|
||||||
|
return {
|
||||||
|
"asof": asof.isoformat(),
|
||||||
|
"updated": len(successes),
|
||||||
|
"failures": [str(f) for f in failures],
|
||||||
|
"duration_sec": round(time.time() - started, 2),
|
||||||
|
"tokens_input": sum(r["tokens_input"] for r in successes),
|
||||||
|
"tokens_output": sum(r["tokens_output"] for r in successes),
|
||||||
|
"top_pos": top_pos, "top_neg": top_neg, "model": model,
|
||||||
|
"mapping": mapping_stats, # 신규
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _process_one_articles(ticker, name, articles, sem, llm, model):
|
||||||
|
async with sem:
|
||||||
|
return await _analyzer.score_sentiment(llm, ticker, articles, name=name, model=model)
|
||||||
|
```
|
||||||
|
|
||||||
|
`_make_http()` 제거. legacy scraper 의존 없음.
|
||||||
|
|
||||||
|
`_upsert_news_sentiment` 에 `source` 인자 추가:
|
||||||
|
```python
|
||||||
|
def _upsert_news_sentiment(conn, asof, rows, *, source="articles"):
|
||||||
|
iso = asof.isoformat()
|
||||||
|
data = [(
|
||||||
|
r["ticker"], iso, r["score_raw"], r["reason"], r["news_count"],
|
||||||
|
r["tokens_input"], r["tokens_output"], r["model"], source,
|
||||||
|
) for r in rows]
|
||||||
|
conn.executemany(
|
||||||
|
"""INSERT INTO news_sentiment
|
||||||
|
(ticker, date, score_raw, reason, news_count,
|
||||||
|
tokens_input, tokens_output, model, source)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(ticker, date) DO UPDATE SET
|
||||||
|
score_raw=excluded.score_raw, reason=excluded.reason,
|
||||||
|
news_count=excluded.news_count, tokens_input=excluded.tokens_input,
|
||||||
|
tokens_output=excluded.tokens_output, model=excluded.model,
|
||||||
|
source=excluded.source
|
||||||
|
""", data,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. `analyzer.py` 변경 (미세)
|
||||||
|
|
||||||
|
`news_block` 빌더만:
|
||||||
|
```python
|
||||||
|
def _format_news_block(news: List[Dict[str, Any]]) -> str:
|
||||||
|
lines = []
|
||||||
|
for n in news:
|
||||||
|
date = n.get("pub_date", "")
|
||||||
|
title = n["title"]
|
||||||
|
summary = (n.get("summary") or "").strip()
|
||||||
|
if summary:
|
||||||
|
lines.append(f"- [{date}] {title}\n {summary[:200]}")
|
||||||
|
else:
|
||||||
|
lines.append(f"- [{date}] {title}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
```
|
||||||
|
|
||||||
|
`score_sentiment()` 의 prompt 빌드 부분:
|
||||||
|
```python
|
||||||
|
news_block = _format_news_block(news)
|
||||||
|
```
|
||||||
|
|
||||||
|
LLM 입력 토큰 ~2-3배 (summary 200자 cap). 매핑 수가 감소(예상 100 → 30-60)하므로 총 토큰 비용은 비슷하거나 약간 감소.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 텔레그램 매핑 통계 표시
|
||||||
|
|
||||||
|
`telegram.build_message()` 에 `mapping` 인자 추가:
|
||||||
|
```python
|
||||||
|
def build_message(*, asof, top_pos, top_neg, tokens_input, tokens_output,
|
||||||
|
mapping=None):
|
||||||
|
...
|
||||||
|
cost = _cost_won(tokens_input, tokens_output)
|
||||||
|
mapping_line = ""
|
||||||
|
if mapping:
|
||||||
|
mapping_line = (
|
||||||
|
f"매핑: {mapping['hit_tickers']}/100 ticker "
|
||||||
|
f"\\({mapping['matched_pairs']}쌍 / articles {mapping['total_articles']}건\\) · "
|
||||||
|
)
|
||||||
|
lines += [
|
||||||
|
"",
|
||||||
|
f"_분석: 시총 상위 100종목 · {mapping_line}"
|
||||||
|
f"토큰 {tokens_input:,} in / {tokens_output:,} out · 약 ₩{cost:,}_",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
```
|
||||||
|
|
||||||
|
`router.py` 에서 `mapping=summary.get('mapping')` 전달.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 테스트 전략
|
||||||
|
|
||||||
|
### 10.1 신규 `test_ai_news_articles_source.py` (6 tests)
|
||||||
|
1. **single_ticker_match_in_title** — title 에 회사명 → 매핑 hit
|
||||||
|
2. **single_ticker_match_in_summary** — summary 에 회사명 → 매핑 hit
|
||||||
|
3. **multi_ticker_match** — 한 article 이 두 회사명 포함 → 두 ticker 모두 매핑
|
||||||
|
4. **no_match_returns_empty_list** — 회사명 미포함 article → 빈 리스트
|
||||||
|
5. **max_per_ticker_caps_results** — 6개 매핑 가능한 articles 중 max=5
|
||||||
|
6. **window_days_filters_old_articles** — crawled_at < cutoff 인 article 제외
|
||||||
|
|
||||||
|
### 10.2 갱신 `test_ai_news_pipeline.py`
|
||||||
|
기존 `patch.object(pipeline, "_scraper")` 패턴을 `patch.object(pipeline, "articles_source")` 로 교체. 시나리오:
|
||||||
|
- happy path: 3 ticker × 1 article each
|
||||||
|
- failures isolated: 한 ticker LLM error
|
||||||
|
- 매핑 0 ticker (skip 검증)
|
||||||
|
|
||||||
|
### 10.3 갱신 `test_ai_news_analyzer.py`
|
||||||
|
- `news` 입력에 `summary` 가 있을 때 prompt 에 포함되는지
|
||||||
|
- summary 없을 때 title 만 사용
|
||||||
|
- pub_date 표시
|
||||||
|
|
||||||
|
### 10.4 갱신 `test_ai_news_telegram.py`
|
||||||
|
- `mapping` 인자 있을 때 매핑 라인 포함
|
||||||
|
- `mapping=None` 일 때 기존 동작
|
||||||
|
|
||||||
|
### 10.5 갱신 `test_ai_news_router.py`
|
||||||
|
- response 에 `mapping` 필드 포함
|
||||||
|
|
||||||
|
### 10.6 갱신 `test_screener_schema.py`
|
||||||
|
- migration 시 `source` 컬럼 생성
|
||||||
|
- 기존 row 의 source default 검증
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 운영 가정 + 모니터링
|
||||||
|
|
||||||
|
| 가정 | 모니터링 |
|
||||||
|
|------|----------|
|
||||||
|
| 기존 `stock_news` cron (7:30 KST)이 articles 매일 수집 | 그게 깨지면 ai_news 도 0 결과 — articles 일별 count 별도 모니터링 권장 (이번 슬라이스 외) |
|
||||||
|
| 시장 뉴스에 시총 상위 100종목 회사명이 자주 등장 | hit-rate 텔레그램 라인으로 일별 확인. <30% 면 alias dict 추가 검토 |
|
||||||
|
| 회사명 substring match가 false positive 적음 | 4주 IC 결과로 검증 (positive면 매핑 정확도 OK 추정) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 에러 처리
|
||||||
|
|
||||||
|
| 상황 | 처리 |
|
||||||
|
|------|------|
|
||||||
|
| articles 테이블 비어 있음 | gather() 반환 = `{}`, stats `total=0`. 모든 ticker skip, news_sentiment 0 row 추가, telegram에 "매핑 0/100" 표시 |
|
||||||
|
| 시총 상위 ticker 모두 매핑 0 | `updated=0` → on_ai_news_schedule 의 운영자 알림 분기 (기존 그대로) |
|
||||||
|
| krx_master 비어 있음 | gather() 가 빈 결과, 위와 동일 |
|
||||||
|
| LLM 실패 (특정 ticker) | 기존 fail-soft 그대로. failures 리스트에 추가, 다른 ticker 영향 없음 |
|
||||||
|
| migration 실행 실패 (예: 이미 컬럼 존재) | PRAGMA table_info 체크로 idempotent. ALTER 안 실행 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 비용 / 성능 비교
|
||||||
|
|
||||||
|
| 항목 | 현재 (Naver) | Phase 1 (articles) |
|
||||||
|
|------|--------------|-------------------|
|
||||||
|
| 외부 HTTP | 100건/일 (Naver) | 0건 |
|
||||||
|
| 실패율 | 30%+ (Naver 차단) | 0% (DB 조회) |
|
||||||
|
| LLM calls | 100 | hit_tickers 수 (예상 30-60) |
|
||||||
|
| LLM input tokens | ~25K | ~30-50K (summary 포함) |
|
||||||
|
| 일 비용 | ~$0.075 | ~$0.05-0.10 (실측 후) |
|
||||||
|
| 처리 시간 | 30-60초 | 5-15초 (DB + LLM) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Rollback
|
||||||
|
|
||||||
|
- 데이터: `news_sentiment.source` 컬럼으로 Phase 1 데이터와 이전 Naver 데이터 구분 가능
|
||||||
|
- 코드: `git revert` 만으로 가능. legacy `scraper.py` 유지로 코드 회복 즉시
|
||||||
|
- 환경변수 토글: **미포함** (YAGNI)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 후속 슬라이스 (Phase 1 이후 결정)
|
||||||
|
|
||||||
|
- **Phase 1.5** — 매핑 hit-rate < 30% 면 alias dict 추가 (50-100개)
|
||||||
|
- **Phase 2** — 4주 IC ≥ 0.05 시 DART OpenAPI 추가 (하이브리드 점수)
|
||||||
|
- **Phase X** — IC < 0.05 시 노드 deprecate 후 삭제 (scraper + analyzer + pipeline + node + DB cleanup)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 완료 조건 (Definition of Done)
|
||||||
|
|
||||||
|
- [ ] `articles_source.py` + 6개 단위 테스트
|
||||||
|
- [ ] `news_sentiment.source` 컬럼 추가 + migration
|
||||||
|
- [ ] `pipeline.py` 가 articles_source 사용 (scraper 호출 없음)
|
||||||
|
- [ ] `analyzer.py` 가 summary 포함 prompt
|
||||||
|
- [ ] `telegram.py` 에 매핑 통계 라인
|
||||||
|
- [ ] `router.py` 응답에 `mapping` 필드
|
||||||
|
- [ ] 기존 76 단위 테스트 + 갱신/신규 테스트 모두 통과
|
||||||
|
- [ ] 운영 환경 트리거 시 텔레그램에 "매핑 N/100" 표시 + news_sentiment 행에 source='articles'
|
||||||
|
- [ ] LLM 비용이 일 ~$0.05-0.10 범위로 감소 (텔레그램 ₩ 라인으로 확인)
|
||||||
|
- [ ] 첫 실행 후 매핑 hit-rate 메모리 기록 (1.5/2 결정 baseline)
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
# Confidence Signal Pipeline V2 — Architecture & Contract (Phase 0)
|
||||||
|
|
||||||
|
**작성일**: 2026-05-15
|
||||||
|
**작성자**: gahusb
|
||||||
|
**상태**: Approved for implementation (Phase 0 = architecture decisions, 코드 변경 없음)
|
||||||
|
**Amended 2026-05-15**: Chronos-2 채택 (LSTM 폐기) + Qwen3 14B 채택 (Claude Haiku 폐기). 모델 결정 11개 보정.
|
||||||
|
**선행 컨텍스트**:
|
||||||
|
- adversarial review (2026-05-13) — 신호 검증 인프라 필요성
|
||||||
|
- Stock Screener V1 (post-close 16:30 Top-N) — 가치 발굴 완성
|
||||||
|
- AI News Phase 1 (`articles` source, weight=0 검증 대기) — sentiment 신호
|
||||||
|
- web-ai (Windows GPU, RTX 5070 Ti) — LSTM + KIS API + Telegram Bot 기존 자산
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 비전
|
||||||
|
|
||||||
|
**"주식을 쉽게 잘하기"** — 다층 신뢰도 시스템으로 사용자 + 아내 모두에게 확신 있는 매매 신호 전달.
|
||||||
|
|
||||||
|
V1 screener는 종가 기반 일별 Top-N 만 산출. V2는:
|
||||||
|
- **가치 발굴 (stock-lab 종가 기반)** ×
|
||||||
|
- **시점 분석 (web-ai 장중 Chronos-2 + 분봉)** ×
|
||||||
|
- **2차 검증 (agent-office → web-ai Qwen3 14B Ollama)** ×
|
||||||
|
- **이중 텔레그램 (본인 = 기술 풀 / 아내 = 간소화)**
|
||||||
|
= **확신의 신호**
|
||||||
|
|
||||||
|
**역할 분리 — 두 AI 모델**:
|
||||||
|
- **Chronos-2** (Amazon, 120M params, FP16 ~1GB) = 시계열 예측 엔진 (수치 → quantile 분포)
|
||||||
|
- **Qwen3 14B Q4** (Ollama, ~8.3GB) = 분석가/개발자 보조 두뇌 (자연어 메시지 + 전략 해석 + 코드 자동화)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Phase 0 산출물
|
||||||
|
|
||||||
|
**본 spec 1 문서**. 코드 변경 0. 후속 Phase 1-7 의 모든 구현이 본 spec 의 결정을 따른다.
|
||||||
|
|
||||||
|
핵심 결정 8개 (amend 시점):
|
||||||
|
1. 데이터 채널 — `web-ai pull from stock-lab` (web-ai 가 polling)
|
||||||
|
2. 데이터 소스 — KIS API 직접 (web-ai) + stock-lab API (settings/screener/portfolio)
|
||||||
|
3. **시점 예측 모델 — Chronos-2 (Amazon, 120M, zero-shot, quantile 분포)**
|
||||||
|
4. **2차 검증 모델 — Qwen3 14B Q4 (Ollama on web-ai, ~8.3GB, 응답 ~13초)**
|
||||||
|
5. 2차 검증 방식 — context augmentation (메시지 직접 작성 + 양방향 게이트)
|
||||||
|
6. 트리거 — 매수 (screener Top-20) + 매도 (portfolio 보유). 관심종목은 백로그
|
||||||
|
7. 이중 텔레그램 — 본인 풀버전 + 아내 간소화. LLM 단일 콜에서 양쪽 생성
|
||||||
|
8. 운영 — 시간대별 폴링 주기 (장전 5분 / 장중 1분 / 장후 5분 / 야간 없음 — Chronos-2 zero-shot)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 시스템 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐ ┌──────────────────────────────────┐
|
||||||
|
│ NAS (Synology Docker) │ │ Windows PC (RTX 5070 Ti) │
|
||||||
|
│ │ │ │
|
||||||
|
│ ┌────────────────────────────────┐ │ │ ┌─────────────────────────────┐ │
|
||||||
|
│ │ stock-lab :18500 │ │ │ │ web-ai :8001 │ │
|
||||||
|
│ │ • /screener/settings │◄─┼──────┼─►│ ① Pull Worker │ │
|
||||||
|
│ │ • /screener/run │ │ HTTP │ │ (시간대별 폴링) │ │
|
||||||
|
│ │ • /portfolio │ │ pull │ │ │ │
|
||||||
|
│ │ • /news-sentiment (옵션) │ │ │ │ ② KIS Client │ │
|
||||||
|
│ └────────────────────────────────┘ │ │ │ (WebSocket 분봉/호가) │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ ┌────────────────────────────────┐ │ │ │ ③ Chronos-2 Predictor │ │
|
||||||
|
│ │ agent-office :18900 │◄─┼──────┼──┤ (Chronos-2 120M zero-shot)│ │
|
||||||
|
│ │ • /signal (Ollama 라우팅) │ │ HTTP │ │ 60일 → quantile 분포 │ │
|
||||||
|
│ │ • Telegram dispatcher (이중) │ │ push │ │ │ │
|
||||||
|
│ │ → web-ai Ollama HTTP 호출 │ │ │ │ ④ Timing Analyzer │ │
|
||||||
|
│ └─────────┬──────────────────────┘ │ trig │ │ (분봉 모멘텀) │ │
|
||||||
|
│ │ │ │ │ │ │
|
||||||
|
└────────────┼──────────────────────────┘ │ │ ⑤ Signal Generator │ │
|
||||||
|
│ │ │ (매수/매도 룰) │ │
|
||||||
|
▼ │ │ │ │
|
||||||
|
┌─────────────────┐ │ │ ⑥ Rate Limiter │ │
|
||||||
|
│ Telegram │ │ │ (24h 중복 차단) │ │
|
||||||
|
│ - 본인 (full) │ │ └─────────────┬───────────────┘ │
|
||||||
|
│ - 아내 (lite) │ │ │
|
||||||
|
└─────────────────┘ └───────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**책임 분리**:
|
||||||
|
- **stock-lab**: 가치 발굴 (8 노드 + 위생 게이트 + ATR), 사용자 설정 저장, portfolio 단일 진실원
|
||||||
|
- **web-ai**: 시점 분석 (Chronos-2 + 분봉), 시그널 생성, rate limit, **Ollama LLM 호스팅 (Qwen3 14B Q4)**
|
||||||
|
- **agent-office**: 신호 라우팅 (web-ai Ollama HTTP 호출), 텔레그램 발송 (본인 + 아내)
|
||||||
|
- **web-ui**: stock-lab settings 편집 (캔버스 UI). 신호 수신/표시는 V2 NOT.
|
||||||
|
|
||||||
|
**VRAM 분배 (RTX 5070 Ti 16GB, usable 15.5GB)**:
|
||||||
|
- Chronos-2: ~1GB
|
||||||
|
- Qwen3 14B Q4: ~8.3GB
|
||||||
|
- 합: ~9.3GB
|
||||||
|
- 여유: ~6GB (안전 마진)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 데이터 소스 분담
|
||||||
|
|
||||||
|
| 데이터 | 출처 | 갱신 주기 | 저장소 |
|
||||||
|
|--------|------|----------|-------|
|
||||||
|
| KRX 일봉 60일 (Chronos-2 입력) | KIS API (web-ai 직접) | 시작 시 + 종가 후 갱신 | web-ai 로컬 |
|
||||||
|
| 정규장 분봉/실시간 호가 | KIS API WebSocket (web-ai 직접) | 실시간 | web-ai 메모리 |
|
||||||
|
| NXT 가격 스냅샷 (장전/장후) | KIS API + 네이버 모바일 백업 | 30초~1분 폴링 | web-ai 로컬 |
|
||||||
|
| screener settings (가중치) | stock-lab API (web-ai pull) | 1-5분 | NAS `stock.db` |
|
||||||
|
| screener 점수 (Top-20) | stock-lab `/run` 호출 결과 | 1-5분 | NAS (preview 모드, 미저장) |
|
||||||
|
| portfolio (보유 종목 + 평단) | stock-lab API (web-ai pull) | 1-5분 | NAS `stock.db` |
|
||||||
|
| 외인/기관 수급 | stock-lab (네이버 frgn) | 종가 후 16:30 | NAS `stock.db` |
|
||||||
|
| AI 뉴스 sentiment | stock-lab (articles 기반 Claude) | 평일 08:00 | NAS `stock.db` |
|
||||||
|
| 사용자 텔레그램 chat IDs | agent-office 환경변수 | 정적 | docker-compose env |
|
||||||
|
|
||||||
|
**원칙**:
|
||||||
|
- web-ai는 NAS DB 직접 접근 안 함 — 모든 데이터는 stock-lab API 경유
|
||||||
|
- KIS API 데이터는 web-ai 로컬에만 — NAS push 안 함 (실시간성 + 용량)
|
||||||
|
- 본인+아내 chat ID 는 agent-office 단독 보관 — web-ai 는 ticker/action 만 push
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API 계약
|
||||||
|
|
||||||
|
### 5.1 stock-lab → web-ai (pull 응답)
|
||||||
|
|
||||||
|
**기존 endpoint (변경 없음)**:
|
||||||
|
- `GET /api/stock/screener/settings` — 현재 가중치/임계값
|
||||||
|
- `POST /api/stock/screener/run {mode:"preview"}` — 8 노드 점수 + Top-N (DB 미저장)
|
||||||
|
- `GET /api/portfolio` — 보유 종목 리스트
|
||||||
|
|
||||||
|
**신규 endpoint (Phase 1)**:
|
||||||
|
- `GET /api/stock/screener/news-sentiment?days=1` — 종목별 sentiment 점수 (옵션, Phase 1 에 추가)
|
||||||
|
|
||||||
|
### 5.2 web-ai → agent-office (push)
|
||||||
|
|
||||||
|
**신규 endpoint** (Phase 5):
|
||||||
|
```
|
||||||
|
POST /api/agent-office/signal
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ticker": "005930",
|
||||||
|
"name": "삼성전자",
|
||||||
|
"action": "buy" | "sell",
|
||||||
|
"confidence_webai": 0.82,
|
||||||
|
"current_price": 78500,
|
||||||
|
"avg_price": 75000, // sell 시에만
|
||||||
|
"pnl_pct": 0.047, // sell 시에만
|
||||||
|
"context": {
|
||||||
|
"lstm_pred_1d": 0.023,
|
||||||
|
"lstm_pred_conf": 0.82,
|
||||||
|
"screener_rank": 3,
|
||||||
|
"screener_scores": {"foreign_buy": 88, "volume_surge": 75, "momentum": 60, ...},
|
||||||
|
"minute_momentum": "strong_up" | "weak_up" | "neutral" | "weak_down" | "strong_down",
|
||||||
|
"kospi_change": 0.004,
|
||||||
|
"news_sentiment": 6.2,
|
||||||
|
"news_top": ["HBM 양산 가시화", "1분기 어닝 서프라이즈"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response (agent-office → web-ai):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"decision": "send" | "hold",
|
||||||
|
"final_confidence": 0.745,
|
||||||
|
"telegram_self_sent": true,
|
||||||
|
"telegram_wife_sent": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 web-ai Ollama 응답 (agent-office → Ollama HTTP)
|
||||||
|
|
||||||
|
agent-office 가 web-ai 의 Ollama (Qwen3 14B Q4) 에 보내는 prompt 의 응답 schema:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"decision": "send" | "hold",
|
||||||
|
"confidence_llm": 0.91,
|
||||||
|
"reason": "외인+거래량+호재 일관성 강함",
|
||||||
|
"warnings": ["KOSPI 약세 가능성"],
|
||||||
|
"message_self": "🔔 매수 신호: 삼성전자 (005930)\n💡 신뢰도 ...",
|
||||||
|
"message_wife": "📈 추천: 삼성전자 매수 검토\n사유: ..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`final_confidence = confidence_webai × confidence_llm`. 임계값 (default 0.7) 미만 또는 `decision="hold"` 면 silent (텔레그램 발송 안 함).
|
||||||
|
|
||||||
|
**프롬프트 엔지니어링 (Qwen3 14B JSON 강제)** — ai_news 슬라이스의 Claude JSON 강제 패턴 적용:
|
||||||
|
- system: "너는 한국 주식 분석가다. JSON 객체 하나만 반환한다."
|
||||||
|
- assistant prefill `"{"` 로 응답 시작 강제
|
||||||
|
- temperature=0
|
||||||
|
- 응답 파싱 실패 시 `decision="hold"` 폴백 (silent block)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 시그널 룰
|
||||||
|
|
||||||
|
### 6.1 매수 신호 (screener Top-20 종목 대상)
|
||||||
|
|
||||||
|
조건 (전부 충족):
|
||||||
|
1. Chronos-2 1-day quantile (median) 예측 > 0% 그리고 분포 폭 (90-10 분위수 / 50 분위수) < 0.6 (좁은 분포 = 높은 conf)
|
||||||
|
2. 분봉 모멘텀 = `strong_up`:
|
||||||
|
- 5분봉 5개 연속 양봉
|
||||||
|
- 거래량 > 평균 1.5배
|
||||||
|
3. KIS 호가 매수세 ≥ 60%
|
||||||
|
|
||||||
|
종합 confidence:
|
||||||
|
```
|
||||||
|
confidence_webai = chronos_conf × 0.5 + minute_score × 0.3 + screener_norm × 0.2
|
||||||
|
```
|
||||||
|
- `chronos_conf` ∈ [0, 1] — Chronos-2 분포 폭에서 변환 (좁을수록 1에 가까움)
|
||||||
|
- `minute_score` ∈ [0, 1] (5분봉 강도 + 거래량 multiplier 정규화)
|
||||||
|
- `screener_norm` = 1 - (rank - 1) / 20 (rank 1 = 1.0, rank 20 = 0.05)
|
||||||
|
|
||||||
|
**임계값**: `confidence_webai > 0.7` → agent-office 전송. 아니면 silent.
|
||||||
|
|
||||||
|
### 6.2 매도 신호 (portfolio 보유 종목 대상)
|
||||||
|
|
||||||
|
**손절선** (사용자 조정 가능, default -7%):
|
||||||
|
- `pnl_pct < -0.07` 시 즉시 매도 시그널 (Chronos-2/분봉 무관)
|
||||||
|
- 메시지: "손절선 도달, 매도 검토"
|
||||||
|
|
||||||
|
**익절선** (default +15%):
|
||||||
|
- `pnl_pct > 0.15` 시 검토 알림 (강제 매도 아님)
|
||||||
|
- 메시지: "익절선 도달, 부분 매도 또는 추세 추종 검토"
|
||||||
|
|
||||||
|
**이상 신호** (보유 중 급격한 약세):
|
||||||
|
- Chronos-2 1-day quantile (median) 예측 < -1% + 분포 폭 좁음 (chronos_conf > 0.7)
|
||||||
|
- 분봉 모멘텀 = `strong_down`
|
||||||
|
- KIS 호가 매도세 ≥ 60%
|
||||||
|
- `confidence_webai > 0.7` 동일 임계값으로 전송
|
||||||
|
|
||||||
|
### 6.3 Rate limit
|
||||||
|
|
||||||
|
- **같은 종목 + 같은 action**: 24h 내 재알림 금지
|
||||||
|
- **장 마감 후 재실행**: 손절선/익절선 알림은 1일 1회 maximum
|
||||||
|
- Rate limit state: web-ai 로컬 SQLite 또는 메모리 dict (재기동 시 reset = 운영상 허용)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 텔레그램 메시지 형식
|
||||||
|
|
||||||
|
### 7.1 본인 (기술 풀)
|
||||||
|
|
||||||
|
```
|
||||||
|
🔔 매수 신호: 삼성전자 (005930)
|
||||||
|
💡 신뢰도 87/100 (web-ai 82 × Qwen3 91)
|
||||||
|
|
||||||
|
📊 분석 근거:
|
||||||
|
• Chronos-2 예측: 다음날 +2.3% (분포 폭 좁음, conf 0.82)
|
||||||
|
• Screener Top-3: 외인+거래량 강세
|
||||||
|
• AI 뉴스: +6.2 (HBM 양산 가시화)
|
||||||
|
• 분봉 모멘텀: 강세 (5분봉 5연속 양봉)
|
||||||
|
• KOSPI: +0.4% (약강세)
|
||||||
|
|
||||||
|
⚠️ 주의:
|
||||||
|
• 코스피 약세 구간 진입 가능성
|
||||||
|
• 분할 매수 권고
|
||||||
|
|
||||||
|
현재가: 78,500원
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 아내 (간소화)
|
||||||
|
|
||||||
|
```
|
||||||
|
📈 추천: 삼성전자 매수 검토
|
||||||
|
사유: 외국인 매수 강세 + 호재 뉴스
|
||||||
|
추천 강도: ★★★★☆ (높음)
|
||||||
|
현재가: 78,500원
|
||||||
|
```
|
||||||
|
|
||||||
|
추천 강도 표시: `final_confidence` 기준
|
||||||
|
- ★★★★★ (0.85+)
|
||||||
|
- ★★★★☆ (0.7-0.85)
|
||||||
|
- ★★★☆☆ (0.55-0.7) — 텔레그램 발송은 0.7 임계값이라 도달 안 함
|
||||||
|
|
||||||
|
### 7.3 매도 메시지 (본인/아내 양쪽)
|
||||||
|
|
||||||
|
본인:
|
||||||
|
```
|
||||||
|
🚨 매도 신호: SK하이닉스 (000660)
|
||||||
|
💡 신뢰도 78/100
|
||||||
|
|
||||||
|
📊 사유:
|
||||||
|
• 평단 대비 -7.2% (손절선 도달)
|
||||||
|
• Chronos-2 다음날 -1.5% 예측 (conf 0.75)
|
||||||
|
• 분봉 강한 매도세
|
||||||
|
|
||||||
|
매도 검토 권고. 평단 152,000원 → 현재 141,100원
|
||||||
|
```
|
||||||
|
|
||||||
|
아내:
|
||||||
|
```
|
||||||
|
⚠️ 매도 검토: SK하이닉스
|
||||||
|
사유: 손절선 도달, 약세 신호
|
||||||
|
손익: -7.2%
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 운영 모드
|
||||||
|
|
||||||
|
| 시간대 | web-ai 동작 | 폴링 주기 | 비용 |
|
||||||
|
|--------|------------|----------|------|
|
||||||
|
| **장전 (07:00-09:00)** | settings + screener pull + NXT 가격 + sentiment | 5분 | 0 |
|
||||||
|
| **장중 (09:00-15:30)** | KIS 분봉 + 호가 + Chronos-2 추론 + 시그널 + Qwen3 검증 | 1분 | 0 (LLM 로컬) |
|
||||||
|
| **장후 (15:30-20:00)** | NXT 가격 + 보유 종목 PnL 추적 + 손절/익절 알림 | 5분 | 0 |
|
||||||
|
| **야간 (20:00-07:00)** | (재학습 cron 없음 — Chronos-2 zero-shot) | — | 0 |
|
||||||
|
|
||||||
|
**예상 LLM 비용**:
|
||||||
|
- **월 LLM API 비용 = 0** (Qwen3 14B Q4 로컬 호스팅)
|
||||||
|
- 전기료만 (Windows PC 상시 가동, RTX 5070 Ti 평균 idle ~30W + 추론 spike ~200W)
|
||||||
|
- 일 신호 3-5건 × ~13초 추론 = 일 GPU full load ~1분 정도, 무시 가능
|
||||||
|
- **Chronos-2 추론은 GPU 로컬, 비용 0**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Phase 1-7 분해
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1: stock-lab API 보강 (1주)
|
||||||
|
- /api/portfolio 외부 노출 (현재 web-ui 내부용)
|
||||||
|
- /api/stock/screener/news-sentiment endpoint 추가
|
||||||
|
- /api/stock/screener/run preview 옵션 검증
|
||||||
|
|
||||||
|
Phase 2: web-ai Pull Worker + Signal API Client (2주)
|
||||||
|
- 기존 main_server.py + bot.py 분리
|
||||||
|
- stock-lab API client (httpx + retry + cache)
|
||||||
|
- 시간대별 폴링 스케줄러
|
||||||
|
- rate limit DB
|
||||||
|
|
||||||
|
Phase 3: KIS WebSocket + 분봉 + Chronos-2 추론 (2주, ↓ 1주)
|
||||||
|
- KIS WebSocket client (정규장 분봉 + 호가)
|
||||||
|
- NXT 폴링 client (스냅샷 + 네이버 백업)
|
||||||
|
- Chronos-2 zero-shot 추론 파이프라인 (HuggingFace 모델 로드 + 배치 추론)
|
||||||
|
- 분봉 모멘텀 분류기
|
||||||
|
- (재학습 인프라 X — Chronos-2 zero-shot)
|
||||||
|
|
||||||
|
Phase 4: Signal Generator (1주)
|
||||||
|
- 매수 룰 (Chronos-2 quantile + 분봉 + 호가 + screener)
|
||||||
|
- 매도 룰 (손절/익절/이상)
|
||||||
|
- confidence 계산 + 임계값
|
||||||
|
|
||||||
|
Phase 5: agent-office /signal + Ollama Qwen3 검증 + 이중 텔레그램 (2주)
|
||||||
|
- POST /signal 라우터 (agent-office)
|
||||||
|
- web-ai 에 Ollama 서버 + Qwen3 14B Q4 설치
|
||||||
|
- agent-office → web-ai Ollama HTTP client (Anthropic SDK 대체)
|
||||||
|
- Qwen3 prompt (system + user + assistant prefill JSON)
|
||||||
|
- 본인/아내 dispatcher
|
||||||
|
- **A/B 테스트 1주 — 본인 chat 에 Qwen3/Claude Haiku 메시지 동시 발송 후 한 쪽 채택**
|
||||||
|
|
||||||
|
Phase 6: web-ai 기존 trading bot 정리 (1주)
|
||||||
|
- 자체 watchlist_manager 삭제
|
||||||
|
- 자체 뉴스 크롤링 (Ollama) 삭제
|
||||||
|
- 기존 자동 매매 (KIS 실주문) 비활성화 또는 별도 모드 분리
|
||||||
|
|
||||||
|
Phase 7: 운영 모니터링 + 4주 IC 검증 (1주 + 4주)
|
||||||
|
- 신호 hit-rate 추적 (forward return correlation)
|
||||||
|
- false positive rate
|
||||||
|
- 임계값 점진 조정
|
||||||
|
- Phase 8 (자동 매매) 검토
|
||||||
|
```
|
||||||
|
|
||||||
|
총 10-12주 (개인 페이스). 각 Phase 마다 자체 spec + plan + 검증 사이클.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Backlog (V2 본 spec NOT)
|
||||||
|
|
||||||
|
미래 슬라이스로 분리:
|
||||||
|
- **관심종목 (watchlist) 모니터링** — Top-N + portfolio 외, 사용자 관심종목의 변동성 spike / 거래량 급증 알람
|
||||||
|
- **자동 매매 (KIS 실주문)** — Phase 8 검토. 4주 신호 hit-rate ≥ 60% 후 단계적
|
||||||
|
- **DART 공시 통합** — LLM 검증 컨텍스트에 공시 추가
|
||||||
|
- **백테스트 화면** — 과거 신호 정확도 시각화
|
||||||
|
- **신호 hit-rate 대시보드** — web-ui 신규 페이지
|
||||||
|
- **분할 매수/매도 전략 추천** — Phase 7 이후
|
||||||
|
- **옵션/선물/해외 주식** — V3 검토
|
||||||
|
- **Qwen3 14B "개발자 보조" 별도 endpoint** — 전략 해석/코드 자동화/디버그 도구. V2 흐름 외 사용자 챗봇 형태 (텔레그램 또는 web-ui chat). 같은 Ollama 인스턴스 재활용
|
||||||
|
- **Claude API 폴백** — web-ai/Ollama 장애 시 anthropic 으로 자동 전환 (가용성 보강)
|
||||||
|
- **Kimi K2.6 API 옵션** — Qwen3 응답 품질 부족 시 ~80% 저비용 외부 API 대안
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 위험 및 완화
|
||||||
|
|
||||||
|
| 위험 | 완화 |
|
||||||
|
|------|------|
|
||||||
|
| Windows PC 다운 시 신호 zero | stock-lab은 정상. web-ai down 시 헬스체크 → 텔레그램 운영자 알림. Ollama도 함께 다운 (같은 머신) → Claude API 폴백은 백로그 |
|
||||||
|
| KIS API 장애 | NXT는 네이버 모바일 API 폴백. 분봉은 단기 재시도 + 일정 시간 후 alert |
|
||||||
|
| **Qwen3 14B 한국어 메시지 품질 부족** | **Phase 5 A/B 테스트 1주 — Qwen3 vs Claude Haiku 메시지 동시 발송 후 우월한 쪽 채택. Qwen3 부족 시 Claude Haiku 로 폴백** |
|
||||||
|
| False positive 다수 | 4주 IC + Phase 7 모니터링. 임계값 점진 상향 |
|
||||||
|
| Chronos-2 분포 drift | 주간 ablation (forward return correlation 추적). drift 시 다른 foundation 모델 (Moirai-2.0) 으로 교체 검토 |
|
||||||
|
| 메시지 본인-아내 drift | LLM 단일 콜에서 양쪽 동시 생성 (drift 회피, 같은 reasoning) |
|
||||||
|
| 매도 신호 지연 | 분봉 1분 폴링. 손절선은 보유 종목 단순 비교 (Chronos-2 무관 즉시 트리거) |
|
||||||
|
| stock-lab API 응답 지연 | web-ai 측 timeout 10s + 캐시 (마지막 성공 응답 ttl 5분) |
|
||||||
|
| 종목 갱신 race condition | screener Top-20 변동 시 rate limit 키 = (ticker, action, date) |
|
||||||
|
| **Qwen3 응답 13초로 분봉 1분 안에 한 사이클 끝낼 수 없을 위험** | 신호 발생 빈도 일 3-5건이라 동시 처리 거의 없음. 큐 직렬 처리로 충분. 대량 신호 시 backpressure → Phase 7 모니터링 |
|
||||||
|
| **VRAM 빡빡 (Chronos-2 + Qwen3 = 9.3GB / 15.5GB)** | 여유 6GB 안전. 동시 로딩 시점 분리 (Chronos-2 추론 → 결과 메모리 보관 → Qwen3 호출). swap 발생 시 Phase 7 에서 Qwen3 8B 로 다운그레이드 검토 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 명시적 NOT 범위 (Phase 0)
|
||||||
|
|
||||||
|
- **자동 매매 (실주문)**: V2 는 신호만. 사용자가 수동 매매. Phase 8 별도 검토
|
||||||
|
- **종목 매수 가격/수량 추천**: 사용자 결정. 신호는 "검토 권고" 수준
|
||||||
|
- **분할 매수/매도 전략**: Phase 7 이후 별도 슬라이스
|
||||||
|
- **옵션/선물/해외 주식**: KRX 정규장 + NXT 한정
|
||||||
|
- **관심종목 모니터링**: 백로그 (§10)
|
||||||
|
- **신호 hit-rate 시각화 UI**: 백로그
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 완료 조건 (Phase 0 DoD)
|
||||||
|
|
||||||
|
본 spec 완료 = 다음 조건 모두 충족:
|
||||||
|
- [x] 사용자가 spec 검토 + 승인 (2026-05-15)
|
||||||
|
- [x] git commit (`docs/superpowers/specs/2026-05-15-confidence-signal-pipeline-v2-architecture.md`)
|
||||||
|
- [x] 8 핵심 결정 명시적 (데이터 채널, 데이터 소스, Chronos-2 예측, Qwen3 검증, context augmentation, 매수+매도, 이중 텔레그램, 운영 모드)
|
||||||
|
- [x] 4개 API 계약 (3 stock-lab pull + 1 agent-office push) 모두 schema 정의
|
||||||
|
- [x] Phase 1-7 분해 + 각 Phase 추정 기간 (Phase 3 -1주, Phase 5 +0주 → 총 10-11주)
|
||||||
|
- [x] backlog + 위험/완화 매트릭스 + NOT 범위
|
||||||
|
- [x] **Amend (2026-05-15): Chronos-2 + Qwen3 14B Q4 채택 + 11 보정**
|
||||||
|
|
||||||
|
Phase 0 자체에는 코드 변경 0. 본 spec 승인 후 Phase 1 brainstorming 으로 자연스럽게 이어진다.
|
||||||
369
docs/superpowers/specs/2026-05-15-signal-v2-phase1-webai-api.md
Normal file
369
docs/superpowers/specs/2026-05-15-signal-v2-phase1-webai-api.md
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
# Confidence Signal Pipeline V2 — Phase 1: stock WebAI API Design
|
||||||
|
|
||||||
|
**작성일**: 2026-05-15
|
||||||
|
**작성자**: gahusb
|
||||||
|
**상태**: Approved for implementation
|
||||||
|
**선행 spec**:
|
||||||
|
- Phase 0 architecture (`2026-05-15-confidence-signal-pipeline-v2-architecture.md`)
|
||||||
|
- stock-lab → stock graduation (`2026-05-15-stock-lab-rename-to-stock.md`) — 본 spec 부터 새 이름 `stock` 사용
|
||||||
|
**브레인스토밍 결정 7개**: scope=B / auth=A(정적키) / portfolio shape=B(pnl_pct 추가) / news-sentiment=A(일별 dump) / endpoint 구조=1(/api/webai 분리) / rate limit=B(nginx + 인증 로그) / 테스트=B(pytest schema 검증)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 목표
|
||||||
|
|
||||||
|
Confidence Signal Pipeline V2 의 Phase 2 (web-ai pull worker) 가 stock 컨테이너에서 polling 으로 가져갈 **입력 계약 3종**을 stock 측에 신설.
|
||||||
|
|
||||||
|
stock 의 가치 발굴 데이터 (portfolio, news sentiment, screener 점수) 를 web-ai 가 안전하게 polling 할 수 있는 인증된 endpoint 묶음 = Phase 2 진입 전 필수 의존성.
|
||||||
|
|
||||||
|
**Why**: Phase 0 §3 책임 분리 — "stock = 가치 발굴, web-ai = 시점 분석". web-ai 가 NAS DB 직접 접근 안 함, 모든 데이터는 stock API 경유. 본 Phase 가 이 API 표면을 정의.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 범위
|
||||||
|
|
||||||
|
### 포함 (Phase 1)
|
||||||
|
|
||||||
|
- ① 새 endpoint `GET /api/webai/portfolio` — 기존 portfolio 응답 + `pnl_pct` 필드 보강 + `X-WebAI-Key` 인증
|
||||||
|
- ② 새 endpoint `GET /api/webai/news-sentiment` — news_sentiment 테이블 일별 dump + 인증
|
||||||
|
- ③ X-WebAI-Key 인증 인프라 — `verify_webai_key` FastAPI dependency, env `WEBAI_API_KEY`
|
||||||
|
- ④ nginx `/api/webai/*` location + `limit_req` rate limit (분당 60 + burst 20)
|
||||||
|
- ⑤ 인증 실패 logger (path + remote_addr 1회 기록)
|
||||||
|
- ⑥ 단위 + 통합 테스트 15 케이스
|
||||||
|
|
||||||
|
### 범위 외 (NOT)
|
||||||
|
|
||||||
|
- `/api/webai/screener/run` 신규 endpoint **불필요** — web-ai 는 기존 `/api/stock/screener/run` `{mode:"preview"}` 직접 호출 (Phase 2 client 구현 시 동작 검증)
|
||||||
|
- 기존 `/api/portfolio` 의 무인증 외부 노출 보안 강화 — 별도 슬라이스 (사용자 인증 도입은 Lab 사이트 통합 로그인 검토 시점)
|
||||||
|
- portfolio 의 `entry_date` / `days_held` / `position_weight` 등 추가 필드 — backlog (V2 운영 후 sell signal 정밀화 시)
|
||||||
|
- HMAC 서명, mTLS, IP allowlist — 단일 클라이언트 시나리오 + 정적 키로 충분
|
||||||
|
- nginx rate limit 응답 시간/에러율 메트릭 + 알림 — Phase 7 운영 모니터링 슬라이스
|
||||||
|
- 운영 .env 변경 자동화 — 사용자 1회 수동 갱신
|
||||||
|
- web-ui 변경 — Phase 1 은 백엔드 + 인프라만
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 변경 매트릭스
|
||||||
|
|
||||||
|
### 3.1 web-backend 코드
|
||||||
|
|
||||||
|
| 파일 | 변경 |
|
||||||
|
|------|------|
|
||||||
|
| `stock/app/auth.py` (신규) | `verify_webai_key()` FastAPI dependency |
|
||||||
|
| `stock/app/main.py` | 신규 endpoint 2개: `GET /api/webai/portfolio`, `GET /api/webai/news-sentiment` (둘 다 `dependencies=[Depends(verify_webai_key)]`). portfolio 는 기존 `get_portfolio()` 호출 + `pnl_pct` 보강 mapper |
|
||||||
|
| `stock/app/test_webai_auth.py` (신규) | `verify_webai_key` 단위 3 케이스 |
|
||||||
|
| `stock/app/test_webai_endpoints.py` (신규) | 두 endpoint × 4 케이스 + 공통 4 케이스 = 12 케이스 |
|
||||||
|
| `nginx/default.conf` | `limit_req_zone webai` 정의 + `/api/webai/` location + `X-WebAI-Key` 헤더 forward |
|
||||||
|
| `docker-compose.yml` | stock 의 env 에 `WEBAI_API_KEY=${WEBAI_API_KEY}` 추가 |
|
||||||
|
|
||||||
|
### 3.2 운영 (사용자 1회)
|
||||||
|
|
||||||
|
| 파일 | 변경 |
|
||||||
|
|------|------|
|
||||||
|
| 운영 `.env` (NAS `/volume1/docker/webpage/.env`) | `WEBAI_API_KEY=<랜덤 32~64자>` 추가 |
|
||||||
|
| Windows web-ai 의 `.env` | `WEBAI_API_KEY=<동일 값>` 추가 (Phase 2 진입 시점에 사용) |
|
||||||
|
|
||||||
|
### 3.3 web-ui
|
||||||
|
|
||||||
|
**변경 없음**. 기존 `/api/portfolio` 호출 무영향.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API 계약
|
||||||
|
|
||||||
|
### 4.1 `GET /api/webai/portfolio`
|
||||||
|
|
||||||
|
요청:
|
||||||
|
```
|
||||||
|
GET /api/webai/portfolio HTTP/1.1
|
||||||
|
X-WebAI-Key: <key>
|
||||||
|
```
|
||||||
|
|
||||||
|
응답 200 — 기존 `/api/portfolio` 응답 + 각 holdings 항목에 `pnl_pct` (비율) 추가 + summary 에 `total_pnl_pct` 추가:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"holdings": [
|
||||||
|
{
|
||||||
|
"id": 1, "broker": "키움", "ticker": "005930", "name": "삼성전자",
|
||||||
|
"quantity": 100, "avg_price": 75000, "purchase_price": 75500,
|
||||||
|
"current_price": 78500, "price_session": "REGULAR",
|
||||||
|
"price_as_of": "2026-05-15T15:30:00",
|
||||||
|
"eval_amount": 7850000, "profit_amount": 350000,
|
||||||
|
"profit_rate": 4.67,
|
||||||
|
"pnl_pct": 0.0467
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cash": [{"broker": "키움", "cash": 1000000}],
|
||||||
|
"summary": {
|
||||||
|
"total_buy": 7550000, "total_eval": 7850000,
|
||||||
|
"total_profit": 350000, "total_profit_rate": 4.67, "total_pnl_pct": 0.0467,
|
||||||
|
"total_cash": 1000000, "total_assets": 8850000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
- `pnl_pct = profit_rate / 100`
|
||||||
|
- 빈 portfolio 시 응답은 `{"holdings": [], "cash": [...], "summary": {..., "total_pnl_pct": 0.0}}`
|
||||||
|
- `profit_rate` 가 null 인 holding (현재가 조회 실패) 의 `pnl_pct` 도 null
|
||||||
|
|
||||||
|
### 4.2 `GET /api/webai/news-sentiment?date=YYYY-MM-DD`
|
||||||
|
|
||||||
|
요청:
|
||||||
|
```
|
||||||
|
GET /api/webai/news-sentiment HTTP/1.1
|
||||||
|
X-WebAI-Key: <key>
|
||||||
|
```
|
||||||
|
|
||||||
|
쿼리:
|
||||||
|
- `date` (옵션) — `YYYY-MM-DD`. 생략 시 news_sentiment 테이블의 최신 date.
|
||||||
|
|
||||||
|
응답 200:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"date": "2026-05-15",
|
||||||
|
"count": 87,
|
||||||
|
"items": [
|
||||||
|
{"ticker": "005930", "name": "삼성전자", "score": 6.2,
|
||||||
|
"reason": "HBM 양산 가시화", "news_count": 12, "source": "articles"},
|
||||||
|
{"ticker": "000660", "name": "SK하이닉스", "score": 5.5,
|
||||||
|
"reason": "...", "news_count": 8, "source": "articles"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
- `score` = news_sentiment.score_raw 그대로 (단위 -10 ~ +10 가정, ai_news/analyzer.py 결정)
|
||||||
|
- `name` = krx_master JOIN (없으면 ticker 그대로)
|
||||||
|
- `source` = 디버그용 (articles / scraper / etc.)
|
||||||
|
- 정렬 = `score DESC` (web-ai 가 자체 필터링)
|
||||||
|
- 테이블 empty 또는 지정 date 데이터 없음 → `{"date": null, "count": 0, "items": []}`
|
||||||
|
|
||||||
|
### 4.3 인증 실패 (모든 `/api/webai/*` 공통)
|
||||||
|
|
||||||
|
```
|
||||||
|
HTTP/1.1 401 Unauthorized
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"detail": "invalid or missing X-WebAI-Key"}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 페이로드 leak 없음 (응답에 endpoint 별 데이터 0)
|
||||||
|
- stock logger 에 `WARNING auth_fail path=/api/webai/portfolio remote=1.2.3.4` 1회 기록 (IP 만, 키는 로그하지 않음)
|
||||||
|
|
||||||
|
### 4.4 운영 .env 누락 시
|
||||||
|
|
||||||
|
env `WEBAI_API_KEY` 가 빈 문자열 또는 미정의 시:
|
||||||
|
- startup 시점에 stock logger 가 `ERROR WEBAI_API_KEY not configured` 1회 출력
|
||||||
|
- `/api/webai/*` 호출은 모두 503 `{"detail": "webai auth not configured"}`
|
||||||
|
- 다른 endpoint (`/api/portfolio`, `/api/stock/*`) 영향 없음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 인증 구현
|
||||||
|
|
||||||
|
`stock/app/auth.py`:
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from fastapi import Header, HTTPException, Request
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
_WEBAI_API_KEY = os.getenv("WEBAI_API_KEY", "").strip()
|
||||||
|
|
||||||
|
def verify_webai_key(
|
||||||
|
request: Request,
|
||||||
|
x_webai_key: str | None = Header(default=None, alias="X-WebAI-Key"),
|
||||||
|
):
|
||||||
|
if not _WEBAI_API_KEY:
|
||||||
|
logger.error("WEBAI_API_KEY not configured — refusing all /api/webai/* requests")
|
||||||
|
raise HTTPException(status_code=503, detail="webai auth not configured")
|
||||||
|
if not x_webai_key or x_webai_key != _WEBAI_API_KEY:
|
||||||
|
logger.warning(
|
||||||
|
"auth_fail path=%s remote=%s",
|
||||||
|
request.url.path,
|
||||||
|
request.client.host if request.client else "?",
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=401, detail="invalid or missing X-WebAI-Key")
|
||||||
|
```
|
||||||
|
|
||||||
|
디자인 노트:
|
||||||
|
- env 누락 시 import-time crash 회피 → 다른 endpoint 무영향. 호출 시점에만 503.
|
||||||
|
- 키 비교는 `==` (constant-time 비교 불필요 — 단일 정적 키, timing attack 가치 낮음, 회전 후 즉시 무효화 가능).
|
||||||
|
- 헤더 이름은 alias `X-WebAI-Key` (FastAPI 가 `x_webai_key` 매개변수로 받음).
|
||||||
|
|
||||||
|
`stock/app/main.py` 적용:
|
||||||
|
```python
|
||||||
|
from .auth import verify_webai_key
|
||||||
|
|
||||||
|
@app.get("/api/webai/portfolio", dependencies=[Depends(verify_webai_key)])
|
||||||
|
def get_webai_portfolio():
|
||||||
|
raw = get_portfolio() # 기존 함수 그대로 호출 (내부 분리: 응답 dict 생성 로직을 함수로)
|
||||||
|
return _augment_portfolio_with_pnl_pct(raw)
|
||||||
|
|
||||||
|
@app.get("/api/webai/news-sentiment", dependencies=[Depends(verify_webai_key)])
|
||||||
|
def get_webai_news_sentiment(date: str | None = None):
|
||||||
|
return _fetch_news_sentiment_dump(date)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. nginx config
|
||||||
|
|
||||||
|
`web-backend/nginx/default.conf` 변경:
|
||||||
|
|
||||||
|
### 6.1 `http {}` 블록 상단 (기존 limit_req_zone 옆에 추가)
|
||||||
|
```nginx
|
||||||
|
limit_req_zone $binary_remote_addr zone=webai:5m rate=60r/m;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 `server {}` 블록 내 신규 location (`/api/stock/` location 위에 우선순위)
|
||||||
|
```nginx
|
||||||
|
location /api/webai/ {
|
||||||
|
limit_req zone=webai burst=20 nodelay;
|
||||||
|
limit_req_status 429;
|
||||||
|
|
||||||
|
proxy_pass http://stock:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-WebAI-Key $http_x_webai_key;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
디자인 노트:
|
||||||
|
- `60r/m` = 분당 60 요청, `burst=20 nodelay` = 짧은 spike 20 까지 허용.
|
||||||
|
- web-ai 폴링 빈도 (장중 분당 3 call) 대비 20배 여유 — 정상 운영 시 절대 hit 안 됨.
|
||||||
|
- 한도 초과 시 429. web-ai 측 retry/backoff 는 Phase 2 client 구현 (본 Phase 외).
|
||||||
|
- `X-WebAI-Key` 헤더 명시적 forward (nginx 가 underscore 헤더를 기본 drop 하므로 dash 헤더는 OK, 그래도 안전상 명시).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 테스트
|
||||||
|
|
||||||
|
### 7.1 단위 (`stock/app/test_webai_auth.py`, 3 케이스)
|
||||||
|
|
||||||
|
| 케이스 | 검증 |
|
||||||
|
|--------|------|
|
||||||
|
| `test_verify_with_valid_key_passes` | `WEBAI_API_KEY=secret` + 헤더 `X-WebAI-Key: secret` → 통과 |
|
||||||
|
| `test_verify_without_key_raises_401` | 헤더 누락 → HTTPException 401 |
|
||||||
|
| `test_verify_with_wrong_key_raises_401` | 헤더 `X-WebAI-Key: wrong` → HTTPException 401 |
|
||||||
|
|
||||||
|
### 7.2 통합 (`stock/app/test_webai_endpoints.py`, 12 케이스)
|
||||||
|
|
||||||
|
FastAPI TestClient + `WEBAI_API_KEY` monkeypatch + 임시 sqlite seed.
|
||||||
|
|
||||||
|
portfolio:
|
||||||
|
- `test_portfolio_normal_response_includes_pnl_pct`
|
||||||
|
- `test_portfolio_summary_has_total_pnl_pct`
|
||||||
|
- `test_portfolio_pnl_pct_matches_profit_rate_divided_100`
|
||||||
|
- `test_portfolio_missing_key_returns_401`
|
||||||
|
|
||||||
|
news-sentiment:
|
||||||
|
- `test_news_sentiment_returns_latest_date_when_no_param`
|
||||||
|
- `test_news_sentiment_filters_by_date_param`
|
||||||
|
- `test_news_sentiment_empty_table_returns_count_zero`
|
||||||
|
- `test_news_sentiment_items_sorted_by_score_desc`
|
||||||
|
|
||||||
|
공통:
|
||||||
|
- `test_401_response_has_no_payload_leak`
|
||||||
|
- `test_503_when_webai_key_not_configured`
|
||||||
|
- `test_wrong_key_returns_401`
|
||||||
|
- `test_news_sentiment_unknown_date_returns_empty`
|
||||||
|
|
||||||
|
### 7.3 Manual smoke (배포 후)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 정상 통과
|
||||||
|
curl -H "X-WebAI-Key: $WEBAI_API_KEY" https://gahusb.synology.me/api/webai/portfolio
|
||||||
|
# → 200, JSON 응답에 pnl_pct 필드 존재
|
||||||
|
|
||||||
|
# 인증 실패
|
||||||
|
curl -i https://gahusb.synology.me/api/webai/portfolio
|
||||||
|
# → 401 + {"detail": "invalid or missing X-WebAI-Key"}
|
||||||
|
|
||||||
|
# news-sentiment
|
||||||
|
curl -H "X-WebAI-Key: $WEBAI_API_KEY" "https://gahusb.synology.me/api/webai/news-sentiment?date=2026-05-15"
|
||||||
|
# → 200, items 배열
|
||||||
|
|
||||||
|
# rate limit
|
||||||
|
for i in {1..100}; do curl -s -o /dev/null -w "%{http_code}\n" \
|
||||||
|
-H "X-WebAI-Key: $WEBAI_API_KEY" \
|
||||||
|
https://gahusb.synology.me/api/webai/portfolio; done | sort | uniq -c
|
||||||
|
# → 200 다수 + 429 일부
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 위험 및 완화
|
||||||
|
|
||||||
|
| 위험 | 완화 |
|
||||||
|
|------|------|
|
||||||
|
| 운영 .env 의 `WEBAI_API_KEY` 누락 → web-ai 호출 503 | startup 시점 ERROR log + Phase 2 web-ai 구현 시 startup health check 로 즉시 발견 |
|
||||||
|
| 키 노출 (.env 유출) | 회전 — NAS .env + web-ai .env 동시 갱신 + 컨테이너 재기동. 다운타임 ~10초 |
|
||||||
|
| nginx rate limit 너무 빡빡해서 web-ai 정상 폴링 차단 | `60r/m + burst=20` 은 web-ai 폴링 (분당 3 call) 대비 20배 여유. Phase 7 운영 모니터링에서 조정 |
|
||||||
|
| pnl_pct 단위 실수 (백분율 vs 비율) | 단위 명세 (비율, 0.047) 명시 + `test_portfolio_pnl_pct_matches_profit_rate_divided_100` 으로 검증 |
|
||||||
|
| news_sentiment 테이블 empty | 응답 `{"date": null, "count": 0, "items": []}` (테스트 케이스 포함) |
|
||||||
|
| `/api/webai/portfolio` vs `/api/portfolio` 응답 drift | 둘 다 동일 `get_portfolio()` 내부 함수 호출 + webai 측 augment mapper 만 적용. drift 회피 |
|
||||||
|
| nginx 가 underscore 헤더 drop | `X-WebAI-Key` (dash) 사용으로 회피. 명시적 forward 도 추가 |
|
||||||
|
| 외부에서 endpoint 무인증 접근 시도 | logger.warning 으로 IP 1회 기록 (대량 시도 시 IDS/alert 검토는 별도) |
|
||||||
|
| 키 brute force 시도 | nginx rate limit 분당 60 + 키 64자 랜덤 → 현실적 brute force 불가능 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 운영 영향
|
||||||
|
|
||||||
|
| 항목 | 영향 |
|
||||||
|
|------|------|
|
||||||
|
| 다운타임 | ~10초 (stock + nginx 재기동) |
|
||||||
|
| 사용자 영향 | 없음 (web-ui 무변경) |
|
||||||
|
| 운영 .env 갱신 | 1회 (`WEBAI_API_KEY=<랜덤>`) |
|
||||||
|
| frontend 재배포 | 불필요 |
|
||||||
|
| 다른 lab 영향 | 없음 |
|
||||||
|
| DB 마이그레이션 | 없음 (news_sentiment 테이블 기존, 추가 컬럼 없음) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Phase 1 완료 조건 (DoD)
|
||||||
|
|
||||||
|
- [ ] `stock/app/auth.py` 신규 + 단위 테스트 3 PASS
|
||||||
|
- [ ] `stock/app/main.py` 의 2 신규 endpoint + 통합 테스트 12 PASS
|
||||||
|
- [ ] `nginx/default.conf` 의 `limit_req_zone webai` + `/api/webai/` location 추가
|
||||||
|
- [ ] `docker-compose.yml` 의 stock env `WEBAI_API_KEY` 추가
|
||||||
|
- [ ] 운영 .env 갱신 (사용자 1회) — 본 Phase plan 의 마지막 task
|
||||||
|
- [ ] 배포 후 manual smoke 4 항목 PASS (정상 200 / 인증 누락 401 / news-sentiment 200 / rate limit 429)
|
||||||
|
- [ ] stock pytest 전체 86 + 신규 15 = **101 PASS**
|
||||||
|
- [ ] web-ui 영향 없음 검증 (web-ui 의 `/api/portfolio` 정상 동작)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Phase 2 와의 관계
|
||||||
|
|
||||||
|
본 Phase 1 완료 후 즉시 **Phase 2 (web-ai pull worker + signal API client)** spec → plan → 구현. 의존성:
|
||||||
|
|
||||||
|
```
|
||||||
|
[Phase 1 spec/plan/실행] → [Phase 2 spec/plan/실행]
|
||||||
|
1주 2주
|
||||||
|
```
|
||||||
|
|
||||||
|
Phase 2 의 입력 계약 = 본 spec 의 §4 API 계약. Phase 2 client 가 본 endpoint 들을 polling + 캐시 + retry.
|
||||||
|
|
||||||
|
Phase 2 시작 시점 검증 항목:
|
||||||
|
- web-ai 의 `.env` 에 `WEBAI_API_KEY` 설정
|
||||||
|
- web-ai 의 httpx client 가 `X-WebAI-Key` 헤더 자동 첨부
|
||||||
|
- 429 응답 시 backoff 정책 (exponential, max 60s)
|
||||||
|
- 5xx 응답 시 short retry (3회) 후 alert
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Backlog (본 spec NOT)
|
||||||
|
|
||||||
|
V2 운영 후 별도 슬라이스로:
|
||||||
|
|
||||||
|
- `/api/webai/screener/run` 신규 endpoint — 현재 `/api/stock/screener/run` 직접 호출, drift 발견 시 분리
|
||||||
|
- portfolio 의 `entry_date` / `days_held` / `position_weight` 추가 — sell signal 정밀화 시
|
||||||
|
- ticker filter — news-sentiment 의 `?tickers=` 옵션 (Top-20 만 가져올 때 payload 절약)
|
||||||
|
- 사용자 인증 도입 (Lab 사이트 통합 로그인) — 기존 `/api/portfolio` 무인증 외부 노출 해결
|
||||||
|
- nginx 응답 시간/에러율 메트릭 + 텔레그램 alert — Phase 7 모니터링 통합
|
||||||
|
- HMAC 서명 옵션 — 외부 노출 endpoint 추가 시 검토
|
||||||
|
- Key rotation 자동화 — 일정 운영 안정화 후
|
||||||
214
docs/superpowers/specs/2026-05-15-stock-lab-rename-to-stock.md
Normal file
214
docs/superpowers/specs/2026-05-15-stock-lab-rename-to-stock.md
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
# stock-lab → stock 리네이밍 Design
|
||||||
|
|
||||||
|
**작성일**: 2026-05-15
|
||||||
|
**작성자**: gahusb
|
||||||
|
**상태**: Approved for implementation
|
||||||
|
**선행 spec**: Confidence Signal Pipeline V2 Phase 0 (`2026-05-15-confidence-signal-pipeline-v2-architecture.md`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 목표
|
||||||
|
|
||||||
|
`stock-lab` 컨테이너/디렉토리/환경변수의 `-lab` 접미사를 제거해 **stock** 으로 graduation. lab 네이밍 규칙 (`feedback_lab_naming.md`) 에 따라 정식 서비스로 명확화.
|
||||||
|
|
||||||
|
본 리네이밍은 **Confidence Signal Pipeline V2 Phase 1** 작업 시작 전 선행. 이름이 stock-lab인 채로 Phase 1 spec/plan/code 가 작성되면 다시 갱신하는 비용 회피.
|
||||||
|
|
||||||
|
**Why**: 메모리 `feedback_lab_naming.md` 정책 — "-lab은 개발/연구 단계에만, 정식 서비스에는 미사용". stock 서비스는 (a) 8 노드 screener 완성, (b) 캔버스 UI, (c) AI 뉴스 Phase 1, (d) V2 시그널 파이프라인의 중심 = 정식 graduation 단계.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 범위
|
||||||
|
|
||||||
|
**포함**:
|
||||||
|
- web-backend 디렉토리 `git mv stock-lab stock`
|
||||||
|
- `docker-compose.yml` 4 곳 갱신
|
||||||
|
- agent-office 환경변수 `STOCK_LAB_URL` → `STOCK_URL` 코드 + 컴포즈
|
||||||
|
- nginx config (`nginx/default.conf` in web-backend repo) `upstream stock-lab` → `stock`
|
||||||
|
- 운영 문서 (`web-backend/CLAUDE.md`, `README.md`, `STATUS.md`, scripts)
|
||||||
|
- workspace `CLAUDE.md` + web-ui `CLAUDE.md`
|
||||||
|
- 메모리 4개 (`project_workspace.md`, `project_scale.md`, `project_stock_screener.md`, `nas_infra.md`)
|
||||||
|
- 메모리 정책 추가 (`feedback_lab_naming.md` 에 stock graduation 케이스 등재)
|
||||||
|
|
||||||
|
**범위 외 (NOT)**:
|
||||||
|
- API URL 경로 (`/api/stock/...` 그대로)
|
||||||
|
- Python `app.*` import 경로
|
||||||
|
- DB 파일명 (`stock.db` 그대로)
|
||||||
|
- frontend 라우트 (`/stock/*` 그대로)
|
||||||
|
- 다른 lab 의 이름 (lotto/music-lab/blog-lab/realestate-lab/packs-lab/travel-proxy 모두 그대로)
|
||||||
|
- 과거 spec/plan 문서 (`docs/superpowers/specs|plans/2026-05-*.md`) — 역사적 기록 유지
|
||||||
|
- `.venv` 디렉토리 — gitignore, 사용자 로컬에서 재생성
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 변경 매트릭스
|
||||||
|
|
||||||
|
### 3.1 web-backend 코드 (필수)
|
||||||
|
|
||||||
|
| 파일 | 변경 |
|
||||||
|
|------|------|
|
||||||
|
| `stock-lab/` → `stock/` | `git mv` |
|
||||||
|
| `docker-compose.yml` | service key `stock-lab` → `stock` (1) / container_name `stock-lab` → `stock` (1) / build.context `./stock-lab` → `./stock` (1) / frontend.depends_on의 `stock-lab` → `stock` (1) |
|
||||||
|
| `agent-office/app/config.py` | `STOCK_LAB_URL = os.getenv("STOCK_LAB_URL", ...)` → `STOCK_URL = os.getenv("STOCK_URL", ...)` |
|
||||||
|
| `agent-office/app/service_proxy.py` | `from .config import STOCK_LAB_URL` → `STOCK_URL`. 함수 본문의 `STOCK_LAB_URL` 사용처 5개 (fetch_stock_news / fetch_stock_indices / summarize_stock_news / refresh_screener_snapshot / run_stock_screener) → `STOCK_URL` |
|
||||||
|
| `agent-office/app/agents/stock.py` | `STOCK_LAB_URL` 직접 참조 시 갱신 (만약 있다면) |
|
||||||
|
| `agent-office/tests/test_stock_screener_job.py` | mock URL 또는 env var 참조 갱신 |
|
||||||
|
| `agent-office docker-compose.yml 부분` | `STOCK_LAB_URL=http://stock-lab:8000` → `STOCK_URL=http://stock:8000` |
|
||||||
|
| `nginx/default.conf` | `upstream stock-lab { server stock-lab:8000; }` → `upstream stock { server stock:8000; }` + `proxy_pass http://stock-lab` → `http://stock` |
|
||||||
|
| `web-backend/CLAUDE.md` | stock-lab 언급 모두 stock 으로 |
|
||||||
|
| `web-backend/README.md` | 동일 |
|
||||||
|
| `web-backend/STATUS.md` | 동일 |
|
||||||
|
| `web-backend/scripts/deploy-nas.sh`, `deploy.sh` | stock-lab 호출/경로 갱신 |
|
||||||
|
|
||||||
|
### 3.2 web-ui (문서만)
|
||||||
|
|
||||||
|
| 파일 | 변경 |
|
||||||
|
|------|------|
|
||||||
|
| `web-ui/CLAUDE.md` | stock-lab 언급을 stock 으로 (디렉토리 경로 표 포함) |
|
||||||
|
|
||||||
|
**과거 spec/plan 문서들** (`web-ui/docs/superpowers/specs|plans/2026-05-*.md`): 역사적 기록 유지 — **변경 없음**.
|
||||||
|
|
||||||
|
### 3.3 workspace 최상위
|
||||||
|
|
||||||
|
| 파일 | 변경 |
|
||||||
|
|------|------|
|
||||||
|
| `workspace/CLAUDE.md` | "stock-lab" 컨테이너 이름 표 + 디렉토리 경로 갱신 |
|
||||||
|
|
||||||
|
### 3.4 메모리 (controller 직접 적용)
|
||||||
|
|
||||||
|
| 메모리 | 변경 |
|
||||||
|
|--------|------|
|
||||||
|
| `project_workspace.md` | stock-lab → stock |
|
||||||
|
| `project_scale.md` | 백엔드 서비스 표의 stock-lab 행 갱신, `stock-lab/` 디렉토리 → `stock/` |
|
||||||
|
| `project_stock_screener.md` | 다수 언급 (백엔드 위치) 갱신 |
|
||||||
|
| `nas_infra.md` | Docker 서비스 포트 표 + nginx 라우팅 |
|
||||||
|
| `feedback_lab_naming.md` | stock graduation 사례 추가 (2026-05-15) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 작업 순서
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 사전 검토 (10분)
|
||||||
|
- 본 spec 의 3장 매트릭스 모든 파일이 grep 결과와 일치하는지 cross-check
|
||||||
|
- `.venv` / `__pycache__` 제외 확인
|
||||||
|
- nginx default.conf 의 정확한 변경 줄 식별
|
||||||
|
|
||||||
|
2. web-backend 디렉토리 + 컴포즈 + agent-office 코드 (한 commit)
|
||||||
|
- git mv stock-lab stock
|
||||||
|
- docker-compose.yml 4 곳
|
||||||
|
- agent-office config.py, service_proxy.py, agents/stock.py, tests/
|
||||||
|
- nginx/default.conf
|
||||||
|
- web-backend의 CLAUDE.md, README.md, STATUS.md, scripts/
|
||||||
|
|
||||||
|
3. workspace + web-ui CLAUDE.md (별도 commit, 각 repo)
|
||||||
|
- workspace/CLAUDE.md
|
||||||
|
- web-ui/CLAUDE.md
|
||||||
|
|
||||||
|
4. 메모리 갱신 (controller 직접)
|
||||||
|
- 4개 메모리 파일 + feedback_lab_naming.md graduation 케이스
|
||||||
|
|
||||||
|
5. 배포 검증
|
||||||
|
- web-backend push → Gitea webhook → deployer rsync + docker compose up
|
||||||
|
- docker logs stock --tail 30
|
||||||
|
- docker ps 에서 stock 컨테이너 healthy
|
||||||
|
- curl https://gahusb.synology.me/api/stock/news (200)
|
||||||
|
- curl https://gahusb.synology.me/api/stock/screener/runs (200)
|
||||||
|
- agent-office 다음 16:30 cron 결과 (텔레그램) 정상 도착 확인 또는 수동 트리거
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 위험 및 완화
|
||||||
|
|
||||||
|
| 위험 | 완화 |
|
||||||
|
|------|------|
|
||||||
|
| nginx config 가 옛 호스트 `stock-lab` 으로 라우팅 → 502 | nginx config 도 같은 commit 에 포함. deployer rsync 가 web-backend repo 의 nginx 폴더를 NAS runtime 에 동기화 |
|
||||||
|
| agent-office 가 옛 환경변수 `STOCK_LAB_URL` 사용 → connection refused | 컴포즈의 환경변수 항목 동시 변경. agent-office 재기동 후 새 변수 적용 |
|
||||||
|
| `.env` 파일에 `STOCK_LAB_URL=...` 남아 있으면 새 변수 빈 값 → 기본값 `http://stock:8000` fallback | service_proxy 의 `os.getenv("STOCK_URL", "http://stock:8000")` default 확인. 운영 .env 갱신은 사용자 1회 작업 |
|
||||||
|
| 다른 lab 의 stock-lab 호출 누락 | grep `STOCK_LAB_URL` 결과 5개 파일 모두 commit 에 포함. 추가 누락 시 다음 cron 실패로 즉시 발견 |
|
||||||
|
| 컨테이너 교체 다운타임 | 약 10초 (docker compose up 의 stop+start). 1인 운영 + 비치명적, 허용 |
|
||||||
|
| Python `app.*` import 경로 회귀 | 디렉토리 이름만 변경. 빌드 컨텍스트 변경으로 도커 이미지 안의 app 패키지 그대로. 회귀 없음 (76 + 신규 테스트 전부 통과 검증) |
|
||||||
|
| 메모리 갱신 누락 | grep "stock-lab" / "STOCK_LAB" 메모리 폴더 0건 검증 |
|
||||||
|
| 과거 spec/plan 문서의 stock-lab 언급 | 역사적 기록 — 의도적 보존. 미래 spec 부터 stock 사용 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 테스트 / 검증
|
||||||
|
|
||||||
|
### 6.1 자동 (코드 검증)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# stock-lab 잔여 참조 0건 (의도적 보존 spec/plan 제외)
|
||||||
|
grep -rln "stock-lab\|STOCK_LAB" /c/Users/jaeoh/Desktop/workspace/web-backend/ \
|
||||||
|
| grep -v "\.venv" | grep -v "__pycache__" | grep -v "/docs/" | grep -v "\.git"
|
||||||
|
# Expected: 0 lines
|
||||||
|
|
||||||
|
# agent-office 테스트
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-backend/agent-office
|
||||||
|
python -m pytest tests/test_stock_screener_job.py -v
|
||||||
|
# Expected: PASS
|
||||||
|
|
||||||
|
# stock pytest
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-backend/stock
|
||||||
|
python -m pytest --ignore=app/test_scraper.py -q
|
||||||
|
# Expected: 76+ tests passed
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 수동 (배포 검증)
|
||||||
|
|
||||||
|
배포 후 NAS:
|
||||||
|
```bash
|
||||||
|
docker logs stock --tail 30
|
||||||
|
docker logs agent-office --tail 20
|
||||||
|
docker ps --format "{{.Names}}: {{.Status}}" | grep stock
|
||||||
|
```
|
||||||
|
|
||||||
|
브라우저 / curl:
|
||||||
|
- `https://gahusb.synology.me/api/stock/news` → 200
|
||||||
|
- `https://gahusb.synology.me/api/stock/screener/runs` → 200
|
||||||
|
- `https://gahusb.synology.me/stock/screener` (web-ui) → 캔버스 모드 진입 정상
|
||||||
|
|
||||||
|
agent-office 수동 트리거 (다음 cron 기다리지 않고):
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://gahusb.synology.me/api/agent-office/command" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"agent":"stock","action":"run_ai_news"}'
|
||||||
|
```
|
||||||
|
응답 `{"ok": true}` + 텔레그램 도착 → stock 호스트 라우팅 정상.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 운영 영향
|
||||||
|
|
||||||
|
| 항목 | 영향 |
|
||||||
|
|------|------|
|
||||||
|
| 다운타임 | ~10초 (컨테이너 교체) |
|
||||||
|
| 사용자 영향 | 없음 (API URL/UI 경로 그대로) |
|
||||||
|
| .env 파일 갱신 | 사용자 1회 (STOCK_LAB_URL 줄 삭제 또는 STOCK_URL 추가) |
|
||||||
|
| frontend 재배포 | 불필요 (web-ui 는 문서만 변경) |
|
||||||
|
| 다른 lab 영향 | agent-office 만 영향 (환경변수). 나머지 lab 무영향 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Phase 1 와의 관계
|
||||||
|
|
||||||
|
본 리네이밍 완료 후 즉시 **Confidence Signal Pipeline V2 Phase 1** spec 작성 (이전 발표 디자인 그대로, 새 이름 `stock` 기준). 의존성:
|
||||||
|
```
|
||||||
|
[본 리네이밍 spec/plan/실행] → [Phase 1 spec → plan → 실행]
|
||||||
|
1-2시간 1주
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 완료 조건 (DoD)
|
||||||
|
|
||||||
|
- [ ] `web-backend/stock-lab/` 디렉토리 사라지고 `stock/` 존재 (git history 보존)
|
||||||
|
- [ ] `docker-compose.yml` 의 4 곳 갱신
|
||||||
|
- [ ] agent-office env 변수 `STOCK_LAB_URL` 코드/컴포즈/문서에서 0건
|
||||||
|
- [ ] nginx config `upstream stock-lab` 0건, `upstream stock` 존재
|
||||||
|
- [ ] grep "stock-lab" 결과: 의도적 보존 (`docs/superpowers/*`) 외 0건
|
||||||
|
- [ ] stock pytest 76+ tests passed
|
||||||
|
- [ ] 배포 후 `docker ps` 에 `stock` 컨테이너 healthy
|
||||||
|
- [ ] curl `/api/stock/news`, `/api/stock/screener/runs` 200
|
||||||
|
- [ ] agent-office `run_ai_news` 수동 트리거 텔레그램 도착
|
||||||
|
- [ ] 메모리 4 파일 갱신 + `feedback_lab_naming.md` graduation 케이스 등재
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
# Confidence Signal Pipeline V2 — Phase 2: web-ai Pull Worker Design
|
||||||
|
|
||||||
|
**작성일**: 2026-05-16
|
||||||
|
**작성자**: gahusb
|
||||||
|
**상태**: Approved for implementation
|
||||||
|
**선행 spec**:
|
||||||
|
- Phase 0 architecture (`2026-05-15-confidence-signal-pipeline-v2-architecture.md`)
|
||||||
|
- Phase 1 stock WebAI API (`2026-05-15-signal-v2-phase1-webai-api.md`)
|
||||||
|
- signal_v1 rename (`2026-05-16-web-ai-v1-rename-to-signal-v1.md`) — 본 spec 부터 `web-ai/signal_v1/` + `web-ai/signal_v2/` 구조 사용
|
||||||
|
|
||||||
|
**브레인스토밍 결정 6개**:
|
||||||
|
- 배치 = A (별도 FastAPI app `:8001`, 새 디렉토리 `web-ai/signal_v2/`)
|
||||||
|
- Scope = A (client + scheduler + rate limit DB 3 항목)
|
||||||
|
- Scheduler = B (asyncio + 자체 cron loop, FastAPI lifespan)
|
||||||
|
- HTTP client = B (httpx async + 자체 retry loop + 메모리 cache)
|
||||||
|
- Rate limit DB = A (SQLite + WAL + busy_timeout)
|
||||||
|
- Test = B (pytest + pytest-asyncio + httpx mock + tmp_path sqlite)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 목표
|
||||||
|
|
||||||
|
web-ai 머신에 V2 신호 파이프라인 인프라 구축. stock NAS 와 안정적으로 통신하는 client + 시간대별 polling scheduler + 24h dedup 인프라.
|
||||||
|
|
||||||
|
Phase 3 (Chronos-2 추론) 이 이 위에 추론 코드를 얹는다. Phase 4 (signal generator) 가 rate limit DB 를 사용. Phase 5 에서 같은 FastAPI app 에 `POST /signal` endpoint 추가.
|
||||||
|
|
||||||
|
**Why**: Phase 0 §3 책임 분리 — "web-ai = 시점 분석". web-ai 가 NAS DB 직접 접근 안 함, 모든 데이터는 stock API 경유. Phase 1 endpoint (X-WebAI-Key 인증) 가 입력 계약 = Phase 2 의 client 가 이 위에 동작.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 범위
|
||||||
|
|
||||||
|
### 포함
|
||||||
|
|
||||||
|
- ① **StockClient 클래스** — httpx async + 자체 retry loop (max 3, exponential backoff 1s→2s→4s) + 메모리 dict cache (TTL: portfolio 60s / news-sentiment 300s / screener 60s) + 마지막 성공 응답 stale fallback
|
||||||
|
- ② **Polling scheduler** — asyncio cron loop (FastAPI lifespan + asyncio.create_task). 시간대별 분기 (장전 5분 / 장중 1분 / 장후 5분 / 야간·휴장 skip)
|
||||||
|
- ③ **Rate limit DB** — SQLite (WAL + busy_timeout=120000) `signal_dedup` 테이블. Phase 4 가 사용
|
||||||
|
- ④ **FastAPI app** — 새 port `:8001`. `GET /health` endpoint + startup/shutdown lifespan
|
||||||
|
- ⑤ **PollState** — process-wide singleton (portfolio/news_sentiment/screener_preview + last_updated + fetch_errors)
|
||||||
|
- ⑥ **테스트 16 케이스** (stock_client 6 + scheduler 5 + rate_limit 3 + main 2)
|
||||||
|
|
||||||
|
### 범위 외 (NOT)
|
||||||
|
|
||||||
|
- Chronos-2 추론, KIS WebSocket, 분봉 (Phase 3)
|
||||||
|
- Signal generator 매수/매도 룰 (Phase 4) — rate limit DB 사용은 Phase 4
|
||||||
|
- agent-office `POST /signal` 호출 (Phase 5)
|
||||||
|
- 기존 signal_v1 (V1 자동매매) 분리/정리/deprecation (Phase 6)
|
||||||
|
- Ollama Qwen3 호스팅 (Phase 5)
|
||||||
|
- ticker filter / 운영 모니터링 메트릭 (Phase 7)
|
||||||
|
- holidays.json 자동 동기화 (backlog — 일단 stock/app/holidays.json 의 manual copy)
|
||||||
|
- 메모리 cache TTL 만료 entry 명시 cleanup (YAGNI)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 파일 구조
|
||||||
|
|
||||||
|
### 3.1 신규 디렉토리: `web-ai/signal_v2/`
|
||||||
|
|
||||||
|
```
|
||||||
|
web-ai/signal_v2/
|
||||||
|
├── __init__.py
|
||||||
|
├── main.py # FastAPI app + lifespan + GET /health
|
||||||
|
├── config.py # env 로딩 (STOCK_API_URL, WEBAI_API_KEY, SIGNAL_V2_PORT)
|
||||||
|
├── stock_client.py # StockClient: httpx async + retry + cache + auth header
|
||||||
|
├── scheduler.py # poll_loop, _next_interval, _is_market_day, _seconds_until_next_market_open
|
||||||
|
├── pull_worker.py # _run_polling_cycle: 3 endpoint 병렬 fetch + state 갱신
|
||||||
|
├── rate_limit.py # SignalDedup: is_recent + record (WAL + busy_timeout)
|
||||||
|
├── state.py # PollState dataclass (process-wide singleton)
|
||||||
|
├── holidays.json # 한국 휴장일 (stock/app/holidays.json 복사)
|
||||||
|
├── start.bat # uvicorn signal_v2.main:app --port 8001
|
||||||
|
├── data/
|
||||||
|
│ ├── .gitkeep
|
||||||
|
│ └── signal_v2.db # SQLite (gitignore)
|
||||||
|
└── tests/
|
||||||
|
├── __init__.py
|
||||||
|
├── conftest.py # pytest-asyncio + fixtures
|
||||||
|
├── test_stock_client.py # 6 케이스
|
||||||
|
├── test_scheduler.py # 5 케이스
|
||||||
|
├── test_rate_limit.py # 3 케이스
|
||||||
|
└── test_main.py # 2 케이스
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 변경 매트릭스
|
||||||
|
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `web-ai/signal_v2/` 전체 | 신규 디렉토리 |
|
||||||
|
| `web-ai/.env` | 3 줄 추가: `STOCK_API_URL=https://gahusb.synology.me`, `WEBAI_API_KEY=<Phase 1 동일 값>`, `SIGNAL_V2_PORT=8001` |
|
||||||
|
| `web-ai/.gitignore` | `signal_v2/data/*.db`, `signal_v2/data/*.db-*` (WAL/SHM) 추가 |
|
||||||
|
| `web-ai/CLAUDE.md` | `signal_v2/` 섹션은 이미 signal_v1 rename slice 에서 작성됨 — 무변경 |
|
||||||
|
|
||||||
|
### 3.3 기존 파일 무변경
|
||||||
|
|
||||||
|
- `web-ai/signal_v1/` 전체 (V1 자동매매)
|
||||||
|
- `web-ai/start.bat` (V1 진입)
|
||||||
|
- 다른 lab / web-backend / web-ui 영향 0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API 계약
|
||||||
|
|
||||||
|
### 4.1 `StockClient` 클래스 (signal_v2/stock_client.py)
|
||||||
|
|
||||||
|
```python
|
||||||
|
class StockClient:
|
||||||
|
"""stock API 호출 wrapper. httpx async + 자체 retry + 메모리 cache."""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, api_key: str, timeout: float = 10.0):
|
||||||
|
self._base_url = base_url.rstrip("/")
|
||||||
|
self._api_key = api_key
|
||||||
|
self._client = httpx.AsyncClient(timeout=timeout)
|
||||||
|
self._cache: dict[str, tuple[Any, float]] = {}
|
||||||
|
|
||||||
|
async def get_portfolio(self) -> dict:
|
||||||
|
"""GET /api/webai/portfolio. cache TTL 60s."""
|
||||||
|
|
||||||
|
async def get_news_sentiment(self, date: str | None = None) -> dict:
|
||||||
|
"""GET /api/webai/news-sentiment. cache TTL 300s."""
|
||||||
|
|
||||||
|
async def run_screener_preview(
|
||||||
|
self, weights: dict | None = None, top_n: int = 20
|
||||||
|
) -> dict:
|
||||||
|
"""POST /api/stock/screener/run {mode:'preview', ...}. cache TTL 60s."""
|
||||||
|
|
||||||
|
async def close(self) -> None: ...
|
||||||
|
|
||||||
|
# internal
|
||||||
|
async def _request_with_retry(self, method, path, **kwargs) -> dict: ...
|
||||||
|
def _cache_get(self, key: str) -> Any | None: ...
|
||||||
|
def _cache_set(self, key: str, data: Any) -> None: ...
|
||||||
|
def _auth_headers(self) -> dict[str, str]: ... # {"X-WebAI-Key": self._api_key}
|
||||||
|
```
|
||||||
|
|
||||||
|
retry 정책:
|
||||||
|
- max_attempts = 3
|
||||||
|
- timeout = 10s
|
||||||
|
- 429 응답: exponential backoff (1s → 2s → 4s)
|
||||||
|
- 5xx 응답: 짧은 retry (max 3회) 후 raise
|
||||||
|
- 모든 retry 실패 + cache 에 이전 성공 응답 있음 → stale fallback + `logger.warning`
|
||||||
|
|
||||||
|
cache TTL:
|
||||||
|
- portfolio: 60s
|
||||||
|
- news-sentiment: 300s (일별 갱신이라 TTL 길어도 무방)
|
||||||
|
- screener preview: 60s
|
||||||
|
|
||||||
|
### 4.2 FastAPI app (signal_v2/main.py)
|
||||||
|
|
||||||
|
```python
|
||||||
|
app = FastAPI(title="Signal V2 Pull Worker", version="0.1.0")
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup():
|
||||||
|
# 1. config 로드
|
||||||
|
# 2. SignalDedup DB 초기화
|
||||||
|
# 3. StockClient 생성 (전역 상태)
|
||||||
|
# 4. asyncio.create_task(poll_loop(...))
|
||||||
|
|
||||||
|
@app.on_event("shutdown")
|
||||||
|
async def shutdown():
|
||||||
|
# 1. shutdown_event.set() → poll_loop 종료
|
||||||
|
# 2. StockClient.close()
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health() -> dict:
|
||||||
|
return {
|
||||||
|
"status": "online",
|
||||||
|
"stock_api_url": settings.stock_api_url,
|
||||||
|
"last_poll": state.last_updated,
|
||||||
|
"cache_size": len(client._cache),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Phase 5 이후 추가될 endpoint (본 spec 외): `POST /signal` (agent-office 호출).
|
||||||
|
|
||||||
|
### 4.3 PollState (signal_v2/state.py)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class PollState:
|
||||||
|
portfolio: dict | None = None
|
||||||
|
news_sentiment: dict | None = None
|
||||||
|
screener_preview: dict | None = None
|
||||||
|
last_updated: dict[str, str] = field(default_factory=dict)
|
||||||
|
fetch_errors: dict[str, int] = field(default_factory=dict)
|
||||||
|
```
|
||||||
|
|
||||||
|
단일 process-wide 인스턴스 (`state.py` 모듈 변수). Phase 3 가 `from signal_v2.state import state` 로 read-only 참조.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Scheduler 구현
|
||||||
|
|
||||||
|
### 5.1 polling 주기 결정 (signal_v2/scheduler.py)
|
||||||
|
|
||||||
|
```python
|
||||||
|
KST = ZoneInfo("Asia/Seoul")
|
||||||
|
_HOLIDAYS = set(json.loads((Path(__file__).parent / "holidays.json").read_text()))
|
||||||
|
|
||||||
|
_PRE_MARKET = (time(7, 0), time(9, 0)) # 5분
|
||||||
|
_MARKET = (time(9, 0), time(15, 30)) # 1분
|
||||||
|
_POST_MARKET = (time(15, 30), time(20, 0)) # 5분
|
||||||
|
# 그 외 야간 (20:00-07:00): polling 없음
|
||||||
|
|
||||||
|
def _is_market_day(now: datetime) -> bool:
|
||||||
|
if now.weekday() >= 5: return False
|
||||||
|
if now.strftime("%Y-%m-%d") in _HOLIDAYS: return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _next_interval(now: datetime) -> float:
|
||||||
|
"""다음 폴링까지 sleep 초수."""
|
||||||
|
if not _is_market_day(now):
|
||||||
|
return _seconds_until_next_market_open(now)
|
||||||
|
t = now.time()
|
||||||
|
if _PRE_MARKET[0] <= t < _PRE_MARKET[1]: return 300
|
||||||
|
elif _MARKET[0] <= t < _MARKET[1]: return 60
|
||||||
|
elif _POST_MARKET[0] <= t < _POST_MARKET[1]: return 300
|
||||||
|
else: return _seconds_until_next_market_open(now)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 polling loop
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def poll_loop(client: StockClient, state: PollState, shutdown: asyncio.Event) -> None:
|
||||||
|
logger.info("poll_loop started")
|
||||||
|
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)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("poll cycle failed")
|
||||||
|
interval = _next_interval(now)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(shutdown.wait(), timeout=interval)
|
||||||
|
break
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
async def _run_polling_cycle(client: StockClient, state: PollState) -> None:
|
||||||
|
"""3 endpoint 병렬 fetch + state 갱신."""
|
||||||
|
portfolio, sentiment, screener = await asyncio.gather(
|
||||||
|
client.get_portfolio(),
|
||||||
|
client.get_news_sentiment(),
|
||||||
|
client.run_screener_preview(),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
now_iso = datetime.now(KST).isoformat()
|
||||||
|
if isinstance(portfolio, dict):
|
||||||
|
state.portfolio = portfolio
|
||||||
|
state.last_updated["portfolio"] = now_iso
|
||||||
|
state.fetch_errors["portfolio"] = 0
|
||||||
|
elif isinstance(portfolio, Exception):
|
||||||
|
state.fetch_errors["portfolio"] = state.fetch_errors.get("portfolio", 0) + 1
|
||||||
|
# 동일 처리 for sentiment, screener
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 holidays.json
|
||||||
|
|
||||||
|
`stock/app/holidays.json` 의 복사본을 `signal_v2/holidays.json` 으로 manual copy. 향후 backlog: 자동 동기화 또는 shared library.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Rate Limit DB
|
||||||
|
|
||||||
|
### 6.1 SQLite schema (signal_v2/rate_limit.py 의 startup 시 생성)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS signal_dedup (
|
||||||
|
ticker TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL, -- 'buy' or 'sell'
|
||||||
|
last_sent TEXT NOT NULL, -- ISO timestamp KST
|
||||||
|
confidence REAL NOT NULL,
|
||||||
|
PRIMARY KEY (ticker, action)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_signal_dedup_last_sent ON signal_dedup(last_sent);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 `SignalDedup` 클래스
|
||||||
|
|
||||||
|
```python
|
||||||
|
class SignalDedup:
|
||||||
|
"""Phase 4 signal generator 가 사용. WAL + busy_timeout=120000."""
|
||||||
|
|
||||||
|
def __init__(self, db_path: Path): ...
|
||||||
|
|
||||||
|
def _conn(self) -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(self._db_path, timeout=120.0)
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA busy_timeout=120000")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def _init_schema(self) -> None: ...
|
||||||
|
|
||||||
|
def is_recent(self, ticker: str, action: str, within_hours: int = 24) -> bool:
|
||||||
|
"""True 면 24h 내 발송됨 → silent."""
|
||||||
|
|
||||||
|
def record(self, ticker: str, action: str, confidence: float) -> None:
|
||||||
|
"""발송 직후 호출. PK 충돌 시 last_sent 갱신 (UPSERT)."""
|
||||||
|
```
|
||||||
|
|
||||||
|
Phase 2 에서는 인프라만 구축. Phase 4 가 매수/매도 결정 직전 `is_recent()` 체크 + 발송 후 `record()` 호출.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 테스트
|
||||||
|
|
||||||
|
### 7.1 `test_stock_client.py` (6 케이스)
|
||||||
|
|
||||||
|
| 케이스 | 검증 |
|
||||||
|
|--------|------|
|
||||||
|
| `test_get_portfolio_normal_returns_dict_with_pnl_pct` | 정상 200 + 응답 파싱 + cache 저장 |
|
||||||
|
| `test_get_portfolio_uses_cache_within_ttl` | 첫 호출 후 60s 내 두번째 호출 = mock httpx 콜 1회 |
|
||||||
|
| `test_get_portfolio_refetches_after_ttl_expiry` | frozen_time 으로 60s+1 진행 후 mock httpx 콜 2회 |
|
||||||
|
| `test_get_portfolio_retries_3_times_on_timeout` | mock 이 처음 2회 timeout → 3회차 200 → exponential sleep 검증 |
|
||||||
|
| `test_get_portfolio_429_triggers_backoff` | 429 응답 → 1s sleep → 재시도 → 200 |
|
||||||
|
| `test_get_portfolio_falls_back_to_stale_on_all_failures` | cache 에 이전 성공 + 모든 retry 5xx → stale 반환 + logger.warning |
|
||||||
|
|
||||||
|
### 7.2 `test_scheduler.py` (5 케이스)
|
||||||
|
|
||||||
|
| 케이스 | 검증 |
|
||||||
|
|--------|------|
|
||||||
|
| `test_next_interval_pre_market_5min` | now=08:30 평일 → 300 |
|
||||||
|
| `test_next_interval_market_open_1min` | now=10:00 평일 → 60 |
|
||||||
|
| `test_next_interval_post_market_5min` | now=17:00 평일 → 300 |
|
||||||
|
| `test_next_interval_overnight_skip_to_next_morning` | now=22:00 평일 → 다음날 07:00 까지 |
|
||||||
|
| `test_next_interval_holiday_skip` | now=2026-08-15 (공휴일) → 다음 영업일 07:00 까지 |
|
||||||
|
|
||||||
|
### 7.3 `test_rate_limit.py` (3 케이스)
|
||||||
|
|
||||||
|
| 케이스 | 검증 |
|
||||||
|
|--------|------|
|
||||||
|
| `test_is_recent_returns_false_for_new_ticker_action` | record 없음 → False |
|
||||||
|
| `test_is_recent_returns_true_within_24h` | record 호출 1초 후 → True |
|
||||||
|
| `test_is_recent_returns_false_after_24h` | record + 24h 1분 후 → False |
|
||||||
|
|
||||||
|
### 7.4 `test_main.py` (2 케이스)
|
||||||
|
|
||||||
|
| 케이스 | 검증 |
|
||||||
|
|--------|------|
|
||||||
|
| `test_health_endpoint_returns_status_online` | TestClient → GET /health → 200 + status online |
|
||||||
|
| `test_startup_warns_if_webai_api_key_missing` | env 미설정 + startup → logger.warning |
|
||||||
|
|
||||||
|
**총 16 신규 테스트**. 외부 stock 호출 0 (전부 mock).
|
||||||
|
|
||||||
|
### 7.5 conftest.py
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
import respx
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tmp_dedup_db(tmp_path) -> Path:
|
||||||
|
return tmp_path / "test_signal_v2.db"
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def mock_stock_api():
|
||||||
|
async with respx.mock(base_url="https://test.stock.local") as mock:
|
||||||
|
yield mock
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def frozen_now(monkeypatch):
|
||||||
|
"""datetime.now(KST) 고정용 (freezegun 또는 monkeypatch)."""
|
||||||
|
```
|
||||||
|
|
||||||
|
pytest-asyncio mode = "auto" — `pyproject.toml` 또는 `pytest.ini` 에 명시.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 위험 및 완화
|
||||||
|
|
||||||
|
| 위험 | 완화 |
|
||||||
|
|------|------|
|
||||||
|
| stock API 응답 지연 (NAS 부하 / 네트워크) | timeout 10s + retry 3회 + cache fallback (stale) |
|
||||||
|
| `.env` 의 WEBAI_API_KEY 미설정 → 모든 호출 401 | startup ERROR log + Phase 1 의 503 응답 fallback 활용 |
|
||||||
|
| Polling cycle 중 web-ai 종료 | shutdown.wait timeout 으로 즉시 break, asyncio cleanup |
|
||||||
|
| holidays.json 미동기화 → 휴일 폴링 시도 | stock 측 응답 정상 (데이터 stale). Phase 7 모니터링 |
|
||||||
|
| SQLite WAL lock (Phase 4 가 signal generator 동시 write) | busy_timeout=120000 + WAL → reader/writer 분리. Phase 4 단일 writer 직렬 보장 |
|
||||||
|
| 메모리 cache 누수 (장기 운영) | TTL 만료 entry 명시 cleanup 없음 (YAGNI). Phase 7 모니터링 |
|
||||||
|
| signal_v1 (port 8000) ↔ signal_v2 (port 8001) 충돌 | 다른 port. 같은 머신에서 동시 가동 가능 |
|
||||||
|
| 시간대 (KST) 계산 오류 (DST) | KST 는 DST 없음 (Asia/Seoul +09:00 고정). 안전 |
|
||||||
|
| asyncio + sqlite3 (sync) 혼합 | rate_limit 호출은 짧음. Phase 4 의 호출 패턴 결정 시 점검 |
|
||||||
|
| Phase 1 rate limit (60r/m) 초과 | polling 빈도 분당 3 → 20x 여유. 정상 동작 시 무관 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 운영 영향
|
||||||
|
|
||||||
|
| 항목 | 영향 |
|
||||||
|
|------|------|
|
||||||
|
| 다운타임 | 0 (V1 영향 없음, V2 신규 시작) |
|
||||||
|
| 사용자 영향 | 없음 (V2 silent, Phase 5 까지 신호 발송 없음) |
|
||||||
|
| `.env` 갱신 | 사용자 1회 (`WEBAI_API_KEY`, `STOCK_API_URL`, `SIGNAL_V2_PORT`) |
|
||||||
|
| V1 영향 | 0 (별도 process / port / 디렉토리) |
|
||||||
|
| stock NAS 부하 | 매우 작음 (장중 분당 3 call) |
|
||||||
|
| 외부 의존성 추가 | `httpx`, `pytest-asyncio`, `respx` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Phase 2 완료 조건 (DoD)
|
||||||
|
|
||||||
|
- [ ] `web-ai/signal_v2/` 디렉토리 + 7 파이썬 파일 (main.py / config.py / stock_client.py / scheduler.py / pull_worker.py / rate_limit.py / state.py + __init__.py)
|
||||||
|
- [ ] `holidays.json` 복사
|
||||||
|
- [ ] `tests/` 디렉토리 + conftest.py + 4 test 파일 + 16 케이스 모두 PASS
|
||||||
|
- [ ] `python -m uvicorn signal_v2.main:app --port 8001` 정상 시작 + `GET http://localhost:8001/health` 200
|
||||||
|
- [ ] 1 회 polling cycle 완료 → `state.portfolio` + `state.news_sentiment` + `state.screener_preview` 갱신 확인 (수동 trigger 또는 첫 자연 cycle)
|
||||||
|
- [ ] rate_limit DB 파일 생성 + WAL + busy_timeout 설정 확인
|
||||||
|
- [ ] `.env` 갱신 (사용자 1회): `STOCK_API_URL=https://gahusb.synology.me`, `WEBAI_API_KEY=<Phase 1 동일>`, `SIGNAL_V2_PORT=8001`
|
||||||
|
- [ ] web-ai V1 봇 무영향 검증 (`start.bat` 정상 시작)
|
||||||
|
- [ ] git push (web-ai repo)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Phase 3 와의 관계
|
||||||
|
|
||||||
|
본 Phase 2 완료 후 즉시 **Phase 3 (KIS WebSocket + 분봉 + Chronos-2 추론)** spec → plan → 구현. 의존성:
|
||||||
|
|
||||||
|
```
|
||||||
|
[Phase 2 spec/plan/실행] → [Phase 3 spec/plan/실행]
|
||||||
|
2주 2주
|
||||||
|
```
|
||||||
|
|
||||||
|
Phase 3 의 입력 계약 = 본 spec 의 `PollState` (Phase 3 코드가 read-only 로 import). Phase 3 의 추론 결과 (Chronos-2 quantile 등) 는 별도 state 객체 또는 PollState 확장 — Phase 3 spec 에서 결정.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Backlog (본 spec NOT)
|
||||||
|
|
||||||
|
- ticker filter (news-sentiment `?tickers=` 옵션 활용) — V2 운영 후 종목 필터 시
|
||||||
|
- 운영 메트릭 (응답시간 / 에러율 / 텔레그램 alert) — Phase 7
|
||||||
|
- holidays.json 자동 동기화 (stock → web-ai)
|
||||||
|
- cache 만료 entry 명시 cleanup (장기 운영 시 메모리 누수 발견 시)
|
||||||
|
- Phase 5 `POST /signal` endpoint (agent-office 호출) — Phase 5 spec
|
||||||
|
- WebSocket-based polling (현재 HTTP polling, 향후 stock 측이 WebSocket push 도입 시)
|
||||||
|
- Phase 6 signal_v1 deprecation (V1 자동매매 정리)
|
||||||
|
- Phase 4 가 rate_limit 호출 시 asyncio.to_thread vs 직접 호출 결정
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
# Confidence Signal Pipeline V2 — Phase 3a: KIS Data Collection Design
|
||||||
|
|
||||||
|
**작성일**: 2026-05-16
|
||||||
|
**작성자**: gahusb
|
||||||
|
**상태**: Approved for implementation
|
||||||
|
**선행 spec**:
|
||||||
|
- Phase 0 architecture (`2026-05-15-confidence-signal-pipeline-v2-architecture.md`)
|
||||||
|
- Phase 1 stock WebAI API (`2026-05-15-signal-v2-phase1-webai-api.md`)
|
||||||
|
- signal_v1 rename (`2026-05-16-web-ai-v1-rename-to-signal-v1.md`)
|
||||||
|
- Phase 2 web-ai pull worker (`2026-05-16-signal-v2-phase2-web-ai-pull-worker.md`)
|
||||||
|
|
||||||
|
**Phase 3 분해**: Phase 0 spec 의 Phase 3 (KIS WebSocket + NXT + Chronos-2 + 분봉 모멘텀) 를 2 sub-phase 로 분해:
|
||||||
|
- **Phase 3a (본 spec)**: KIS 데이터 수집 (분봉 REST + 호가 WebSocket + scheduler NXT 확장)
|
||||||
|
- **Phase 3b (별도 spec)**: Chronos-2 추론 + 분봉 모멘텀 분류기
|
||||||
|
|
||||||
|
**브레인스토밍 결정 6개**:
|
||||||
|
- scope = B (3a / 3b 분해)
|
||||||
|
- 데이터 수집 = B (분봉 REST + 호가 WebSocket)
|
||||||
|
- KIS 인증 = A (V1 토큰 read-only 공유)
|
||||||
|
- 구독 범위 = A (portfolio WebSocket + screener REST polling)
|
||||||
|
- NXT 처리 = C (stock 자동 처리 + scheduler 의 NXT 시간대 폴링 추가)
|
||||||
|
- 테스트 = A (respx REST mock + WebSocket mock + tmp sqlite)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 목표
|
||||||
|
|
||||||
|
signal_v2 가 신호 판단에 필요한 KIS 실시간/준실시간 데이터 (분봉 OHLCV + 호가 매수세) 를 수집해 `PollState` 에 채워 넣는다. Phase 3b (Chronos-2 추론) + Phase 4 (signal generator) 가 이 위에 동작.
|
||||||
|
|
||||||
|
**Why**: Phase 0 §3 "web-ai = 시점 분석" 책임의 데이터 수집 부분. KIS REST 의 분봉/호가 + KIS WebSocket 의 실시간 호가가 매수/매도 룰의 핵심 입력.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 범위
|
||||||
|
|
||||||
|
### 포함 (6 항목)
|
||||||
|
|
||||||
|
- ① **KIS REST client** (`signal_v2/kis_client.py`) — 분봉 polling + screener Top-N 호가 polling. V1 토큰 파일 (`signal_v1/data/kis_token.json`) read-only 공유.
|
||||||
|
- ② **KIS WebSocket client** (`signal_v2/kis_websocket.py`) — approval_key 신규 발급 + portfolio 보유 종목 호가 실시간 구독 + reconnect with exponential backoff.
|
||||||
|
- ③ **`pull_worker.py` 확장** — 분봉 1분 polling cycle 추가 + WebSocket 메시지 처리 task.
|
||||||
|
- ④ **`PollState` 확장** — `minute_bars: dict[ticker, deque(maxlen=60)]`, `asking_price: dict[ticker, dict]`, `last_updated["minute_bars"]` / `["asking_price"]`.
|
||||||
|
- ⑤ **`scheduler.py` 수정** — NXT 시간대 폴링 (20:00-23:30 / 04:30-07:00) 5분 cron 추가.
|
||||||
|
- ⑥ **테스트 13 신규** (KIS REST 4 + WebSocket 4 + scheduler NXT 3 + pull_worker 2). 기존 19 + 신규 13 = 32 total.
|
||||||
|
|
||||||
|
### 범위 외 (NOT)
|
||||||
|
|
||||||
|
- Chronos-2 모델 로드 + 추론 (Phase 3b)
|
||||||
|
- 분봉 모멘텀 분류기 (Phase 3b — 5분봉 aggregate + 5연속 양봉 룰)
|
||||||
|
- Signal generator 매수/매도 룰 (Phase 4)
|
||||||
|
- NXT 자체 API 호출 — V2 가 별도 NXT API client 없음. stock 측 `price_fetcher` 가 NXT 시간대 가격 자동 반환 (`price_session` 필드)
|
||||||
|
- WebSocket 동적 subscribe 갱신 — portfolio 변동 시 다음 cycle 에서 일괄 갱신
|
||||||
|
- 분봉 daily aggregate — 60 분봉 sliding window 만
|
||||||
|
- 분봉 영속 저장 — 메모리만, 재기동 시 reset
|
||||||
|
- V2 자체 KIS 토큰 발급 — Phase 6 deprecation 까지 V1 단독 갱신 책임
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 파일 구조 + 변경 매트릭스
|
||||||
|
|
||||||
|
### 3.1 신규 / 수정
|
||||||
|
|
||||||
|
| 파일 | 작업 | 라인 |
|
||||||
|
|------|------|------|
|
||||||
|
| `signal_v2/kis_client.py` | 신규 | ~150 |
|
||||||
|
| `signal_v2/kis_websocket.py` | 신규 | ~180 |
|
||||||
|
| `signal_v2/state.py` | 필드 2개 추가 | +5 |
|
||||||
|
| `signal_v2/pull_worker.py` | 분봉 cycle + WebSocket task | +60 |
|
||||||
|
| `signal_v2/scheduler.py` | NXT 시간대 분기 | +15 |
|
||||||
|
| `signal_v2/main.py` | KIS lifespan 통합 | +20 |
|
||||||
|
| `signal_v2/config.py` | KIS env 5개 + V1 token path | +10 |
|
||||||
|
| `signal_v2/tests/test_kis_client.py` | 신규 4 케이스 | ~150 |
|
||||||
|
| `signal_v2/tests/test_kis_websocket.py` | 신규 4 케이스 | ~170 |
|
||||||
|
| `signal_v2/tests/test_pull_worker.py` | 신규 2 케이스 | ~80 |
|
||||||
|
| `signal_v2/tests/test_scheduler.py` | NXT 3 케이스 추가 | +30 |
|
||||||
|
| `signal_v2/tests/test_main.py` | KIS lifespan 케이스 | +20 |
|
||||||
|
| `signal_v2/requirements.txt` | `websockets>=12` | +1 |
|
||||||
|
| `web-ai/.env` | KIS env 5 + V1_TOKEN_PATH (사용자 수동) | +6 |
|
||||||
|
|
||||||
|
### 3.2 외부 의존성 신규
|
||||||
|
|
||||||
|
- `websockets>=12` (KIS WebSocket client)
|
||||||
|
|
||||||
|
### 3.3 V1 공유 / 무영향
|
||||||
|
|
||||||
|
- **공유** (read-only): `signal_v1/data/kis_token.json` — V1 의 단독 갱신 책임. V2 는 mtime 캐시 + read.
|
||||||
|
- **무영향**: V1 의 main_server.py / modules / 자동매매 봇 — Phase 6 까지 분리 유지.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. KIS REST client (`kis_client.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
class KISClient:
|
||||||
|
"""KIS REST API (분봉 + 호가). V1 토큰 read-only 공유."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
app_key: str, app_secret: str, account: str, is_virtual: bool,
|
||||||
|
v1_token_path: Path,
|
||||||
|
timeout: float = 10.0,
|
||||||
|
):
|
||||||
|
self._app_key = app_key
|
||||||
|
self._app_secret = app_secret
|
||||||
|
self._account = account
|
||||||
|
self._is_virtual = is_virtual
|
||||||
|
self._v1_token_path = Path(v1_token_path)
|
||||||
|
self._base_url = (
|
||||||
|
"https://openapivts.koreainvestment.com:29443" if is_virtual
|
||||||
|
else "https://openapi.koreainvestment.com:9443"
|
||||||
|
)
|
||||||
|
self._client = httpx.AsyncClient(timeout=timeout)
|
||||||
|
self._token_cache: tuple[str, float] | None = None # (token, file_mtime)
|
||||||
|
self._last_throttle_at = 0.0 # 초당 2회 제한
|
||||||
|
|
||||||
|
async def get_minute_ohlcv(self, ticker: str) -> list[dict]:
|
||||||
|
"""현재 시점 직전 30개 1분봉 OHLCV (TR_ID: FHKST03010200).
|
||||||
|
|
||||||
|
Returns: [{"datetime", "open", "high", "low", "close", "volume"}, ...] (시간 오름차순)
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def get_asking_price(self, ticker: str) -> dict:
|
||||||
|
"""현재 호가 5단계 + 매수/매도 잔량 (TR_ID: FHKST01010200).
|
||||||
|
|
||||||
|
Returns: {
|
||||||
|
"bid_total": int,
|
||||||
|
"ask_total": int,
|
||||||
|
"bid_ratio": float,
|
||||||
|
"current_price": int,
|
||||||
|
"as_of": str (ISO),
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def close(self) -> None: ...
|
||||||
|
|
||||||
|
# internal
|
||||||
|
def _read_v1_token(self) -> str:
|
||||||
|
"""signal_v1/data/kis_token.json 읽기. mtime 캐시 — 갱신 시 자동 재로드."""
|
||||||
|
|
||||||
|
async def _throttle(self) -> None:
|
||||||
|
"""V1 패턴 — 초당 2회 제한 (0.5s sleep)."""
|
||||||
|
|
||||||
|
def _common_headers(self, tr_id: str) -> dict:
|
||||||
|
"""authorization, appkey, appsecret, tr_id."""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.1 토큰 공유 디자인
|
||||||
|
|
||||||
|
- `_v1_token_path` env `V1_TOKEN_PATH` 에서 로드. 기본값 `../signal_v1/data/kis_token.json`.
|
||||||
|
- 첫 호출 시 파일 read + mtime 캐시.
|
||||||
|
- 매 호출 전 mtime 비교 — 변경 시 재로드. 캐시 hit 시 빠른 통과.
|
||||||
|
- 파일 미존재 / 만료 시: WARNING log + `HTTPException` (Phase 6 까지 V1 단독 책임 명시).
|
||||||
|
|
||||||
|
### 4.2 분봉 응답 정규화
|
||||||
|
|
||||||
|
KIS API 의 분봉 raw 응답 (`output2` 배열) → 표준 dict 리스트로 변환. 시간 오름차순, 거래량 0 인 분봉 skip.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. KIS WebSocket client (`kis_websocket.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
class KISWebSocket:
|
||||||
|
"""KIS WebSocket — approval_key 발급 + 호가 실시간 구독."""
|
||||||
|
|
||||||
|
def __init__(self, app_key: str, app_secret: str, is_virtual: bool):
|
||||||
|
self._app_key = app_key
|
||||||
|
self._app_secret = app_secret
|
||||||
|
self._ws_url = (
|
||||||
|
"wss://openapivts.koreainvestment.com:29443/tryitout" if is_virtual
|
||||||
|
else "wss://openapi.koreainvestment.com:9443/tryitout"
|
||||||
|
)
|
||||||
|
self._approval_key: str | None = None
|
||||||
|
self._ws: WebSocketClientProtocol | None = None
|
||||||
|
self._subscriptions: set[str] = set()
|
||||||
|
self._on_asking_price: Callable[[str, dict], None] | None = None
|
||||||
|
self._recv_task: asyncio.Task | None = None
|
||||||
|
self._shutdown = asyncio.Event()
|
||||||
|
|
||||||
|
async def start(
|
||||||
|
self, tickers: list[str],
|
||||||
|
on_asking_price: Callable[[str, dict], None],
|
||||||
|
) -> None:
|
||||||
|
"""approval_key 발급 + WebSocket 연결 + 종목 호가 구독 + receive loop 시작."""
|
||||||
|
|
||||||
|
async def subscribe(self, ticker: str) -> None:
|
||||||
|
"""동적 구독 추가."""
|
||||||
|
|
||||||
|
async def unsubscribe(self, ticker: str) -> None: ...
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""unsubscribe all + shutdown event + close socket."""
|
||||||
|
|
||||||
|
# internal
|
||||||
|
async def _fetch_approval_key(self) -> str:
|
||||||
|
"""POST {base_rest}/oauth2/Approval — approval_key 발급."""
|
||||||
|
|
||||||
|
async def _send_subscription(self, ticker: str, tr_id: str = "H0STASP0") -> None:
|
||||||
|
"""tr_id H0STASP0 = 실시간 호가."""
|
||||||
|
|
||||||
|
async def _receive_loop(self) -> None:
|
||||||
|
"""메시지 receive loop. PING/PONG 30초 + 호가 message parse → callback.
|
||||||
|
끊김 감지 → exponential backoff (1s→2s→4s→max 30s) + reconnect + subscribe 재등록."""
|
||||||
|
|
||||||
|
def _parse_asking_price(self, raw: str) -> tuple[str, dict] | None:
|
||||||
|
"""KIS 호가 raw string '0|H0STASP0|...|005930^...' 파싱.
|
||||||
|
|
||||||
|
Returns: (ticker, {bid_total, ask_total, bid_ratio, current_price, as_of})
|
||||||
|
또는 None (parse fail).
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.1 메시지 형식 (KIS 공식 문서)
|
||||||
|
|
||||||
|
호가 메시지 raw 예시 (실제는 더 긴 `^` 구분 필드):
|
||||||
|
```
|
||||||
|
0|H0STASP0|001|005930^091500^78500^...^bid_total^ask_total^...
|
||||||
|
```
|
||||||
|
파싱 키 (필드 인덱스 기반):
|
||||||
|
- ticker = 4번째 필드의 종목코드 부분
|
||||||
|
- as_of = 5번째 필드 (HHMMSS)
|
||||||
|
- bid_total / ask_total = 정해진 인덱스 (KIS 문서 참조)
|
||||||
|
|
||||||
|
### 5.2 Reconnect 정책
|
||||||
|
|
||||||
|
- websockets 의 `ConnectionClosed` 캐치
|
||||||
|
- exponential backoff: 1s → 2s → 4s → 8s → 16s → max 30s
|
||||||
|
- 재연결 후 `_subscriptions` 의 모든 ticker 재구독
|
||||||
|
- 5분 이상 연결 실패 시 ERROR log + shutdown event 발생 (운영자 알림은 Phase 7)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. PollState 확장 + pull_worker
|
||||||
|
|
||||||
|
### 6.1 PollState 추가 필드
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class PollState:
|
||||||
|
portfolio: dict | None = None
|
||||||
|
news_sentiment: dict | None = None
|
||||||
|
screener_preview: dict | None = None
|
||||||
|
# 신규 (Phase 3a)
|
||||||
|
minute_bars: dict[str, deque] = field(default_factory=dict) # {ticker: deque(maxlen=60)}
|
||||||
|
asking_price: dict[str, dict] = field(default_factory=dict) # {ticker: {bid_total, ask_total, bid_ratio, ...}}
|
||||||
|
last_updated: dict[str, str] = field(default_factory=dict)
|
||||||
|
fetch_errors: dict[str, int] = field(default_factory=dict)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 pull_worker 확장
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _run_polling_cycle(client, state, kis_client):
|
||||||
|
"""기존 3 endpoint (stock) + 분봉 (KIS REST) 4 fetch 병렬."""
|
||||||
|
portfolio, sentiment, screener = await asyncio.gather(
|
||||||
|
client.get_portfolio(),
|
||||||
|
client.get_news_sentiment(),
|
||||||
|
client.run_screener_preview(),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
# ... (기존 state 갱신)
|
||||||
|
|
||||||
|
# 분봉 갱신 — portfolio + screener top-N 종목 대상
|
||||||
|
tickers = _collect_tickers(state) # portfolio + screener Top-N union
|
||||||
|
minute_results = await asyncio.gather(*[
|
||||||
|
kis_client.get_minute_ohlcv(t) for t in tickers
|
||||||
|
], return_exceptions=True)
|
||||||
|
for ticker, result in zip(tickers, minute_results):
|
||||||
|
if isinstance(result, list):
|
||||||
|
state.minute_bars.setdefault(ticker, deque(maxlen=60)).extend(result)
|
||||||
|
state.last_updated[f"minute_bars/{ticker}"] = now_iso
|
||||||
|
|
||||||
|
# 호가 갱신 (screener Top-N 만, portfolio 는 WebSocket 으로 들어옴)
|
||||||
|
screener_only = _screener_tickers_excluding_portfolio(state)
|
||||||
|
asking_results = await asyncio.gather(*[
|
||||||
|
kis_client.get_asking_price(t) for t in screener_only
|
||||||
|
], return_exceptions=True)
|
||||||
|
for ticker, result in zip(screener_only, asking_results):
|
||||||
|
if isinstance(result, dict):
|
||||||
|
state.asking_price[ticker] = result
|
||||||
|
state.last_updated[f"asking_price/{ticker}"] = now_iso
|
||||||
|
|
||||||
|
|
||||||
|
def on_websocket_asking_price(ticker: str, data: dict):
|
||||||
|
"""KIS WebSocket callback — portfolio 호가 실시간 갱신."""
|
||||||
|
state.asking_price[ticker] = data
|
||||||
|
state.last_updated[f"asking_price/{ticker}"] = datetime.now(KST).isoformat()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 종목 동기화
|
||||||
|
|
||||||
|
매 cycle 후 `state.portfolio.holdings` 의 ticker 목록과 `kis_websocket._subscriptions` 비교 → 신규 추가 / 제거 ticker 별로 `subscribe()` / `unsubscribe()` 호출.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Scheduler NXT 시간대
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Market windows (기존)
|
||||||
|
_PRE_OPEN = time(7, 0)
|
||||||
|
_OPEN = time(9, 0)
|
||||||
|
_CLOSE = time(15, 30)
|
||||||
|
_POST_END = time(20, 0)
|
||||||
|
|
||||||
|
# NXT windows (신규)
|
||||||
|
_NXT_PRE_END = time(23, 30)
|
||||||
|
_NXT_POST_OPEN = time(4, 30)
|
||||||
|
# 23:30 - 04:30 (새벽) skip
|
||||||
|
|
||||||
|
|
||||||
|
def _next_interval(now: datetime) -> float:
|
||||||
|
if not _is_market_day(now):
|
||||||
|
return _seconds_until_next_market_open(now)
|
||||||
|
|
||||||
|
t = now.time()
|
||||||
|
if _PRE_OPEN <= t < _OPEN:
|
||||||
|
return 300.0
|
||||||
|
elif _OPEN <= t < _CLOSE:
|
||||||
|
return 60.0
|
||||||
|
elif _CLOSE <= t < _POST_END:
|
||||||
|
return 300.0
|
||||||
|
elif _POST_END <= t < _NXT_PRE_END:
|
||||||
|
return 300.0 # NXT 야간 5분 (신규)
|
||||||
|
elif _NXT_POST_OPEN <= t < _PRE_OPEN:
|
||||||
|
return 300.0 # NXT 새벽 5분 (신규)
|
||||||
|
else:
|
||||||
|
return _seconds_until_next_market_open(now)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_polling_window(now: datetime) -> bool:
|
||||||
|
"""이제 야간 NXT 도 포함."""
|
||||||
|
t = now.time()
|
||||||
|
return (
|
||||||
|
(_PRE_OPEN <= t < _NXT_PRE_END)
|
||||||
|
or (_NXT_POST_OPEN <= t < _PRE_OPEN)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 테스트 (13 신규)
|
||||||
|
|
||||||
|
### 8.1 `test_kis_client.py` (4)
|
||||||
|
|
||||||
|
- `test_get_minute_ohlcv_normal_returns_30_bars` — respx 200 → list[30 dict]
|
||||||
|
- `test_get_minute_ohlcv_429_retry_then_success` — 429 → 1s backoff → 200
|
||||||
|
- `test_get_minute_ohlcv_uses_v1_token` — v1_token_path fixture → token in header
|
||||||
|
- `test_get_asking_price_computes_bid_ratio` — bid_total=600/ask_total=400 → bid_ratio=0.6
|
||||||
|
|
||||||
|
### 8.2 `test_kis_websocket.py` (4)
|
||||||
|
|
||||||
|
- `test_fetch_approval_key_via_oauth_endpoint` — respx POST /oauth2/Approval → approval_key 추출
|
||||||
|
- `test_subscribe_sends_h0stasp0_message` — fake WebSocket server → 종목 구독 메시지 전송 검증
|
||||||
|
- `test_parse_asking_price_extracts_bid_ask_totals` — KIS raw string fixture → (ticker, dict)
|
||||||
|
- `test_reconnect_on_disconnect_with_backoff` — fake server close → exponential retry
|
||||||
|
|
||||||
|
### 8.3 `test_scheduler.py` 추가 (3)
|
||||||
|
|
||||||
|
- `test_next_interval_nxt_evening_5min` — now=22:00 평일 → 300
|
||||||
|
- `test_next_interval_nxt_dawn_5min` — now=05:30 평일 → 300
|
||||||
|
- `test_next_interval_dead_zone_skip` — now=02:00 평일 → 다음 04:30 까지
|
||||||
|
|
||||||
|
### 8.4 `test_pull_worker.py` (2)
|
||||||
|
|
||||||
|
- `test_minute_polling_cycle_updates_state_minute_bars` — KIS mock → state.minute_bars[ticker] deque 갱신
|
||||||
|
- `test_websocket_message_updates_state_asking_price` — WebSocket callback → state.asking_price[ticker] dict
|
||||||
|
|
||||||
|
**합계**: 4 + 4 + 3 + 2 = **13 신규**. 기존 19 + 13 = **32 total signal_v2 tests**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 위험 및 완화
|
||||||
|
|
||||||
|
| 위험 | 완화 |
|
||||||
|
|------|------|
|
||||||
|
| V1 토큰 파일 미존재 (V1 미가동) | startup ERROR log + KIS REST 호출 fail. Phase 6 까지 V1 단독 책임 |
|
||||||
|
| KIS WebSocket 연결 끊김 | exponential backoff (1s→2s→4s→max 30s) + subscription 재등록 |
|
||||||
|
| KIS WebSocket 호가 메시지 형식 변경 | `_parse_asking_price` parse fail → WARNING log + skip. KIS API 변경 시 spec 갱신 |
|
||||||
|
| V1 토큰 갱신 race (V1 갱신 중 V2 read) | mtime 캐시 + 짧은 fail 허용 (다음 호출에서 새 token 사용) |
|
||||||
|
| approval_key 만료 | 매 reconnect 시 재발급 |
|
||||||
|
| KIS REST rate limit (초당 2회) | `_throttle()` 0.5s sleep (V1 패턴) |
|
||||||
|
| 분봉 buffer 메모리 누수 | `deque(maxlen=60)` 자동 cap. ticker ~40 → ~200KB |
|
||||||
|
| websockets 라이브러리 호환 | `websockets>=12` 명시 |
|
||||||
|
| WebSocket subscription / portfolio drift | pull_worker 가 매 cycle 후 비교 + 동적 subscribe/unsubscribe |
|
||||||
|
| NXT 시간대 polling 시 stock API 부하 | 5분 cron × portfolio 11 종목 → 분당 ~2 call 무시 가능 |
|
||||||
|
| 분봉 데이터 누락 (network 단절) | retry 3회 + cache. 누락 분봉 skip + WARNING |
|
||||||
|
| KIS API 점검 시간대 | KIS 점검 (보통 새벽 02:00-04:30) 은 dead zone 시간대와 일치 — 영향 없음 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 운영 영향
|
||||||
|
|
||||||
|
| 항목 | 영향 |
|
||||||
|
|------|------|
|
||||||
|
| 다운타임 | 0 (signal_v2 재기동만, V1 무영향) |
|
||||||
|
| 사용자 영향 | 없음 (Phase 3a 데이터 수집만, 신호 발송은 Phase 5) |
|
||||||
|
| `.env` 갱신 | 사용자 1회 (KIS_APP_KEY/SECRET/ACCOUNT/IS_VIRTUAL + V1_TOKEN_PATH) |
|
||||||
|
| V1 영향 | 0 (read-only 토큰 공유) |
|
||||||
|
| stock NAS 부하 | 무관 |
|
||||||
|
| KIS API 부하 | 매 분봉 cycle 분당 ~20 종목 × 2 call (분봉+호가) = 40 call/min ≈ 초당 0.67 < 2 한도 |
|
||||||
|
| WebSocket 세션 | 1 세션 / portfolio 보유 종목 (~11) 구독 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Phase 3a 완료 조건 (DoD)
|
||||||
|
|
||||||
|
- [ ] `signal_v2/kis_client.py` 신규 (REST 분봉 + 호가)
|
||||||
|
- [ ] `signal_v2/kis_websocket.py` 신규 (WebSocket approval_key + 호가)
|
||||||
|
- [ ] `signal_v2/state.py` `PollState` 확장 (minute_bars + asking_price)
|
||||||
|
- [ ] `signal_v2/pull_worker.py` 분봉 cycle + WebSocket task 추가
|
||||||
|
- [ ] `signal_v2/scheduler.py` NXT 시간대 추가
|
||||||
|
- [ ] `signal_v2/main.py` lifespan 에 KISClient/KISWebSocket 통합
|
||||||
|
- [ ] `signal_v2/config.py` KIS env + V1_TOKEN_PATH
|
||||||
|
- [ ] `requirements.txt` 에 `websockets>=12`
|
||||||
|
- [ ] 13 신규 테스트 PASS (총 32)
|
||||||
|
- [ ] `.env` 갱신 (사용자 1회)
|
||||||
|
- [ ] 운영 smoke: signal_v2 시작 → KIS WebSocket 연결 → portfolio 호가 1건 수신 → `state.asking_price` 갱신 → 분봉 1회 fetch → `state.minute_bars` 갱신
|
||||||
|
- [ ] V1 봇 무영향 (토큰 read-only 공유 동작)
|
||||||
|
- [ ] git push (web-ai repo)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Phase 3b 와의 관계
|
||||||
|
|
||||||
|
본 Phase 3a 완료 후 즉시 **Phase 3b (Chronos-2 + 분봉 모멘텀)** brainstorming. 의존성:
|
||||||
|
|
||||||
|
```
|
||||||
|
[Phase 3a spec/plan/실행] → [Phase 3b spec/plan/실행]
|
||||||
|
1주 1주
|
||||||
|
```
|
||||||
|
|
||||||
|
Phase 3b 의 입력 = 본 spec 의 `state.minute_bars` + `state.asking_price`. Phase 3b 산출 = `state.chronos_predictions` + `state.minute_momentum` (Phase 4 가 사용).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Backlog (본 spec NOT)
|
||||||
|
|
||||||
|
- WebSocket 동적 subscribe (현재 매 cycle 일괄, 즉시 갱신 안 됨)
|
||||||
|
- KIS 분봉 60+ 보관 (장기 추세 분석용)
|
||||||
|
- 체결 데이터 (`H0STCNT0`) 추가 — 자체 분봉 builder 가능성
|
||||||
|
- KIS API 응답 시간 모니터링 (Phase 7)
|
||||||
|
- V2 자체 KIS 토큰 갱신 (Phase 6 deprecation 시)
|
||||||
|
- WebSocket session 멀티 (41 종목 한도 초과 시)
|
||||||
|
- approval_key 만료 자동 감지 (현재는 reconnect 시점)
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
# Confidence Signal Pipeline V2 — Phase 3b: Chronos-2 + Minute Momentum Design
|
||||||
|
|
||||||
|
**작성일**: 2026-05-16
|
||||||
|
**작성자**: gahusb
|
||||||
|
**상태**: Approved for implementation
|
||||||
|
**선행 spec**:
|
||||||
|
- Phase 0 architecture (`2026-05-15-confidence-signal-pipeline-v2-architecture.md`)
|
||||||
|
- Phase 1 stock WebAI API (`2026-05-15-signal-v2-phase1-webai-api.md`)
|
||||||
|
- Phase 2 web-ai pull worker (`2026-05-16-signal-v2-phase2-web-ai-pull-worker.md`)
|
||||||
|
- Phase 3a KIS data collection (`2026-05-16-signal-v2-phase3a-kis-data-collection.md`)
|
||||||
|
|
||||||
|
**브레인스토밍 결정 7개**:
|
||||||
|
- daily data 소스 = B (KIS REST `kis_client.get_daily_ohlcv`)
|
||||||
|
- 추론 빈도 = A (종가 후 1회 + 메모리 보관)
|
||||||
|
- 모델 = A (env `CHRONOS_MODEL` 외부화, 기본 `amazon/chronos-2`, 항상 로드)
|
||||||
|
- 분봉 모멘텀 = A (5-level 룰 기반)
|
||||||
|
- State output = B (median + q10 + q90 + conf + as_of)
|
||||||
|
- 테스트 = A (모델 mock + 순수 함수)
|
||||||
|
- scope = 통합 9 항목 (Phase 3a 와 같은 1주 단위)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 목표
|
||||||
|
|
||||||
|
Phase 3a 의 데이터 위에 추론 레이어 추가. Chronos-2 zero-shot 으로 다음날 가격 분포 예측 + 1분봉 → 5분봉 aggregate 후 5-level 모멘텀 분류. Phase 4 (signal generator) 가 두 출력 + Phase 3a 의 호가/분봉 + Phase 2 의 portfolio/news_sentiment 를 종합해 매수/매도 신호 룰 적용.
|
||||||
|
|
||||||
|
**Why**: Phase 0 §3 "web-ai = 시점 분석" 책임의 추론 부분. Chronos-2 의 zero-shot quantile 분포 + 분봉 모멘텀 5-level 이 매수/매도 룰의 핵심 입력.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 범위
|
||||||
|
|
||||||
|
### 포함 (9 항목)
|
||||||
|
|
||||||
|
- ① `kis_client.get_daily_ohlcv(ticker, days=60)` — KIS REST TR_ID `FHKST03010100`
|
||||||
|
- ② `chronos_predictor.py` 신규 — `ChronosPredictor` (HuggingFace 모델 + batch predict)
|
||||||
|
- ③ `momentum_classifier.py` 신규 — `aggregate_1min_to_5min` + `classify_minute_momentum`
|
||||||
|
- ④ `pull_worker.py` 확장 — `_run_post_close_cycle` + `update_minute_momentum_for_all`
|
||||||
|
- ⑤ `scheduler.py` 확장 — `_is_post_close_trigger` (16:00 KST)
|
||||||
|
- ⑥ `state.py` 확장 — `daily_ohlcv` + `chronos_predictions` + `minute_momentum`
|
||||||
|
- ⑦ `main.py` 확장 — lifespan 에 ChronosPredictor 로드
|
||||||
|
- ⑧ `config.py` 확장 — `CHRONOS_MODEL` env
|
||||||
|
- ⑨ `requirements.txt` — `transformers>=4.40`, `chronos-forecasting>=1.4`, `torch>=2.0`
|
||||||
|
|
||||||
|
### 범위 외 (NOT)
|
||||||
|
|
||||||
|
- Signal generator 매수/매도 룰 (Phase 4)
|
||||||
|
- agent-office `/signal` 호출 (Phase 5)
|
||||||
|
- 모델 재학습/fine-tune — zero-shot only
|
||||||
|
- 다중 horizon 예측 — 1-day median 만, 다른 horizon Phase 7
|
||||||
|
- 외부 데이터 (yfinance/FDR) — KIS REST 만
|
||||||
|
- Chronos lazy load — 항상 로드 (Phase 7 모니터링 후 검토)
|
||||||
|
- 분봉 모멘텀 ML 모델 — 룰 기반만 (Phase 7 백테스트 후 ML 검토)
|
||||||
|
- WebSocket 동적 subscribe (Phase 3a backlog 그대로)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 파일 구조 + 변경 매트릭스
|
||||||
|
|
||||||
|
| 파일 | 작업 | 라인 |
|
||||||
|
|------|------|------|
|
||||||
|
| `signal_v2/kis_client.py` | `get_daily_ohlcv` 메서드 추가 | +50 |
|
||||||
|
| `signal_v2/chronos_predictor.py` | 신규 | ~120 |
|
||||||
|
| `signal_v2/momentum_classifier.py` | 신규 | ~80 |
|
||||||
|
| `signal_v2/pull_worker.py` | post-close cycle + momentum 갱신 | +50 |
|
||||||
|
| `signal_v2/scheduler.py` | `_is_post_close_trigger` 헬퍼 | +20 |
|
||||||
|
| `signal_v2/state.py` | 3 필드 추가 | +5 |
|
||||||
|
| `signal_v2/main.py` | lifespan ChronosPredictor 로드 | +15 |
|
||||||
|
| `signal_v2/config.py` | `chronos_model` 필드 | +3 |
|
||||||
|
| `signal_v2/requirements.txt` | 3 의존성 | +3 |
|
||||||
|
| `signal_v2/tests/test_kis_client.py` | daily 1 케이스 | +30 |
|
||||||
|
| `signal_v2/tests/test_chronos_predictor.py` | 신규 4 케이스 | ~120 |
|
||||||
|
| `signal_v2/tests/test_momentum_classifier.py` | 신규 6 케이스 | ~150 |
|
||||||
|
| `signal_v2/tests/test_pull_worker.py` | post-close 1 케이스 | +50 |
|
||||||
|
|
||||||
|
**합계**: 13 파일 변경 (8 코드 + 4 테스트 + 1 requirements), **12 신규 테스트** (33 → 45 total).
|
||||||
|
|
||||||
|
### 외부 의존성 신규
|
||||||
|
|
||||||
|
- `transformers>=4.40`
|
||||||
|
- `chronos-forecasting>=1.4`
|
||||||
|
- `torch>=2.0` (CUDA 12.x 빌드, V1 venv 공유 시 재설치 불필요)
|
||||||
|
|
||||||
|
### 모델 다운로드
|
||||||
|
|
||||||
|
`amazon/chronos-2` HuggingFace 모델 첫 로드 시 ~1GB 다운로드 (~수십 초). `~/.cache/huggingface/` 캐시 후 무영향. Task 7 manual smoke 에 시간 예상 명시.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. KIS Daily OHLCV (`kis_client.get_daily_ohlcv`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def get_daily_ohlcv(self, ticker: str, days: int = 60) -> list[dict]:
|
||||||
|
"""KRX 일봉 OHLCV (TR_ID FHKST03010100).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ticker: 6자리 종목코드
|
||||||
|
days: 최근 N영업일 (KIS 한도 100영업일)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
[{"datetime": "2026-05-15", "open": int, "high": int, "low": int,
|
||||||
|
"close": int, "volume": int}, ...]
|
||||||
|
시간 오름차순 (가장 최근이 마지막).
|
||||||
|
"""
|
||||||
|
path = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
|
||||||
|
today = datetime.now(KST).strftime("%Y%m%d")
|
||||||
|
start_date = (datetime.now(KST) - timedelta(days=days * 2)).strftime("%Y%m%d")
|
||||||
|
params = {
|
||||||
|
"FID_COND_MRKT_DIV_CODE": "J",
|
||||||
|
"FID_INPUT_ISCD": ticker,
|
||||||
|
"FID_INPUT_DATE_1": start_date,
|
||||||
|
"FID_INPUT_DATE_2": today,
|
||||||
|
"FID_PERIOD_DIV_CODE": "D",
|
||||||
|
"FID_ORG_ADJ_PRC": "1",
|
||||||
|
}
|
||||||
|
raw = await self._request_with_retry(
|
||||||
|
"GET", path, tr_id="FHKST03010100", params=params,
|
||||||
|
)
|
||||||
|
output2 = raw.get("output2", [])
|
||||||
|
bars = []
|
||||||
|
for row in output2:
|
||||||
|
try:
|
||||||
|
date = row["stck_bsop_date"]
|
||||||
|
bars.append({
|
||||||
|
"datetime": f"{date[:4]}-{date[4:6]}-{date[6:]}",
|
||||||
|
"open": int(row["stck_oprc"]),
|
||||||
|
"high": int(row["stck_hgpr"]),
|
||||||
|
"low": int(row["stck_lwpr"]),
|
||||||
|
"close": int(row["stck_clpr"]),
|
||||||
|
"volume": int(row["acml_vol"]),
|
||||||
|
})
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
continue
|
||||||
|
bars.reverse() # KIS descending → ascending
|
||||||
|
return bars[-days:]
|
||||||
|
```
|
||||||
|
|
||||||
|
핵심:
|
||||||
|
- TR_ID `FHKST03010100` (V1 패턴)
|
||||||
|
- 수정주가 (`FID_ORG_ADJ_PRC=1`)
|
||||||
|
- start_date 를 `days*2` 로 → 휴장일 + 주말 고려 → `[-days:]` 트리밍
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. ChronosPredictor
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class ChronosPrediction:
|
||||||
|
median: float
|
||||||
|
q10: float
|
||||||
|
q90: float
|
||||||
|
conf: float
|
||||||
|
as_of: str
|
||||||
|
|
||||||
|
|
||||||
|
class ChronosPredictor:
|
||||||
|
"""HuggingFace Chronos-2 zero-shot forecaster."""
|
||||||
|
|
||||||
|
def __init__(self, model_name: str = "amazon/chronos-2", device: str | None = None):
|
||||||
|
from chronos import ChronosPipeline
|
||||||
|
import torch
|
||||||
|
|
||||||
|
self._device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
||||||
|
logger.info("Loading Chronos pipeline: %s on %s", model_name, self._device)
|
||||||
|
self._pipeline = ChronosPipeline.from_pretrained(
|
||||||
|
model_name,
|
||||||
|
device_map=self._device,
|
||||||
|
torch_dtype=torch.float16 if self._device == "cuda" else torch.float32,
|
||||||
|
)
|
||||||
|
|
||||||
|
def predict_batch(
|
||||||
|
self,
|
||||||
|
daily_ohlcv_dict: dict[str, list[dict]],
|
||||||
|
prediction_length: int = 1,
|
||||||
|
num_samples: int = 100,
|
||||||
|
) -> dict[str, ChronosPrediction]:
|
||||||
|
"""종목별 1-day return 분포 예측."""
|
||||||
|
import torch
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
tickers = list(daily_ohlcv_dict.keys())
|
||||||
|
contexts = [
|
||||||
|
torch.tensor([bar["close"] for bar in daily_ohlcv_dict[t]], dtype=torch.float32)
|
||||||
|
for t in tickers
|
||||||
|
]
|
||||||
|
forecasts = self._pipeline.predict(
|
||||||
|
context=contexts, prediction_length=prediction_length, num_samples=num_samples,
|
||||||
|
)
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
now_iso = datetime.now(KST).isoformat()
|
||||||
|
results: dict[str, ChronosPrediction] = {}
|
||||||
|
for i, ticker in enumerate(tickers):
|
||||||
|
samples = forecasts[i, :, 0].numpy()
|
||||||
|
last_close = daily_ohlcv_dict[ticker][-1]["close"]
|
||||||
|
returns = (samples - last_close) / last_close
|
||||||
|
median = float(np.quantile(returns, 0.5))
|
||||||
|
q10 = float(np.quantile(returns, 0.1))
|
||||||
|
q90 = float(np.quantile(returns, 0.9))
|
||||||
|
spread = (q90 - q10) / max(abs(median), 0.001)
|
||||||
|
conf = float(max(0.0, min(1.0, 1.0 - spread / 2.0)))
|
||||||
|
results[ticker] = ChronosPrediction(median, q10, q90, conf, now_iso)
|
||||||
|
return results
|
||||||
|
```
|
||||||
|
|
||||||
|
핵심:
|
||||||
|
- Lazy import (`chronos-forecasting` 무거움)
|
||||||
|
- GPU 자동 감지 + FP16 (CUDA) / FP32 (CPU)
|
||||||
|
- Batch predict — 30+ 종목 동시 ~1-2초
|
||||||
|
- Price → return 변환
|
||||||
|
- Confidence — 분포 폭 기반 (좁을수록 1)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 분봉 모멘텀 분류기
|
||||||
|
|
||||||
|
### 6.1 1분봉 → 5분봉 aggregate
|
||||||
|
|
||||||
|
```python
|
||||||
|
def aggregate_1min_to_5min(minute_bars: list[dict]) -> list[dict]:
|
||||||
|
"""1분봉 N개 → 5분봉 floor(N/5) 개. 시간 오름차순."""
|
||||||
|
bars_5min = []
|
||||||
|
chunks = len(minute_bars) // 5
|
||||||
|
for i in range(chunks):
|
||||||
|
chunk = minute_bars[i * 5 : (i + 1) * 5]
|
||||||
|
bars_5min.append({
|
||||||
|
"datetime": chunk[0]["datetime"],
|
||||||
|
"open": chunk[0]["open"],
|
||||||
|
"high": max(b["high"] for b in chunk),
|
||||||
|
"low": min(b["low"] for b in chunk),
|
||||||
|
"close": chunk[-1]["close"],
|
||||||
|
"volume": sum(b["volume"] for b in chunk),
|
||||||
|
})
|
||||||
|
return bars_5min
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 5-level 분류
|
||||||
|
|
||||||
|
```python
|
||||||
|
def classify_minute_momentum(minute_bars: deque) -> str:
|
||||||
|
"""1분봉 deque → strong_up / weak_up / neutral / weak_down / strong_down."""
|
||||||
|
minute_list = list(minute_bars)
|
||||||
|
if len(minute_list) < 5 * 5: # 25 bars minimum
|
||||||
|
return NEUTRAL
|
||||||
|
|
||||||
|
bars_5min = aggregate_1min_to_5min(minute_list)
|
||||||
|
if len(bars_5min) < 5:
|
||||||
|
return NEUTRAL
|
||||||
|
|
||||||
|
recent = bars_5min[-5:] # 직전 5개 5분봉
|
||||||
|
up_count = sum(1 for b in recent if b["close"] > b["open"])
|
||||||
|
|
||||||
|
# 거래량 multiplier — recent 5 vs 60분 평균
|
||||||
|
recent_vol_avg = sum(b["volume"] for b in recent) / len(recent)
|
||||||
|
long_window = bars_5min[-12:] # 60분 = 5분봉 12개
|
||||||
|
long_vol_avg = sum(b["volume"] for b in long_window) / len(long_window)
|
||||||
|
vol_mult = recent_vol_avg / long_vol_avg if long_vol_avg > 0 else 1.0
|
||||||
|
|
||||||
|
if up_count == 5 and vol_mult >= 1.5:
|
||||||
|
return STRONG_UP
|
||||||
|
elif up_count >= 3 and vol_mult >= 1.0:
|
||||||
|
return WEAK_UP
|
||||||
|
elif up_count == 0 and vol_mult >= 1.5:
|
||||||
|
return STRONG_DOWN
|
||||||
|
elif up_count <= 2 and vol_mult < 1.0:
|
||||||
|
return WEAK_DOWN
|
||||||
|
else:
|
||||||
|
return NEUTRAL
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. PollState 확장 + pull_worker
|
||||||
|
|
||||||
|
### 7.1 PollState 추가 필드
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class PollState:
|
||||||
|
# ... 기존 필드 ...
|
||||||
|
# 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)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 pull_worker 확장
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _run_post_close_cycle(
|
||||||
|
kis_client: KISClient, chronos: ChronosPredictor, state: PollState,
|
||||||
|
) -> None:
|
||||||
|
"""16:00 KST 종가 후 1회: daily fetch + chronos predict."""
|
||||||
|
tickers = list(set(_portfolio_tickers(state)) | set(_screener_tickers(state)))
|
||||||
|
daily_results = await asyncio.gather(*[
|
||||||
|
kis_client.get_daily_ohlcv(t, days=60) for t in tickers
|
||||||
|
], return_exceptions=True)
|
||||||
|
daily_dict = {}
|
||||||
|
for ticker, result in zip(tickers, daily_results):
|
||||||
|
if isinstance(result, list) and len(result) >= 30:
|
||||||
|
daily_dict[ticker] = result
|
||||||
|
state.daily_ohlcv[ticker] = result
|
||||||
|
|
||||||
|
if daily_dict:
|
||||||
|
predictions = chronos.predict_batch(daily_dict)
|
||||||
|
now_iso = datetime.now(KST).isoformat()
|
||||||
|
for ticker, pred in predictions.items():
|
||||||
|
state.chronos_predictions[ticker] = {
|
||||||
|
"median": pred.median, "q10": pred.q10, "q90": pred.q90,
|
||||||
|
"conf": pred.conf, "as_of": pred.as_of,
|
||||||
|
}
|
||||||
|
state.last_updated[f"chronos/{ticker}"] = pred.as_of
|
||||||
|
|
||||||
|
|
||||||
|
def update_minute_momentum_for_all(state: PollState) -> None:
|
||||||
|
"""매 분봉 cycle 후 호출 — 모든 종목 모멘텀 갱신."""
|
||||||
|
from signal_v2.momentum_classifier import classify_minute_momentum
|
||||||
|
for ticker, bars in state.minute_bars.items():
|
||||||
|
state.minute_momentum[ticker] = classify_minute_momentum(bars)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 scheduler `_is_post_close_trigger`
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _is_post_close_trigger(now: datetime) -> bool:
|
||||||
|
"""16:00 KST ±1분 (post-close cycle 트리거)."""
|
||||||
|
if not _is_market_day(now):
|
||||||
|
return False
|
||||||
|
t = now.time()
|
||||||
|
return time(16, 0) <= t < time(16, 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
`poll_loop` 안에서 매 cycle:
|
||||||
|
```python
|
||||||
|
if _is_post_close_trigger(now) and chronos is not None:
|
||||||
|
await _run_post_close_cycle(kis_client, chronos, state)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 테스트 (12 신규)
|
||||||
|
|
||||||
|
### 8.1 `test_kis_client.py` (1)
|
||||||
|
- `test_get_daily_ohlcv_returns_60_bars` — respx mock 200 → 60 bars 시간 오름차순
|
||||||
|
|
||||||
|
### 8.2 `test_chronos_predictor.py` (4, 모델 mock)
|
||||||
|
- `test_predict_batch_returns_prediction_dict` — mock pipeline → ChronosPrediction
|
||||||
|
- `test_conf_high_when_distribution_narrow` — narrow → conf ≈ 1
|
||||||
|
- `test_conf_low_when_distribution_wide` — wide → conf ≈ 0
|
||||||
|
- `test_return_computed_from_price_relative_to_last_close` — price → return 변환
|
||||||
|
|
||||||
|
### 8.3 `test_momentum_classifier.py` (6)
|
||||||
|
- `test_strong_up_5_consecutive_green_with_high_volume`
|
||||||
|
- `test_weak_up_3of5_green_normal_volume`
|
||||||
|
- `test_neutral_mixed`
|
||||||
|
- `test_weak_down_low_green_low_volume`
|
||||||
|
- `test_strong_down_5_consecutive_red_high_volume`
|
||||||
|
- `test_aggregate_1min_to_5min_correctness`
|
||||||
|
|
||||||
|
### 8.4 `test_pull_worker.py` (1)
|
||||||
|
- `test_post_close_cycle_updates_chronos_predictions` — mock kis + mock chronos → state 갱신
|
||||||
|
|
||||||
|
**합계**: 1 + 4 + 6 + 1 = **12 신규**. 기존 33 + 12 = **45 total**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 위험 및 완화
|
||||||
|
|
||||||
|
| 위험 | 완화 |
|
||||||
|
|------|------|
|
||||||
|
| Chronos-2 첫 로드 ~1GB 다운로드 | startup INFO + Task 7 smoke 시간 예상 명시 |
|
||||||
|
| GPU OOM (Chronos + V1 Ollama 동거) | FP16 ~400MB + Ollama 4GB = 5GB / 15.5GB 여유. Phase 5 Qwen3 추가 시 13.3GB. Phase 6 V1 deprecation 후 해소 |
|
||||||
|
| `chronos-forecasting` 호환 (transformers 버전) | 명시 버전. 운영 첫 install 검증 |
|
||||||
|
| KIS daily fetch + V1 Macro 동시 → rate limit (EGW00201) | post-close 16:00 트리거 vs V1 Trading Bot 의 장 마감 cycle 충돌 위험. 운영 검증 후 16:05 으로 조정 가능 |
|
||||||
|
| Chronos-2 예측 정확도 불확실 | Phase 7 IC 검증 + 신호 hit-rate 추적. 부족 시 model env 변경 또는 Moirai-2.0 |
|
||||||
|
| 모멘텀 룰 임계값 (1.5x / 5/5) 보수적 | Phase 7 운영 후 임계값 조정 |
|
||||||
|
| 1분봉 60개 미만 (장 시작 1시간 내) | NEUTRAL 폴백. 09:00-10:00 신호 발생 안 함 (운영 허용) |
|
||||||
|
| Chronos 모델 다운로드 네트워크 단절 | startup RuntimeError + 운영자 알림 + 재시작. 캐시 후 무관 |
|
||||||
|
| daily_ohlcv 메모리 누수 | 종목 ~30 × 60일 ~100B = ~180KB. 무시 |
|
||||||
|
| Chronos 추론 시 V1 Ollama 와 동시 GPU 사용 | 일 1회 + 짧음 (~2초). V1 Ollama 의 GPU 점유 사이에 끼어들 가능성 → 일시 deferred. Phase 7 모니터링 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 운영 영향
|
||||||
|
|
||||||
|
| 항목 | 영향 |
|
||||||
|
|------|------|
|
||||||
|
| 다운타임 | signal_v2 재기동 ~30초 (첫 모델 로드) |
|
||||||
|
| 사용자 영향 | 없음 (Phase 3b 도 silent, 신호 발송은 Phase 5) |
|
||||||
|
| `.env` 갱신 | optional 1줄 (`CHRONOS_MODEL=amazon/chronos-2` — 기본값과 동일 시 미설정 OK) |
|
||||||
|
| V1 영향 | 0 (별도 process). GPU 메모리만 공유 |
|
||||||
|
| KIS API 부하 | post-close cycle 일 1회 30 종목 daily fetch ~60 calls. 평소 분봉/호가 cycle 그대로 |
|
||||||
|
| 모델 다운로드 | 첫 시작 ~1GB / 캐시 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Phase 3b 완료 조건 (DoD)
|
||||||
|
|
||||||
|
- [ ] `signal_v2/kis_client.py` `get_daily_ohlcv` 메서드 추가
|
||||||
|
- [ ] `signal_v2/chronos_predictor.py` 신규
|
||||||
|
- [ ] `signal_v2/momentum_classifier.py` 신규
|
||||||
|
- [ ] `signal_v2/pull_worker.py` post-close cycle + momentum 갱신
|
||||||
|
- [ ] `signal_v2/scheduler.py` `_is_post_close_trigger`
|
||||||
|
- [ ] `signal_v2/state.py` 3 필드 추가
|
||||||
|
- [ ] `signal_v2/main.py` lifespan ChronosPredictor 로드
|
||||||
|
- [ ] `signal_v2/config.py` `CHRONOS_MODEL` env
|
||||||
|
- [ ] `requirements.txt` 3 의존성 추가
|
||||||
|
- [ ] 12 신규 테스트 PASS (총 45)
|
||||||
|
- [ ] 운영 smoke: signal_v2 시작 → Chronos 모델 로드 성공 → 16:00 post-close cycle 1회 실행 → state.chronos_predictions 갱신 확인
|
||||||
|
- [ ] V1 무영향 (GPU OOM 없음)
|
||||||
|
- [ ] git push
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Phase 4 와의 관계
|
||||||
|
|
||||||
|
본 Phase 3b 완료 후 즉시 **Phase 4 (Signal Generator)** brainstorming. 의존성:
|
||||||
|
|
||||||
|
```
|
||||||
|
[Phase 3b spec/plan/실행] → [Phase 4 spec/plan/실행]
|
||||||
|
1주 1주
|
||||||
|
```
|
||||||
|
|
||||||
|
Phase 4 의 입력 = 본 spec 의 `state.chronos_predictions` + `state.minute_momentum` + Phase 3a 의 `state.asking_price` + Phase 2 의 `state.portfolio` + `state.news_sentiment`. Phase 4 산출 = `state.signals[ticker]` (buy/sell decision + confidence).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Backlog (본 spec NOT)
|
||||||
|
|
||||||
|
- Chronos lazy load (Phase 5 Qwen3 동거 시 VRAM 압박 검토)
|
||||||
|
- 다중 horizon (1-day + 5-day + 20-day)
|
||||||
|
- ML 기반 분봉 모멘텀 (현재 룰 기반만)
|
||||||
|
- Chronos model A/B (chronos-bolt-base vs chronos-2 비교 실험)
|
||||||
|
- KIS daily fetch 의 V1 충돌 회피 — file mutex 또는 V2 별도 app_key
|
||||||
|
- Chronos quantile 의 임의 quantile 지원 (현재 q10/q50/q90 만)
|
||||||
|
- daily_ohlcv 영속 저장 (재기동 시 reset 회피)
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
# web-ai V1 루트 → `signal_v1/` Rename Design
|
||||||
|
|
||||||
|
**작성일**: 2026-05-16
|
||||||
|
**작성자**: gahusb
|
||||||
|
**상태**: Approved for implementation
|
||||||
|
**선행 spec**:
|
||||||
|
- Confidence Signal Pipeline V2 Phase 0 (`2026-05-15-confidence-signal-pipeline-v2-architecture.md`)
|
||||||
|
- stock-lab → stock graduation (`2026-05-15-stock-lab-rename-to-stock.md`) — 동일 atomic refactor 패턴
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 목표
|
||||||
|
|
||||||
|
`web-ai/` 디렉토리에 V1 자동매매 시스템 (main_server.py + modules/ + 자체 LSTM + KIS + Telegram Bot) 과 V2 시그널 파이프라인 (`signal_v2/` Phase 2 시작) 이 함께 거주할 예정. V1 자산을 모두 `signal_v1/` 하위로 격리해 신/구 분리 명확.
|
||||||
|
|
||||||
|
**Why**: 사용자 명시 ("기존 기능들도 봤을때 헷갈리지 않게 signal_v2에서 사용하는거 아니면 web-ai/signal_v1 으로 몰아넣어줘"). V2 Phase 6 deprecation 시점에 `rm -rf signal_v1/` 단순화. Phase 2 spec 작성 전에 새 이름 `signal_v1/` 기준으로 진행하면 후속 갱신 비용 회피.
|
||||||
|
|
||||||
|
본 리네이밍은 **Phase 2 brainstorming 의 도중 분기**한 별도 슬라이스 — stock-lab → stock graduation 과 동일 패턴.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 범위
|
||||||
|
|
||||||
|
### 포함
|
||||||
|
|
||||||
|
- `git mv` web-ai 루트의 모든 V1 자산을 `signal_v1/` 안으로:
|
||||||
|
- 진입점: `main_server.py`, `warmup_and_restart.py`, `watchlist_manager.py`, `backtester.py`, `theme_manager.py`, `backtest_runner.py`
|
||||||
|
- 모듈: `modules/` (전체)
|
||||||
|
- 데이터: `data/` (전체 — runtime data 보존)
|
||||||
|
- 테스트: `tests/` (전체)
|
||||||
|
- 스크립트: `start.bat`
|
||||||
|
- 문서: `KIS_SETUP.md`, `README.md`, `CLAUDE.md` (기존 V1 가이드)
|
||||||
|
- 로그: `bot_ipc.json`, `bot_output.log`, `daily_launcher.log`, `server.log`, `telegram_bot.log`, `warmup.log`
|
||||||
|
- `__pycache__/` (gitignore)
|
||||||
|
- `web-ai/CLAUDE.md` 신규 — web-ai 루트의 새 가이드 (signal_v1 + signal_v2 디렉토리 안내, 공유 `.env`, Phase 6 deprecation 계획)
|
||||||
|
- `web-ai/start.bat` 신규 — `cd signal_v1 && python main_server.py` (또는 절대 경로 형태)
|
||||||
|
- 운영 검증: 자체 자동매매 봇 정상 기동 + Telegram Bot polling + KIS 토큰 로딩
|
||||||
|
|
||||||
|
### 범위 외 (NOT)
|
||||||
|
|
||||||
|
- Python import 경로 변경 — `signal_v1/` 안에서 진입점 실행 시 cwd 가 `signal_v1/` 이라 기존 `from modules.X` 그대로 작동. import 전면 갱신 불필요.
|
||||||
|
- `signal_v2/` 디렉토리 생성 — Phase 2 spec 의 작업.
|
||||||
|
- `.env` 분리 — V1 + V2 환경변수 모두 `web-ai/.env` 한 곳 (signal_v1 의 python 진입점이 cwd 기준 `.env` 로드 시 path 갱신 필요, 단순 조정).
|
||||||
|
- `.gitignore` — 기존 패턴 그대로 (`signal_v1/__pycache__`, `signal_v1/data/*.db` 등은 일반 패턴으로 커버).
|
||||||
|
- 다른 lab / web-backend / web-ui 영향 — 0.
|
||||||
|
- start_signal_v2.bat — Phase 2 spec 의 작업.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 변경 매트릭스
|
||||||
|
|
||||||
|
### 3.1 web-ai 루트 (작업 전)
|
||||||
|
|
||||||
|
```
|
||||||
|
web-ai/
|
||||||
|
├── .env ← 유지
|
||||||
|
├── .gitignore ← 유지
|
||||||
|
├── CLAUDE.md ← signal_v1/ 로 mv (현 V1 가이드)
|
||||||
|
├── KIS_SETUP.md ← signal_v1/ 로 mv
|
||||||
|
├── README.md ← signal_v1/ 로 mv
|
||||||
|
├── main_server.py ← signal_v1/ 로 mv
|
||||||
|
├── warmup_and_restart.py ← signal_v1/ 로 mv
|
||||||
|
├── watchlist_manager.py ← signal_v1/ 로 mv
|
||||||
|
├── backtester.py ← signal_v1/ 로 mv
|
||||||
|
├── backtest_runner.py ← signal_v1/ 로 mv
|
||||||
|
├── theme_manager.py ← signal_v1/ 로 mv
|
||||||
|
├── start.bat ← signal_v1/ 로 mv (이후 web-ai/start.bat 신규)
|
||||||
|
├── modules/ ← signal_v1/ 로 mv
|
||||||
|
├── data/ ← signal_v1/ 로 mv
|
||||||
|
├── tests/ ← signal_v1/ 로 mv
|
||||||
|
├── __pycache__/ ← signal_v1/ 로 mv (gitignore)
|
||||||
|
├── bot_ipc.json ← signal_v1/ 로 mv
|
||||||
|
├── bot_output.log ← signal_v1/ 로 mv
|
||||||
|
├── daily_launcher.log ← signal_v1/ 로 mv
|
||||||
|
├── server.log ← signal_v1/ 로 mv
|
||||||
|
├── telegram_bot.log ← signal_v1/ 로 mv
|
||||||
|
└── warmup.log ← signal_v1/ 로 mv
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 web-ai 루트 (작업 후)
|
||||||
|
|
||||||
|
```
|
||||||
|
web-ai/
|
||||||
|
├── .env ← 공유 (V1 + V2 변수)
|
||||||
|
├── .gitignore ← 기존
|
||||||
|
├── CLAUDE.md ← 신규 (web-ai 레벨 가이드)
|
||||||
|
├── start.bat ← 신규 (signal_v1 진입)
|
||||||
|
├── signal_v1/
|
||||||
|
│ ├── CLAUDE.md ← 기존 V1 가이드 (이동)
|
||||||
|
│ ├── KIS_SETUP.md
|
||||||
|
│ ├── README.md
|
||||||
|
│ ├── main_server.py
|
||||||
|
│ ├── warmup_and_restart.py
|
||||||
|
│ ├── ... (이하 모든 V1 자산)
|
||||||
|
│ ├── start.bat ← 이동본 (사용 안 함, 향후 정리)
|
||||||
|
│ ├── modules/
|
||||||
|
│ ├── data/
|
||||||
|
│ ├── tests/
|
||||||
|
│ └── (log 파일들)
|
||||||
|
└── signal_v2/ ← Phase 2 작업 (본 spec 외)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 신규 파일 2개 — 정확한 내용
|
||||||
|
|
||||||
|
**`web-ai/CLAUDE.md` (신규)**:
|
||||||
|
```markdown
|
||||||
|
# web-ai — Workspace 가이드
|
||||||
|
|
||||||
|
Windows AI 머신 (AMD 9800X3D + RTX 5070 Ti) 의 두 시그널 파이프라인 컨테이너.
|
||||||
|
|
||||||
|
## 디렉토리 구조
|
||||||
|
|
||||||
|
| 경로 | 역할 | 상태 |
|
||||||
|
|------|------|------|
|
||||||
|
| `signal_v1/` | V1 자체 자동매매 시스템 (main_server.py + Trading Bot + Telegram Bot + LSTM + Ollama + KIS 자동주문) | 운영 중. Confidence Signal Pipeline V2 Phase 6 에서 deprecation 예정 |
|
||||||
|
| `signal_v2/` | V2 신호 파이프라인 (stock pull worker + Chronos-2 + signal API client) | Phase 2 에서 신설 |
|
||||||
|
| `.env` | V1 + V2 환경변수 공유 | KIS_*, TELEGRAM_*, STOCK_API_URL, WEBAI_API_KEY 등 |
|
||||||
|
| `start.bat` | V1 진입 (signal_v1 디렉토리 안 main_server.py 실행) | V2 별도 start 스크립트는 signal_v2/start.bat |
|
||||||
|
|
||||||
|
## 운영 가이드
|
||||||
|
|
||||||
|
- V1 시작: `start.bat` 또는 `cd signal_v1 && python main_server.py`
|
||||||
|
- V2 시작 (Phase 2 이후): `cd signal_v2 && python -m uvicorn main:app --port 8001`
|
||||||
|
- 둘 다 동시 실행 가능 (포트 분리: V1=8000, V2=8001)
|
||||||
|
|
||||||
|
## Phase 진행 상태 (Confidence Signal Pipeline V2)
|
||||||
|
|
||||||
|
`web-ui/docs/superpowers/specs/2026-05-15-confidence-signal-pipeline-v2-architecture.md` 참조.
|
||||||
|
|
||||||
|
자세한 V1 가이드는 `signal_v1/CLAUDE.md` 참조.
|
||||||
|
```
|
||||||
|
|
||||||
|
**`web-ai/start.bat` (신규)**:
|
||||||
|
```bat
|
||||||
|
@echo off
|
||||||
|
cd /d "%~dp0\signal_v1"
|
||||||
|
python main_server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 운영 영향 — `.env` 로드 경로
|
||||||
|
|
||||||
|
기존 V1 코드 (`signal_v1/modules/config.py` 등) 는 `load_dotenv()` 호출 시 cwd 또는 절대 경로의 `.env` 를 찾음. cwd 가 `signal_v1/` 이라면 `.env` 가 `web-ai/.env` (parent) 이라 못 찾을 수 있음.
|
||||||
|
|
||||||
|
**해결**: 진입점 (`signal_v1/main_server.py` 등) 의 `load_dotenv()` 호출에 명시적 경로 추가:
|
||||||
|
```python
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# web-ai/.env (signal_v1/ 의 parent) 명시 로드
|
||||||
|
load_dotenv(Path(__file__).parent.parent / ".env")
|
||||||
|
```
|
||||||
|
|
||||||
|
작업 매트릭스:
|
||||||
|
- `signal_v1/main_server.py` 의 `load_dotenv()` 1-2 줄 갱신
|
||||||
|
- `signal_v1/warmup_and_restart.py` 동일
|
||||||
|
- `signal_v1/modules/config.py` 같은 환경변수 로딩 위치 점검
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 작업 순서
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 사전 검토 (10분)
|
||||||
|
- web-ai 자체 자동매매 봇 운영 중 → 작업 시간대 결정 (장외: 평일 16:00 이후 / 주말)
|
||||||
|
- 본 spec §3 매트릭스 모든 파일 grep cross-check
|
||||||
|
- .env 로드 위치 grep — `load_dotenv` 호출 모두 찾기
|
||||||
|
- 데이터 파일 (data/, *.log, *.json) 손실 위험 없음 확인 (git mv 는 history 보존)
|
||||||
|
|
||||||
|
2. atomic refactor (1 commit)
|
||||||
|
- mkdir signal_v1
|
||||||
|
- git mv (위 매트릭스 항목 전부) signal_v1/
|
||||||
|
- signal_v1/main_server.py 외 .env 로드 위치 갱신
|
||||||
|
- web-ai/CLAUDE.md 신규
|
||||||
|
- web-ai/start.bat 신규
|
||||||
|
|
||||||
|
3. 로컬 검증 (cwd=signal_v1)
|
||||||
|
- python -m pytest tests/unit -q (기존 V1 테스트 통과)
|
||||||
|
- python main_server.py 시작 검증
|
||||||
|
- .env 로딩 확인 (KIS / Telegram / Ollama 환경변수)
|
||||||
|
- 봇 정상 시작 → telegram 알림 도착 → /status 응답 → 종료
|
||||||
|
|
||||||
|
4. git push (web-ai repo)
|
||||||
|
- sub Gitea: https://gitea.gahusb.synology.me/gahusb/ai-trade.git
|
||||||
|
- 본 작업은 NAS deploy 와 무관 (web-ai 는 로컬 Windows 머신).
|
||||||
|
|
||||||
|
5. 사용자 수동 검증
|
||||||
|
- 시장 시작 (다음 평일 09:00) 시점 봇 정상 동작 확인 또는 일/주말 가짜 트리거
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 위험 및 완화
|
||||||
|
|
||||||
|
| 위험 | 완화 |
|
||||||
|
|------|------|
|
||||||
|
| `.env` 로드 실패 → KIS 토큰 못 가져옴 → 자동매매 중단 | 진입점 (main_server.py / warmup_and_restart.py) 의 `load_dotenv` 명시 경로 추가. 시작 직후 KIS auth 확인 |
|
||||||
|
| 자동매매 중 작업 → 거래 중단 | 작업 시간대를 장외 (평일 16:00+ 또는 주말) 로 제한 |
|
||||||
|
| Python import 회귀 | `signal_v1/` cwd 기준 `from modules.X` 그대로. 외부 import 불필요. 기존 76+ 테스트 통과로 검증 |
|
||||||
|
| 데이터 파일 (data/models/, data/ensemble_history.json 등) 손실 | git mv 사용 — history 보존, 파일 내용 무변경. 사전 git status 로 dirty 없음 확인 |
|
||||||
|
| Telegram Bot 중복 polling (이전 프로세스 미종료) | start.bat 재시작 시 main_server.py 의 좀비 정리 로직 자동 동작 |
|
||||||
|
| .env 의 절대 경로 참조 (e.g. `data/kis_token.json` 같은 상대 경로) | cwd 변경 영향 — 진입점이 working directory 를 `signal_v1/` 으로 설정하면 기존 상대 경로 그대로 작동. start.bat 의 `cd /d "%~dp0\signal_v1"` 가 보장 |
|
||||||
|
| 향후 web-ai 레벨 외부 호출 (e.g. agent-office → web-ai :8000) | V1 main_server.py 는 port 8000 유지. URL 변경 없음. |
|
||||||
|
| signal_v2 진입점이 signal_v1 의 IPC 와 충돌 | Phase 2 가 별도 port :8001 + 별도 디렉토리. IPC SharedMemory 이름 분리 (V1 의 `web_ai_bot_ipc` 그대로 유지, V2 는 IPC 사용 안 함) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 테스트 / 검증
|
||||||
|
|
||||||
|
### 6.1 자동
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# V1 테스트 전체 통과
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ai/signal_v1
|
||||||
|
python -m pytest tests/unit -q
|
||||||
|
# Expected: 기존 PASS 개수 그대로
|
||||||
|
|
||||||
|
# stock-lab → stock 의 잔여 참조 패턴 검증과 동일 — V1 안에서 import 회귀 없음
|
||||||
|
grep -rn "from web-ai" /c/Users/jaeoh/Desktop/workspace/web-ai/signal_v1
|
||||||
|
# Expected: 0 lines (없어야 함)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 수동
|
||||||
|
|
||||||
|
- `cd web-ai && start.bat` (또는 `cd web-ai/signal_v1 && python main_server.py`)
|
||||||
|
- 콘솔 로그에 KIS 인증 성공 / Telegram Bot connected / Ollama 모델 로드 확인
|
||||||
|
- Telegram /status 명령 → 정상 응답
|
||||||
|
- 30분 관측 후 Watchdog 정상 (자식 프로세스 healthy)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 운영 영향
|
||||||
|
|
||||||
|
| 항목 | 영향 |
|
||||||
|
|------|------|
|
||||||
|
| 다운타임 | 작업 시간 + 첫 시작 검증 = ~30분 |
|
||||||
|
| 사용자 영향 | V1 자동매매 봇 일시 중단 (장외 시간대 진행 권장) |
|
||||||
|
| `.env` 갱신 | 없음 (위치 그대로, 진입점만 명시 경로 변경) |
|
||||||
|
| frontend 영향 | 없음 |
|
||||||
|
| 다른 lab / web-backend | 없음 (web-ai 외부 의존 0) |
|
||||||
|
| Gitea push | web-ai repo 만 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 완료 조건 (DoD)
|
||||||
|
|
||||||
|
- [ ] `web-ai/signal_v1/` 디렉토리 신설 + 매트릭스의 모든 V1 자산 mv 완료 (git history 보존)
|
||||||
|
- [ ] `web-ai/CLAUDE.md` 신규 (web-ai 레벨 가이드)
|
||||||
|
- [ ] `web-ai/start.bat` 신규 (signal_v1 cd 후 main_server.py)
|
||||||
|
- [ ] `signal_v1/main_server.py`, `warmup_and_restart.py` 등의 `load_dotenv()` 가 `web-ai/.env` 를 명시 로드
|
||||||
|
- [ ] `signal_v1/tests/unit/` 전체 pytest 통과 (기존 baseline 그대로)
|
||||||
|
- [ ] `cd web-ai && start.bat` 으로 V1 봇 정상 시작 + Telegram /status 응답
|
||||||
|
- [ ] grep `from web-ai\.` 또는 `from web-ai/` 결과 0 lines
|
||||||
|
- [ ] web-ai repo push 완료 (단일 commit)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Phase 2 와의 관계
|
||||||
|
|
||||||
|
본 리네이밍 완료 후 즉시 **Phase 2 brainstorming 재개**. Phase 2 spec 은:
|
||||||
|
- 새 이름 `web-ai/signal_v2/` 기준
|
||||||
|
- Phase 2 의 모든 결정 (배치 = 별도 FastAPI app :8001 / scope = 3 항목 / scheduler = asyncio cron / client = httpx + 자체 retry / rate limit = SQLite / test = pytest-asyncio) 그대로 반영
|
||||||
|
- 디자인 섹션 1 (목표/scope) + 섹션 2 (파일 구조 = web-ai/signal_v2/) 의 검토 완료 상태에서 섹션 3-7 진행
|
||||||
|
|
||||||
|
```
|
||||||
|
[본 리네이밍 spec/plan/실행] → [Phase 2 spec 작성 재개]
|
||||||
|
~30분-1시간 ~15분 (남은 섹션)
|
||||||
|
```
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<title>가후습 개인기록</title>
|
<title>가후습 개인기록</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
2588
package-lock.json
generated
2588
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -10,19 +10,26 @@
|
|||||||
"deploy:nas": "node scripts/deploy-nas.cjs",
|
"deploy:nas": "node scripts/deploy-nas.cjs",
|
||||||
"release:nas": "npm run build && npm run deploy:nas",
|
"release:nas": "npm run build && npm run deploy:nas",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:run": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@xyflow/react": "^12.10.2",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-router-dom": "^6.30.3",
|
"react-router-dom": "^6.30.3",
|
||||||
|
"react-swipeable": "^7.0.2",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
"three": "^0.182.0"
|
"three": "^0.182.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/react": "^18.2.79",
|
"@types/react": "^18.2.79",
|
||||||
"@types/react-dom": "^18.2.25",
|
"@types/react-dom": "^18.2.25",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
@@ -30,7 +37,9 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
"rimraf": "^6.1.2",
|
"rimraf": "^6.1.2",
|
||||||
"vite": "^7.2.4"
|
"vite": "^7.2.4",
|
||||||
|
"vitest": "^2.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +1,121 @@
|
|||||||
const { execSync } = require("child_process");
|
const { execSync } = require("child_process");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
|
const os = require("os");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
// Load .env.local from project root if present (persists NAS_SSH_TARGET etc.)
|
||||||
|
const envLocalPath = path.join(__dirname, "..", ".env.local");
|
||||||
|
if (fs.existsSync(envLocalPath)) {
|
||||||
|
for (const line of fs.readFileSync(envLocalPath, "utf8").split("\n")) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||||
|
const idx = trimmed.indexOf("=");
|
||||||
|
if (idx < 0) continue;
|
||||||
|
const k = trimmed.slice(0, idx).trim();
|
||||||
|
const v = trimmed.slice(idx + 1).trim();
|
||||||
|
if (!(k in process.env)) process.env[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isWin = process.platform === "win32";
|
const isWin = process.platform === "win32";
|
||||||
const isMac = process.platform === "darwin";
|
const isMac = process.platform === "darwin";
|
||||||
const src = "dist";
|
const src = "dist";
|
||||||
const dstWin = "Z:\\docker\\webpage\\frontend\\";
|
// Windows 배포 경로 — Z: 매핑이 NAS 루트(/volume1/)인 경우 docker\webpage\frontend,
|
||||||
const dstMac = "/Volumes/gahusb.synology.me/docker/webpage/frontend/";
|
// /volume1/docker/만 매핑된 경우 webpage\frontend, /volume1/docker/webpage 매핑이면 frontend.
|
||||||
|
// NAS_FRONTEND_DEST_WIN env로 override (예: "Z:\\webpage\\frontend\\")
|
||||||
|
const dstWin = process.env.NAS_FRONTEND_DEST_WIN || "Z:\\docker\\webpage\\frontend\\";
|
||||||
|
const dstMac = process.env.NAS_FRONTEND_DEST_MAC || "/Volumes/gahusb.synology.me/docker/webpage/frontend/";
|
||||||
const dst = isWin ? dstWin : dstMac;
|
const dst = isWin ? dstWin : dstMac;
|
||||||
|
|
||||||
if (!fs.existsSync(src)) {
|
if (!fs.existsSync(src)) {
|
||||||
console.error("dist not found. Run build first.");
|
console.error("dist not found. Run build first.");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
if (!fs.existsSync(dst)) {
|
|
||||||
console.error("NAS path not found. Check mount: " + dst);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isWin) {
|
if (isWin) {
|
||||||
|
// PowerShell single-quote literal로 path 전달 — backslash over-escape 회피
|
||||||
const cmd =
|
const cmd =
|
||||||
'powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference=\\"Stop\\"; $src=\\"dist\\"; $dst=\\"Z:\\\\docker\\\\webpage\\\\frontend\\\\\\"; if(!(Test-Path $src)){ throw \\"dist not found. Run build first.\\" }; if(!(Test-Path $dst)){ throw \\"NAS drive not found. Check Z: mapping.\\" }; $log = Join-Path (Get-Location) \\"robocopy.log\\"; robocopy $src $dst /MIR /R:1 /W:1 /E /NFL /NDL /NP /V /TEE /LOG:$log; $rc = $LASTEXITCODE; if($rc -ge 8){ Write-Host \\"robocopy failed with code $rc. See $log\\"; exit $rc } else { exit 0 }"';
|
`powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; $src='dist'; $dst='${dstWin}'; if(!(Test-Path $src)){ throw 'dist not found. Run build first.' }; if(!(Test-Path $dst)){ throw ('NAS 경로를 찾을 수 없음: ' + $dst + ' — Z: 매핑 또는 NAS_FRONTEND_DEST_WIN env 확인') }; $log = Join-Path (Get-Location) 'robocopy.log'; robocopy $src $dst /MIR /R:1 /W:1 /E /NFL /NDL /NP /V /TEE /LOG:$log; $rc = $LASTEXITCODE; if($rc -ge 8){ Write-Host ('robocopy failed with code ' + $rc + '. See ' + $log); exit $rc } else { exit 0 }"`;
|
||||||
execSync(cmd, { stdio: "inherit" });
|
execSync(cmd, { stdio: "inherit" });
|
||||||
} else if (isMac) {
|
} else if (isMac) {
|
||||||
const sshTarget = process.env.NAS_SSH_TARGET;
|
const sshTarget = process.env.NAS_SSH_TARGET;
|
||||||
const sshPath =
|
const sshPath =
|
||||||
process.env.NAS_SSH_PATH || "/volume1/docker/webpage/frontend/";
|
process.env.NAS_SSH_PATH || "/volume1/docker/webpage/frontend/";
|
||||||
const sshPort = process.env.NAS_SSH_PORT;
|
const sshPort = process.env.NAS_SSH_PORT;
|
||||||
|
|
||||||
|
// SSH 경로: NAS_SSH_TARGET이 설정된 경우 항상 우선
|
||||||
if (sshTarget) {
|
if (sshTarget) {
|
||||||
const sshCmd = sshPort ? `ssh -p ${sshPort}` : "ssh";
|
// 제어문자·줄바꿈 제거 (잘못된 export/copy-paste 대비)
|
||||||
|
const cleanTarget = sshTarget.replace(/[\r\n\t]/g, "").trim();
|
||||||
|
const cleanPath = sshPath.replace(/[\r\n\t]/g, "").trim();
|
||||||
|
const cleanPort = sshPort ? sshPort.replace(/\D/g, "").trim() : "";
|
||||||
|
|
||||||
|
if (!cleanTarget) {
|
||||||
|
console.error("NAS_SSH_TARGET 값이 비어있습니다. .env.local 또는 환경변수를 확인하세요.");
|
||||||
|
printSshHint();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (cleanPort && !/^\d{1,5}$/.test(cleanPort)) {
|
||||||
|
console.error(`NAS_SSH_PORT 값이 잘못됐습니다: "${sshPort}" → 숫자만 입력하세요.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS Keychain은 서브프로세스(rsync)에서 SSH 키를 자동 로드하지 못함 → -i 명시
|
||||||
|
const keyFile = (process.env.NAS_SSH_KEY || path.join(os.homedir(), ".ssh", "id_rsa"))
|
||||||
|
.replace(/[\r\n]/g, "").trim();
|
||||||
|
|
||||||
|
if (!fs.existsSync(keyFile)) {
|
||||||
|
console.error(`SSH 키 파일을 찾을 수 없습니다: ${keyFile}`);
|
||||||
|
console.error("NAS_SSH_KEY 환경변수를 올바른 키 경로로 설정하거나, ~/.ssh/id_rsa 가 있는지 확인하세요.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const portOpt = cleanPort ? `-p ${cleanPort}` : "";
|
||||||
|
// Synology는 rsync --server 모드를 별도 인증으로 막음 → tar | ssh 방식 사용
|
||||||
|
const sshBase = `ssh ${portOpt} -i ${keyFile} -o StrictHostKeyChecking=accept-new -o PreferredAuthentications=publickey`
|
||||||
|
.replace(/\s+/g, " ").trim();
|
||||||
|
|
||||||
|
console.log(`Deploying via tar|ssh → ${cleanTarget}:${cleanPath}`);
|
||||||
|
|
||||||
|
// 1단계: 원격 디렉토리 초기화
|
||||||
execSync(
|
execSync(
|
||||||
`rsync -r --delete --delete-delay -e \"${sshCmd}\" ${src}/ ${sshTarget}:${sshPath}`,
|
`${sshBase} ${cleanTarget} "rm -rf '${cleanPath}'/* 2>/dev/null; mkdir -p '${cleanPath}'"`,
|
||||||
{ stdio: "inherit" }
|
{ stdio: "inherit" }
|
||||||
);
|
);
|
||||||
|
// 2단계: 빌드 산출물 tar로 전송 → 원격에서 압축 해제
|
||||||
|
execSync(
|
||||||
|
`cd ${src} && tar czf - . | ${sshBase} ${cleanTarget} "cd '${cleanPath}' && tar xzf -"`,
|
||||||
|
{ stdio: "inherit" }
|
||||||
|
);
|
||||||
|
console.log("Deploy complete.");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
// rsync on macOS + SMB/NAS can be flaky; use ditto after a safe clean.
|
|
||||||
|
// SMB 마운트 경로 fallback
|
||||||
|
if (!fs.existsSync(dst)) {
|
||||||
|
console.error("NAS path not found: " + dst);
|
||||||
|
printSshHint();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
if (!dst.includes("docker/webpage/frontend")) {
|
if (!dst.includes("docker/webpage/frontend")) {
|
||||||
console.error("Safety check failed: unexpected dst path: " + dst);
|
console.error("Safety check failed: unexpected dst path: " + dst);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const testPath = `${dst}.deploy-write-test`;
|
const testPath = `${dst}.deploy-write-test`;
|
||||||
fs.writeFileSync(testPath, "ok");
|
fs.writeFileSync(testPath, "ok");
|
||||||
fs.unlinkSync(testPath);
|
fs.unlinkSync(testPath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error("NAS write test failed (EIO / permission error).");
|
||||||
console.error(
|
console.error(
|
||||||
"NAS write test failed. Files may be locked or permissions are read-only."
|
"macOS SMB → Synology 쓰기 실패는 흔한 이슈입니다. SSH 배포를 사용하세요.\n"
|
||||||
);
|
);
|
||||||
console.error(
|
printSshHint();
|
||||||
"Try stopping services using the folder, remounting the share with write access,",
|
process.exit(1);
|
||||||
"or set NAS_SSH_TARGET to deploy over SSH instead."
|
|
||||||
);
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sleep = (ms) =>
|
const sleep = (ms) =>
|
||||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||||
const retry = (fn, attempts = 6) => {
|
const retry = (fn, attempts = 6) => {
|
||||||
@@ -96,3 +159,15 @@ if (isWin) {
|
|||||||
const cmd = `${baseArgs.join(" ")} ${src}/ ${dst}`;
|
const cmd = `${baseArgs.join(" ")} ${src}/ ${dst}`;
|
||||||
execSync(cmd, { stdio: "inherit" });
|
execSync(cmd, { stdio: "inherit" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function printSshHint() {
|
||||||
|
console.error("──────────────────────────────────────────────────");
|
||||||
|
console.error("SSH 배포 설정 방법:");
|
||||||
|
console.error(" 프로젝트 루트에 .env.local 파일을 만들고 아래 내용을 입력하세요:");
|
||||||
|
console.error("");
|
||||||
|
console.error(" NAS_SSH_TARGET=<NAS_유저명>@gahusb.synology.me");
|
||||||
|
console.error(" NAS_SSH_PORT=<SSH_포트> # 기본 22, DSM에서 확인");
|
||||||
|
console.error("");
|
||||||
|
console.error(" 이후 npm run release:nas 를 다시 실행하면 rsync over SSH로 배포됩니다.");
|
||||||
|
console.error("──────────────────────────────────────────────────");
|
||||||
|
}
|
||||||
|
|||||||
13
src/App.css
13
src/App.css
@@ -62,6 +62,7 @@
|
|||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.site-main {
|
.site-main {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
padding-bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px) + 16px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,3 +492,15 @@
|
|||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Accessibility: Reduced Motion ──────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import Navbar from './components/Navbar';
|
import Navbar from './components/Navbar';
|
||||||
|
import BottomNav from './components/BottomNav';
|
||||||
import PageHeader from './components/PageHeader';
|
import PageHeader from './components/PageHeader';
|
||||||
import Loading from './components/Loading';
|
import Loading from './components/Loading';
|
||||||
|
import { useIsMobile } from './hooks/useIsMobile';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
@@ -17,6 +21,7 @@ function App() {
|
|||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
{isMobile && <BottomNav />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
219
src/api.js
219
src/api.js
@@ -479,112 +479,205 @@ export function deleteBlogPost(id) {
|
|||||||
return apiDelete(`/api/blog/posts/${id}`);
|
return apiDelete(`/api/blog/posts/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 블로그 마케팅 API ────────────────────────────────────────────────────────
|
// ── insta-lab ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function getBlogMarketingStatus() {
|
export function getInstaStatus() {
|
||||||
return apiGet('/api/blog-marketing/status');
|
return apiGet('/api/insta/status');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startResearch(keyword) {
|
export function instaCollectNews(categories) {
|
||||||
return apiPost('/api/blog-marketing/research', { keyword });
|
return apiPost('/api/insta/news/collect', categories ? { categories } : {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getResearchHistory(limit = 30) {
|
export function getInstaArticles({ category, days = 7 } = {}) {
|
||||||
return apiGet(`/api/blog-marketing/research/history?limit=${limit}`);
|
const q = new URLSearchParams();
|
||||||
|
if (category) q.set('category', category);
|
||||||
|
q.set('days', String(days));
|
||||||
|
return apiGet(`/api/insta/news/articles?${q.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getResearchDetail(id) {
|
export function instaExtractKeywords(categories) {
|
||||||
return apiGet(`/api/blog-marketing/research/${id}`);
|
return apiPost('/api/insta/keywords/extract', categories ? { categories } : {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteResearch(id) {
|
export function getInstaKeywords({ category, used } = {}) {
|
||||||
return apiDelete(`/api/blog-marketing/research/${id}`);
|
const q = new URLSearchParams();
|
||||||
|
if (category) q.set('category', category);
|
||||||
|
if (used !== undefined) q.set('used', used ? 'true' : 'false');
|
||||||
|
const qs = q.toString();
|
||||||
|
return apiGet(`/api/insta/keywords${qs ? '?' + qs : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBlogMarketingTask(taskId) {
|
export function createInstaSlate({ keyword, category, keyword_id }) {
|
||||||
return apiGet(`/api/blog-marketing/task/${encodeURIComponent(taskId)}`);
|
return apiPost('/api/insta/slates', { keyword, category, keyword_id });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startGenerate(keywordId) {
|
export function getInstaSlates(limit = 50) {
|
||||||
return apiPost('/api/blog-marketing/generate', { keyword_id: keywordId });
|
return apiGet(`/api/insta/slates?limit=${limit}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startReview(postId) {
|
export function getInstaSlate(id) {
|
||||||
return apiPost(`/api/blog-marketing/review/${postId}`);
|
return apiGet(`/api/insta/slates/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startRegenerate(postId) {
|
export function renderInstaSlate(id) {
|
||||||
return apiPost(`/api/blog-marketing/regenerate/${postId}`);
|
return apiPost(`/api/insta/slates/${id}/render`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBlogMarketingPosts(status, limit = 50) {
|
export function deleteInstaSlate(id) {
|
||||||
const qs = new URLSearchParams();
|
return apiDelete(`/api/insta/slates/${id}`);
|
||||||
if (status) qs.set('status', status);
|
|
||||||
if (limit) qs.set('limit', String(limit));
|
|
||||||
const q = qs.toString();
|
|
||||||
return apiGet(`/api/blog-marketing/posts${q ? '?' + q : ''}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBlogMarketingPost(id) {
|
export function getInstaAssetUrl(slateId, page) {
|
||||||
return apiGet(`/api/blog-marketing/posts/${id}`);
|
return `/api/insta/slates/${slateId}/assets/${page}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateBlogMarketingPost(id, data) {
|
export function getInstaTask(taskId) {
|
||||||
return apiPut(`/api/blog-marketing/posts/${id}`, data);
|
return apiGet(`/api/insta/tasks/${encodeURIComponent(taskId)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteBlogMarketingPost(id) {
|
export function getInstaPrompt(name) {
|
||||||
return apiDelete(`/api/blog-marketing/posts/${id}`);
|
return apiGet(`/api/insta/templates/prompts/${encodeURIComponent(name)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function publishBlogMarketingPost(id, naverUrl) {
|
export function putInstaPrompt(name, template, description = '') {
|
||||||
return apiPost(`/api/blog-marketing/posts/${id}/publish`, { naver_url: naverUrl || '' });
|
return apiPut(`/api/insta/templates/prompts/${encodeURIComponent(name)}`, { template, description });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBlogMarketingCommissions(postId) {
|
// ── insta-lab trends ──
|
||||||
const qs = postId ? `?post_id=${postId}` : '';
|
export function getInstaTrends({ source, category, days = 1 } = {}) {
|
||||||
return apiGet(`/api/blog-marketing/commissions${qs}`);
|
const q = new URLSearchParams();
|
||||||
|
if (source) q.set('source', source);
|
||||||
|
if (category) q.set('category', category);
|
||||||
|
q.set('days', String(days));
|
||||||
|
return apiGet(`/api/insta/trends?${q.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addBlogMarketingCommission(data) {
|
export function instaCollectTrends(categories) {
|
||||||
return apiPost('/api/blog-marketing/commissions', data);
|
return apiPost('/api/insta/trends/collect', categories ? { categories } : {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateBlogMarketingCommission(id, data) {
|
export function getInstaPreferences() {
|
||||||
return apiPut(`/api/blog-marketing/commissions/${id}`, data);
|
return apiGet('/api/insta/preferences');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteBlogMarketingCommission(id) {
|
export function putInstaPreferences(categories) {
|
||||||
return apiDelete(`/api/blog-marketing/commissions/${id}`);
|
return apiPut('/api/insta/preferences', { categories });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBlogMarketingDashboard() {
|
// ── Agent Office ──────────────────────────────────
|
||||||
return apiGet('/api/blog-marketing/dashboard');
|
export const getAgents = () => apiGet('/api/agent-office/agents');
|
||||||
|
export const getAgentDetail = (id) => apiGet(`/api/agent-office/agents/${id}`);
|
||||||
|
export const updateAgentConfig = (id, body) => apiPut(`/api/agent-office/agents/${id}`, body);
|
||||||
|
export const getAgentTasks = (id, limit=20) => apiGet(`/api/agent-office/agents/${id}/tasks?limit=${limit}`);
|
||||||
|
export const getAgentLogs = (id, limit=50) => apiGet(`/api/agent-office/agents/${id}/logs?limit=${limit}`);
|
||||||
|
export const getPendingTasks = () => apiGet('/api/agent-office/tasks/pending');
|
||||||
|
export const sendAgentCommand = (agent, action, params={}) => apiPost('/api/agent-office/command', { agent, action, params });
|
||||||
|
export const approveAgentTask = (agent, task_id, approved, feedback='') => apiPost('/api/agent-office/approve', { agent, task_id, approved, feedback });
|
||||||
|
export const getAgentStates = () => apiGet('/api/agent-office/states');
|
||||||
|
export const getActivityFeed = (limit=50, offset=0) => apiGet(`/api/agent-office/activity?limit=${limit}&offset=${offset}`);
|
||||||
|
export const getAgentTokenUsage = (id, days=1) => apiGet(`/api/agent-office/agents/${id}/token-usage?days=${days}`);
|
||||||
|
|
||||||
|
// --- Lotto Briefing ---
|
||||||
|
|
||||||
|
export async function getLatestBriefing() {
|
||||||
|
const r = await fetch('/api/lotto/briefing/latest');
|
||||||
|
if (r.status === 404) return null;
|
||||||
|
if (!r.ok) throw new Error(`briefing fetch failed: ${r.status}`);
|
||||||
|
return r.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 마케터 단계
|
export async function getCuratorUsage(days = 30) {
|
||||||
export function startMarket(postId) {
|
const r = await fetch(`/api/lotto/curator/usage?days=${days}`);
|
||||||
return apiPost(`/api/blog-marketing/market/${postId}`);
|
if (!r.ok) throw new Error(`usage fetch failed: ${r.status}`);
|
||||||
|
return r.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 브랜드커넥트 링크 CRUD
|
export async function triggerLottoCurate() {
|
||||||
export function getBrandLinks(params = {}) {
|
const r = await fetch('/api/agent-office/command', {
|
||||||
const qs = new URLSearchParams();
|
method: 'POST',
|
||||||
if (params.post_id) qs.set('post_id', String(params.post_id));
|
headers: { 'Content-Type': 'application/json' },
|
||||||
if (params.keyword_id) qs.set('keyword_id', String(params.keyword_id));
|
body: JSON.stringify({ agent: 'lotto', action: 'curate_now', params: {} }),
|
||||||
const q = qs.toString();
|
});
|
||||||
return apiGet(`/api/blog-marketing/links${q ? '?' + q : ''}`);
|
if (!r.ok) throw new Error(`curate trigger failed: ${r.status}`);
|
||||||
|
return r.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBrandLink(data) {
|
// ── Music Lab — Video Projects ────────────────────
|
||||||
return apiPost('/api/blog-marketing/links', data);
|
export const createVideoProject = (data) => apiPost('/api/music/video-project', data);
|
||||||
}
|
export const getVideoProjects = () => apiGet('/api/music/video-projects');
|
||||||
|
export const renderVideoProject = (id) => apiPost(`/api/music/video-project/${id}/render`);
|
||||||
|
export const exportVideoProject = (id) => apiGet(`/api/music/video-project/${id}/export`);
|
||||||
|
export const deleteVideoProject = (id) => apiDelete(`/api/music/video-project/${id}`);
|
||||||
|
|
||||||
export function updateBrandLink(id, data) {
|
// ── Music Lab — Revenue ───────────────────────────
|
||||||
return apiPut(`/api/blog-marketing/links/${id}`, data);
|
export const getRevenueDashboard = () => apiGet('/api/music/revenue/dashboard');
|
||||||
}
|
export const getRevenueRecords = () => apiGet('/api/music/revenue');
|
||||||
|
export const addRevenueRecord = (data) => apiPost('/api/music/revenue', data);
|
||||||
|
export const updateRevenueRecord = (id, data) => apiPut(`/api/music/revenue/${id}`, data);
|
||||||
|
export const deleteRevenueRecord = (id) => apiDelete(`/api/music/revenue/${id}`);
|
||||||
|
|
||||||
export function deleteBrandLink(id) {
|
// ── Music Lab — Market Trends ─────────────────────
|
||||||
return apiDelete(`/api/blog-marketing/links/${id}`);
|
export const getLatestTrendReport = () => apiGet('/api/music/market/report/latest');
|
||||||
}
|
export const getTrendReports = () => apiGet('/api/music/market/report');
|
||||||
|
export const getMarketSuggestions = () => apiGet('/api/music/market/suggest');
|
||||||
|
export const triggerYoutubeResearch = () => apiPost('/api/agent-office/youtube/research', {});
|
||||||
|
|
||||||
|
// ── Music Lab — Compile ──────────────────────────────────
|
||||||
|
export const createCompileJob = (data) => apiPost('/api/music/compile', data);
|
||||||
|
export const getCompileJobs = () => apiGet('/api/music/compiles');
|
||||||
|
export const getCompileJob = (id) => apiGet(`/api/music/compile/${id}`);
|
||||||
|
export const deleteCompileJob = (id) => apiDelete(`/api/music/compile/${id}`);
|
||||||
|
export const exportCompileJob = (id) => apiGet(`/api/music/compile/${id}/export`);
|
||||||
|
|
||||||
|
// --- Music Pipeline ---
|
||||||
|
export const listPipelines = (status='all') => apiGet(`/api/music/pipeline?status=${status}`);
|
||||||
|
export const getPipeline = (id) => apiGet(`/api/music/pipeline/${id}`);
|
||||||
|
export const createPipeline = (payload) => {
|
||||||
|
// 옛 호출 호환: createPipeline(13) → { track_id: 13 }
|
||||||
|
if (typeof payload === 'number') payload = { track_id: payload };
|
||||||
|
return apiPost('/api/music/pipeline', payload);
|
||||||
|
};
|
||||||
|
export const startPipeline = (id) => apiPost(`/api/music/pipeline/${id}/start`);
|
||||||
|
export const cancelPipeline = (id) => apiPost(`/api/music/pipeline/${id}/cancel`);
|
||||||
|
export const publishPipeline = (id) => apiPost(`/api/music/pipeline/${id}/publish`);
|
||||||
|
|
||||||
|
// --- Music Setup ---
|
||||||
|
export const getMusicSetup = () => apiGet('/api/music/setup');
|
||||||
|
export const updateMusicSetup = (payload) => apiPut('/api/music/setup', payload);
|
||||||
|
|
||||||
|
// --- YouTube OAuth ---
|
||||||
|
export const getYoutubeAuthUrl = () => apiGet('/api/music/youtube/auth-url');
|
||||||
|
export const getYoutubeStatus = () => apiGet('/api/music/youtube/status');
|
||||||
|
export const disconnectYoutube = () => apiPost('/api/music/youtube/disconnect');
|
||||||
|
|
||||||
|
// === Batch generation ===
|
||||||
|
export const startBatchGen = (payload) => apiPost('/api/music/generate-batch', payload);
|
||||||
|
export const getBatchJob = (id) => apiGet(`/api/music/generate-batch/${id}`);
|
||||||
|
export const listBatchJobs = (status='all') => apiGet(`/api/music/generate-batch?status=${status}`);
|
||||||
|
export const listGenres = () => apiGet('/api/music/genres');
|
||||||
|
|
||||||
|
// === 주간 회고 (weekly_review) ===
|
||||||
|
// apiGet은 비-2xx 응답에서 `HTTP <status> ...` 메시지로 Error를 throw 하므로
|
||||||
|
// 404 케이스는 메시지를 파싱하여 null로 변환한다.
|
||||||
|
export const getLatestReview = () => apiGet('/api/lotto/review/latest').catch(e => {
|
||||||
|
if (e?.status === 404 || /^HTTP 404\b/.test(e?.message || '')) return null;
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getReviewHistory = (limit = 4) =>
|
||||||
|
apiGet(`/api/lotto/review/history?limit=${limit}`).then(d => d.reviews || []);
|
||||||
|
|
||||||
|
// === 큐레이터 4계층 원클릭 구매 ===
|
||||||
|
export const bulkPurchase = ({ draw_no, tier_mode, sets, amount }) =>
|
||||||
|
apiPost('/api/lotto/purchase/bulk', { draw_no, tier_mode, sets, amount });
|
||||||
|
|
||||||
|
// ---- Stock Screener ----
|
||||||
|
export const getScreenerNodes = () => apiGet ('/api/stock/screener/nodes');
|
||||||
|
export const getScreenerSettings = () => apiGet ('/api/stock/screener/settings');
|
||||||
|
export const saveScreenerSettings = (body) => apiPut ('/api/stock/screener/settings', body);
|
||||||
|
export const runScreener = (body) => apiPost('/api/stock/screener/run', body);
|
||||||
|
export const refreshScreenerSnap = () => apiPost('/api/stock/screener/snapshot/refresh');
|
||||||
|
export const listScreenerRuns = (limit = 30) => apiGet (`/api/stock/screener/runs?limit=${limit}`);
|
||||||
|
export const getScreenerRun = (id) => apiGet (`/api/stock/screener/runs/${id}`);
|
||||||
|
|
||||||
|
|||||||
167
src/components/BottomNav.css
Normal file
167
src/components/BottomNav.css
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/* BottomNav — mobile bottom navigation */
|
||||||
|
|
||||||
|
.bottom-nav {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: var(--bottom-nav-h);
|
||||||
|
padding-bottom: var(--safe-area-bottom);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
z-index: 300;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.bottom-nav {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Primary nav items */
|
||||||
|
.bottom-nav__item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 48px;
|
||||||
|
min-height: 48px;
|
||||||
|
gap: 3px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-decoration: none;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
transition: color 0.18s var(--ease-out);
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav__item:hover,
|
||||||
|
.bottom-nav__item.is-active,
|
||||||
|
.bottom-nav__item--active {
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav__item:hover .bottom-nav__icon,
|
||||||
|
.bottom-nav__item.is-active .bottom-nav__icon,
|
||||||
|
.bottom-nav__item--active .bottom-nav__icon {
|
||||||
|
filter: drop-shadow(0 0 6px var(--neon-cyan-dim));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon wrapper */
|
||||||
|
.bottom-nav__icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: filter 0.18s var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav__icon svg,
|
||||||
|
.bottom-nav__icon > * {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Label */
|
||||||
|
.bottom-nav__label {
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- More overlay backdrop ---- */
|
||||||
|
.bottom-nav__more-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
z-index: 298;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.22s var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav__more-overlay.is-open {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- More panel ---- */
|
||||||
|
.bottom-nav__more-panel {
|
||||||
|
position: fixed;
|
||||||
|
bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom));
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 299;
|
||||||
|
padding: 16px 12px 12px;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform 0.25s var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav__more-panel.is-open {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* More panel item */
|
||||||
|
.bottom-nav__more-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px 4px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-decoration: none;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: color 0.18s var(--ease-out), border-color 0.18s var(--ease-out);
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav__more-item:hover,
|
||||||
|
.bottom-nav__more-item.is-active {
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
border-color: var(--neon-cyan-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav__more-item:hover .bottom-nav__icon,
|
||||||
|
.bottom-nav__more-item.is-active .bottom-nav__icon {
|
||||||
|
filter: drop-shadow(0 0 6px var(--neon-cyan-dim));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduce motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.bottom-nav__item,
|
||||||
|
.bottom-nav__icon,
|
||||||
|
.bottom-nav__more-overlay,
|
||||||
|
.bottom-nav__more-panel,
|
||||||
|
.bottom-nav__more-item {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
114
src/components/BottomNav.jsx
Normal file
114
src/components/BottomNav.jsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { NavLink, useLocation } from 'react-router-dom';
|
||||||
|
import { navLinks } from '../routes';
|
||||||
|
import './BottomNav.css';
|
||||||
|
|
||||||
|
const PRIMARY_PATHS = ['/', '/lotto', '/stock', '/travel'];
|
||||||
|
|
||||||
|
// Vertical dots (three circles) icon for "more"
|
||||||
|
function MoreDotsIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="22"
|
||||||
|
height="22"
|
||||||
|
viewBox="0 0 22 22"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="4.5" r="1.8" />
|
||||||
|
<circle cx="11" cy="11" r="1.8" />
|
||||||
|
<circle cx="11" cy="17.5" r="1.8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryLinks = navLinks.filter((link) =>
|
||||||
|
PRIMARY_PATHS.includes(link.path)
|
||||||
|
);
|
||||||
|
// Preserve the order defined in PRIMARY_PATHS
|
||||||
|
const orderedPrimaryLinks = PRIMARY_PATHS.map((p) =>
|
||||||
|
primaryLinks.find((l) => l.path === p)
|
||||||
|
).filter(Boolean);
|
||||||
|
|
||||||
|
const moreLinks = navLinks.filter(
|
||||||
|
(link) => !PRIMARY_PATHS.includes(link.path)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function BottomNav() {
|
||||||
|
const [moreOpen, setMoreOpen] = useState(false);
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const openMore = useCallback(() => setMoreOpen(true), []);
|
||||||
|
const closeMore = useCallback(() => setMoreOpen(false), []);
|
||||||
|
const toggleMore = useCallback(() => setMoreOpen((prev) => !prev), []);
|
||||||
|
|
||||||
|
// Highlight the "more" button when the current path belongs to moreLinks
|
||||||
|
const isMoreActive =
|
||||||
|
moreOpen || moreLinks.some((link) => location.pathname === link.path);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className={`bottom-nav__more-overlay${moreOpen ? ' is-open' : ''}`}
|
||||||
|
onClick={closeMore}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* More panel */}
|
||||||
|
<div
|
||||||
|
className={`bottom-nav__more-panel${moreOpen ? ' is-open' : ''}`}
|
||||||
|
role="menu"
|
||||||
|
aria-label="더보기 메뉴"
|
||||||
|
>
|
||||||
|
{moreLinks.map((link) => (
|
||||||
|
<NavLink
|
||||||
|
key={link.id}
|
||||||
|
to={link.path}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`bottom-nav__more-item${isActive ? ' is-active' : ''}`
|
||||||
|
}
|
||||||
|
onClick={closeMore}
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<span className="bottom-nav__icon">{link.icon}</span>
|
||||||
|
<span className="bottom-nav__label">{link.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom nav bar */}
|
||||||
|
<nav className="bottom-nav" aria-label="하단 내비게이션">
|
||||||
|
{orderedPrimaryLinks.map((link) => (
|
||||||
|
<NavLink
|
||||||
|
key={link.id}
|
||||||
|
to={link.path}
|
||||||
|
end={link.path === '/'}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`bottom-nav__item${isActive ? ' is-active' : ''}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="bottom-nav__icon">{link.icon}</span>
|
||||||
|
<span className="bottom-nav__label">{link.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* More button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`bottom-nav__item${isMoreActive ? ' is-active' : ''}`}
|
||||||
|
onClick={toggleMore}
|
||||||
|
aria-expanded={moreOpen}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-label="더보기"
|
||||||
|
>
|
||||||
|
<span className="bottom-nav__icon">
|
||||||
|
<MoreDotsIcon />
|
||||||
|
</span>
|
||||||
|
<span className="bottom-nav__label">더보기</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
src/components/FAB.css
Normal file
50
src/components/FAB.css
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/* FAB — Floating Action Button (mobile-only) */
|
||||||
|
|
||||||
|
.fab {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
right: 20px;
|
||||||
|
bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom) + 16px);
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--grad-accent);
|
||||||
|
border: none;
|
||||||
|
color: #000;
|
||||||
|
font-size: 24px;
|
||||||
|
z-index: 250;
|
||||||
|
box-shadow: 0 0 0 1px var(--neon-cyan-dim), 0 4px 16px rgba(0, 255, 255, 0.25);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
transition: transform 0.15s var(--ease-out), box-shadow 0.15s var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.fab {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:active {
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab__icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Variant: positioned above a music mini-player */
|
||||||
|
.fab--above-player {
|
||||||
|
bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom) + 16px + 56px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduced motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.fab {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/components/FAB.jsx
Normal file
37
src/components/FAB.jsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
|
import './FAB.css';
|
||||||
|
|
||||||
|
const PlusIcon = () => (
|
||||||
|
<svg
|
||||||
|
className="fab__icon"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 5v14M5 12h14"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function FAB({ onClick, icon, label = '액션', className = '' }) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
if (!isMobile) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`fab ${className}`}
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
{icon ?? <PlusIcon />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -102,6 +102,16 @@ export const IconBlogMarketing = () =>
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const IconPortfolio = () =>
|
||||||
|
svg(
|
||||||
|
<>
|
||||||
|
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
export const IconBuilding = () =>
|
export const IconBuilding = () =>
|
||||||
svg(
|
svg(
|
||||||
<>
|
<>
|
||||||
@@ -115,3 +125,12 @@ export const IconBuilding = () =>
|
|||||||
<rect x="11" y="16" width="3" height="3" />
|
<rect x="11" y="16" width="3" height="3" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const IconInsta = () =>
|
||||||
|
svg(
|
||||||
|
<>
|
||||||
|
<rect x="2" y="2" width="20" height="20" rx="5" />
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<circle cx="17.5" cy="6.5" r="1" fill="currentColor" strokeWidth="0" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|||||||
147
src/components/LogoLoop.css
Normal file
147
src/components/LogoLoop.css
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
.logoloop {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoloop:not(.logoloop--vertical) {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoloop--vertical {
|
||||||
|
overflow-y: hidden;
|
||||||
|
height: 100%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoloop__track {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
width: max-content;
|
||||||
|
will-change: transform;
|
||||||
|
user-select: none;
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoloop__track--vertical {
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.logoloop__track {
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoloop__list {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoloop__list--vertical {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoloop__item {
|
||||||
|
flex: none;
|
||||||
|
font-size: var(--logoloop-logoHeight, 36px);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoloop__list:not(.logoloop__list--vertical) .logoloop__item {
|
||||||
|
margin-right: var(--logoloop-gap, 32px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoloop__list--vertical .logoloop__item {
|
||||||
|
margin-bottom: var(--logoloop-gap, 32px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoloop__item--scalable {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoloop__link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: opacity 0.2s linear;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoloop__link:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoloop__link:focus-visible {
|
||||||
|
outline: 2px solid currentColor;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoloop__node {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoloop__node--scale,
|
||||||
|
.logoloop__img--scale {
|
||||||
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoloop__item--scalable:hover .logoloop__node--scale,
|
||||||
|
.logoloop__item--scalable:hover .logoloop__img--scale {
|
||||||
|
transform: scale(1.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoloop__img {
|
||||||
|
height: var(--logoloop-logoHeight, 36px);
|
||||||
|
width: auto;
|
||||||
|
display: block;
|
||||||
|
object-fit: contain;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
pointer-events: none;
|
||||||
|
image-rendering: -webkit-optimize-contrast;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoloop__fade {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoloop__fade--left {
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: clamp(24px, 8%, 120px);
|
||||||
|
background: linear-gradient(to right, var(--logoloop-fadeColor, var(--bg-primary, #0b0b0b)) 0%, transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoloop__fade--right {
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
width: clamp(24px, 8%, 120px);
|
||||||
|
background: linear-gradient(to left, var(--logoloop-fadeColor, var(--bg-primary, #0b0b0b)) 0%, transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoloop__fade--top {
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
height: clamp(24px, 8%, 120px);
|
||||||
|
background: linear-gradient(to bottom, var(--logoloop-fadeColor, var(--bg-primary, #0b0b0b)) 0%, transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoloop__fade--bottom {
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: clamp(24px, 8%, 120px);
|
||||||
|
background: linear-gradient(to top, var(--logoloop-fadeColor, var(--bg-primary, #0b0b0b)) 0%, transparent 100%);
|
||||||
|
}
|
||||||
322
src/components/LogoLoop.jsx
Normal file
322
src/components/LogoLoop.jsx
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import './LogoLoop.css';
|
||||||
|
|
||||||
|
const ANIMATION_CONFIG = {
|
||||||
|
SMOOTH_TAU: 0.25,
|
||||||
|
MIN_COPIES: 2,
|
||||||
|
COPY_HEADROOM: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const toCssLength = (value) =>
|
||||||
|
typeof value === 'number' ? `${value}px` : value ?? undefined;
|
||||||
|
|
||||||
|
const cx = (...parts) => parts.filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
function useResizeObserver(callback, elements, deps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!window.ResizeObserver) {
|
||||||
|
const handler = () => callback();
|
||||||
|
window.addEventListener('resize', handler);
|
||||||
|
callback();
|
||||||
|
return () => window.removeEventListener('resize', handler);
|
||||||
|
}
|
||||||
|
const observers = elements.map((ref) => {
|
||||||
|
if (!ref.current) return null;
|
||||||
|
const observer = new ResizeObserver(callback);
|
||||||
|
observer.observe(ref.current);
|
||||||
|
return observer;
|
||||||
|
});
|
||||||
|
callback();
|
||||||
|
return () => {
|
||||||
|
observers.forEach((o) => o?.disconnect());
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, deps);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useImageLoader(seqRef, onLoad, deps) {
|
||||||
|
useEffect(() => {
|
||||||
|
const images = seqRef.current?.querySelectorAll('img') ?? [];
|
||||||
|
if (images.length === 0) {
|
||||||
|
onLoad();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let remaining = images.length;
|
||||||
|
const handleLoad = () => {
|
||||||
|
remaining -= 1;
|
||||||
|
if (remaining === 0) onLoad();
|
||||||
|
};
|
||||||
|
images.forEach((img) => {
|
||||||
|
if (img.complete) {
|
||||||
|
handleLoad();
|
||||||
|
} else {
|
||||||
|
img.addEventListener('load', handleLoad, { once: true });
|
||||||
|
img.addEventListener('error', handleLoad, { once: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
images.forEach((img) => {
|
||||||
|
img.removeEventListener('load', handleLoad);
|
||||||
|
img.removeEventListener('error', handleLoad);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, deps);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useAnimationLoop(trackRef, targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical) {
|
||||||
|
const rafRef = useRef(null);
|
||||||
|
const lastTsRef = useRef(null);
|
||||||
|
const offsetRef = useRef(0);
|
||||||
|
const velocityRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const track = trackRef.current;
|
||||||
|
if (!track) return;
|
||||||
|
|
||||||
|
const prefersReduced =
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
window.matchMedia &&
|
||||||
|
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
|
const seqSize = isVertical ? seqHeight : seqWidth;
|
||||||
|
|
||||||
|
if (seqSize > 0) {
|
||||||
|
offsetRef.current = ((offsetRef.current % seqSize) + seqSize) % seqSize;
|
||||||
|
track.style.transform = isVertical
|
||||||
|
? `translate3d(0, ${-offsetRef.current}px, 0)`
|
||||||
|
: `translate3d(${-offsetRef.current}px, 0, 0)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefersReduced) {
|
||||||
|
track.style.transform = 'translate3d(0, 0, 0)';
|
||||||
|
return () => {
|
||||||
|
lastTsRef.current = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const animate = (ts) => {
|
||||||
|
if (lastTsRef.current === null) lastTsRef.current = ts;
|
||||||
|
const dt = Math.max(0, ts - lastTsRef.current) / 1000;
|
||||||
|
lastTsRef.current = ts;
|
||||||
|
|
||||||
|
const target = isHovered && hoverSpeed !== undefined ? hoverSpeed : targetVelocity;
|
||||||
|
const easing = 1 - Math.exp(-dt / ANIMATION_CONFIG.SMOOTH_TAU);
|
||||||
|
velocityRef.current += (target - velocityRef.current) * easing;
|
||||||
|
|
||||||
|
if (seqSize > 0) {
|
||||||
|
let next = offsetRef.current + velocityRef.current * dt;
|
||||||
|
next = ((next % seqSize) + seqSize) % seqSize;
|
||||||
|
offsetRef.current = next;
|
||||||
|
track.style.transform = isVertical
|
||||||
|
? `translate3d(0, ${-offsetRef.current}px, 0)`
|
||||||
|
: `translate3d(${-offsetRef.current}px, 0, 0)`;
|
||||||
|
}
|
||||||
|
rafRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
rafRef.current = requestAnimationFrame(animate);
|
||||||
|
return () => {
|
||||||
|
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
|
||||||
|
rafRef.current = null;
|
||||||
|
lastTsRef.current = null;
|
||||||
|
};
|
||||||
|
}, [trackRef, targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LogoLoop({
|
||||||
|
logos,
|
||||||
|
speed = 60,
|
||||||
|
direction = 'left',
|
||||||
|
width = '100%',
|
||||||
|
logoHeight = 36,
|
||||||
|
gap = 32,
|
||||||
|
pauseOnHover = true,
|
||||||
|
hoverSpeed,
|
||||||
|
fadeOut = true,
|
||||||
|
fadeOutColor,
|
||||||
|
scaleOnHover = true,
|
||||||
|
ariaLabel = 'Skill logos',
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}) {
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const trackRef = useRef(null);
|
||||||
|
const seqRef = useRef(null);
|
||||||
|
|
||||||
|
const [seqWidth, setSeqWidth] = useState(0);
|
||||||
|
const [seqHeight, setSeqHeight] = useState(0);
|
||||||
|
const [copyCount, setCopyCount] = useState(ANIMATION_CONFIG.MIN_COPIES);
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
|
const effectiveHoverSpeed = useMemo(() => {
|
||||||
|
if (hoverSpeed !== undefined) return hoverSpeed;
|
||||||
|
if (pauseOnHover === true) return 0;
|
||||||
|
if (pauseOnHover === false) return undefined;
|
||||||
|
return 0;
|
||||||
|
}, [hoverSpeed, pauseOnHover]);
|
||||||
|
|
||||||
|
const isVertical = direction === 'up' || direction === 'down';
|
||||||
|
|
||||||
|
const targetVelocity = useMemo(() => {
|
||||||
|
const magnitude = Math.abs(speed);
|
||||||
|
const dirMul = isVertical
|
||||||
|
? direction === 'up'
|
||||||
|
? 1
|
||||||
|
: -1
|
||||||
|
: direction === 'left'
|
||||||
|
? 1
|
||||||
|
: -1;
|
||||||
|
const speedMul = speed < 0 ? -1 : 1;
|
||||||
|
return magnitude * dirMul * speedMul;
|
||||||
|
}, [speed, direction, isVertical]);
|
||||||
|
|
||||||
|
const updateDimensions = useCallback(() => {
|
||||||
|
const containerWidth = containerRef.current?.clientWidth ?? 0;
|
||||||
|
const seqRect = seqRef.current?.getBoundingClientRect?.();
|
||||||
|
const sw = seqRect?.width ?? 0;
|
||||||
|
const sh = seqRect?.height ?? 0;
|
||||||
|
if (isVertical) {
|
||||||
|
const parentH = containerRef.current?.parentElement?.clientHeight ?? 0;
|
||||||
|
if (containerRef.current && parentH > 0) {
|
||||||
|
const h = Math.ceil(parentH);
|
||||||
|
if (containerRef.current.style.height !== `${h}px`)
|
||||||
|
containerRef.current.style.height = `${h}px`;
|
||||||
|
}
|
||||||
|
if (sh > 0) {
|
||||||
|
setSeqHeight(Math.ceil(sh));
|
||||||
|
const viewport = containerRef.current?.clientHeight ?? parentH ?? sh;
|
||||||
|
const copies = Math.ceil(viewport / sh) + ANIMATION_CONFIG.COPY_HEADROOM;
|
||||||
|
setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copies));
|
||||||
|
}
|
||||||
|
} else if (sw > 0) {
|
||||||
|
setSeqWidth(Math.ceil(sw));
|
||||||
|
const copies = Math.ceil(containerWidth / sw) + ANIMATION_CONFIG.COPY_HEADROOM;
|
||||||
|
setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copies));
|
||||||
|
}
|
||||||
|
}, [isVertical]);
|
||||||
|
|
||||||
|
useResizeObserver(updateDimensions, [containerRef, seqRef], [logos, gap, logoHeight, isVertical]);
|
||||||
|
useImageLoader(seqRef, updateDimensions, [logos, gap, logoHeight, isVertical]);
|
||||||
|
useAnimationLoop(trackRef, targetVelocity, seqWidth, seqHeight, isHovered, effectiveHoverSpeed, isVertical);
|
||||||
|
|
||||||
|
const cssVars = useMemo(() => ({
|
||||||
|
'--logoloop-gap': `${gap}px`,
|
||||||
|
'--logoloop-logoHeight': `${logoHeight}px`,
|
||||||
|
...(fadeOutColor && { '--logoloop-fadeColor': fadeOutColor }),
|
||||||
|
}), [gap, logoHeight, fadeOutColor]);
|
||||||
|
|
||||||
|
const containerStyle = useMemo(() => ({
|
||||||
|
width: isVertical
|
||||||
|
? toCssLength(width) === '100%'
|
||||||
|
? undefined
|
||||||
|
: toCssLength(width)
|
||||||
|
: toCssLength(width) ?? '100%',
|
||||||
|
...cssVars,
|
||||||
|
...style,
|
||||||
|
}), [width, cssVars, style, isVertical]);
|
||||||
|
|
||||||
|
const handleEnter = useCallback(() => {
|
||||||
|
if (effectiveHoverSpeed !== undefined) setIsHovered(true);
|
||||||
|
}, [effectiveHoverSpeed]);
|
||||||
|
const handleLeave = useCallback(() => {
|
||||||
|
if (effectiveHoverSpeed !== undefined) setIsHovered(false);
|
||||||
|
}, [effectiveHoverSpeed]);
|
||||||
|
|
||||||
|
const renderItem = (item, key) => {
|
||||||
|
const isNode = 'node' in item;
|
||||||
|
const inner = isNode ? (
|
||||||
|
<span
|
||||||
|
className={cx('logoloop__node', scaleOnHover && 'logoloop__node--scale')}
|
||||||
|
aria-hidden={!!item.href && !item.ariaLabel}
|
||||||
|
>
|
||||||
|
{item.node}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
className={cx('logoloop__img', scaleOnHover && 'logoloop__img--scale')}
|
||||||
|
src={item.src}
|
||||||
|
srcSet={item.srcSet}
|
||||||
|
sizes={item.sizes}
|
||||||
|
width={item.width}
|
||||||
|
height={item.height}
|
||||||
|
alt={item.alt ?? ''}
|
||||||
|
title={item.title}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const itemAriaLabel = isNode ? item.ariaLabel ?? item.title : item.alt ?? item.title;
|
||||||
|
const wrapper = item.href ? (
|
||||||
|
<a
|
||||||
|
className="logoloop__link"
|
||||||
|
href={item.href}
|
||||||
|
aria-label={itemAriaLabel || 'logo link'}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
inner
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className={cx('logoloop__item', scaleOnHover && 'logoloop__item--scalable')}
|
||||||
|
key={key}
|
||||||
|
role="listitem"
|
||||||
|
>
|
||||||
|
{wrapper}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const lists = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from({ length: copyCount }, (_, i) => (
|
||||||
|
<ul
|
||||||
|
className={cx('logoloop__list', isVertical && 'logoloop__list--vertical')}
|
||||||
|
key={`copy-${i}`}
|
||||||
|
role="list"
|
||||||
|
aria-hidden={i > 0}
|
||||||
|
ref={i === 0 ? seqRef : undefined}
|
||||||
|
>
|
||||||
|
{logos.map((it, idx) => renderItem(it, `${i}-${idx}`))}
|
||||||
|
</ul>
|
||||||
|
)),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[copyCount, logos, isVertical, scaleOnHover],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={cx('logoloop', isVertical && 'logoloop--vertical', className)}
|
||||||
|
style={containerStyle}
|
||||||
|
role="region"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
{fadeOut && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className={cx('logoloop__fade', isVertical ? 'logoloop__fade--top' : 'logoloop__fade--left')}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className={cx('logoloop__fade', isVertical ? 'logoloop__fade--bottom' : 'logoloop__fade--right')}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cx('logoloop__track', isVertical && 'logoloop__track--vertical')}
|
||||||
|
ref={trackRef}
|
||||||
|
onMouseEnter={handleEnter}
|
||||||
|
onMouseLeave={handleLeave}
|
||||||
|
>
|
||||||
|
{lists}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
src/components/MobileSheet.css
Normal file
125
src/components/MobileSheet.css
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/* MobileSheet — bottom sheet modal */
|
||||||
|
|
||||||
|
/* Backdrop */
|
||||||
|
.mobile-sheet__backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
z-index: 400;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.25s var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sheet__backdrop.is-open {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sheet */
|
||||||
|
.mobile-sheet {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
max-height: 90vh;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
||||||
|
z-index: 401;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
touch-action: none;
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform 0.3s var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sheet.is-open {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Snap variants */
|
||||||
|
.mobile-sheet.snap-half {
|
||||||
|
max-height: 50vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drag handle area */
|
||||||
|
.mobile-sheet__handle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 12px 0 8px;
|
||||||
|
cursor: grab;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sheet__handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sheet__handle-bar {
|
||||||
|
display: block;
|
||||||
|
width: 36px;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--text-muted);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.mobile-sheet__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 20px 12px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sheet__title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sheet__close {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
transition: color 0.18s var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sheet__close:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollable body */
|
||||||
|
.mobile-sheet__body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 20px;
|
||||||
|
padding-bottom: calc(20px + var(--safe-area-bottom));
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduced motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.mobile-sheet__backdrop,
|
||||||
|
.mobile-sheet {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sheet__close {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
113
src/components/MobileSheet.jsx
Normal file
113
src/components/MobileSheet.jsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import './MobileSheet.css';
|
||||||
|
|
||||||
|
export default function MobileSheet({ open, onClose, title, snap = 'full', children }) {
|
||||||
|
const [dragY, setDragY] = useState(0);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const startYRef = useRef(null);
|
||||||
|
const sheetRef = useRef(null);
|
||||||
|
|
||||||
|
// Lock body scroll when open
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Reset drag state on close
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setDragY(0);
|
||||||
|
setIsDragging(false);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleHandleTouchStart = (e) => {
|
||||||
|
startYRef.current = e.touches[0].clientY;
|
||||||
|
setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHandleTouchMove = (e) => {
|
||||||
|
if (startYRef.current === null) return;
|
||||||
|
const delta = e.touches[0].clientY - startYRef.current;
|
||||||
|
if (delta < 0) return; // no drag up
|
||||||
|
setDragY(delta);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHandleTouchEnd = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
if (dragY > 100) {
|
||||||
|
setDragY(0);
|
||||||
|
onClose?.();
|
||||||
|
} else {
|
||||||
|
setDragY(0);
|
||||||
|
}
|
||||||
|
startYRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sheetTransform = open
|
||||||
|
? `translateY(${isDragging ? dragY : 0}px)`
|
||||||
|
: 'translateY(100%)';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`mobile-sheet__backdrop ${open ? 'is-open' : ''}`}
|
||||||
|
onClick={onClose}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={sheetRef}
|
||||||
|
className={`mobile-sheet snap-${snap} ${open ? 'is-open' : ''}`}
|
||||||
|
style={{
|
||||||
|
transform: sheetTransform,
|
||||||
|
transition: isDragging ? 'none' : undefined,
|
||||||
|
}}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={title}
|
||||||
|
>
|
||||||
|
{/* Drag handle */}
|
||||||
|
<div
|
||||||
|
className="mobile-sheet__handle"
|
||||||
|
onTouchStart={handleHandleTouchStart}
|
||||||
|
onTouchMove={handleHandleTouchMove}
|
||||||
|
onTouchEnd={handleHandleTouchEnd}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span className="mobile-sheet__handle-bar" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mobile-sheet__header">
|
||||||
|
<span className="mobile-sheet__title">{title}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mobile-sheet__close"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="닫기"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M3 3l12 12M15 3L3 15"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="mobile-sheet__body">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -334,26 +334,6 @@
|
|||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.sidebar {
|
.sidebar {
|
||||||
transform: translateX(-100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar.is-open {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-toggle {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 데스크톱: 토글 버튼 숨김 ────────────────────────────────────────── */
|
|
||||||
|
|
||||||
@media (min-width: 769px) {
|
|
||||||
.sidebar-toggle {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar__overlay {
|
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,92 +1,58 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React from 'react';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import { navLinks } from '../routes.jsx';
|
import { navLinks } from '../routes.jsx';
|
||||||
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
import mainLogo from '../assets/main_logo.png';
|
import mainLogo from '../assets/main_logo.png';
|
||||||
import './Navbar.css';
|
import './Navbar.css';
|
||||||
|
|
||||||
const Navbar = () => {
|
const Navbar = () => {
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const isMobile = useIsMobile();
|
||||||
const closeMenu = () => setMenuOpen(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
// 모바일에서는 BottomNav가 대체하므로 사이드바 미렌더링
|
||||||
document.body.style.overflow = menuOpen ? 'hidden' : '';
|
if (isMobile) return null;
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
};
|
|
||||||
}, [menuOpen]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<aside className="sidebar">
|
||||||
{/* 모바일 오버레이 */}
|
<div className="sidebar__brand">
|
||||||
<div
|
<img src={mainLogo} alt="Logo" className="sidebar__logo" />
|
||||||
className={`sidebar__overlay${menuOpen ? ' is-visible' : ''}`}
|
<div className="sidebar__brand-text">
|
||||||
onClick={closeMenu}
|
<p className="sidebar__brand-name">Jaeoh</p>
|
||||||
aria-hidden="true"
|
<p className="sidebar__brand-sub">MANAGEMENT ROOM</p>
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 모바일 토글 버튼 */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="sidebar-toggle"
|
|
||||||
onClick={() => setMenuOpen((prev) => !prev)}
|
|
||||||
aria-label="메뉴 열기/닫기"
|
|
||||||
aria-expanded={menuOpen}
|
|
||||||
>
|
|
||||||
<span className={`sidebar-toggle__icon${menuOpen ? ' is-open' : ''}`}>
|
|
||||||
<span />
|
|
||||||
<span />
|
|
||||||
<span />
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* 사이드바 본체 */}
|
|
||||||
<aside className={`sidebar${menuOpen ? ' is-open' : ''}`}>
|
|
||||||
{/* 브랜드 섹션 */}
|
|
||||||
<div className="sidebar__brand">
|
|
||||||
<img src={mainLogo} alt="Logo" className="sidebar__logo" />
|
|
||||||
<div className="sidebar__brand-text">
|
|
||||||
<p className="sidebar__brand-name">Jaeoh</p>
|
|
||||||
<p className="sidebar__brand-sub">MANAGEMENT ROOM</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 구분선 */}
|
<div className="sidebar__divider" />
|
||||||
|
|
||||||
|
<nav className="sidebar__nav">
|
||||||
|
<p className="sidebar__section-label">NAVIGATION</p>
|
||||||
|
{navLinks.map((link) => (
|
||||||
|
<NavLink
|
||||||
|
key={link.id}
|
||||||
|
to={link.path}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`sidebar__item${isActive ? ' is-active' : ''}`
|
||||||
|
}
|
||||||
|
style={{ '--item-accent': link.accent }}
|
||||||
|
end={link.path === '/'}
|
||||||
|
>
|
||||||
|
<span className="sidebar__item-icon">{link.icon}</span>
|
||||||
|
<span className="sidebar__item-label">{link.label}</span>
|
||||||
|
<span className="sidebar__item-dot" />
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="sidebar__footer">
|
||||||
<div className="sidebar__divider" />
|
<div className="sidebar__divider" />
|
||||||
|
<div className="sidebar__footer-content">
|
||||||
{/* 네비게이션 */}
|
<div className="sidebar__status">
|
||||||
<nav className="sidebar__nav">
|
<span className="sidebar__status-dot" />
|
||||||
<p className="sidebar__section-label">NAVIGATION</p>
|
<span className="sidebar__status-text">System Online</span>
|
||||||
{navLinks.map((link) => (
|
|
||||||
<NavLink
|
|
||||||
key={link.id}
|
|
||||||
to={link.path}
|
|
||||||
onClick={closeMenu}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`sidebar__item${isActive ? ' is-active' : ''}`
|
|
||||||
}
|
|
||||||
style={{ '--item-accent': link.accent }}
|
|
||||||
end={link.path === '/'}
|
|
||||||
>
|
|
||||||
<span className="sidebar__item-icon">{link.icon}</span>
|
|
||||||
<span className="sidebar__item-label">{link.label}</span>
|
|
||||||
<span className="sidebar__item-dot" />
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* 사이드바 푸터 */}
|
|
||||||
<div className="sidebar__footer">
|
|
||||||
<div className="sidebar__divider" />
|
|
||||||
<div className="sidebar__footer-content">
|
|
||||||
<div className="sidebar__status">
|
|
||||||
<span className="sidebar__status-dot" />
|
|
||||||
<span className="sidebar__status-text">System Online</span>
|
|
||||||
</div>
|
|
||||||
<p className="sidebar__version">v2.0.0</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="sidebar__version">v2.0.0</p>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</div>
|
||||||
</>
|
</aside>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
86
src/components/PullToRefresh.css
Normal file
86
src/components/PullToRefresh.css
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/* PullToRefresh — pull-down-to-refresh wrapper */
|
||||||
|
|
||||||
|
.pull-to-refresh {
|
||||||
|
position: relative;
|
||||||
|
overscroll-behavior-y: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Indicator circle */
|
||||||
|
.pull-to-refresh__indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: -48px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--surface-card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.18s var(--ease-out);
|
||||||
|
z-index: 10;
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pull-to-refresh__indicator.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spinner */
|
||||||
|
.pull-to-refresh__spinner {
|
||||||
|
display: block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid var(--line);
|
||||||
|
border-top-color: var(--neon-cyan);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: ptr-spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ptr-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Arrow chevron */
|
||||||
|
.pull-to-refresh__arrow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
transition: transform 0.2s var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pull-to-refresh__arrow.is-ready {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content area */
|
||||||
|
.pull-to-refresh__content {
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduced motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.pull-to-refresh__spinner {
|
||||||
|
animation: none;
|
||||||
|
border-top-color: var(--neon-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pull-to-refresh__arrow {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pull-to-refresh__indicator {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pull-to-refresh__content {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/components/PullToRefresh.jsx
Normal file
99
src/components/PullToRefresh.jsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { useRef, useState, useCallback } from 'react';
|
||||||
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
|
import './PullToRefresh.css';
|
||||||
|
|
||||||
|
const THRESHOLD = 60;
|
||||||
|
const MAX_PULL = 120;
|
||||||
|
const RESISTANCE = 0.5;
|
||||||
|
const CONTENT_SHIFT_FACTOR = 0.3;
|
||||||
|
|
||||||
|
export default function PullToRefresh({ onRefresh, children, className = '' }) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [pullY, setPullY] = useState(0);
|
||||||
|
const [state, setState] = useState('idle'); // idle | pulling | ready | refreshing
|
||||||
|
const startYRef = useRef(null);
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
|
||||||
|
const handleTouchStart = useCallback((e) => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (el.scrollTop > 0) return; // only trigger at top
|
||||||
|
startYRef.current = e.touches[0].clientY;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTouchMove = useCallback((e) => {
|
||||||
|
if (startYRef.current === null) return;
|
||||||
|
const delta = e.touches[0].clientY - startYRef.current;
|
||||||
|
if (delta <= 0) {
|
||||||
|
setPullY(0);
|
||||||
|
setState('idle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const clamped = Math.min(delta * RESISTANCE, MAX_PULL);
|
||||||
|
setPullY(clamped);
|
||||||
|
setState(clamped >= THRESHOLD ? 'ready' : 'pulling');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTouchEnd = useCallback(async () => {
|
||||||
|
if (startYRef.current === null) return;
|
||||||
|
startYRef.current = null;
|
||||||
|
if (state === 'ready') {
|
||||||
|
setState('refreshing');
|
||||||
|
setPullY(THRESHOLD);
|
||||||
|
try {
|
||||||
|
await onRefresh?.();
|
||||||
|
} finally {
|
||||||
|
setState('idle');
|
||||||
|
setPullY(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setState('idle');
|
||||||
|
setPullY(0);
|
||||||
|
}
|
||||||
|
}, [state, onRefresh]);
|
||||||
|
|
||||||
|
if (!isMobile) {
|
||||||
|
return <div className={className}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const indicatorVisible = state !== 'idle';
|
||||||
|
const contentShift = pullY * CONTENT_SHIFT_FACTOR;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`pull-to-refresh ${className}`}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`pull-to-refresh__indicator ${indicatorVisible ? 'is-visible' : ''}`}
|
||||||
|
style={{ transform: `translateY(${pullY}px)` }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{state === 'refreshing' ? (
|
||||||
|
<span className="pull-to-refresh__spinner" />
|
||||||
|
) : (
|
||||||
|
<span className={`pull-to-refresh__arrow ${state === 'ready' ? 'is-ready' : ''}`}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M9 3v10M4 8l5 5 5-5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="pull-to-refresh__content"
|
||||||
|
style={{ transform: `translateY(${contentShift}px)`, transition: state === 'idle' ? 'transform 0.3s var(--ease-out)' : 'none' }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
src/components/SwipeableView.css
Normal file
100
src/components/SwipeableView.css
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/* SwipeableView — swipeable tab container */
|
||||||
|
|
||||||
|
.swipeable-view {
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab bar */
|
||||||
|
.swipeable-view__tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipeable-view__tabs::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Individual tab button */
|
||||||
|
.swipeable-view__tab {
|
||||||
|
flex: 1;
|
||||||
|
min-width: fit-content;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: calc(var(--radius-md) - 2px);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color 0.18s var(--ease-out), background 0.18s var(--ease-out);
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipeable-view__tab.is-active {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipeable-view__tab.is-active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2px;
|
||||||
|
left: 20%;
|
||||||
|
right: 20%;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--neon-cyan);
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sliding track */
|
||||||
|
.swipeable-view__track {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
transition: transform 0.3s var(--ease-out);
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipeable-view__track.is-swiping {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Each panel */
|
||||||
|
.swipeable-view__panel {
|
||||||
|
flex: 0 0 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile touch targets */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.swipeable-view__tab {
|
||||||
|
min-height: 44px;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduced motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.swipeable-view__track {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipeable-view__tab {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/components/SwipeableView.jsx
Normal file
92
src/components/SwipeableView.jsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { useSwipeable } from 'react-swipeable';
|
||||||
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
|
import './SwipeableView.css';
|
||||||
|
|
||||||
|
export default function SwipeableView({
|
||||||
|
tabs = [],
|
||||||
|
activeIndex: controlledIndex,
|
||||||
|
onTabChange,
|
||||||
|
showTabs = true,
|
||||||
|
}) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [internalIndex, setInternalIndex] = useState(0);
|
||||||
|
const [swipeOffset, setSwipeOffset] = useState(0);
|
||||||
|
const [isSwiping, setIsSwiping] = useState(false);
|
||||||
|
const trackRef = useRef(null);
|
||||||
|
|
||||||
|
const activeIndex = controlledIndex !== undefined ? controlledIndex : internalIndex;
|
||||||
|
|
||||||
|
const setIndex = (idx) => {
|
||||||
|
const clamped = Math.max(0, Math.min(tabs.length - 1, idx));
|
||||||
|
if (controlledIndex === undefined) setInternalIndex(clamped);
|
||||||
|
onTabChange?.(clamped);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlers = useSwipeable({
|
||||||
|
onSwiping: ({ deltaX }) => {
|
||||||
|
if (!isMobile) return;
|
||||||
|
setIsSwiping(true);
|
||||||
|
setSwipeOffset(deltaX);
|
||||||
|
},
|
||||||
|
onSwipedLeft: ({ deltaX }) => {
|
||||||
|
if (!isMobile) return;
|
||||||
|
setIsSwiping(false);
|
||||||
|
setSwipeOffset(0);
|
||||||
|
if (Math.abs(deltaX) > 30) setIndex(activeIndex + 1);
|
||||||
|
},
|
||||||
|
onSwipedRight: ({ deltaX }) => {
|
||||||
|
if (!isMobile) return;
|
||||||
|
setIsSwiping(false);
|
||||||
|
setSwipeOffset(0);
|
||||||
|
if (Math.abs(deltaX) > 30) setIndex(activeIndex - 1);
|
||||||
|
},
|
||||||
|
onTouchEndOrOnMouseUp: () => {
|
||||||
|
setIsSwiping(false);
|
||||||
|
setSwipeOffset(0);
|
||||||
|
},
|
||||||
|
trackMouse: false,
|
||||||
|
trackTouch: true,
|
||||||
|
delta: 30,
|
||||||
|
preventScrollOnSwipe: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const trackTranslate = -activeIndex * 100 + (isSwiping ? (swipeOffset / (trackRef.current?.offsetWidth || 1)) * 100 : 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="swipeable-view">
|
||||||
|
{showTabs && (
|
||||||
|
<div className="swipeable-view__tabs" role="tablist">
|
||||||
|
{tabs.map((tab, i) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={i === activeIndex}
|
||||||
|
className={`swipeable-view__tab ${i === activeIndex ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setIndex(i)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
{...(isMobile ? handlers : {})}
|
||||||
|
ref={trackRef}
|
||||||
|
className={`swipeable-view__track ${isSwiping ? 'is-swiping' : ''}`}
|
||||||
|
style={{ transform: `translateX(${trackTranslate}%)` }}
|
||||||
|
>
|
||||||
|
{tabs.map((tab, i) => (
|
||||||
|
<div
|
||||||
|
key={tab.key}
|
||||||
|
role="tabpanel"
|
||||||
|
aria-hidden={i !== activeIndex}
|
||||||
|
className="swipeable-view__panel"
|
||||||
|
>
|
||||||
|
{tab.content}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/hooks/useIsMobile.js
Normal file
18
src/hooks/useIsMobile.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
|
||||||
|
export function useIsMobile() {
|
||||||
|
const [isMobile, setIsMobile] = useState(
|
||||||
|
() => window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`).matches
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`);
|
||||||
|
const handler = (e) => setIsMobile(e.matches);
|
||||||
|
mql.addEventListener('change', handler);
|
||||||
|
return () => mql.removeEventListener('change', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isMobile;
|
||||||
|
}
|
||||||
@@ -72,6 +72,8 @@
|
|||||||
/* ── Layout ──────────────────────────────────────────────────────── */
|
/* ── Layout ──────────────────────────────────────────────────────── */
|
||||||
--sidebar-w: 240px;
|
--sidebar-w: 240px;
|
||||||
--topbar-h: 56px;
|
--topbar-h: 56px;
|
||||||
|
--bottom-nav-h: 64px;
|
||||||
|
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
|
||||||
/* ── Typography ──────────────────────────────────────────────────── */
|
/* ── Typography ──────────────────────────────────────────────────── */
|
||||||
--font-display: 'Space Grotesk', 'Noto Sans KR', system-ui, serif;
|
--font-display: 'Space Grotesk', 'Noto Sans KR', system-ui, serif;
|
||||||
@@ -113,6 +115,10 @@ html {
|
|||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html { scroll-behavior: auto; }
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -240,5 +246,6 @@ select option {
|
|||||||
body {
|
body {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
background-attachment: scroll;
|
background-attachment: scroll;
|
||||||
|
padding-bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
477
src/pages/agent-office/AgentOffice.css
Normal file
477
src/pages/agent-office/AgentOffice.css
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
/* src/pages/agent-office/AgentOffice.css */
|
||||||
|
|
||||||
|
/* ===== Root Layout ===== */
|
||||||
|
.ao-root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
background: #0d0d1a;
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Top Bar ===== */
|
||||||
|
.ao-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 16px;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ao-topbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.ao-topbar-title {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #8b5cf6;
|
||||||
|
}
|
||||||
|
.ao-topbar-status {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.ao-topbar-status.connected { color: #22c55e; }
|
||||||
|
.ao-topbar-status.disconnected { color: #ef4444; }
|
||||||
|
.ao-topbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.ao-topbar-select {
|
||||||
|
background: #2a2a3e;
|
||||||
|
color: #aaa;
|
||||||
|
border: 1px solid #444;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.ao-topbar-zoom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.ao-topbar-zoom button {
|
||||||
|
background: #2a2a3e;
|
||||||
|
color: #aaa;
|
||||||
|
border: 1px solid #444;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.ao-topbar-zoom button:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.ao-topbar-zoom span {
|
||||||
|
color: #888;
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 28px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Main Area ===== */
|
||||||
|
.ao-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ao-canvas {
|
||||||
|
flex: 1;
|
||||||
|
cursor: grab;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.ao-canvas:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Side Panel ===== */
|
||||||
|
.ao-sidepanel {
|
||||||
|
width: 320px;
|
||||||
|
background: #111;
|
||||||
|
border-left: 1px solid #333;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
animation: slideIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes slideIn {
|
||||||
|
from { transform: translateX(100%); }
|
||||||
|
to { transform: translateX(0); }
|
||||||
|
}
|
||||||
|
.ao-sidepanel-header {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.ao-sidepanel-agent {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.ao-sidepanel-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.ao-sidepanel-name {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.ao-sidepanel-state {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
.ao-sidepanel-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #666;
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
.ao-sidepanel-close:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.ao-sidepanel-tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
.ao-sidepanel-tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 4px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: #666;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ao-sidepanel-tab.active {
|
||||||
|
color: #8b5cf6;
|
||||||
|
border-bottom-color: #8b5cf6;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.ao-sidepanel-tab:hover {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
.ao-sidepanel-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Command Tab ===== */
|
||||||
|
.ao-command-tab { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.ao-section { margin-bottom: 4px; }
|
||||||
|
.ao-section-label {
|
||||||
|
color: #888;
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.ao-quick-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.ao-btn-quick {
|
||||||
|
background: #2a2a4e;
|
||||||
|
color: #8b5cf6;
|
||||||
|
border: 1px solid #4c1d95;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ao-btn-quick:hover { background: #3a3a5e; }
|
||||||
|
.ao-btn-quick:disabled { opacity: 0.4; }
|
||||||
|
|
||||||
|
.ao-param-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.ao-input {
|
||||||
|
flex: 1;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border: 1px solid #333;
|
||||||
|
color: #fff;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.ao-input::placeholder { color: #555; }
|
||||||
|
.ao-btn-send {
|
||||||
|
background: #4c1d95;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ao-btn-send:hover { background: #5b21b6; }
|
||||||
|
.ao-btn-send:disabled { opacity: 0.4; }
|
||||||
|
|
||||||
|
/* Approval */
|
||||||
|
.ao-approval-card {
|
||||||
|
background: rgba(146,64,14,0.15);
|
||||||
|
border: 1px solid #92400e;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.ao-approval-title {
|
||||||
|
color: #fbbf24;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.ao-approval-desc {
|
||||||
|
color: #ddd;
|
||||||
|
font-size: 11px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.ao-approval-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.ao-btn-approve {
|
||||||
|
flex: 1;
|
||||||
|
background: #065f46;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ao-btn-reject {
|
||||||
|
flex: 1;
|
||||||
|
background: #7f1d1d;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Task Tab ===== */
|
||||||
|
.ao-task-tab { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.ao-task-item {
|
||||||
|
background: #1a1a2e;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ao-task-item:hover { background: #222240; }
|
||||||
|
.ao-task-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.ao-task-type { color: #ccc; font-weight: bold; flex: 1; }
|
||||||
|
.ao-task-badge {
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.ao-task-time { color: #666; font-size: 10px; }
|
||||||
|
.ao-task-result {
|
||||||
|
margin-top: 6px;
|
||||||
|
background: #0d0d1a;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #aaa;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Token Tab ===== */
|
||||||
|
.ao-token-tab { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.ao-token-period {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.ao-btn-period {
|
||||||
|
flex: 1;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #888;
|
||||||
|
border: 1px solid #333;
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ao-btn-period.active {
|
||||||
|
background: #4c1d95;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #4c1d95;
|
||||||
|
}
|
||||||
|
.ao-token-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.ao-token-card {
|
||||||
|
background: #1a1a2e;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.ao-token-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #888;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.ao-token-value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.ao-token-bar { margin-top: 4px; }
|
||||||
|
.ao-token-bar-label { font-size: 10px; color: #888; margin-bottom: 4px; }
|
||||||
|
.ao-token-bar-track {
|
||||||
|
display: flex;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #1a1a2e;
|
||||||
|
}
|
||||||
|
.ao-token-bar-fill.input { background: #3b82f6; }
|
||||||
|
.ao-token-bar-fill.output { background: #8b5cf6; }
|
||||||
|
.ao-token-bar-legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.ao-token-bar-legend .dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.ao-token-bar-legend .dot.input { background: #3b82f6; }
|
||||||
|
.ao-token-bar-legend .dot.output { background: #8b5cf6; }
|
||||||
|
.ao-token-detail {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Log Tab ===== */
|
||||||
|
.ao-log-tab {
|
||||||
|
max-height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.ao-log-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 0;
|
||||||
|
border-bottom: 1px solid #1a1a2e;
|
||||||
|
}
|
||||||
|
.ao-log-time { color: #555; min-width: 60px; }
|
||||||
|
.ao-log-level { min-width: 48px; font-weight: bold; }
|
||||||
|
.ao-log-msg { color: #ccc; word-break: break-all; }
|
||||||
|
|
||||||
|
/* ===== Common ===== */
|
||||||
|
.ao-empty {
|
||||||
|
color: #555;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Mobile (< 768px) ===== */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.ao-topbar-right { gap: 6px; }
|
||||||
|
.ao-topbar-select { font-size: 11px; padding: 2px 6px; }
|
||||||
|
|
||||||
|
.ao-main {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-canvas {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Side panel → bottom sheet */
|
||||||
|
.ao-sidepanel {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 55vh;
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
animation: slideUp 0.25s ease-out;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { transform: translateY(100%); }
|
||||||
|
to { transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-sidepanel-header {
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
.ao-sidepanel-header::before {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
width: 32px;
|
||||||
|
height: 4px;
|
||||||
|
background: #555;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin: 0 auto 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-sidepanel-tab {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 6px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-sidepanel-content {
|
||||||
|
padding: 8px 12px;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 16px);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/pages/agent-office/AgentOffice.jsx
Normal file
101
src/pages/agent-office/AgentOffice.jsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
// src/pages/agent-office/AgentOffice.jsx
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useAgentManager } from './hooks/useAgentManager.js';
|
||||||
|
import { useOfficeCanvas } from './hooks/useOfficeCanvas.js';
|
||||||
|
import TopBar from './components/TopBar.jsx';
|
||||||
|
import SidePanel from './components/SidePanel.jsx';
|
||||||
|
import './AgentOffice.css';
|
||||||
|
|
||||||
|
export default function AgentOffice() {
|
||||||
|
const {
|
||||||
|
agents, pendingTasks, notifications, connected,
|
||||||
|
refreshTrigger, clearNotifications
|
||||||
|
} = useAgentManager();
|
||||||
|
|
||||||
|
const {
|
||||||
|
canvasRef, updateAgentState, setAgentNotification,
|
||||||
|
setTheme, setZoom, hitTest, getZoom, wasDragging
|
||||||
|
} = useOfficeCanvas();
|
||||||
|
|
||||||
|
const [selectedAgent, setSelectedAgent] = useState(null);
|
||||||
|
const [theme, setThemeState] = useState(localStorage.getItem('agent-office-theme') || 'modern');
|
||||||
|
const [zoom, setZoomState] = useState(2);
|
||||||
|
|
||||||
|
// WebSocket 상태 → 캔버스 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
for (const [id, agentState] of Object.entries(agents)) {
|
||||||
|
updateAgentState(id, agentState.state, agentState.detail);
|
||||||
|
}
|
||||||
|
}, [agents, updateAgentState]);
|
||||||
|
|
||||||
|
// 알림 → 캔버스 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
for (const [id, count] of Object.entries(notifications)) {
|
||||||
|
setAgentNotification(id, count);
|
||||||
|
}
|
||||||
|
}, [notifications, setAgentNotification]);
|
||||||
|
|
||||||
|
// 캔버스 클릭 핸들러
|
||||||
|
const handleCanvasClick = useCallback((e) => {
|
||||||
|
if (wasDragging()) return; // 드래그 후 발생하는 클릭 무시
|
||||||
|
const result = hitTest(e.clientX, e.clientY);
|
||||||
|
if (result.type === 'agent') {
|
||||||
|
setSelectedAgent(result.id);
|
||||||
|
clearNotifications(result.id);
|
||||||
|
setAgentNotification(result.id, 0);
|
||||||
|
} else {
|
||||||
|
setSelectedAgent(null);
|
||||||
|
}
|
||||||
|
}, [hitTest, clearNotifications, setAgentNotification, wasDragging]);
|
||||||
|
|
||||||
|
// 테마 변경
|
||||||
|
const handleThemeChange = useCallback((name) => {
|
||||||
|
setThemeState(name);
|
||||||
|
setTheme(name);
|
||||||
|
}, [setTheme]);
|
||||||
|
|
||||||
|
// 줌 변경
|
||||||
|
const handleZoomChange = useCallback((level) => {
|
||||||
|
setZoomState(level);
|
||||||
|
setZoom(level);
|
||||||
|
}, [setZoom]);
|
||||||
|
|
||||||
|
// 선택된 에이전트의 pending task
|
||||||
|
const pendingTask = selectedAgent
|
||||||
|
? pendingTasks.find(t => t.agent_id === selectedAgent)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ao-root">
|
||||||
|
<TopBar
|
||||||
|
connected={connected}
|
||||||
|
theme={theme}
|
||||||
|
onThemeChange={handleThemeChange}
|
||||||
|
zoom={zoom}
|
||||||
|
onZoomChange={handleZoomChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="ao-main">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="ao-canvas"
|
||||||
|
onClick={handleCanvasClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedAgent && (
|
||||||
|
<SidePanel
|
||||||
|
agentId={selectedAgent}
|
||||||
|
agentState={agents[selectedAgent]}
|
||||||
|
pendingTask={pendingTask}
|
||||||
|
onClose={() => setSelectedAgent(null)}
|
||||||
|
refreshTrigger={refreshTrigger}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Component() {
|
||||||
|
return <AgentOffice />;
|
||||||
|
}
|
||||||
72
src/pages/agent-office/assets/office-map.json
Normal file
72
src/pages/agent-office/assets/office-map.json
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"cols": 32,
|
||||||
|
"rows": 20,
|
||||||
|
"tileSize": 32,
|
||||||
|
"floor": [
|
||||||
|
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||||
|
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||||
|
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||||
|
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||||
|
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||||
|
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||||
|
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||||
|
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||||
|
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||||
|
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||||
|
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||||
|
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||||
|
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||||
|
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||||
|
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||||
|
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||||
|
[0,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||||
|
[0,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||||
|
[0,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
|
||||||
|
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
|
||||||
|
],
|
||||||
|
"furniture": [
|
||||||
|
{"type": "desk_monitor", "col": 3, "row": 3, "agent": "stock", "monitors": 3},
|
||||||
|
{"type": "desk_monitor", "col": 10, "row": 3, "agent": "music", "monitors": 1, "accent": "instrument"},
|
||||||
|
{"type": "desk_monitor", "col": 17, "row": 3, "agent": "blog", "monitors": 2, "accent": "papers"},
|
||||||
|
{"type": "desk_monitor", "col": 24, "row": 3, "agent": "realestate", "monitors": 2, "accent": "briefcase"},
|
||||||
|
{"type": "desk_monitor", "col": 14, "row": 7, "agent": "lotto", "monitors": 1, "accent": "dice"},
|
||||||
|
{"type": "meeting_table","col": 13, "row": 11,"width": 6, "height": 2},
|
||||||
|
{"type": "sofa", "col": 2, "row": 17},
|
||||||
|
{"type": "coffee_machine","col": 5, "row": 16},
|
||||||
|
{"type": "bookshelf", "col": 27, "row": 16, "height": 3},
|
||||||
|
{"type": "plant", "col": 1, "row": 1},
|
||||||
|
{"type": "plant", "col": 30, "row": 1},
|
||||||
|
{"type": "plant", "col": 1, "row": 14},
|
||||||
|
{"type": "plant", "col": 30, "row": 14},
|
||||||
|
{"type": "water_cooler", "col": 8, "row": 17}
|
||||||
|
],
|
||||||
|
"waypoints": {
|
||||||
|
"desk_stock": {"col": 3, "row": 4},
|
||||||
|
"desk_music": {"col": 10, "row": 4},
|
||||||
|
"desk_blog": {"col": 17, "row": 4},
|
||||||
|
"desk_realestate": {"col": 24, "row": 4},
|
||||||
|
"desk_lotto": {"col": 14, "row": 8},
|
||||||
|
"meeting": {"col": 16, "row": 13},
|
||||||
|
"break_room": {"col": 4, "row": 17},
|
||||||
|
"coffee": {"col": 6, "row": 17},
|
||||||
|
"water_cooler": {"col": 8, "row": 18}
|
||||||
|
},
|
||||||
|
"blocked": [
|
||||||
|
[3,3],[4,3],[5,3],
|
||||||
|
[10,3],[11,3],
|
||||||
|
[17,3],[18,3],[19,3],
|
||||||
|
[24,3],[25,3],[26,3],
|
||||||
|
[14,7],[15,7],
|
||||||
|
[13,11],[14,11],[15,11],[16,11],[17,11],[18,11],
|
||||||
|
[13,12],[14,12],[15,12],[16,12],[17,12],[18,12],
|
||||||
|
[2,17],[3,17],
|
||||||
|
[5,16],[6,16],
|
||||||
|
[27,16],[27,17],[27,18],
|
||||||
|
[8,17]
|
||||||
|
],
|
||||||
|
"tileTypes": {
|
||||||
|
"0": "wall",
|
||||||
|
"1": "floor",
|
||||||
|
"2": "floor_break"
|
||||||
|
}
|
||||||
|
}
|
||||||
261
src/pages/agent-office/canvas/AgentSprite.js
Normal file
261
src/pages/agent-office/canvas/AgentSprite.js
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
// src/pages/agent-office/canvas/AgentSprite.js
|
||||||
|
|
||||||
|
import { ProceduralSprite } from './ProceduralSprite.js';
|
||||||
|
|
||||||
|
const WALK_SPEED = 3; // tiles per second
|
||||||
|
const WANDER_DELAY_MIN = 3;
|
||||||
|
const WANDER_DELAY_MAX = 8;
|
||||||
|
const WANDER_LIMIT_MIN = 3;
|
||||||
|
const WANDER_LIMIT_MAX = 6;
|
||||||
|
const REST_DELAY_MIN = 2;
|
||||||
|
const REST_DELAY_MAX = 20;
|
||||||
|
|
||||||
|
export class AgentSprite {
|
||||||
|
constructor(id, meta, col, row, pathfinder) {
|
||||||
|
this.id = id;
|
||||||
|
this.meta = meta;
|
||||||
|
this.pathfinder = pathfinder;
|
||||||
|
|
||||||
|
// 위치 (타일 좌표, 실수)
|
||||||
|
this.x = col;
|
||||||
|
this.y = row;
|
||||||
|
this.deskCol = col;
|
||||||
|
this.deskRow = row;
|
||||||
|
|
||||||
|
// 상태
|
||||||
|
this.state = 'idle'; // FSM 상태 (from backend)
|
||||||
|
this.detail = '';
|
||||||
|
this.notificationCount = 0;
|
||||||
|
|
||||||
|
// 애니메이션
|
||||||
|
this.animState = 'idle'; // 렌더링용 상태
|
||||||
|
this.direction = 'down';
|
||||||
|
this.animFrame = 0;
|
||||||
|
this.animTimer = 0;
|
||||||
|
|
||||||
|
// 이동
|
||||||
|
this.path = []; // BFS 경로 [{col, row}, ...]
|
||||||
|
this.moveProgress = 0; // 0~1 현재 타일 → 다음 타일
|
||||||
|
this.moveFrom = { col, row };
|
||||||
|
this.moveTo_target = null;
|
||||||
|
|
||||||
|
// 배회
|
||||||
|
this._wandering = false;
|
||||||
|
this._wanderTimer = 0;
|
||||||
|
this._wanderCount = 0;
|
||||||
|
this._wanderLimit = 0;
|
||||||
|
this._restTimer = 0;
|
||||||
|
this._isResting = false;
|
||||||
|
this._isAtDesk = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 매 프레임 호출 */
|
||||||
|
update(dt) {
|
||||||
|
// 이동 처리
|
||||||
|
if (this.path.length > 0) {
|
||||||
|
this._updateMovement(dt);
|
||||||
|
} else if (this._wandering) {
|
||||||
|
this._updateWander(dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 애니메이션 프레임 업데이트
|
||||||
|
this._updateAnimation(dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateMovement(dt) {
|
||||||
|
this.animState = 'walk';
|
||||||
|
this.moveProgress += WALK_SPEED * dt;
|
||||||
|
|
||||||
|
if (this.moveProgress >= 1) {
|
||||||
|
// 현재 구간 완료
|
||||||
|
const arrived = this.path.shift();
|
||||||
|
this.x = arrived.col;
|
||||||
|
this.y = arrived.row;
|
||||||
|
this.moveFrom = { col: arrived.col, row: arrived.row };
|
||||||
|
this.moveProgress = 0;
|
||||||
|
|
||||||
|
if (this.path.length === 0) {
|
||||||
|
// 최종 목적지 도착
|
||||||
|
this._onArrival();
|
||||||
|
} else {
|
||||||
|
// 다음 구간의 방향 설정
|
||||||
|
this._updateDirection(this.path[0]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 보간
|
||||||
|
const next = this.path[0];
|
||||||
|
this.x = this.moveFrom.col + (next.col - this.moveFrom.col) * this.moveProgress;
|
||||||
|
this.y = this.moveFrom.row + (next.row - this.moveFrom.row) * this.moveProgress;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onArrival() {
|
||||||
|
const atDesk = Math.abs(this.x - this.deskCol) < 0.5 && Math.abs(this.y - this.deskRow) < 0.5;
|
||||||
|
this._isAtDesk = atDesk;
|
||||||
|
|
||||||
|
if (this.state === 'working' || this.state === 'reporting') {
|
||||||
|
this.animState = 'type';
|
||||||
|
this.direction = 'up'; // 모니터를 바라봄
|
||||||
|
} else if (this.state === 'waiting') {
|
||||||
|
this.animState = 'wait';
|
||||||
|
} else if (this.state === 'break') {
|
||||||
|
this.animState = 'break_anim';
|
||||||
|
} else {
|
||||||
|
// idle 도착 — 배회 계속 또는 자리에서 쉬기
|
||||||
|
if (this._wandering && this._wanderCount < this._wanderLimit) {
|
||||||
|
// 다음 배회 타이머 설정
|
||||||
|
this._wanderTimer = WANDER_DELAY_MIN + Math.random() * (WANDER_DELAY_MAX - WANDER_DELAY_MIN);
|
||||||
|
} else if (this._wandering) {
|
||||||
|
// 배회 끝, 휴식
|
||||||
|
this._wandering = false;
|
||||||
|
this._isResting = true;
|
||||||
|
this._restTimer = REST_DELAY_MIN + Math.random() * (REST_DELAY_MAX - REST_DELAY_MIN);
|
||||||
|
}
|
||||||
|
this.animState = 'idle';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateWander(dt) {
|
||||||
|
if (this._isResting) {
|
||||||
|
this._restTimer -= dt;
|
||||||
|
if (this._restTimer <= 0) {
|
||||||
|
this._isResting = false;
|
||||||
|
this._startWandering();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._wanderTimer -= dt;
|
||||||
|
if (this._wanderTimer <= 0) {
|
||||||
|
// 랜덤 인접 타일로 이동
|
||||||
|
const target = this.pathfinder.getRandomNearbyFloor(
|
||||||
|
Math.round(this.x), Math.round(this.y), 4
|
||||||
|
);
|
||||||
|
if (target) {
|
||||||
|
const path = this.pathfinder.findPath(
|
||||||
|
Math.round(this.x), Math.round(this.y), target.col, target.row
|
||||||
|
);
|
||||||
|
if (path.length > 0 && path.length <= 6) {
|
||||||
|
this.path = path;
|
||||||
|
this.moveFrom = { col: Math.round(this.x), row: Math.round(this.y) };
|
||||||
|
this.moveProgress = 0;
|
||||||
|
this._updateDirection(path[0]);
|
||||||
|
this._wanderCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 실패해도 타이머 리셋
|
||||||
|
this._wanderTimer = WANDER_DELAY_MIN + Math.random() * (WANDER_DELAY_MAX - WANDER_DELAY_MIN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateDirection(nextTile) {
|
||||||
|
const dx = nextTile.col - Math.round(this.x);
|
||||||
|
const dy = nextTile.row - Math.round(this.y);
|
||||||
|
if (Math.abs(dx) > Math.abs(dy)) {
|
||||||
|
this.direction = dx > 0 ? 'right' : 'left';
|
||||||
|
} else {
|
||||||
|
this.direction = dy > 0 ? 'down' : 'up';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateAnimation(dt) {
|
||||||
|
const config = ProceduralSprite.getAnimConfig(
|
||||||
|
this.animState === 'walk' ? 'walk' : this.state
|
||||||
|
);
|
||||||
|
this.animTimer += dt;
|
||||||
|
if (this.animTimer >= config.speed) {
|
||||||
|
this.animTimer = 0;
|
||||||
|
this.animFrame = (this.animFrame + 1) % config.frames;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 백엔드 상태 변경 시 호출 */
|
||||||
|
onStateChange(newState, detail, waypoints) {
|
||||||
|
const prevState = this.state;
|
||||||
|
this.state = newState;
|
||||||
|
this.detail = detail || '';
|
||||||
|
|
||||||
|
// 배회 중단
|
||||||
|
this._wandering = false;
|
||||||
|
this._isResting = false;
|
||||||
|
|
||||||
|
switch (newState) {
|
||||||
|
case 'working':
|
||||||
|
case 'reporting':
|
||||||
|
case 'waiting':
|
||||||
|
// 자리에 없으면 자리로 이동
|
||||||
|
if (!this._isAtDesk) {
|
||||||
|
this._moveToDesk();
|
||||||
|
} else {
|
||||||
|
this.animState = newState === 'waiting' ? 'wait' : 'type';
|
||||||
|
this.direction = 'up';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'break': {
|
||||||
|
// 휴게실로 이동
|
||||||
|
const breakWp = waypoints.break_room || waypoints.coffee;
|
||||||
|
if (breakWp) {
|
||||||
|
this._navigateTo(breakWp.col, breakWp.row);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'idle':
|
||||||
|
if (prevState === 'break') {
|
||||||
|
// 휴게실에서 자리로 복귀
|
||||||
|
this._moveToDesk();
|
||||||
|
}
|
||||||
|
// 복귀 후 배회 시작 (도착 콜백에서 처리)
|
||||||
|
this._startWanderingAfterDelay(3);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_moveToDesk() {
|
||||||
|
this._navigateTo(this.deskCol, this.deskRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
_navigateTo(goalCol, goalRow) {
|
||||||
|
const startCol = Math.round(this.x);
|
||||||
|
const startRow = Math.round(this.y);
|
||||||
|
const path = this.pathfinder.findPath(startCol, startRow, goalCol, goalRow);
|
||||||
|
if (path.length > 0) {
|
||||||
|
this.path = path;
|
||||||
|
this.moveFrom = { col: startCol, row: startRow };
|
||||||
|
this.moveProgress = 0;
|
||||||
|
this._updateDirection(path[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_startWanderingAfterDelay(delay) {
|
||||||
|
this._wandering = true;
|
||||||
|
this._wanderCount = 0;
|
||||||
|
this._wanderLimit = WANDER_LIMIT_MIN + Math.floor(Math.random() * (WANDER_LIMIT_MAX - WANDER_LIMIT_MIN));
|
||||||
|
this._wanderTimer = delay;
|
||||||
|
this._isResting = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_startWandering() {
|
||||||
|
this._startWanderingAfterDelay(WANDER_DELAY_MIN + Math.random() * (WANDER_DELAY_MAX - WANDER_DELAY_MIN));
|
||||||
|
}
|
||||||
|
|
||||||
|
isAtDesk() {
|
||||||
|
return this._isAtDesk;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 렌더링 */
|
||||||
|
draw(ctx, zoom, panX, panY, tileSize) {
|
||||||
|
const ts = tileSize * zoom;
|
||||||
|
const screenX = this.x * ts + panX + ts / 2;
|
||||||
|
const screenY = this.y * ts + panY + ts;
|
||||||
|
const spriteScale = zoom * 1.5; // 캐릭터 약간 크게
|
||||||
|
|
||||||
|
ProceduralSprite.draw(
|
||||||
|
ctx, this.id,
|
||||||
|
this.animState === 'walk' ? 'walk' : this.state,
|
||||||
|
this.direction, this.animFrame,
|
||||||
|
screenX, screenY, spriteScale
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
209
src/pages/agent-office/canvas/FurnitureRenderer.js
Normal file
209
src/pages/agent-office/canvas/FurnitureRenderer.js
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
// src/pages/agent-office/canvas/FurnitureRenderer.js
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 가구 프로시저럴 렌더러 — 테마 팔레트 기반
|
||||||
|
* 각 가구 타입별 draw 함수, Y-sort를 위한 zY 반환
|
||||||
|
*/
|
||||||
|
export class FurnitureRenderer {
|
||||||
|
constructor(furnitureList, tileSize) {
|
||||||
|
this.furnitureList = furnitureList;
|
||||||
|
this.tileSize = tileSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 가구를 Y-sort 순서로 반환 (에이전트와 함께 정렬하기 위함)
|
||||||
|
* @returns {Array<{type, col, row, zY, draw: Function}>}
|
||||||
|
*/
|
||||||
|
getRenderables(theme, scale, offsetX, offsetY) {
|
||||||
|
const ts = this.tileSize * scale;
|
||||||
|
return this.furnitureList.map(f => ({
|
||||||
|
...f,
|
||||||
|
zY: f.row,
|
||||||
|
draw: (ctx) => this._drawFurniture(ctx, f, theme, ts, offsetX, offsetY)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawFurniture(ctx, f, theme, ts, ox, oy) {
|
||||||
|
const x = f.col * ts + ox;
|
||||||
|
const y = f.row * ts + oy;
|
||||||
|
|
||||||
|
switch (f.type) {
|
||||||
|
case 'desk_monitor': this._drawDesk(ctx, f, theme, ts, x, y); break;
|
||||||
|
case 'meeting_table': this._drawMeetingTable(ctx, f, theme, ts, x, y); break;
|
||||||
|
case 'sofa': this._drawSofa(ctx, theme, ts, x, y); break;
|
||||||
|
case 'coffee_machine':this._drawCoffeeMachine(ctx, theme, ts, x, y); break;
|
||||||
|
case 'bookshelf': this._drawBookshelf(ctx, f, theme, ts, x, y); break;
|
||||||
|
case 'plant': this._drawPlant(ctx, theme, ts, x, y); break;
|
||||||
|
case 'water_cooler': this._drawWaterCooler(ctx, theme, ts, x, y); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawDesk(ctx, f, theme, ts, x, y) {
|
||||||
|
// 책상 상판
|
||||||
|
const dw = ts * 2;
|
||||||
|
const dh = ts * 0.6;
|
||||||
|
ctx.fillStyle = theme.furniture.desk;
|
||||||
|
ctx.fillRect(x, y + ts * 0.2, dw, dh);
|
||||||
|
// 책상 다리
|
||||||
|
ctx.fillStyle = theme.wall.border;
|
||||||
|
ctx.fillRect(x + ts * 0.1, y + dh + ts * 0.2, ts * 0.15, ts * 0.3);
|
||||||
|
ctx.fillRect(x + dw - ts * 0.25, y + dh + ts * 0.2, ts * 0.15, ts * 0.3);
|
||||||
|
|
||||||
|
// 모니터들
|
||||||
|
const monCount = f.monitors || 1;
|
||||||
|
const monW = ts * 0.5;
|
||||||
|
const monH = ts * 0.4;
|
||||||
|
const totalW = monCount * monW + (monCount - 1) * ts * 0.1;
|
||||||
|
let monX = x + (dw - totalW) / 2;
|
||||||
|
|
||||||
|
for (let i = 0; i < monCount; i++) {
|
||||||
|
// 모니터 프레임
|
||||||
|
ctx.fillStyle = theme.furniture.monitor;
|
||||||
|
ctx.fillRect(monX, y - monH + ts * 0.2, monW, monH);
|
||||||
|
// 화면
|
||||||
|
ctx.fillStyle = theme.furniture.monitorScreen;
|
||||||
|
ctx.fillRect(monX + ts * 0.05, y - monH + ts * 0.25, monW - ts * 0.1, monH - ts * 0.1);
|
||||||
|
// 모니터 받침대
|
||||||
|
ctx.fillStyle = theme.furniture.monitor;
|
||||||
|
ctx.fillRect(monX + monW * 0.35, y + ts * 0.2 - ts * 0.05, monW * 0.3, ts * 0.08);
|
||||||
|
monX += monW + ts * 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 의자 (책상 아래)
|
||||||
|
ctx.fillStyle = theme.furniture.chair;
|
||||||
|
ctx.fillRect(x + dw * 0.35, y + ts, dw * 0.3, ts * 0.5);
|
||||||
|
ctx.fillRect(x + dw * 0.3, y + ts * 0.8, dw * 0.4, ts * 0.25);
|
||||||
|
|
||||||
|
// 에이전트별 악센트 소품
|
||||||
|
if (f.accent === 'instrument') {
|
||||||
|
// 음표 모양
|
||||||
|
ctx.fillStyle = theme.ui.accent;
|
||||||
|
ctx.fillRect(x + dw + ts * 0.2, y + ts * 0.3, ts * 0.1, ts * 0.5);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x + dw + ts * 0.2, y + ts * 0.8, ts * 0.15, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
} else if (f.accent === 'papers') {
|
||||||
|
// 서류 더미
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(x + dw + ts * 0.1, y + ts * 0.3, ts * 0.35, ts * 0.45);
|
||||||
|
ctx.fillStyle = theme.text.label;
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
ctx.fillRect(x + dw + ts * 0.15, y + ts * 0.38 + i * ts * 0.1, ts * 0.25, ts * 0.02);
|
||||||
|
}
|
||||||
|
} else if (f.accent === 'briefcase') {
|
||||||
|
ctx.fillStyle = '#8B4513';
|
||||||
|
ctx.fillRect(x + dw + ts * 0.1, y + ts * 0.5, ts * 0.4, ts * 0.3);
|
||||||
|
ctx.fillStyle = '#D4A06A';
|
||||||
|
ctx.fillRect(x + dw + ts * 0.2, y + ts * 0.45, ts * 0.2, ts * 0.08);
|
||||||
|
} else if (f.accent === 'dice') {
|
||||||
|
ctx.fillStyle = '#ef4444';
|
||||||
|
ctx.fillRect(x + dw + ts * 0.15, y + ts * 0.4, ts * 0.3, ts * 0.3);
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x + dw + ts * 0.3, y + ts * 0.55, ts * 0.05, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawMeetingTable(ctx, f, theme, ts, x, y) {
|
||||||
|
const w = (f.width || 4) * ts;
|
||||||
|
const h = (f.height || 2) * ts;
|
||||||
|
// 테이블 상판
|
||||||
|
ctx.fillStyle = theme.furniture.table;
|
||||||
|
ctx.fillRect(x + ts * 0.1, y + ts * 0.1, w - ts * 0.2, h - ts * 0.2);
|
||||||
|
// 테이블 그림자
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.15)';
|
||||||
|
ctx.fillRect(x + ts * 0.15, y + h - ts * 0.1, w - ts * 0.25, ts * 0.1);
|
||||||
|
// 의자들 (상하 4개씩)
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const cx = x + ts * 0.5 + i * (w - ts) / 3;
|
||||||
|
ctx.fillStyle = theme.furniture.chair;
|
||||||
|
ctx.fillRect(cx, y - ts * 0.3, ts * 0.4, ts * 0.35);
|
||||||
|
ctx.fillRect(cx, y + h - ts * 0.05, ts * 0.4, ts * 0.35);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawSofa(ctx, theme, ts, x, y) {
|
||||||
|
ctx.fillStyle = theme.furniture.sofa;
|
||||||
|
ctx.fillRect(x, y, ts * 2, ts * 0.8);
|
||||||
|
// 등받이
|
||||||
|
ctx.fillStyle = theme.furniture.sofa;
|
||||||
|
ctx.fillRect(x, y - ts * 0.3, ts * 2, ts * 0.35);
|
||||||
|
// 쿠션 구분선
|
||||||
|
ctx.strokeStyle = theme.wall.border;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + ts, y);
|
||||||
|
ctx.lineTo(x + ts, y + ts * 0.8);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawCoffeeMachine(ctx, theme, ts, x, y) {
|
||||||
|
ctx.fillStyle = theme.furniture.coffee;
|
||||||
|
ctx.fillRect(x + ts * 0.15, y, ts * 0.7, ts * 0.8);
|
||||||
|
// 디스펜서
|
||||||
|
ctx.fillStyle = theme.furniture.monitor;
|
||||||
|
ctx.fillRect(x + ts * 0.25, y + ts * 0.15, ts * 0.5, ts * 0.3);
|
||||||
|
// 커피 잔
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(x + ts * 0.3, y + ts * 0.55, ts * 0.2, ts * 0.15);
|
||||||
|
// 스팀
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + ts * 0.4, y + ts * 0.5);
|
||||||
|
ctx.quadraticCurveTo(x + ts * 0.45, y + ts * 0.35, x + ts * 0.4, y + ts * 0.2);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawBookshelf(ctx, f, theme, ts, x, y) {
|
||||||
|
const h = (f.height || 3) * ts;
|
||||||
|
ctx.fillStyle = theme.furniture.shelf;
|
||||||
|
ctx.fillRect(x, y, ts * 0.9, h);
|
||||||
|
// 선반 및 책
|
||||||
|
const bookColors = ['#aa4444', '#4444aa', '#44aa44', '#aaaa44', '#aa44aa', '#44aaaa'];
|
||||||
|
const shelfCount = f.height || 3;
|
||||||
|
for (let i = 0; i < shelfCount; i++) {
|
||||||
|
const sy = y + i * ts + ts * 0.1;
|
||||||
|
// 선반 판
|
||||||
|
ctx.fillStyle = theme.furniture.shelf;
|
||||||
|
ctx.fillRect(x, sy + ts * 0.7, ts * 0.9, ts * 0.05);
|
||||||
|
// 책들
|
||||||
|
for (let b = 0; b < 4; b++) {
|
||||||
|
ctx.fillStyle = bookColors[(i * 4 + b) % bookColors.length];
|
||||||
|
ctx.fillRect(x + ts * 0.05 + b * ts * 0.2, sy + ts * 0.1, ts * 0.15, ts * 0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawPlant(ctx, theme, ts, x, y) {
|
||||||
|
// 화분
|
||||||
|
ctx.fillStyle = theme.decor.pot;
|
||||||
|
ctx.fillRect(x + ts * 0.25, y + ts * 0.6, ts * 0.5, ts * 0.35);
|
||||||
|
ctx.fillRect(x + ts * 0.2, y + ts * 0.55, ts * 0.6, ts * 0.1);
|
||||||
|
// 잎
|
||||||
|
ctx.fillStyle = theme.decor.plant;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(x + ts * 0.5, y + ts * 0.35, ts * 0.3, ts * 0.25, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(x + ts * 0.35, y + ts * 0.25, ts * 0.15, ts * 0.2, -0.3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(x + ts * 0.65, y + ts * 0.25, ts * 0.15, ts * 0.2, 0.3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawWaterCooler(ctx, theme, ts, x, y) {
|
||||||
|
// 본체
|
||||||
|
ctx.fillStyle = theme.furniture.shelf;
|
||||||
|
ctx.fillRect(x + ts * 0.2, y + ts * 0.3, ts * 0.6, ts * 0.6);
|
||||||
|
// 물통
|
||||||
|
ctx.fillStyle = 'rgba(100,180,255,0.5)';
|
||||||
|
ctx.fillRect(x + ts * 0.3, y, ts * 0.4, ts * 0.35);
|
||||||
|
ctx.fillStyle = 'rgba(100,180,255,0.3)';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x + ts * 0.5, y, ts * 0.2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
316
src/pages/agent-office/canvas/OfficeRenderer.js
Normal file
316
src/pages/agent-office/canvas/OfficeRenderer.js
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
// src/pages/agent-office/canvas/OfficeRenderer.js
|
||||||
|
|
||||||
|
import mapData from '../assets/office-map.json';
|
||||||
|
import { TileMap } from './TileMap.js';
|
||||||
|
import { FurnitureRenderer } from './FurnitureRenderer.js';
|
||||||
|
import { Pathfinder } from './Pathfinder.js';
|
||||||
|
import { AgentSprite } from './AgentSprite.js';
|
||||||
|
import { OverlayRenderer } from './OverlayRenderer.js';
|
||||||
|
import { getTheme } from './themes.js';
|
||||||
|
|
||||||
|
const AGENT_META = {
|
||||||
|
stock: { displayName: '주식 트레이더', emoji: '📈', color: '#4488cc' },
|
||||||
|
music: { displayName: '음악 프로듀서', emoji: '🎵', color: '#44aa88' },
|
||||||
|
blog: { displayName: '블로그 마케터', emoji: '✍️', color: '#d97706' },
|
||||||
|
realestate: { displayName: '청약 애널리스트', emoji: '🏢', color: '#c026d3' },
|
||||||
|
lotto: { displayName: '로또 큐레이터', emoji: '🎱', color: '#ef4444' }
|
||||||
|
};
|
||||||
|
|
||||||
|
export class OfficeRenderer {
|
||||||
|
constructor(canvas) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// 맵 & 렌더러
|
||||||
|
this.tileMap = new TileMap(mapData);
|
||||||
|
this.furnitureRenderer = new FurnitureRenderer(mapData.furniture, mapData.tileSize);
|
||||||
|
this.pathfinder = new Pathfinder(mapData.cols, mapData.rows);
|
||||||
|
this.overlayRenderer = new OverlayRenderer();
|
||||||
|
|
||||||
|
// blocked 타일 설정
|
||||||
|
this.pathfinder.setWalls(mapData.floor);
|
||||||
|
this.pathfinder.setBlocked(mapData.blocked);
|
||||||
|
|
||||||
|
// 테마 & 뷰포트
|
||||||
|
this.theme = getTheme(
|
||||||
|
(typeof localStorage !== 'undefined' && localStorage.getItem('agent-office-theme')) || 'modern'
|
||||||
|
);
|
||||||
|
this.zoom = 2;
|
||||||
|
this.panX = 0;
|
||||||
|
this.panY = 0;
|
||||||
|
this._isPanning = false;
|
||||||
|
this._panStart = { x: 0, y: 0 };
|
||||||
|
|
||||||
|
// 에이전트
|
||||||
|
this.agents = new Map();
|
||||||
|
this._initAgents();
|
||||||
|
|
||||||
|
// 게임 루프
|
||||||
|
this._lastTime = 0;
|
||||||
|
this._animId = null;
|
||||||
|
this._lastDpr = window.devicePixelRatio || 1;
|
||||||
|
|
||||||
|
// 드래그 감지
|
||||||
|
this._mouseDownPos = { x: 0, y: 0 };
|
||||||
|
this._wasDragging = false;
|
||||||
|
|
||||||
|
// 이벤트
|
||||||
|
this._setupInputHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
_initAgents() {
|
||||||
|
for (const [id, meta] of Object.entries(AGENT_META)) {
|
||||||
|
const waypoint = mapData.waypoints[`desk_${id}`];
|
||||||
|
if (!waypoint) continue;
|
||||||
|
const sprite = new AgentSprite(id, meta, waypoint.col, waypoint.row, this.pathfinder);
|
||||||
|
sprite.deskCol = waypoint.col;
|
||||||
|
sprite.deskRow = waypoint.row;
|
||||||
|
this.agents.set(id, sprite);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 줌/팬/클릭 이벤트 핸들러 */
|
||||||
|
_setupInputHandlers() {
|
||||||
|
// 마우스 휠 줌
|
||||||
|
this.canvas.addEventListener('wheel', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const oldZoom = this.zoom;
|
||||||
|
if (e.deltaY < 0) {
|
||||||
|
this.zoom = Math.min(this.zoom + 0.5, 4);
|
||||||
|
} else {
|
||||||
|
this.zoom = Math.max(this.zoom - 0.5, 1);
|
||||||
|
}
|
||||||
|
// 마우스 위치 기준 줌
|
||||||
|
if (this.zoom !== oldZoom) {
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const mx = e.clientX - rect.left;
|
||||||
|
const my = e.clientY - rect.top;
|
||||||
|
const ratio = this.zoom / oldZoom;
|
||||||
|
this.panX = mx - (mx - this.panX) * ratio;
|
||||||
|
this.panY = my - (my - this.panY) * ratio;
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
// 마우스 드래그 패닝
|
||||||
|
this.canvas.addEventListener('mousedown', (e) => {
|
||||||
|
if (e.button === 0) {
|
||||||
|
this._isPanning = true;
|
||||||
|
this._panStart = { x: e.clientX - this.panX, y: e.clientY - this.panY };
|
||||||
|
this._mouseDownPos = { x: e.clientX, y: e.clientY };
|
||||||
|
this._wasDragging = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this._onMouseMove = (e) => {
|
||||||
|
if (this._isPanning) {
|
||||||
|
this.panX = e.clientX - this._panStart.x;
|
||||||
|
this.panY = e.clientY - this._panStart.y;
|
||||||
|
const dx = e.clientX - this._mouseDownPos.x;
|
||||||
|
const dy = e.clientY - this._mouseDownPos.y;
|
||||||
|
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) this._wasDragging = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this._onMouseUp = () => {
|
||||||
|
this._isPanning = false;
|
||||||
|
};
|
||||||
|
window.addEventListener('mousemove', this._onMouseMove);
|
||||||
|
window.addEventListener('mouseup', this._onMouseUp);
|
||||||
|
|
||||||
|
// 터치 (모바일)
|
||||||
|
let lastTouchDist = 0;
|
||||||
|
let lastTouchCenter = { x: 0, y: 0 };
|
||||||
|
this.canvas.addEventListener('touchstart', (e) => {
|
||||||
|
if (e.touches.length === 1) {
|
||||||
|
this._isPanning = true;
|
||||||
|
this._panStart = { x: e.touches[0].clientX - this.panX, y: e.touches[0].clientY - this.panY };
|
||||||
|
} else if (e.touches.length === 2) {
|
||||||
|
this._isPanning = false;
|
||||||
|
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||||
|
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||||
|
lastTouchDist = Math.hypot(dx, dy);
|
||||||
|
lastTouchCenter = {
|
||||||
|
x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
|
||||||
|
y: (e.touches[0].clientY + e.touches[1].clientY) / 2
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
this.canvas.addEventListener('touchmove', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.touches.length === 1 && this._isPanning) {
|
||||||
|
this.panX = e.touches[0].clientX - this._panStart.x;
|
||||||
|
this.panY = e.touches[0].clientY - this._panStart.y;
|
||||||
|
} else if (e.touches.length === 2) {
|
||||||
|
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||||
|
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||||
|
const dist = Math.hypot(dx, dy);
|
||||||
|
const oldZoom = this.zoom;
|
||||||
|
this.zoom = Math.min(4, Math.max(1, this.zoom * (dist / lastTouchDist)));
|
||||||
|
lastTouchDist = dist;
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const cx = lastTouchCenter.x - rect.left;
|
||||||
|
const cy = lastTouchCenter.y - rect.top;
|
||||||
|
const ratio = this.zoom / oldZoom;
|
||||||
|
this.panX = cx - (cx - this.panX) * ratio;
|
||||||
|
this.panY = cy - (cy - this.panY) * ratio;
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
this.canvas.addEventListener('touchend', () => {
|
||||||
|
this._isPanning = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 클릭 히트 테스트 — AgentOffice에서 호출 */
|
||||||
|
hitTest(clientX, clientY) {
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const screenX = clientX - rect.left;
|
||||||
|
const screenY = clientY - rect.top;
|
||||||
|
const { col, row } = this.tileMap.screenToTile(screenX, screenY, this.zoom, this.panX, this.panY);
|
||||||
|
|
||||||
|
// 에이전트 히트 (역순, 최상위 우선)
|
||||||
|
for (const [id, sprite] of [...this.agents.entries()].reverse()) {
|
||||||
|
const dx = Math.abs(sprite.x - col);
|
||||||
|
const dy = Math.abs(sprite.y - row);
|
||||||
|
if (dx < 1.2 && dy < 1.5) {
|
||||||
|
return { type: 'agent', id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { type: 'empty' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 에이전트 상태 업데이트 (WebSocket에서 호출) */
|
||||||
|
updateAgentState(agentId, state, detail) {
|
||||||
|
const sprite = this.agents.get(agentId);
|
||||||
|
if (!sprite) return;
|
||||||
|
sprite.onStateChange(state, detail, mapData.waypoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 에이전트 알림 배지 설정 */
|
||||||
|
setAgentNotification(agentId, count) {
|
||||||
|
const sprite = this.agents.get(agentId);
|
||||||
|
if (sprite) sprite.notificationCount = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 테마 변경 */
|
||||||
|
setTheme(themeName) {
|
||||||
|
this.theme = getTheme(themeName);
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.setItem('agent-office-theme', themeName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 줌 레벨 설정 */
|
||||||
|
setZoom(level) {
|
||||||
|
const cx = this.canvas.width / 2;
|
||||||
|
const cy = this.canvas.height / 2;
|
||||||
|
const oldZoom = this.zoom;
|
||||||
|
this.zoom = Math.min(4, Math.max(1, level));
|
||||||
|
const ratio = this.zoom / oldZoom;
|
||||||
|
this.panX = cx - (cx - this.panX) * ratio;
|
||||||
|
this.panY = cy - (cy - this.panY) * ratio;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 카메라를 맵 중앙에 맞추기 */
|
||||||
|
centerCamera() {
|
||||||
|
const mapW = mapData.cols * mapData.tileSize * this.zoom;
|
||||||
|
const mapH = mapData.rows * mapData.tileSize * this.zoom;
|
||||||
|
this.panX = (this.canvas.clientWidth - mapW) / 2;
|
||||||
|
this.panY = (this.canvas.clientHeight - mapH) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 게임 루프 시작 */
|
||||||
|
start() {
|
||||||
|
this.centerCamera();
|
||||||
|
this._lastTime = performance.now();
|
||||||
|
this._loop(this._lastTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 게임 루프 중지 */
|
||||||
|
stop() {
|
||||||
|
if (this._animId) {
|
||||||
|
cancelAnimationFrame(this._animId);
|
||||||
|
this._animId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_loop(timestamp) {
|
||||||
|
const dt = Math.min((timestamp - this._lastTime) / 1000, 0.1); // cap dt to avoid spiral
|
||||||
|
this._lastTime = timestamp;
|
||||||
|
|
||||||
|
this._update(dt);
|
||||||
|
this._render();
|
||||||
|
|
||||||
|
this._animId = requestAnimationFrame((t) => this._loop(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
_update(dt) {
|
||||||
|
for (const sprite of this.agents.values()) {
|
||||||
|
sprite.update(dt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_render() {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
|
||||||
|
// 캔버스 크기 조정
|
||||||
|
const displayW = this.canvas.clientWidth;
|
||||||
|
const displayH = this.canvas.clientHeight;
|
||||||
|
if (this.canvas.width !== displayW * dpr || this.canvas.height !== displayH * dpr || this._lastDpr !== dpr) {
|
||||||
|
this.canvas.width = displayW * dpr;
|
||||||
|
this.canvas.height = displayH * dpr;
|
||||||
|
this._lastDpr = dpr;
|
||||||
|
}
|
||||||
|
// setTransform 방식으로 누적 없이 항상 올바른 변환 적용
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
ctx.clearRect(0, 0, displayW, displayH);
|
||||||
|
|
||||||
|
// 배경
|
||||||
|
ctx.fillStyle = this.theme.wall.color;
|
||||||
|
ctx.fillRect(0, 0, displayW, displayH);
|
||||||
|
|
||||||
|
// 1. 타일맵 (바닥 + 벽)
|
||||||
|
this.tileMap.render(ctx, this.theme, this.zoom, this.panX, this.panY);
|
||||||
|
|
||||||
|
// 2. Y-sorted: 가구 + 에이전트
|
||||||
|
const renderables = [];
|
||||||
|
|
||||||
|
// 가구
|
||||||
|
const furnitureItems = this.furnitureRenderer.getRenderables(this.theme, this.zoom, this.panX, this.panY);
|
||||||
|
renderables.push(...furnitureItems);
|
||||||
|
|
||||||
|
// 에이전트
|
||||||
|
for (const sprite of this.agents.values()) {
|
||||||
|
renderables.push({
|
||||||
|
zY: sprite.y,
|
||||||
|
draw: (ctx2) => sprite.draw(ctx2, this.zoom, this.panX, this.panY, mapData.tileSize)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Y좌표 정렬
|
||||||
|
renderables.sort((a, b) => a.zY - b.zY);
|
||||||
|
for (const item of renderables) {
|
||||||
|
item.draw(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 오버레이 (항상 최상위)
|
||||||
|
for (const sprite of this.agents.values()) {
|
||||||
|
this.overlayRenderer.draw(ctx, sprite, this.theme, this.zoom, this.panX, this.panY, mapData.tileSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 드래그 여부 반환 (클릭 이벤트 필터링용) */
|
||||||
|
wasDragging() { return this._wasDragging; }
|
||||||
|
|
||||||
|
/** 리사이즈 처리 */
|
||||||
|
resize() {
|
||||||
|
// 다음 프레임에서 자동 조정됨 (_render에서 크기 체크)
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.stop();
|
||||||
|
// window 이벤트 리스너 정리
|
||||||
|
if (this._onMouseMove) window.removeEventListener('mousemove', this._onMouseMove);
|
||||||
|
if (this._onMouseUp) window.removeEventListener('mouseup', this._onMouseUp);
|
||||||
|
}
|
||||||
|
}
|
||||||
122
src/pages/agent-office/canvas/OverlayRenderer.js
Normal file
122
src/pages/agent-office/canvas/OverlayRenderer.js
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
// src/pages/agent-office/canvas/OverlayRenderer.js
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캔버스 위 오버레이 렌더링:
|
||||||
|
* - 이름 라벨 (항상)
|
||||||
|
* - 상태 배지 (항상)
|
||||||
|
* - 말풍선 (waiting 상태에서만)
|
||||||
|
* - 알림 배지 (notification > 0 일 때)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STATE_BADGE = {
|
||||||
|
idle: { text: 'idle', bg: '#374151', fg: '#9ca3af' },
|
||||||
|
working: { text: 'working', bg: '#1e3a5f', fg: '#60a5fa' },
|
||||||
|
waiting: { text: 'waiting', bg: '#92400e', fg: '#fbbf24' },
|
||||||
|
reporting: { text: 'reporting', bg: '#1e3a5f', fg: '#60a5fa' },
|
||||||
|
break: { text: 'break', bg: '#065f46', fg: '#34d399' }
|
||||||
|
};
|
||||||
|
|
||||||
|
export class OverlayRenderer {
|
||||||
|
constructor() {
|
||||||
|
this._bubbleAlpha = new Map(); // agentId → alpha (fade in/out)
|
||||||
|
}
|
||||||
|
|
||||||
|
draw(ctx, sprite, theme, zoom, panX, panY, tileSize) {
|
||||||
|
const ts = tileSize * zoom;
|
||||||
|
const centerX = sprite.x * ts + panX + ts / 2;
|
||||||
|
const topY = sprite.y * ts + panY - ts * 0.3;
|
||||||
|
|
||||||
|
const fontSize = Math.max(10, 11 * zoom / 2);
|
||||||
|
const smallFontSize = Math.max(8, 9 * zoom / 2);
|
||||||
|
|
||||||
|
// 1. 이름 라벨
|
||||||
|
ctx.font = `bold ${fontSize}px 'Courier New', monospace`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillStyle = sprite.meta.color;
|
||||||
|
ctx.fillText(sprite.meta.displayName, centerX, topY + ts * 1.85);
|
||||||
|
|
||||||
|
// 2. 상태 배지
|
||||||
|
const badge = STATE_BADGE[sprite.state] || STATE_BADGE.idle;
|
||||||
|
const badgeText = badge.text;
|
||||||
|
ctx.font = `${smallFontSize}px 'Courier New', monospace`;
|
||||||
|
const badgeW = ctx.measureText(badgeText).width + 8;
|
||||||
|
const badgeH = smallFontSize + 4;
|
||||||
|
const badgeX = centerX - badgeW / 2;
|
||||||
|
const badgeY = topY + ts * 1.9;
|
||||||
|
|
||||||
|
ctx.fillStyle = badge.bg;
|
||||||
|
this._roundRect(ctx, badgeX, badgeY, badgeW, badgeH, 3);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = badge.fg;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(badgeText, centerX, badgeY + badgeH - 3);
|
||||||
|
|
||||||
|
// 3. 말풍선 (waiting 상태에서만)
|
||||||
|
if (sprite.state === 'waiting') {
|
||||||
|
this._drawBubble(ctx, sprite, centerX, topY - ts * 0.2, zoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 알림 배지
|
||||||
|
if (sprite.notificationCount > 0) {
|
||||||
|
this._drawNotificationBadge(ctx, centerX + ts * 0.5, topY + ts * 0.2, sprite.notificationCount, zoom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawBubble(ctx, sprite, x, y, zoom) {
|
||||||
|
const text = '승인 대기!';
|
||||||
|
const fontSize = Math.max(10, 11 * zoom / 2);
|
||||||
|
ctx.font = `bold ${fontSize}px 'Courier New', monospace`;
|
||||||
|
const tw = ctx.measureText(text).width;
|
||||||
|
const pw = tw + 16;
|
||||||
|
const ph = fontSize + 12;
|
||||||
|
const px = x - pw / 2;
|
||||||
|
const py = y - ph;
|
||||||
|
|
||||||
|
// 말풍선 배경
|
||||||
|
ctx.fillStyle = '#fbbf24';
|
||||||
|
this._roundRect(ctx, px, py, pw, ph, 6);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 꼬리 삼각형
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x - 5, py + ph);
|
||||||
|
ctx.lineTo(x + 5, py + ph);
|
||||||
|
ctx.lineTo(x, py + ph + 6);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 텍스트
|
||||||
|
ctx.fillStyle = '#000000';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(text, x, py + ph - 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawNotificationBadge(ctx, x, y, count, zoom) {
|
||||||
|
const r = Math.max(7, 8 * zoom / 2);
|
||||||
|
ctx.fillStyle = '#ef4444';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = `bold ${r}px sans-serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(count > 9 ? '9+' : String(count), x, y);
|
||||||
|
ctx.textBaseline = 'alphabetic';
|
||||||
|
}
|
||||||
|
|
||||||
|
_roundRect(ctx, x, y, w, h, r) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + r, y);
|
||||||
|
ctx.lineTo(x + w - r, y);
|
||||||
|
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
||||||
|
ctx.lineTo(x + w, y + h - r);
|
||||||
|
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||||||
|
ctx.lineTo(x + r, y + h);
|
||||||
|
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
||||||
|
ctx.lineTo(x, y + r);
|
||||||
|
ctx.quadraticCurveTo(x, y, x + r, y);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
}
|
||||||
112
src/pages/agent-office/canvas/Pathfinder.js
Normal file
112
src/pages/agent-office/canvas/Pathfinder.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
// src/pages/agent-office/canvas/Pathfinder.js
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BFS 4방향 경로 탐색 (대각선 없음)
|
||||||
|
* blocked 타일과 벽 타일을 회피하여 최단 경로 반환
|
||||||
|
*/
|
||||||
|
export class Pathfinder {
|
||||||
|
constructor(cols, rows) {
|
||||||
|
this.cols = cols;
|
||||||
|
this.rows = rows;
|
||||||
|
this.blocked = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** blocked 타일 세팅 (wall + furniture footprint) */
|
||||||
|
setBlocked(blockedList) {
|
||||||
|
// Do NOT clear — setWalls already added wall tiles
|
||||||
|
for (const [col, row] of blockedList) {
|
||||||
|
this.blocked.add(`${col},${row}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** wall 타일도 blocked로 추가 (floor 배열에서 0인 셀) */
|
||||||
|
setWalls(floorGrid) {
|
||||||
|
for (let r = 0; r < this.rows; r++) {
|
||||||
|
for (let c = 0; c < this.cols; c++) {
|
||||||
|
if (floorGrid[r][c] === 0) {
|
||||||
|
this.blocked.add(`${c},${r}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isBlocked(col, row) {
|
||||||
|
if (col < 0 || col >= this.cols || row < 0 || row >= this.rows) return true;
|
||||||
|
return this.blocked.has(`${col},${row}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BFS 최단 경로
|
||||||
|
* @returns {Array<{col, row}>} 시작점 제외, 도착점 포함 경로. 경로 없으면 빈 배열.
|
||||||
|
*/
|
||||||
|
findPath(startCol, startRow, goalCol, goalRow) {
|
||||||
|
if (startCol === goalCol && startRow === goalRow) return [];
|
||||||
|
|
||||||
|
const key = (c, r) => `${c},${r}`;
|
||||||
|
const startKey = key(startCol, startRow);
|
||||||
|
const goalKey = key(goalCol, goalRow);
|
||||||
|
|
||||||
|
const queue = [{ col: startCol, row: startRow }];
|
||||||
|
const visited = new Set([startKey]);
|
||||||
|
const parent = new Map();
|
||||||
|
|
||||||
|
const dirs = [
|
||||||
|
{ dc: 0, dr: -1 }, // up
|
||||||
|
{ dc: 0, dr: 1 }, // down
|
||||||
|
{ dc: -1, dr: 0 }, // left
|
||||||
|
{ dc: 1, dr: 0 } // right
|
||||||
|
];
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const current = queue.shift();
|
||||||
|
|
||||||
|
for (const { dc, dr } of dirs) {
|
||||||
|
const nc = current.col + dc;
|
||||||
|
const nr = current.row + dr;
|
||||||
|
const nk = key(nc, nr);
|
||||||
|
|
||||||
|
if (visited.has(nk)) continue;
|
||||||
|
// 골 지점은 blocked여도 이동 가능 (에이전트가 자기 자리에 앉으려면)
|
||||||
|
if (nk !== goalKey && this.isBlocked(nc, nr)) continue;
|
||||||
|
|
||||||
|
visited.add(nk);
|
||||||
|
parent.set(nk, key(current.col, current.row));
|
||||||
|
queue.push({ col: nc, row: nr });
|
||||||
|
|
||||||
|
if (nc === goalCol && nr === goalRow) {
|
||||||
|
return this._reconstructPath(parent, startKey, goalKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return []; // 경로 없음
|
||||||
|
}
|
||||||
|
|
||||||
|
_reconstructPath(parent, startKey, goalKey) {
|
||||||
|
const path = [];
|
||||||
|
let current = goalKey;
|
||||||
|
while (current !== startKey) {
|
||||||
|
const [c, r] = current.split(',').map(Number);
|
||||||
|
path.unshift({ col: c, row: r });
|
||||||
|
current = parent.get(current);
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** idle 배회용: start 주변 반경 내 랜덤 walkable 타일 */
|
||||||
|
getRandomNearbyFloor(col, row, radius = 4) {
|
||||||
|
const candidates = [];
|
||||||
|
for (let dr = -radius; dr <= radius; dr++) {
|
||||||
|
for (let dc = -radius; dc <= radius; dc++) {
|
||||||
|
const nc = col + dc;
|
||||||
|
const nr = row + dr;
|
||||||
|
if (nc === col && nr === row) continue;
|
||||||
|
if (!this.isBlocked(nc, nr)) {
|
||||||
|
candidates.push({ col: nc, row: nr });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
return candidates[Math.floor(Math.random() * candidates.length)];
|
||||||
|
}
|
||||||
|
}
|
||||||
164
src/pages/agent-office/canvas/ProceduralSprite.js
Normal file
164
src/pages/agent-office/canvas/ProceduralSprite.js
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
// src/pages/agent-office/canvas/ProceduralSprite.js
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로시저럴 픽셀 캐릭터 렌더러 (16×32px 기본 해상도)
|
||||||
|
* Phase 1: 코드로 캐릭터를 그림
|
||||||
|
* Phase 2: SpriteLoader가 PNG 스프라이트로 대체
|
||||||
|
*/
|
||||||
|
|
||||||
|
const AGENT_COLORS = {
|
||||||
|
stock: { body: '#4488cc', hair: '#2255aa', accent: '#66aaee' },
|
||||||
|
music: { body: '#44aa88', hair: '#228866', accent: '#66ccaa' },
|
||||||
|
blog: { body: '#d97706', hair: '#b45e04', accent: '#f59e0b' },
|
||||||
|
realestate: { body: '#c026d3', hair: '#9b1dab', accent: '#e044f0' },
|
||||||
|
lotto: { body: '#ef4444', hair: '#cc2222', accent: '#ff6666' }
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 애니메이션 프레임 설정 */
|
||||||
|
const ANIM_CONFIG = {
|
||||||
|
idle: { frames: 2, speed: 0.8 },
|
||||||
|
walk: { frames: 4, speed: 0.15, cycle: [0, 1, 2, 1] },
|
||||||
|
type: { frames: 2, speed: 0.3 },
|
||||||
|
wait: { frames: 2, speed: 0.5 },
|
||||||
|
break_anim:{ frames: 2, speed: 1.0 }
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ProceduralSprite {
|
||||||
|
/**
|
||||||
|
* 캐릭터 1프레임 렌더링
|
||||||
|
* @param {CanvasRenderingContext2D} ctx
|
||||||
|
* @param {string} agentId
|
||||||
|
* @param {string} state - idle|walk|type|wait|break_anim
|
||||||
|
* @param {string} direction - down|up|right|left
|
||||||
|
* @param {number} frame - 현재 애니메이션 프레임 인덱스
|
||||||
|
* @param {number} x - 캔버스 X 좌표 (캐릭터 중앙 하단)
|
||||||
|
* @param {number} y - 캔버스 Y 좌표 (캐릭터 중앙 하단)
|
||||||
|
* @param {number} scale - 렌더링 스케일
|
||||||
|
*/
|
||||||
|
static draw(ctx, agentId, state, direction, frame, x, y, scale) {
|
||||||
|
const colors = AGENT_COLORS[agentId] || AGENT_COLORS.stock;
|
||||||
|
const px = scale; // 1 pixel = scale 크기
|
||||||
|
const w = 16 * px;
|
||||||
|
const h = 32 * px;
|
||||||
|
const bx = x - w / 2; // 좌상단 기준
|
||||||
|
const by = y - h;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
|
||||||
|
// 좌우 반전 (left = right 플립)
|
||||||
|
if (direction === 'left') {
|
||||||
|
ctx.translate(x, 0);
|
||||||
|
ctx.scale(-1, 1);
|
||||||
|
ctx.translate(-x, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그림자
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.2)';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(x, y, w * 0.35, px * 2, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 상태별 오프셋
|
||||||
|
let bodyOffsetY = 0;
|
||||||
|
let legSpread = 0;
|
||||||
|
let armAngle = 0;
|
||||||
|
|
||||||
|
if (state === 'walk') {
|
||||||
|
const walkFrame = ANIM_CONFIG.walk.cycle[frame % 4];
|
||||||
|
legSpread = (walkFrame - 1) * px * 2;
|
||||||
|
bodyOffsetY = walkFrame === 1 ? -px : 0;
|
||||||
|
} else if (state === 'type') {
|
||||||
|
armAngle = frame % 2 === 0 ? 1 : -1;
|
||||||
|
bodyOffsetY = frame % 2 === 0 ? 0 : -px * 0.5;
|
||||||
|
} else if (state === 'wait') {
|
||||||
|
bodyOffsetY = Math.sin(frame * Math.PI) * px;
|
||||||
|
} else if (state === 'idle') {
|
||||||
|
bodyOffsetY = frame % 2 === 0 ? 0 : -px * 0.5;
|
||||||
|
} else if (state === 'break_anim') {
|
||||||
|
bodyOffsetY = frame % 2 === 0 ? 0 : px * 0.5; // 졸기
|
||||||
|
}
|
||||||
|
|
||||||
|
const by2 = by + bodyOffsetY;
|
||||||
|
|
||||||
|
// 다리
|
||||||
|
ctx.fillStyle = '#2a2a3e';
|
||||||
|
// 왼쪽 다리
|
||||||
|
ctx.fillRect(bx + px * 4 - legSpread, by2 + px * 24, px * 3, px * 8);
|
||||||
|
// 오른쪽 다리
|
||||||
|
ctx.fillRect(bx + px * 9 + legSpread, by2 + px * 24, px * 3, px * 8);
|
||||||
|
// 신발
|
||||||
|
ctx.fillStyle = '#333';
|
||||||
|
ctx.fillRect(bx + px * 3 - legSpread, by2 + px * 30, px * 5, px * 2);
|
||||||
|
ctx.fillRect(bx + px * 8 + legSpread, by2 + px * 30, px * 5, px * 2);
|
||||||
|
|
||||||
|
// 몸통
|
||||||
|
ctx.fillStyle = colors.body;
|
||||||
|
ctx.fillRect(bx + px * 3, by2 + px * 12, px * 10, px * 13);
|
||||||
|
|
||||||
|
// 팔
|
||||||
|
if (state === 'type') {
|
||||||
|
// 타이핑: 팔 앞으로 뻗음
|
||||||
|
ctx.fillStyle = colors.body;
|
||||||
|
ctx.fillRect(bx + px * 1, by2 + px * 13, px * 3, px * 8 + armAngle * px);
|
||||||
|
ctx.fillRect(bx + px * 12, by2 + px * 13, px * 3, px * 8 - armAngle * px);
|
||||||
|
// 손
|
||||||
|
ctx.fillStyle = '#ffcc99';
|
||||||
|
ctx.fillRect(bx + px * 1, by2 + px * 20 + armAngle * px, px * 3, px * 3);
|
||||||
|
ctx.fillRect(bx + px * 12, by2 + px * 20 - armAngle * px, px * 3, px * 3);
|
||||||
|
} else {
|
||||||
|
// 기본 팔
|
||||||
|
ctx.fillStyle = colors.body;
|
||||||
|
ctx.fillRect(bx + px * 1, by2 + px * 13, px * 3, px * 10);
|
||||||
|
ctx.fillRect(bx + px * 12, by2 + px * 13, px * 3, px * 10);
|
||||||
|
// 손
|
||||||
|
ctx.fillStyle = '#ffcc99';
|
||||||
|
ctx.fillRect(bx + px * 1, by2 + px * 22, px * 3, px * 3);
|
||||||
|
ctx.fillRect(bx + px * 12, by2 + px * 22, px * 3, px * 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 머리
|
||||||
|
ctx.fillStyle = '#ffcc99'; // 피부색
|
||||||
|
ctx.fillRect(bx + px * 4, by2 + px * 2, px * 8, px * 10);
|
||||||
|
|
||||||
|
// 머리카락
|
||||||
|
ctx.fillStyle = colors.hair;
|
||||||
|
ctx.fillRect(bx + px * 3, by2 + px * 1, px * 10, px * 4);
|
||||||
|
if (direction === 'down' || direction === 'left' || direction === 'right') {
|
||||||
|
// 앞머리
|
||||||
|
ctx.fillRect(bx + px * 4, by2 + px * 3, px * 2, px * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 눈
|
||||||
|
if (direction !== 'up') {
|
||||||
|
ctx.fillStyle = '#222';
|
||||||
|
if (state === 'break_anim' && frame % 2 === 1) {
|
||||||
|
// 졸기: 눈 감음
|
||||||
|
ctx.fillRect(bx + px * 5, by2 + px * 6, px * 2, px);
|
||||||
|
ctx.fillRect(bx + px * 9, by2 + px * 6, px * 2, px);
|
||||||
|
} else {
|
||||||
|
ctx.fillRect(bx + px * 5, by2 + px * 5, px * 2, px * 2);
|
||||||
|
ctx.fillRect(bx + px * 9, by2 + px * 5, px * 2, px * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// break 소품: 커피잔
|
||||||
|
if (state === 'break_anim') {
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(bx + px * 14, by2 + px * 16, px * 3, px * 4);
|
||||||
|
ctx.fillStyle = '#8B4513';
|
||||||
|
ctx.fillRect(bx + px * 14.5, by2 + px * 16.5, px * 2, px * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
static getAnimConfig(state) {
|
||||||
|
const mapped = state === 'working' ? 'type'
|
||||||
|
: state === 'waiting' ? 'wait'
|
||||||
|
: state === 'reporting' ? 'type'
|
||||||
|
: state === 'break' ? 'break_anim'
|
||||||
|
: state === 'walk' ? 'walk'
|
||||||
|
: 'idle';
|
||||||
|
return { ...(ANIM_CONFIG[mapped] || ANIM_CONFIG.idle), mapped };
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/pages/agent-office/canvas/SpriteLoader.js
Normal file
77
src/pages/agent-office/canvas/SpriteLoader.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
// src/pages/agent-office/canvas/SpriteLoader.js
|
||||||
|
|
||||||
|
import { ProceduralSprite } from './ProceduralSprite.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스프라이트 로더 — PNG 스프라이트시트가 있으면 사용, 없으면 프로시저럴 폴백
|
||||||
|
*
|
||||||
|
* 스프라이트시트 규격 (Phase 2):
|
||||||
|
* - 프레임 크기: 16×32px
|
||||||
|
* - 행: 방향 (0=down, 1=up, 2=right)
|
||||||
|
* - 열: 상태별 프레임 (idle 2 | walk 4 | type 2 | wait 2 | break 2 = 12열)
|
||||||
|
*/
|
||||||
|
export class SpriteLoader {
|
||||||
|
constructor() {
|
||||||
|
this.sprites = new Map(); // agentId → { image: Image, loaded: boolean }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PNG 스프라이트시트 로드 시도 */
|
||||||
|
async tryLoad(agentId, url) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
this.sprites.set(agentId, { image: img, loaded: true });
|
||||||
|
resolve(true);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
resolve(false); // 폴백 사용
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSprite(agentId) {
|
||||||
|
return this.sprites.has(agentId) && this.sprites.get(agentId).loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 에이전트 1프레임 그리기 (스프라이트 또는 프로시저럴)
|
||||||
|
*/
|
||||||
|
draw(ctx, agentId, state, direction, frame, x, y, scale) {
|
||||||
|
if (this.hasSprite(agentId)) {
|
||||||
|
this._drawFromSheet(ctx, agentId, state, direction, frame, x, y, scale);
|
||||||
|
} else {
|
||||||
|
ProceduralSprite.draw(ctx, agentId, state, direction, frame, x, y, scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawFromSheet(ctx, agentId, state, direction, frame, x, y, scale) {
|
||||||
|
const { image } = this.sprites.get(agentId);
|
||||||
|
const frameW = 16;
|
||||||
|
const frameH = 32;
|
||||||
|
|
||||||
|
// 방향 → 행
|
||||||
|
const dirRow = direction === 'up' ? 1 : direction === 'right' || direction === 'left' ? 2 : 0;
|
||||||
|
|
||||||
|
// 상태 → 열 오프셋
|
||||||
|
const stateOffsets = { idle: 0, walk: 2, type: 6, wait: 8, break_anim: 10 };
|
||||||
|
const mappedState = state === 'working' ? 'type' : state === 'waiting' ? 'wait'
|
||||||
|
: state === 'reporting' ? 'type' : state === 'break' ? 'break_anim'
|
||||||
|
: state === 'walk' ? 'walk' : 'idle';
|
||||||
|
const colOffset = stateOffsets[mappedState] || 0;
|
||||||
|
|
||||||
|
const srcX = (colOffset + frame) * frameW;
|
||||||
|
const srcY = dirRow * frameH;
|
||||||
|
const destW = frameW * scale;
|
||||||
|
const destH = frameH * scale;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
if (direction === 'left') {
|
||||||
|
ctx.translate(x, 0);
|
||||||
|
ctx.scale(-1, 1);
|
||||||
|
ctx.translate(-x, 0);
|
||||||
|
}
|
||||||
|
ctx.drawImage(image, srcX, srcY, frameW, frameH, x - destW / 2, y - destH, destW, destH);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/pages/agent-office/canvas/TileMap.js
Normal file
80
src/pages/agent-office/canvas/TileMap.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// src/pages/agent-office/canvas/TileMap.js
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 타일맵 렌더러 — 바닥, 벽, 그리드를 테마 팔레트로 렌더링
|
||||||
|
* 가구는 FurnitureRenderer가 별도 처리
|
||||||
|
*/
|
||||||
|
export class TileMap {
|
||||||
|
constructor(mapData) {
|
||||||
|
this.cols = mapData.cols;
|
||||||
|
this.rows = mapData.rows;
|
||||||
|
this.tileSize = mapData.tileSize;
|
||||||
|
this.floor = mapData.floor;
|
||||||
|
this.tileTypes = mapData.tileTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 바닥 + 벽 렌더링
|
||||||
|
* @param {CanvasRenderingContext2D} ctx
|
||||||
|
* @param {object} theme - themes.js 에서 가져온 테마 객체
|
||||||
|
* @param {number} scale - 줌 레벨
|
||||||
|
* @param {number} offsetX - 패닝 X 오프셋
|
||||||
|
* @param {number} offsetY - 패닝 Y 오프셋
|
||||||
|
*/
|
||||||
|
render(ctx, theme, scale, offsetX, offsetY) {
|
||||||
|
const ts = this.tileSize * scale;
|
||||||
|
|
||||||
|
for (let r = 0; r < this.rows; r++) {
|
||||||
|
for (let c = 0; c < this.cols; c++) {
|
||||||
|
const tileType = this.floor[r][c];
|
||||||
|
const x = c * ts + offsetX;
|
||||||
|
const y = r * ts + offsetY;
|
||||||
|
|
||||||
|
// 화면 밖이면 스킵 (CSS 공간 기준 — DPR 변환 적용된 좌표계)
|
||||||
|
if (x + ts < 0 || y + ts < 0 || x > ctx.canvas.clientWidth || y > ctx.canvas.clientHeight) continue;
|
||||||
|
|
||||||
|
if (tileType === 0) {
|
||||||
|
// 벽
|
||||||
|
ctx.fillStyle = theme.wall.color;
|
||||||
|
ctx.fillRect(x, y, ts, ts);
|
||||||
|
// 벽 하단 경계선
|
||||||
|
ctx.fillStyle = theme.wall.border;
|
||||||
|
ctx.fillRect(x, y + ts - scale, ts, scale);
|
||||||
|
} else {
|
||||||
|
// 바닥
|
||||||
|
const isBreak = this.tileTypes[String(tileType)] === 'floor_break';
|
||||||
|
ctx.fillStyle = isBreak ? theme.floor.color2 : theme.floor.color1;
|
||||||
|
ctx.fillRect(x, y, ts, ts);
|
||||||
|
|
||||||
|
// 체커보드 패턴
|
||||||
|
if ((r + c) % 2 === 0) {
|
||||||
|
ctx.fillStyle = theme.floor.grid;
|
||||||
|
ctx.fillRect(x, y, ts, ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그리드 선
|
||||||
|
ctx.strokeStyle = theme.floor.grid;
|
||||||
|
ctx.lineWidth = scale * 0.5;
|
||||||
|
ctx.strokeRect(x, y, ts, ts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 화면 좌표 → 타일 좌표 변환 */
|
||||||
|
screenToTile(screenX, screenY, scale, offsetX, offsetY) {
|
||||||
|
const ts = this.tileSize * scale;
|
||||||
|
const col = Math.floor((screenX - offsetX) / ts);
|
||||||
|
const row = Math.floor((screenY - offsetY) / ts);
|
||||||
|
return { col, row };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 타일 좌표 → 화면 좌표 (타일 중앙) */
|
||||||
|
tileToScreen(col, row, scale, offsetX, offsetY) {
|
||||||
|
const ts = this.tileSize * scale;
|
||||||
|
return {
|
||||||
|
x: col * ts + offsetX + ts / 2,
|
||||||
|
y: row * ts + offsetY + ts / 2
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/pages/agent-office/canvas/themes.js
Normal file
42
src/pages/agent-office/canvas/themes.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// src/pages/agent-office/canvas/themes.js
|
||||||
|
|
||||||
|
export const THEMES = {
|
||||||
|
modern: {
|
||||||
|
name: 'Modern',
|
||||||
|
wall: { color: '#1a1a2e', border: '#333', accent: '#8b5cf6' },
|
||||||
|
floor: { color1: '#2a2a3e', color2: '#323248', grid: 'rgba(255,255,255,0.03)' },
|
||||||
|
furniture: { desk: '#3a3a5a', chair: '#4c1d95', monitor: '#5555aa', monitorScreen: '#1a3a5a', shelf: '#2a2a4e', table: '#3a3a5a', sofa: '#2a2a4e', coffee: '#3a3a2a' },
|
||||||
|
decor: { plant: '#22c55e', pot: '#4a3a2a', lamp: '#fbbf24', ledStrip: '#8b5cf6' },
|
||||||
|
lighting: { ambient: 'rgba(139,92,246,0.05)', glow: 'rgba(139,92,246,0.15)' },
|
||||||
|
text: { primary: '#ffffff', secondary: '#aaaaaa', label: '#888888' },
|
||||||
|
ui: { panelBg: '#111111', headerBg: '#1a1a2e', border: '#333333', accent: '#8b5cf6' }
|
||||||
|
},
|
||||||
|
retro: {
|
||||||
|
name: 'Retro',
|
||||||
|
wall: { color: '#2a1a0a', border: '#6a4a2a', accent: '#cc8844' },
|
||||||
|
floor: { color1: '#4a3a1a', color2: '#3a2a10', grid: 'rgba(255,255,255,0.02)' },
|
||||||
|
furniture: { desk: '#6a4a1a', chair: '#8a5a2a', monitor: '#555555', monitorScreen: '#1a3a1a', shelf: '#5a3a1a', table: '#5a4a2a', sofa: '#5a3a2a', coffee: '#4a3a1a' },
|
||||||
|
decor: { plant: '#44aa44', pot: '#6a4a2a', lamp: '#ffcc66', brick: '#8a5a2a' },
|
||||||
|
lighting: { ambient: 'rgba(255,200,100,0.05)', glow: 'rgba(255,200,100,0.2)' },
|
||||||
|
text: { primary: '#ffe0b0', secondary: '#aa8866', label: '#887766' },
|
||||||
|
ui: { panelBg: '#1a1008', headerBg: '#2a1a0a', border: '#4a3a2a', accent: '#cc8844' }
|
||||||
|
},
|
||||||
|
minimal: {
|
||||||
|
name: 'Minimal',
|
||||||
|
wall: { color: '#fafafa', border: '#dddddd', accent: '#3b82f6' },
|
||||||
|
floor: { color1: '#e8e8e8', color2: '#f0f0f0', grid: 'rgba(0,0,0,0.04)' },
|
||||||
|
furniture: { desk: '#ffffff', chair: '#e0e0e0', monitor: '#333333', monitorScreen: '#e0e8f0', shelf: '#f5f5f5', table: '#ffffff', sofa: '#e8e8e8', coffee: '#f0f0f0' },
|
||||||
|
decor: { plant: '#86efac', pot: '#ffffff', lamp: '#fbbf24', window: '#e0f0ff' },
|
||||||
|
lighting: { ambient: 'rgba(59,130,246,0.03)', glow: 'rgba(255,255,255,0.1)' },
|
||||||
|
text: { primary: '#1a1a1a', secondary: '#666666', label: '#999999' },
|
||||||
|
ui: { panelBg: '#ffffff', headerBg: '#fafafa', border: '#e0e0e0', accent: '#3b82f6' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getTheme(name) {
|
||||||
|
return THEMES[name] || THEMES.modern;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getThemeNames() {
|
||||||
|
return Object.entries(THEMES).map(([key, val]) => ({ key, name: val.name }));
|
||||||
|
}
|
||||||
164
src/pages/agent-office/components/CommandTab.jsx
Normal file
164
src/pages/agent-office/components/CommandTab.jsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
// src/pages/agent-office/components/CommandTab.jsx
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { sendAgentCommand, approveAgentTask } from '../../../api';
|
||||||
|
|
||||||
|
const QUICK_ACTIONS = {
|
||||||
|
stock: [{ action: 'fetch_news', label: 'Fetch News' }, { action: 'test_telegram', label: 'Test Telegram' }],
|
||||||
|
music: [{ action: 'credits', label: 'Check Credits' }],
|
||||||
|
blog: [{ action: 'list_trend_keywords', label: 'List Keywords' }],
|
||||||
|
realestate: [{ action: 'dashboard', label: 'Dashboard' }],
|
||||||
|
lotto: [{ action: 'status', label: 'Status' }, { action: 'curate_now', label: 'Curate Now' }]
|
||||||
|
};
|
||||||
|
|
||||||
|
const PARAM_ACTIONS = {
|
||||||
|
stock: { action: 'add_alert', label: 'Add Alert', placeholder: '{"symbol":"005930","target_price":70000,"direction":"above"}' },
|
||||||
|
music: { action: 'compose', label: 'Compose', placeholder: 'jazzy lo-fi piano beat' },
|
||||||
|
blog: { action: 'research', label: 'Research', placeholder: 'keyword to research' },
|
||||||
|
realestate: { action: 'fetch_matches', label: 'Fetch Matches', placeholder: '' },
|
||||||
|
lotto: null
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CommandTab({ agentId, agentState, pendingTask, onCommandResult }) {
|
||||||
|
const [customAction, setCustomAction] = useState('');
|
||||||
|
const [customParams, setCustomParams] = useState('');
|
||||||
|
const [paramInput, setParamInput] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const quickActions = QUICK_ACTIONS[agentId] || [];
|
||||||
|
const paramAction = PARAM_ACTIONS[agentId];
|
||||||
|
|
||||||
|
const handleQuickAction = async (action) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await sendAgentCommand(agentId, action, {});
|
||||||
|
onCommandResult?.(result);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleParamAction = async () => {
|
||||||
|
if (!paramAction || !paramInput.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
let params = {};
|
||||||
|
if (paramAction.action === 'compose') {
|
||||||
|
params = { prompt: paramInput };
|
||||||
|
} else if (paramAction.action === 'research') {
|
||||||
|
params = { keyword: paramInput };
|
||||||
|
} else {
|
||||||
|
try { params = JSON.parse(paramInput); } catch { params = { value: paramInput }; }
|
||||||
|
}
|
||||||
|
const result = await sendAgentCommand(agentId, paramAction.action, params);
|
||||||
|
onCommandResult?.(result);
|
||||||
|
setParamInput('');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomCommand = async () => {
|
||||||
|
if (!customAction.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
let params = {};
|
||||||
|
if (customParams.trim()) {
|
||||||
|
try { params = JSON.parse(customParams); } catch { params = { value: customParams }; }
|
||||||
|
}
|
||||||
|
const result = await sendAgentCommand(agentId, customAction, params);
|
||||||
|
onCommandResult?.(result);
|
||||||
|
setCustomAction('');
|
||||||
|
setCustomParams('');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApproval = async (approved) => {
|
||||||
|
if (!pendingTask) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await approveAgentTask(agentId, pendingTask.id, approved);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ao-command-tab">
|
||||||
|
{/* 승인 대기 UI */}
|
||||||
|
{agentState === 'waiting' && pendingTask && (
|
||||||
|
<div className="ao-approval-card">
|
||||||
|
<div className="ao-approval-title">Awaiting Approval</div>
|
||||||
|
<div className="ao-approval-desc">{pendingTask.task_type}: {pendingTask.detail || JSON.stringify(pendingTask.input_data)}</div>
|
||||||
|
<div className="ao-approval-actions">
|
||||||
|
<button className="ao-btn-approve" onClick={() => handleApproval(true)} disabled={loading}>Approve</button>
|
||||||
|
<button className="ao-btn-reject" onClick={() => handleApproval(false)} disabled={loading}>Reject</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="ao-section">
|
||||||
|
<div className="ao-section-label">Quick Actions</div>
|
||||||
|
<div className="ao-quick-actions">
|
||||||
|
{quickActions.map(qa => (
|
||||||
|
<button
|
||||||
|
key={qa.action}
|
||||||
|
className="ao-btn-quick"
|
||||||
|
onClick={() => handleQuickAction(qa.action)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{qa.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Parameterized Action */}
|
||||||
|
{paramAction && (
|
||||||
|
<div className="ao-section">
|
||||||
|
<div className="ao-section-label">{paramAction.label}</div>
|
||||||
|
<div className="ao-param-row">
|
||||||
|
<input
|
||||||
|
className="ao-input"
|
||||||
|
value={paramInput}
|
||||||
|
onChange={e => setParamInput(e.target.value)}
|
||||||
|
placeholder={paramAction.placeholder}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleParamAction()}
|
||||||
|
/>
|
||||||
|
<button className="ao-btn-send" onClick={handleParamAction} disabled={loading || !paramInput.trim()}>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Custom Command */}
|
||||||
|
<div className="ao-section">
|
||||||
|
<div className="ao-section-label">Custom Command</div>
|
||||||
|
<input
|
||||||
|
className="ao-input"
|
||||||
|
value={customAction}
|
||||||
|
onChange={e => setCustomAction(e.target.value)}
|
||||||
|
placeholder="Action name"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="ao-input"
|
||||||
|
value={customParams}
|
||||||
|
onChange={e => setCustomParams(e.target.value)}
|
||||||
|
placeholder='Parameters (JSON)'
|
||||||
|
style={{ marginTop: 4 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="ao-btn-send"
|
||||||
|
onClick={handleCustomCommand}
|
||||||
|
disabled={loading || !customAction.trim()}
|
||||||
|
style={{ marginTop: 4, width: '100%' }}
|
||||||
|
>
|
||||||
|
Send Command
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/pages/agent-office/components/LogTab.jsx
Normal file
45
src/pages/agent-office/components/LogTab.jsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// src/pages/agent-office/components/LogTab.jsx
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { getAgentLogs } from '../../../api';
|
||||||
|
|
||||||
|
const LEVEL_STYLE = {
|
||||||
|
info: { color: '#60a5fa' },
|
||||||
|
warning: { color: '#fbbf24' },
|
||||||
|
error: { color: '#ef4444' }
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LogTab({ agentId, refreshTrigger }) {
|
||||||
|
const [logs, setLogs] = useState([]);
|
||||||
|
const scrollRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
getAgentLogs(agentId, 50).then(data => {
|
||||||
|
if (!cancelled) setLogs(data || []);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [agentId, refreshTrigger]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ao-log-tab" ref={scrollRef}>
|
||||||
|
{logs.length === 0 && <div className="ao-empty">No logs yet</div>}
|
||||||
|
{logs.map((log, i) => {
|
||||||
|
const style = LEVEL_STYLE[log.level] || LEVEL_STYLE.info;
|
||||||
|
const time = new Date(log.created_at).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
return (
|
||||||
|
<div key={log.id || i} className="ao-log-item">
|
||||||
|
<span className="ao-log-time">{time}</span>
|
||||||
|
<span className="ao-log-level" style={style}>[{log.level}]</span>
|
||||||
|
<span className="ao-log-msg">{log.message}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
src/pages/agent-office/components/SidePanel.jsx
Normal file
73
src/pages/agent-office/components/SidePanel.jsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// src/pages/agent-office/components/SidePanel.jsx
|
||||||
|
import { useState } from 'react';
|
||||||
|
import CommandTab from './CommandTab.jsx';
|
||||||
|
import TaskTab from './TaskTab.jsx';
|
||||||
|
import TokenTab from './TokenTab.jsx';
|
||||||
|
import LogTab from './LogTab.jsx';
|
||||||
|
|
||||||
|
const AGENT_META = {
|
||||||
|
stock: { displayName: '주식 트레이더', emoji: '📈', color: '#4488cc' },
|
||||||
|
music: { displayName: '음악 프로듀서', emoji: '🎵', color: '#44aa88' },
|
||||||
|
blog: { displayName: '블로그 마케터', emoji: '✍️', color: '#d97706' },
|
||||||
|
realestate: { displayName: '청약 애널리스트', emoji: '🏢', color: '#c026d3' },
|
||||||
|
lotto: { displayName: '로또 큐레이터', emoji: '🎱', color: '#ef4444' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const TABS = ['Commands', 'Tasks', 'Tokens', 'Logs'];
|
||||||
|
|
||||||
|
export default function SidePanel({ agentId, agentState, pendingTask, onClose, refreshTrigger }) {
|
||||||
|
const [activeTab, setActiveTab] = useState('Commands');
|
||||||
|
const meta = AGENT_META[agentId];
|
||||||
|
if (!meta) return null;
|
||||||
|
|
||||||
|
const stateText = agentState?.detail
|
||||||
|
? `${agentState.state} - ${agentState.detail}`
|
||||||
|
: agentState?.state || 'unknown';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ao-sidepanel">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="ao-sidepanel-header">
|
||||||
|
<div className="ao-sidepanel-agent">
|
||||||
|
<div className="ao-sidepanel-icon" style={{ background: meta.color }}>
|
||||||
|
{meta.emoji}
|
||||||
|
</div>
|
||||||
|
<div className="ao-sidepanel-info">
|
||||||
|
<div className="ao-sidepanel-name">{meta.displayName}</div>
|
||||||
|
<div className="ao-sidepanel-state">● {stateText}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="ao-sidepanel-close" onClick={onClose}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="ao-sidepanel-tabs">
|
||||||
|
{TABS.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
className={`ao-sidepanel-tab ${activeTab === tab ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab(tab)}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
<div className="ao-sidepanel-content">
|
||||||
|
{activeTab === 'Commands' && (
|
||||||
|
<CommandTab agentId={agentId} agentState={agentState?.state} pendingTask={pendingTask} />
|
||||||
|
)}
|
||||||
|
{activeTab === 'Tasks' && (
|
||||||
|
<TaskTab agentId={agentId} refreshTrigger={refreshTrigger} />
|
||||||
|
)}
|
||||||
|
{activeTab === 'Tokens' && (
|
||||||
|
<TokenTab agentId={agentId} />
|
||||||
|
)}
|
||||||
|
{activeTab === 'Logs' && (
|
||||||
|
<LogTab agentId={agentId} refreshTrigger={refreshTrigger} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
src/pages/agent-office/components/TaskTab.jsx
Normal file
60
src/pages/agent-office/components/TaskTab.jsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// src/pages/agent-office/components/TaskTab.jsx
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { getAgentTasks } from '../../../api';
|
||||||
|
|
||||||
|
const STATUS_STYLE = {
|
||||||
|
succeeded: { bg: '#065f46', fg: '#34d399' },
|
||||||
|
failed: { bg: '#7f1d1d', fg: '#fca5a5' },
|
||||||
|
working: { bg: '#1e3a5f', fg: '#60a5fa' },
|
||||||
|
pending: { bg: '#92400e', fg: '#fbbf24' },
|
||||||
|
approved: { bg: '#065f46', fg: '#34d399' },
|
||||||
|
rejected: { bg: '#7f1d1d', fg: '#fca5a5' }
|
||||||
|
};
|
||||||
|
|
||||||
|
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 TaskTab({ agentId, refreshTrigger }) {
|
||||||
|
const [tasks, setTasks] = useState([]);
|
||||||
|
const [expanded, setExpanded] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
getAgentTasks(agentId, 20).then(data => {
|
||||||
|
if (!cancelled) setTasks(data || []);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [agentId, refreshTrigger]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ao-task-tab">
|
||||||
|
{tasks.length === 0 && <div className="ao-empty">No tasks yet</div>}
|
||||||
|
{tasks.map(task => {
|
||||||
|
const style = STATUS_STYLE[task.status] || STATUS_STYLE.pending;
|
||||||
|
return (
|
||||||
|
<div key={task.id} className="ao-task-item" onClick={() => setExpanded(expanded === task.id ? null : task.id)}>
|
||||||
|
<div className="ao-task-header">
|
||||||
|
<span className="ao-task-type">{task.task_type}</span>
|
||||||
|
<span className="ao-task-badge" style={{ background: style.bg, color: style.fg }}>{task.status}</span>
|
||||||
|
<span className="ao-task-time">{formatTime(task.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
{expanded === task.id && task.result_data && (
|
||||||
|
<pre className="ao-task-result">
|
||||||
|
{(() => {
|
||||||
|
try { return JSON.stringify(JSON.parse(task.result_data), null, 2); }
|
||||||
|
catch { return task.result_data; }
|
||||||
|
})()}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
src/pages/agent-office/components/TokenTab.jsx
Normal file
86
src/pages/agent-office/components/TokenTab.jsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// src/pages/agent-office/components/TokenTab.jsx
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { getAgentTokenUsage } from '../../../api';
|
||||||
|
|
||||||
|
export default function TokenTab({ agentId }) {
|
||||||
|
const [usage, setUsage] = useState(null);
|
||||||
|
const [days, setDays] = useState(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
getAgentTokenUsage(agentId, days).then(data => {
|
||||||
|
if (!cancelled) setUsage(data);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [agentId, days]);
|
||||||
|
|
||||||
|
if (!usage) return <div className="ao-empty">Loading...</div>;
|
||||||
|
|
||||||
|
const inputTokens = usage.input_tokens || 0;
|
||||||
|
const outputTokens = usage.output_tokens || 0;
|
||||||
|
const cacheRead = usage.cache_read || 0;
|
||||||
|
const cacheWrite = usage.cache_write || 0;
|
||||||
|
const total = inputTokens + outputTokens;
|
||||||
|
const cacheHitRate = inputTokens > 0 ? Math.round((cacheRead / inputTokens) * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ao-token-tab">
|
||||||
|
<div className="ao-token-period">
|
||||||
|
{[1, 7, 30].map(d => (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
className={`ao-btn-period ${days === d ? 'active' : ''}`}
|
||||||
|
onClick={() => setDays(d)}
|
||||||
|
>
|
||||||
|
{d === 1 ? 'Today' : d === 7 ? '7 Days' : '30 Days'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ao-token-grid">
|
||||||
|
<div className="ao-token-card">
|
||||||
|
<div className="ao-token-label">Input Tokens</div>
|
||||||
|
<div className="ao-token-value">{inputTokens.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
<div className="ao-token-card">
|
||||||
|
<div className="ao-token-label">Output Tokens</div>
|
||||||
|
<div className="ao-token-value">{outputTokens.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
<div className="ao-token-card">
|
||||||
|
<div className="ao-token-label">Total</div>
|
||||||
|
<div className="ao-token-value">{total.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
<div className="ao-token-card">
|
||||||
|
<div className="ao-token-label">Cache Hit Rate</div>
|
||||||
|
<div className="ao-token-value">{cacheHitRate}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Simple bar chart */}
|
||||||
|
<div className="ao-token-bar">
|
||||||
|
<div className="ao-token-bar-label">Input vs Output</div>
|
||||||
|
<div className="ao-token-bar-track">
|
||||||
|
<div
|
||||||
|
className="ao-token-bar-fill input"
|
||||||
|
style={{ width: total > 0 ? `${(inputTokens / total) * 100}%` : '0%' }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="ao-token-bar-fill output"
|
||||||
|
style={{ width: total > 0 ? `${(outputTokens / total) * 100}%` : '0%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ao-token-bar-legend">
|
||||||
|
<span><span className="dot input" />Input</span>
|
||||||
|
<span><span className="dot output" />Output</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cacheRead > 0 && (
|
||||||
|
<div className="ao-token-detail">
|
||||||
|
<span>Cache Read: {cacheRead.toLocaleString()}</span>
|
||||||
|
<span>Cache Write: {cacheWrite.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/pages/agent-office/components/TopBar.jsx
Normal file
33
src/pages/agent-office/components/TopBar.jsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// src/pages/agent-office/components/TopBar.jsx
|
||||||
|
import { getThemeNames } from '../canvas/themes.js';
|
||||||
|
|
||||||
|
export default function TopBar({ connected, theme, onThemeChange, zoom, onZoomChange }) {
|
||||||
|
const themes = getThemeNames();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ao-topbar">
|
||||||
|
<div className="ao-topbar-left">
|
||||||
|
<span className="ao-topbar-title">Agent Office</span>
|
||||||
|
<span className={`ao-topbar-status ${connected ? 'connected' : 'disconnected'}`}>
|
||||||
|
● {connected ? 'Connected' : 'Disconnected'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="ao-topbar-right">
|
||||||
|
<select
|
||||||
|
className="ao-topbar-select"
|
||||||
|
value={theme}
|
||||||
|
onChange={(e) => onThemeChange(e.target.value)}
|
||||||
|
>
|
||||||
|
{themes.map(t => (
|
||||||
|
<option key={t.key} value={t.key}>{t.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="ao-topbar-zoom">
|
||||||
|
<button onClick={() => onZoomChange(Math.max(1, zoom - 0.5))} disabled={zoom <= 1}>-</button>
|
||||||
|
<span>{zoom}x</span>
|
||||||
|
<button onClick={() => onZoomChange(Math.min(4, zoom + 0.5))} disabled={zoom >= 4}>+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
src/pages/agent-office/hooks/useAgentManager.js
Normal file
111
src/pages/agent-office/hooks/useAgentManager.js
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
// src/pages/agent-office/hooks/useAgentManager.js
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
const WS_RECONNECT_DELAY = 3000;
|
||||||
|
|
||||||
|
export function useAgentManager() {
|
||||||
|
const [agents, setAgents] = useState({}); // { agentId: { state, detail, task_id } }
|
||||||
|
const [pendingTasks, setPendingTasks] = useState([]); // [{id, agent_id, task_type, input_data}]
|
||||||
|
const [notifications, setNotifications] = useState({}); // { agentId: count }
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [refreshTrigger, setRefreshTrigger] = useState(0); // 탭 데이터 리프레시용
|
||||||
|
|
||||||
|
const wsRef = useRef(null);
|
||||||
|
const reconnectRef = useRef(null);
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
const ws = new WebSocket(`${protocol}://${window.location.host}/api/agent-office/ws`);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => setConnected(true);
|
||||||
|
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
const msg = JSON.parse(e.data);
|
||||||
|
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'init': {
|
||||||
|
// 에이전트 초기 상태 세팅
|
||||||
|
const agentMap = {};
|
||||||
|
for (const a of msg.agents) {
|
||||||
|
agentMap[a.agent_id] = { state: a.state, detail: a.detail || '', task_id: a.task_id };
|
||||||
|
}
|
||||||
|
setAgents(agentMap);
|
||||||
|
setPendingTasks(msg.pending || []);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'agent_state':
|
||||||
|
setAgents(prev => ({
|
||||||
|
...prev,
|
||||||
|
[msg.agent]: { state: msg.state, detail: msg.detail || '', task_id: msg.task_id }
|
||||||
|
}));
|
||||||
|
// idle 전환 시 데이터 리프레시
|
||||||
|
if (msg.state === 'idle') {
|
||||||
|
setRefreshTrigger(n => n + 1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'task_complete':
|
||||||
|
setRefreshTrigger(n => n + 1);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'notification':
|
||||||
|
setNotifications(prev => ({
|
||||||
|
...prev,
|
||||||
|
[msg.agent]: (prev[msg.agent] || 0) + 1
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'command_result':
|
||||||
|
// 사이드 패널에서 처리
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
setConnected(false);
|
||||||
|
reconnectRef.current = setTimeout(connect, WS_RECONNECT_DELAY);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => ws.close();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
connect();
|
||||||
|
return () => {
|
||||||
|
if (wsRef.current) wsRef.current.close();
|
||||||
|
if (reconnectRef.current) clearTimeout(reconnectRef.current);
|
||||||
|
};
|
||||||
|
}, [connect]);
|
||||||
|
|
||||||
|
const sendCommand = useCallback((agent, action, params = {}) => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(JSON.stringify({ type: 'command', agent, action, params }));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sendApproval = useCallback((agent, taskId, approved) => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(JSON.stringify({ type: 'approval', agent, task_id: taskId, approved }));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearNotifications = useCallback((agentId) => {
|
||||||
|
setNotifications(prev => ({ ...prev, [agentId]: 0 }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
agents,
|
||||||
|
pendingTasks,
|
||||||
|
notifications,
|
||||||
|
connected,
|
||||||
|
refreshTrigger,
|
||||||
|
sendCommand,
|
||||||
|
sendApproval,
|
||||||
|
clearNotifications
|
||||||
|
};
|
||||||
|
}
|
||||||
64
src/pages/agent-office/hooks/useOfficeCanvas.js
Normal file
64
src/pages/agent-office/hooks/useOfficeCanvas.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// src/pages/agent-office/hooks/useOfficeCanvas.js
|
||||||
|
import { useRef, useEffect, useCallback } from 'react';
|
||||||
|
import { OfficeRenderer } from '../canvas/OfficeRenderer.js';
|
||||||
|
|
||||||
|
export function useOfficeCanvas() {
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
const rendererRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current) return;
|
||||||
|
|
||||||
|
const renderer = new OfficeRenderer(canvasRef.current);
|
||||||
|
rendererRef.current = renderer;
|
||||||
|
renderer.start();
|
||||||
|
|
||||||
|
const handleResize = () => renderer.resize();
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
renderer.destroy();
|
||||||
|
rendererRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateAgentState = useCallback((agentId, state, detail) => {
|
||||||
|
rendererRef.current?.updateAgentState(agentId, state, detail);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setAgentNotification = useCallback((agentId, count) => {
|
||||||
|
rendererRef.current?.setAgentNotification(agentId, count);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setTheme = useCallback((themeName) => {
|
||||||
|
rendererRef.current?.setTheme(themeName);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setZoom = useCallback((level) => {
|
||||||
|
rendererRef.current?.setZoom(level);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hitTest = useCallback((clientX, clientY) => {
|
||||||
|
return rendererRef.current?.hitTest(clientX, clientY) || { type: 'empty' };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getZoom = useCallback(() => {
|
||||||
|
return rendererRef.current?.zoom || 2;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const wasDragging = useCallback(() => {
|
||||||
|
return rendererRef.current?.wasDragging?.() || false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
canvasRef,
|
||||||
|
updateAgentState,
|
||||||
|
setAgentNotification,
|
||||||
|
setTheme,
|
||||||
|
setZoom,
|
||||||
|
hitTest,
|
||||||
|
getZoom,
|
||||||
|
wasDragging
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
/* ── Blog Marketing ─────────────────────────────────────────────────────── */
|
|
||||||
.bm { max-width: 1100px; margin: 0 auto; padding: 24px 16px 80px; }
|
|
||||||
|
|
||||||
/* 헤더 */
|
|
||||||
.bm-header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
|
|
||||||
.bm-header h1 { font-size: 1.5rem; font-weight: 700; color: var(--text-primary, #e4e4e7); margin: 0; }
|
|
||||||
.bm-status { display: flex; gap: 8px; margin-left: auto; }
|
|
||||||
.bm-badge { font-size: 0.7rem; padding: 2px 8px; border-radius: 99px; background: rgba(16,185,129,.15); color: #10b981; }
|
|
||||||
.bm-badge--off { background: rgba(239,68,68,.12); color: #ef4444; }
|
|
||||||
|
|
||||||
/* 탭 바 */
|
|
||||||
.bm-tabs { display: flex; gap: 4px; border-bottom: 1px solid rgba(255,255,255,.08); margin-bottom: 20px; }
|
|
||||||
.bm-tab { padding: 8px 16px; font-size: 0.85rem; background: none; border: none; color: rgba(255,255,255,.45); cursor: pointer; border-bottom: 2px solid transparent; transition: all .15s; }
|
|
||||||
.bm-tab:hover { color: rgba(255,255,255,.7); }
|
|
||||||
.bm-tab--active { color: #10b981; border-bottom-color: #10b981; }
|
|
||||||
|
|
||||||
/* ── Dashboard 탭 ─────────────────────────────────────────────────────────── */
|
|
||||||
.bm-dash-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px; }
|
|
||||||
.bm-dash-card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 16px; }
|
|
||||||
.bm-dash-card__label { font-size: 0.75rem; color: rgba(255,255,255,.4); margin-bottom: 4px; }
|
|
||||||
.bm-dash-card__value { font-size: 1.4rem; font-weight: 700; color: var(--text-primary, #e4e4e7); }
|
|
||||||
.bm-dash-card__value--green { color: #10b981; }
|
|
||||||
|
|
||||||
.bm-dash-section { margin-bottom: 24px; }
|
|
||||||
.bm-dash-section h3 { font-size: 0.9rem; font-weight: 600; color: rgba(255,255,255,.6); margin-bottom: 12px; }
|
|
||||||
|
|
||||||
.bm-top-posts { display: flex; flex-direction: column; gap: 8px; }
|
|
||||||
.bm-top-post { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px; background: rgba(255,255,255,.03); border-radius: 8px; }
|
|
||||||
.bm-top-post__title { font-size: 0.85rem; color: var(--text-primary, #e4e4e7); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.bm-top-post__rev { font-size: 0.85rem; font-weight: 600; color: #10b981; margin-left: 12px; white-space: nowrap; }
|
|
||||||
|
|
||||||
/* ── Research 탭 ──────────────────────────────────────────────────────────── */
|
|
||||||
.bm-research-form { display: flex; gap: 8px; margin-bottom: 20px; }
|
|
||||||
.bm-research-input { flex: 1; padding: 10px 14px; border-radius: 8px; border: 1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.04); color: var(--text-primary, #e4e4e7); font-size: 0.9rem; outline: none; }
|
|
||||||
.bm-research-input:focus { border-color: #10b981; }
|
|
||||||
.bm-research-input::placeholder { color: rgba(255,255,255,.25); }
|
|
||||||
|
|
||||||
.bm-btn { padding: 8px 18px; border-radius: 8px; border: none; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: all .15s; display: inline-flex; align-items: center; gap: 6px; }
|
|
||||||
.bm-btn--primary { background: #10b981; color: #fff; }
|
|
||||||
.bm-btn--primary:hover { background: #059669; }
|
|
||||||
.bm-btn--primary:disabled { opacity: .5; cursor: not-allowed; }
|
|
||||||
.bm-btn--secondary { background: rgba(255,255,255,.08); color: rgba(255,255,255,.7); }
|
|
||||||
.bm-btn--secondary:hover { background: rgba(255,255,255,.12); }
|
|
||||||
.bm-btn--danger { background: rgba(239,68,68,.15); color: #ef4444; }
|
|
||||||
.bm-btn--danger:hover { background: rgba(239,68,68,.25); }
|
|
||||||
.bm-btn--sm { padding: 4px 10px; font-size: 0.75rem; }
|
|
||||||
|
|
||||||
.bm-spinner { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: bm-spin .6s linear infinite; display: inline-block; }
|
|
||||||
@keyframes bm-spin { to { transform: rotate(360deg); } }
|
|
||||||
|
|
||||||
/* 분석 카드 */
|
|
||||||
.bm-analyses { display: flex; flex-direction: column; gap: 12px; }
|
|
||||||
.bm-analysis-card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 16px; }
|
|
||||||
.bm-analysis-card__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
|
||||||
.bm-analysis-card__keyword { font-size: 1rem; font-weight: 700; color: var(--text-primary, #e4e4e7); }
|
|
||||||
.bm-analysis-card__date { font-size: 0.7rem; color: rgba(255,255,255,.3); }
|
|
||||||
.bm-analysis-card__scores { display: flex; gap: 16px; margin-bottom: 10px; flex-wrap: wrap; }
|
|
||||||
.bm-score { text-align: center; }
|
|
||||||
.bm-score__label { font-size: 0.65rem; color: rgba(255,255,255,.4); display: block; margin-bottom: 2px; }
|
|
||||||
.bm-score__value { font-size: 1.1rem; font-weight: 700; }
|
|
||||||
.bm-score__value--high { color: #10b981; }
|
|
||||||
.bm-score__value--mid { color: #fbbf24; }
|
|
||||||
.bm-score__value--low { color: #ef4444; }
|
|
||||||
.bm-analysis-card__summary { font-size: 0.8rem; color: rgba(255,255,255,.5); line-height: 1.5; }
|
|
||||||
.bm-analysis-card__actions { display: flex; gap: 8px; margin-top: 12px; }
|
|
||||||
|
|
||||||
/* ── Write 탭 ─────────────────────────────────────────────────────────────── */
|
|
||||||
.bm-write-empty { text-align: center; padding: 60px 20px; color: rgba(255,255,255,.3); }
|
|
||||||
.bm-write-empty p { font-size: 0.85rem; margin-top: 8px; }
|
|
||||||
|
|
||||||
.bm-progress { margin-bottom: 20px; }
|
|
||||||
.bm-progress__bar { height: 4px; background: rgba(255,255,255,.08); border-radius: 2px; overflow: hidden; margin-bottom: 6px; }
|
|
||||||
.bm-progress__fill { height: 100%; background: #10b981; border-radius: 2px; transition: width .3s; }
|
|
||||||
.bm-progress__text { font-size: 0.75rem; color: rgba(255,255,255,.4); }
|
|
||||||
|
|
||||||
.bm-preview { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 20px; margin-bottom: 16px; }
|
|
||||||
.bm-preview__title { font-size: 1.1rem; font-weight: 700; color: var(--text-primary, #e4e4e7); margin-bottom: 12px; }
|
|
||||||
.bm-preview__body { font-size: 0.85rem; color: rgba(255,255,255,.6); line-height: 1.7; max-height: 400px; overflow-y: auto; }
|
|
||||||
.bm-preview__body h1, .bm-preview__body h2, .bm-preview__body h3 { color: var(--text-primary, #e4e4e7); margin: 16px 0 8px; }
|
|
||||||
.bm-preview__body table { width: 100%; border-collapse: collapse; margin: 12px 0; }
|
|
||||||
.bm-preview__body th, .bm-preview__body td { border: 1px solid rgba(255,255,255,.1); padding: 6px 10px; font-size: 0.8rem; }
|
|
||||||
.bm-preview__body th { background: rgba(255,255,255,.06); }
|
|
||||||
.bm-preview__tags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 12px; }
|
|
||||||
.bm-tag { font-size: 0.7rem; padding: 2px 8px; border-radius: 4px; background: rgba(16,185,129,.12); color: #10b981; }
|
|
||||||
|
|
||||||
.bm-review-box { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 16px; margin-bottom: 16px; }
|
|
||||||
.bm-review-box h4 { font-size: 0.85rem; font-weight: 600; color: var(--text-primary, #e4e4e7); margin-bottom: 10px; }
|
|
||||||
.bm-review-scores { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
|
|
||||||
.bm-review-score { text-align: center; min-width: 60px; }
|
|
||||||
.bm-review-score__label { font-size: 0.65rem; color: rgba(255,255,255,.4); display: block; }
|
|
||||||
.bm-review-score__val { font-size: 1rem; font-weight: 700; }
|
|
||||||
.bm-review-total { font-size: 0.85rem; font-weight: 700; margin-bottom: 6px; }
|
|
||||||
.bm-review-total--pass { color: #10b981; }
|
|
||||||
.bm-review-total--fail { color: #ef4444; }
|
|
||||||
.bm-review-feedback { font-size: 0.8rem; color: rgba(255,255,255,.5); line-height: 1.5; }
|
|
||||||
|
|
||||||
.bm-write-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
||||||
|
|
||||||
/* ── Posts 탭 ─────────────────────────────────────────────────────────────── */
|
|
||||||
.bm-posts-filter { display: flex; gap: 4px; margin-bottom: 16px; }
|
|
||||||
.bm-filter-btn { padding: 4px 12px; border-radius: 6px; border: none; font-size: 0.75rem; background: rgba(255,255,255,.06); color: rgba(255,255,255,.5); cursor: pointer; transition: all .15s; }
|
|
||||||
.bm-filter-btn--active { background: rgba(16,185,129,.15); color: #10b981; }
|
|
||||||
|
|
||||||
.bm-posts-list { display: flex; flex-direction: column; gap: 10px; }
|
|
||||||
.bm-post-card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 14px 16px; }
|
|
||||||
.bm-post-card__top { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 6px; }
|
|
||||||
.bm-post-card__title { font-size: 0.9rem; font-weight: 600; color: var(--text-primary, #e4e4e7); flex: 1; }
|
|
||||||
.bm-post-card__status { font-size: 0.65rem; padding: 2px 8px; border-radius: 4px; font-weight: 600; white-space: nowrap; margin-left: 8px; }
|
|
||||||
.bm-post-card__status--draft { background: rgba(255,255,255,.08); color: rgba(255,255,255,.5); }
|
|
||||||
.bm-post-card__status--reviewed { background: rgba(96,165,250,.15); color: #60a5fa; }
|
|
||||||
.bm-post-card__status--published { background: rgba(16,185,129,.15); color: #10b981; }
|
|
||||||
.bm-post-card__excerpt { font-size: 0.8rem; color: rgba(255,255,255,.4); margin-bottom: 8px; line-height: 1.4; }
|
|
||||||
.bm-post-card__meta { font-size: 0.7rem; color: rgba(255,255,255,.25); display: flex; gap: 12px; }
|
|
||||||
.bm-post-card__actions { display: flex; gap: 6px; margin-top: 10px; }
|
|
||||||
|
|
||||||
/* 발행 모달 */
|
|
||||||
.bm-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.6); z-index: 100; display: flex; align-items: center; justify-content: center; }
|
|
||||||
.bm-modal { background: #1e1e24; border: 1px solid rgba(255,255,255,.1); border-radius: 14px; padding: 24px; width: 90%; max-width: 440px; }
|
|
||||||
.bm-modal h3 { font-size: 1rem; font-weight: 700; color: var(--text-primary, #e4e4e7); margin-bottom: 12px; }
|
|
||||||
.bm-modal__input { width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.04); color: var(--text-primary, #e4e4e7); font-size: 0.85rem; outline: none; margin-bottom: 14px; }
|
|
||||||
.bm-modal__input:focus { border-color: #10b981; }
|
|
||||||
.bm-modal__buttons { display: flex; gap: 8px; justify-content: flex-end; }
|
|
||||||
|
|
||||||
/* ── 공통 빈 상태 ─────────────────────────────────────────────────────────── */
|
|
||||||
.bm-empty { text-align: center; padding: 48px 20px; color: rgba(255,255,255,.25); font-size: 0.85rem; }
|
|
||||||
|
|
||||||
/* ── 모바일 ───────────────────────────────────────────────────────────────── */
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.bm { padding: 16px 10px 60px; }
|
|
||||||
.bm-header h1 { font-size: 1.2rem; }
|
|
||||||
.bm-status { display: none; }
|
|
||||||
.bm-tab { padding: 6px 10px; font-size: 0.8rem; }
|
|
||||||
.bm-dash-cards { grid-template-columns: repeat(2, 1fr); }
|
|
||||||
.bm-research-form { flex-direction: column; }
|
|
||||||
.bm-analysis-card__scores { gap: 10px; }
|
|
||||||
.bm-write-actions { flex-direction: column; }
|
|
||||||
.bm-post-card__actions { flex-wrap: wrap; }
|
|
||||||
}
|
|
||||||
@@ -1,696 +0,0 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
||||||
import {
|
|
||||||
getBlogMarketingStatus,
|
|
||||||
startResearch,
|
|
||||||
getResearchHistory,
|
|
||||||
getResearchDetail,
|
|
||||||
deleteResearch,
|
|
||||||
getBlogMarketingTask,
|
|
||||||
startGenerate,
|
|
||||||
startReview,
|
|
||||||
startRegenerate,
|
|
||||||
startMarket,
|
|
||||||
getBlogMarketingPosts,
|
|
||||||
getBlogMarketingPost,
|
|
||||||
deleteBlogMarketingPost,
|
|
||||||
publishBlogMarketingPost,
|
|
||||||
getBlogMarketingDashboard,
|
|
||||||
getBlogMarketingCommissions,
|
|
||||||
addBlogMarketingCommission,
|
|
||||||
deleteBlogMarketingCommission,
|
|
||||||
getBrandLinks,
|
|
||||||
createBrandLink,
|
|
||||||
deleteBrandLink,
|
|
||||||
} from '../../api';
|
|
||||||
import './BlogMarketing.css';
|
|
||||||
|
|
||||||
/* ────────────────────── 유틸 ────────────────────── */
|
|
||||||
function fmtDate(iso) {
|
|
||||||
if (!iso) return '';
|
|
||||||
return new Date(iso).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
||||||
}
|
|
||||||
function fmtMoney(n) {
|
|
||||||
if (n == null) return '-';
|
|
||||||
return n.toLocaleString('ko-KR') + '원';
|
|
||||||
}
|
|
||||||
function copyHtmlToClipboard(html) {
|
|
||||||
const blob = new Blob([html], { type: 'text/html' });
|
|
||||||
const plainBlob = new Blob([html.replace(/<[^>]*>/g, '')], { type: 'text/plain' });
|
|
||||||
navigator.clipboard.write([
|
|
||||||
new ClipboardItem({ 'text/html': blob, 'text/plain': plainBlob }),
|
|
||||||
]).then(() => alert('본문이 클립보드에 복사되었습니다! (서식 포함)'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function scoreColor(v, max = 100) {
|
|
||||||
const r = v / max;
|
|
||||||
if (r >= 0.6) return 'bm-score__value--high';
|
|
||||||
if (r >= 0.3) return 'bm-score__value--mid';
|
|
||||||
return 'bm-score__value--low';
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ────────────────────── 폴링 훅 ────────────────────── */
|
|
||||||
function usePollTask(onDone) {
|
|
||||||
const [taskId, setTaskId] = useState(null);
|
|
||||||
const [task, setTask] = useState(null);
|
|
||||||
const timer = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!taskId) return;
|
|
||||||
let cancelled = false;
|
|
||||||
const poll = async () => {
|
|
||||||
try {
|
|
||||||
const t = await getBlogMarketingTask(taskId);
|
|
||||||
if (cancelled) return;
|
|
||||||
setTask(t);
|
|
||||||
if (t.status === 'succeeded' || t.status === 'failed') {
|
|
||||||
setTaskId(null);
|
|
||||||
onDone?.(t);
|
|
||||||
} else {
|
|
||||||
timer.current = setTimeout(poll, 1500);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) timer.current = setTimeout(poll, 3000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
poll();
|
|
||||||
return () => { cancelled = true; clearTimeout(timer.current); };
|
|
||||||
}, [taskId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
return { taskId, task, start: setTaskId, clear: () => { setTaskId(null); setTask(null); } };
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ══════════════════════════════════════════════════════════════════════════ */
|
|
||||||
export default function BlogMarketing() {
|
|
||||||
const [tab, setTab] = useState('dashboard');
|
|
||||||
const [status, setStatus] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getBlogMarketingStatus().then(setStatus).catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ id: 'dashboard', label: 'Dashboard' },
|
|
||||||
{ id: 'research', label: 'Research' },
|
|
||||||
{ id: 'write', label: 'Write' },
|
|
||||||
{ id: 'posts', label: 'Posts' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bm">
|
|
||||||
<header className="bm-header">
|
|
||||||
<h1>Blog Lab</h1>
|
|
||||||
{status && (
|
|
||||||
<div className="bm-status">
|
|
||||||
<span className={`bm-badge ${status.naver_api ? '' : 'bm-badge--off'}`}>
|
|
||||||
Naver {status.naver_api ? 'ON' : 'OFF'}
|
|
||||||
</span>
|
|
||||||
<span className={`bm-badge ${status.claude_api ? '' : 'bm-badge--off'}`}>
|
|
||||||
Claude {status.claude_api ? 'ON' : 'OFF'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<nav className="bm-tabs">
|
|
||||||
{tabs.map(t => (
|
|
||||||
<button
|
|
||||||
key={t.id}
|
|
||||||
className={`bm-tab ${tab === t.id ? 'bm-tab--active' : ''}`}
|
|
||||||
onClick={() => setTab(t.id)}
|
|
||||||
>
|
|
||||||
{t.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{tab === 'dashboard' && <DashboardTab />}
|
|
||||||
{tab === 'research' && <ResearchTab onGenerate={(id) => { setTab('write'); }} />}
|
|
||||||
{tab === 'write' && <WriteTab />}
|
|
||||||
{tab === 'posts' && <PostsTab />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ══════════════════════ Dashboard 탭 ═════════════════════════════════════ */
|
|
||||||
function DashboardTab() {
|
|
||||||
const [data, setData] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getBlogMarketingDashboard().then(setData).catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!data) return <div className="bm-empty">로딩 중...</div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="bm-dash-cards">
|
|
||||||
<DashCard label="총 포스트" value={data.total_posts} />
|
|
||||||
<DashCard label="발행 완료" value={data.published_posts} />
|
|
||||||
<DashCard label="총 클릭" value={data.total_clicks.toLocaleString()} />
|
|
||||||
<DashCard label="총 수익" value={fmtMoney(data.total_revenue)} green />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{data.top_posts?.length > 0 && (
|
|
||||||
<div className="bm-dash-section">
|
|
||||||
<h3>Top 5 포스트 (수익 기준)</h3>
|
|
||||||
<div className="bm-top-posts">
|
|
||||||
{data.top_posts.map(p => (
|
|
||||||
<div key={p.id} className="bm-top-post">
|
|
||||||
<span className="bm-top-post__title">{p.title || '(제목 없음)'}</span>
|
|
||||||
<span className="bm-top-post__rev">{fmtMoney(p.total_revenue)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data.monthly?.length > 0 && (
|
|
||||||
<div className="bm-dash-section">
|
|
||||||
<h3>월별 수익</h3>
|
|
||||||
<div className="bm-top-posts">
|
|
||||||
{data.monthly.map(m => (
|
|
||||||
<div key={m.month} className="bm-top-post">
|
|
||||||
<span className="bm-top-post__title">{m.month}</span>
|
|
||||||
<span style={{ fontSize: '0.8rem', color: 'rgba(255,255,255,.4)', marginRight: 12 }}>
|
|
||||||
클릭 {m.clicks} / 구매 {m.purchases}
|
|
||||||
</span>
|
|
||||||
<span className="bm-top-post__rev">{fmtMoney(m.revenue)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DashCard({ label, value, green }) {
|
|
||||||
return (
|
|
||||||
<div className="bm-dash-card">
|
|
||||||
<div className="bm-dash-card__label">{label}</div>
|
|
||||||
<div className={`bm-dash-card__value ${green ? 'bm-dash-card__value--green' : ''}`}>{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ══════════════════════ Research 탭 ══════════════════════════════════════ */
|
|
||||||
function ResearchTab() {
|
|
||||||
const [keyword, setKeyword] = useState('');
|
|
||||||
const [analyses, setAnalyses] = useState([]);
|
|
||||||
const [expanded, setExpanded] = useState(null);
|
|
||||||
|
|
||||||
const loadHistory = useCallback(() => {
|
|
||||||
getResearchHistory(30).then(r => setAnalyses(r.analyses || [])).catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => { loadHistory(); }, [loadHistory]);
|
|
||||||
|
|
||||||
const poll = usePollTask((t) => {
|
|
||||||
if (t.status === 'succeeded') loadHistory();
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSearch = async () => {
|
|
||||||
if (!keyword.trim() || poll.taskId) return;
|
|
||||||
try {
|
|
||||||
const { task_id } = await startResearch(keyword.trim());
|
|
||||||
poll.start(task_id);
|
|
||||||
} catch (e) {
|
|
||||||
alert(e.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id) => {
|
|
||||||
if (!confirm('이 분석을 삭제할까요?')) return;
|
|
||||||
await deleteResearch(id);
|
|
||||||
setAnalyses(prev => prev.filter(a => a.id !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGenerate = async (analysisId) => {
|
|
||||||
try {
|
|
||||||
const { task_id } = await startGenerate(analysisId);
|
|
||||||
alert(`글 생성 시작! (task: ${task_id.slice(0, 8)})\nWrite 탭에서 확인하세요.`);
|
|
||||||
} catch (e) {
|
|
||||||
alert(e.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="bm-research-form">
|
|
||||||
<input
|
|
||||||
className="bm-research-input"
|
|
||||||
placeholder="분석할 키워드를 입력하세요 (예: 무선 이어폰 추천)"
|
|
||||||
value={keyword}
|
|
||||||
onChange={e => setKeyword(e.target.value)}
|
|
||||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
|
||||||
disabled={!!poll.taskId}
|
|
||||||
/>
|
|
||||||
<button className="bm-btn bm-btn--primary" onClick={handleSearch} disabled={!!poll.taskId}>
|
|
||||||
{poll.taskId ? <><span className="bm-spinner" /> 분석 중...</> : '분석'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{poll.task && poll.task.status !== 'succeeded' && poll.task.status !== 'failed' && (
|
|
||||||
<div className="bm-progress">
|
|
||||||
<div className="bm-progress__bar">
|
|
||||||
<div className="bm-progress__fill" style={{ width: `${poll.task.progress || 0}%` }} />
|
|
||||||
</div>
|
|
||||||
<div className="bm-progress__text">{poll.task.message || '처리 중...'}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="bm-analyses">
|
|
||||||
{analyses.length === 0 && !poll.taskId && (
|
|
||||||
<div className="bm-empty">아직 분석 결과가 없습니다. 키워드를 입력해 첫 분석을 시작하세요!</div>
|
|
||||||
)}
|
|
||||||
{analyses.map(a => (
|
|
||||||
<div key={a.id} className="bm-analysis-card">
|
|
||||||
<div className="bm-analysis-card__header">
|
|
||||||
<span className="bm-analysis-card__keyword">{a.keyword}</span>
|
|
||||||
<span className="bm-analysis-card__date">{fmtDate(a.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="bm-analysis-card__scores">
|
|
||||||
<div className="bm-score">
|
|
||||||
<span className="bm-score__label">경쟁도</span>
|
|
||||||
<span className={`bm-score__value ${scoreColor(a.competition)}`}>{a.competition}</span>
|
|
||||||
</div>
|
|
||||||
<div className="bm-score">
|
|
||||||
<span className="bm-score__label">기회</span>
|
|
||||||
<span className={`bm-score__value ${scoreColor(a.opportunity)}`}>{a.opportunity}</span>
|
|
||||||
</div>
|
|
||||||
<div className="bm-score">
|
|
||||||
<span className="bm-score__label">블로그</span>
|
|
||||||
<span className="bm-score__value" style={{ color: 'rgba(255,255,255,.6)' }}>
|
|
||||||
{(a.blog_total || 0).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="bm-score">
|
|
||||||
<span className="bm-score__label">쇼핑</span>
|
|
||||||
<span className="bm-score__value" style={{ color: 'rgba(255,255,255,.6)' }}>
|
|
||||||
{(a.shop_total || 0).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{a.avg_price != null && (
|
|
||||||
<div className="bm-score">
|
|
||||||
<span className="bm-score__label">평균가</span>
|
|
||||||
<span className="bm-score__value" style={{ color: 'rgba(255,255,255,.6)' }}>
|
|
||||||
{fmtMoney(a.avg_price)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{expanded === a.id && a.top_products?.length > 0 && (
|
|
||||||
<div className="bm-analysis-card__summary">
|
|
||||||
<strong>상위 상품:</strong>
|
|
||||||
<ul style={{ margin: '4px 0 0 16px', padding: 0 }}>
|
|
||||||
{a.top_products.map((p, i) => (
|
|
||||||
<li key={i}>{p.title} — {fmtMoney(p.lprice)} ({p.mallName})</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="bm-analysis-card__actions">
|
|
||||||
<button className="bm-btn bm-btn--primary bm-btn--sm" onClick={() => handleGenerate(a.id)}>
|
|
||||||
글 생성
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="bm-btn bm-btn--secondary bm-btn--sm"
|
|
||||||
onClick={() => setExpanded(expanded === a.id ? null : a.id)}
|
|
||||||
>
|
|
||||||
{expanded === a.id ? '접기' : '상세'}
|
|
||||||
</button>
|
|
||||||
<button className="bm-btn bm-btn--danger bm-btn--sm" onClick={() => handleDelete(a.id)}>
|
|
||||||
삭제
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ══════════════════════ Write 탭 ═════════════════════════════════════════ */
|
|
||||||
function WriteTab() {
|
|
||||||
const [posts, setPosts] = useState([]);
|
|
||||||
const [selected, setSelected] = useState(null);
|
|
||||||
const [post, setPost] = useState(null);
|
|
||||||
|
|
||||||
// 브랜드 링크 상태
|
|
||||||
const [links, setLinks] = useState([]);
|
|
||||||
const [showLinkForm, setShowLinkForm] = useState(false);
|
|
||||||
const [linkForm, setLinkForm] = useState({ url: '', product_name: '', description: '', placement_hint: '' });
|
|
||||||
|
|
||||||
const loadPosts = useCallback(() => {
|
|
||||||
Promise.all([
|
|
||||||
getBlogMarketingPosts('draft', 20),
|
|
||||||
getBlogMarketingPosts('marketed', 20),
|
|
||||||
]).then(([draftRes, marketedRes]) => {
|
|
||||||
const all = [...(draftRes.posts || []), ...(marketedRes.posts || [])];
|
|
||||||
all.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
|
||||||
setPosts(all);
|
|
||||||
if (all.length > 0 && !selected) setSelected(all[0].id);
|
|
||||||
}).catch(() => {});
|
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
useEffect(() => { loadPosts(); }, [loadPosts]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selected) { setPost(null); setLinks([]); return; }
|
|
||||||
getBlogMarketingPost(selected).then(setPost).catch(() => {});
|
|
||||||
getBrandLinks({ post_id: selected }).then(r => setLinks(r.links || [])).catch(() => setLinks([]));
|
|
||||||
}, [selected]);
|
|
||||||
|
|
||||||
const reviewPoll = usePollTask((t) => {
|
|
||||||
if (t.status === 'succeeded' && t.result_id) {
|
|
||||||
getBlogMarketingPost(t.result_id).then(setPost).catch(() => {});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const regenPoll = usePollTask((t) => {
|
|
||||||
if (t.status === 'succeeded' && t.result_id) {
|
|
||||||
getBlogMarketingPost(t.result_id).then(setPost).catch(() => {});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const marketPoll = usePollTask((t) => {
|
|
||||||
if (t.status === 'succeeded' && t.result_id) {
|
|
||||||
getBlogMarketingPost(t.result_id).then(setPost).catch(() => {});
|
|
||||||
loadPosts();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleReview = async () => {
|
|
||||||
if (!post) return;
|
|
||||||
try {
|
|
||||||
const { task_id } = await startReview(post.id);
|
|
||||||
reviewPoll.start(task_id);
|
|
||||||
} catch (e) { alert(e.message); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRegenerate = async () => {
|
|
||||||
if (!post) return;
|
|
||||||
try {
|
|
||||||
const { task_id } = await startRegenerate(post.id);
|
|
||||||
regenPoll.start(task_id);
|
|
||||||
} catch (e) { alert(e.message); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMarket = async () => {
|
|
||||||
if (!post) return;
|
|
||||||
if (links.length === 0) {
|
|
||||||
alert('마케터 실행 전 브랜드커넥트 링크를 먼저 추가하세요.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const { task_id } = await startMarket(post.id);
|
|
||||||
marketPoll.start(task_id);
|
|
||||||
} catch (e) { alert(e.message); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopy = () => {
|
|
||||||
if (!post) return;
|
|
||||||
copyHtmlToClipboard(post.body);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddLink = async () => {
|
|
||||||
if (!linkForm.url.trim() || !linkForm.product_name.trim()) {
|
|
||||||
alert('URL과 상품명은 필수입니다.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await createBrandLink({ ...linkForm, post_id: selected });
|
|
||||||
setLinkForm({ url: '', product_name: '', description: '', placement_hint: '' });
|
|
||||||
setShowLinkForm(false);
|
|
||||||
getBrandLinks({ post_id: selected }).then(r => setLinks(r.links || [])).catch(() => {});
|
|
||||||
} catch (e) { alert(e.message); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteLink = async (linkId) => {
|
|
||||||
if (!confirm('이 링크를 삭제할까요?')) return;
|
|
||||||
await deleteBrandLink(linkId);
|
|
||||||
setLinks(prev => prev.filter(l => l.id !== linkId));
|
|
||||||
};
|
|
||||||
|
|
||||||
const activePoll = reviewPoll.task || regenPoll.task || marketPoll.task;
|
|
||||||
const isProcessing = activePoll && activePoll.status !== 'succeeded' && activePoll.status !== 'failed';
|
|
||||||
|
|
||||||
if (posts.length === 0 && !post) {
|
|
||||||
return (
|
|
||||||
<div className="bm-write-empty">
|
|
||||||
<div style={{ fontSize: '2rem', marginBottom: 8 }}>✍</div>
|
|
||||||
<p>아직 작성 중인 글이 없습니다.<br />Research 탭에서 키워드를 분석하고 글 생성을 시작하세요.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{posts.length > 1 && (
|
|
||||||
<div style={{ display: 'flex', gap: 6, marginBottom: 16, flexWrap: 'wrap' }}>
|
|
||||||
{posts.map(p => (
|
|
||||||
<button
|
|
||||||
key={p.id}
|
|
||||||
className={`bm-filter-btn ${selected === p.id ? 'bm-filter-btn--active' : ''}`}
|
|
||||||
onClick={() => setSelected(p.id)}
|
|
||||||
>
|
|
||||||
{p.title?.slice(0, 20) || `${p.status === 'marketed' ? 'Marketed' : 'Draft'} #${p.id}`}
|
|
||||||
{p.status === 'marketed' && <span style={{ marginLeft: 4, fontSize: '0.7rem', color: '#f59e0b' }}>[M]</span>}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isProcessing && activePoll && (
|
|
||||||
<div className="bm-progress">
|
|
||||||
<div className="bm-progress__bar">
|
|
||||||
<div className="bm-progress__fill" style={{ width: `${activePoll.progress || 0}%` }} />
|
|
||||||
</div>
|
|
||||||
<div className="bm-progress__text">{activePoll.message || '처리 중...'}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{post && (
|
|
||||||
<>
|
|
||||||
{/* 브랜드커넥트 링크 섹션 */}
|
|
||||||
<div className="bm-links-section" style={{ marginBottom: 16, padding: 12, background: 'rgba(255,255,255,0.04)', borderRadius: 8 }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
|
||||||
<h4 style={{ margin: 0, fontSize: '0.9rem' }}>브랜드커넥트 링크 ({links.length})</h4>
|
|
||||||
<button className="bm-btn bm-btn--secondary bm-btn--sm" onClick={() => setShowLinkForm(!showLinkForm)}>
|
|
||||||
{showLinkForm ? '취소' : '+ 링크 추가'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showLinkForm && (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12, padding: 12, background: 'rgba(0,0,0,0.2)', borderRadius: 6 }}>
|
|
||||||
<input
|
|
||||||
className="bm-research-input"
|
|
||||||
placeholder="제휴 링크 URL (필수)"
|
|
||||||
value={linkForm.url}
|
|
||||||
onChange={e => setLinkForm(p => ({ ...p, url: e.target.value }))}
|
|
||||||
style={{ fontSize: '0.85rem' }}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="bm-research-input"
|
|
||||||
placeholder="상품명 (필수)"
|
|
||||||
value={linkForm.product_name}
|
|
||||||
onChange={e => setLinkForm(p => ({ ...p, product_name: e.target.value }))}
|
|
||||||
style={{ fontSize: '0.85rem' }}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="bm-research-input"
|
|
||||||
placeholder="상품 설명 (선택)"
|
|
||||||
value={linkForm.description}
|
|
||||||
onChange={e => setLinkForm(p => ({ ...p, description: e.target.value }))}
|
|
||||||
style={{ fontSize: '0.85rem' }}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="bm-research-input"
|
|
||||||
placeholder="배치 힌트 (선택, 예: 본문 중간 자연스럽게)"
|
|
||||||
value={linkForm.placement_hint}
|
|
||||||
onChange={e => setLinkForm(p => ({ ...p, placement_hint: e.target.value }))}
|
|
||||||
style={{ fontSize: '0.85rem' }}
|
|
||||||
/>
|
|
||||||
<button className="bm-btn bm-btn--primary bm-btn--sm" onClick={handleAddLink}>등록</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{links.length > 0 && (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
||||||
{links.map(l => (
|
|
||||||
<div key={l.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 8px', background: 'rgba(255,255,255,0.03)', borderRadius: 4, fontSize: '0.8rem' }}>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<strong>{l.product_name}</strong>
|
|
||||||
{l.description && <span style={{ marginLeft: 8, color: 'rgba(255,255,255,.4)' }}>{l.description}</span>}
|
|
||||||
</div>
|
|
||||||
<button className="bm-btn bm-btn--danger bm-btn--sm" onClick={() => handleDeleteLink(l.id)} style={{ fontSize: '0.7rem', padding: '2px 6px' }}>삭제</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bm-preview">
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<div className="bm-preview__title">{post.title || '(제목 없음)'}</div>
|
|
||||||
<span className={`bm-post-card__status bm-post-card__status--${post.status}`} style={{ fontSize: '0.75rem' }}>
|
|
||||||
{post.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="bm-preview__body" dangerouslySetInnerHTML={{ __html: post.body }} />
|
|
||||||
{post.tags?.length > 0 && (
|
|
||||||
<div className="bm-preview__tags">
|
|
||||||
{post.tags.map((t, i) => <span key={i} className="bm-tag">#{t}</span>)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{post.review_detail && post.review_score != null && (
|
|
||||||
<div className="bm-review-box">
|
|
||||||
<h4>품질 리뷰 결과</h4>
|
|
||||||
<div className="bm-review-scores">
|
|
||||||
{Object.entries(post.review_detail.scores || {}).map(([k, v]) => (
|
|
||||||
<div key={k} className="bm-review-score">
|
|
||||||
<span className="bm-review-score__label">{k}</span>
|
|
||||||
<span className={`bm-review-score__val ${scoreColor(v, 10)}`}>{v}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className={`bm-review-total ${post.review_detail.pass ? 'bm-review-total--pass' : 'bm-review-total--fail'}`}>
|
|
||||||
총점: {post.review_score}/60 {post.review_detail.pass ? '(통과)' : '(미달)'}
|
|
||||||
</div>
|
|
||||||
{post.review_detail.feedback && (
|
|
||||||
<div className="bm-review-feedback">{post.review_detail.feedback}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="bm-write-actions">
|
|
||||||
{post.status === 'draft' && (
|
|
||||||
<button className="bm-btn bm-btn--primary" onClick={handleMarket} disabled={isProcessing} title={links.length === 0 ? '브랜드 링크를 먼저 추가하세요' : ''}>
|
|
||||||
{marketPoll.taskId ? <><span className="bm-spinner" /> 마케팅 중...</> : '마케터 실행'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button className="bm-btn bm-btn--primary" onClick={handleReview} disabled={isProcessing}>
|
|
||||||
{reviewPoll.taskId ? <><span className="bm-spinner" /> 리뷰 중...</> : '품질 리뷰'}
|
|
||||||
</button>
|
|
||||||
<button className="bm-btn bm-btn--secondary" onClick={handleRegenerate} disabled={isProcessing}>
|
|
||||||
{regenPoll.taskId ? <><span className="bm-spinner" /> 재생성 중...</> : '재생성'}
|
|
||||||
</button>
|
|
||||||
<button className="bm-btn bm-btn--secondary" onClick={handleCopy}>
|
|
||||||
본문 복사
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ══════════════════════ Posts 탭 ═════════════════════════════════════════ */
|
|
||||||
function PostsTab() {
|
|
||||||
const [filter, setFilter] = useState('');
|
|
||||||
const [posts, setPosts] = useState([]);
|
|
||||||
const [publishModal, setPublishModal] = useState(null);
|
|
||||||
const [naverUrl, setNaverUrl] = useState('');
|
|
||||||
|
|
||||||
const load = useCallback(() => {
|
|
||||||
getBlogMarketingPosts(filter || undefined).then(r => setPosts(r.posts || [])).catch(() => {});
|
|
||||||
}, [filter]);
|
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
|
||||||
|
|
||||||
const handleDelete = async (id) => {
|
|
||||||
if (!confirm('이 포스트를 삭제할까요?')) return;
|
|
||||||
await deleteBlogMarketingPost(id);
|
|
||||||
setPosts(prev => prev.filter(p => p.id !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePublish = async () => {
|
|
||||||
if (!publishModal) return;
|
|
||||||
await publishBlogMarketingPost(publishModal, naverUrl);
|
|
||||||
setPublishModal(null);
|
|
||||||
setNaverUrl('');
|
|
||||||
load();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopy = (body) => {
|
|
||||||
copyHtmlToClipboard(body);
|
|
||||||
};
|
|
||||||
|
|
||||||
const filters = [
|
|
||||||
{ id: '', label: '전체' },
|
|
||||||
{ id: 'draft', label: 'Draft' },
|
|
||||||
{ id: 'marketed', label: 'Marketed' },
|
|
||||||
{ id: 'reviewed', label: 'Reviewed' },
|
|
||||||
{ id: 'published', label: 'Published' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="bm-posts-filter">
|
|
||||||
{filters.map(f => (
|
|
||||||
<button
|
|
||||||
key={f.id}
|
|
||||||
className={`bm-filter-btn ${filter === f.id ? 'bm-filter-btn--active' : ''}`}
|
|
||||||
onClick={() => setFilter(f.id)}
|
|
||||||
>
|
|
||||||
{f.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bm-posts-list">
|
|
||||||
{posts.length === 0 && <div className="bm-empty">포스트가 없습니다.</div>}
|
|
||||||
{posts.map(p => (
|
|
||||||
<div key={p.id} className="bm-post-card">
|
|
||||||
<div className="bm-post-card__top">
|
|
||||||
<span className="bm-post-card__title">{p.title || '(제목 없음)'}</span>
|
|
||||||
<span className={`bm-post-card__status bm-post-card__status--${p.status}`}>
|
|
||||||
{p.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{p.excerpt && <div className="bm-post-card__excerpt">{p.excerpt}</div>}
|
|
||||||
<div className="bm-post-card__meta">
|
|
||||||
{p.review_score != null && <span>리뷰: {p.review_score}/60</span>}
|
|
||||||
{p.naver_url && <a href={p.naver_url} target="_blank" rel="noreferrer" style={{ color: '#10b981' }}>네이버 링크</a>}
|
|
||||||
<span>{fmtDate(p.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="bm-post-card__actions">
|
|
||||||
<button className="bm-btn bm-btn--secondary bm-btn--sm" onClick={() => handleCopy(p.body)}>복사</button>
|
|
||||||
{p.status !== 'published' && (
|
|
||||||
<button className="bm-btn bm-btn--primary bm-btn--sm" onClick={() => { setPublishModal(p.id); setNaverUrl(''); }}>
|
|
||||||
발행
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button className="bm-btn bm-btn--danger bm-btn--sm" onClick={() => handleDelete(p.id)}>삭제</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{publishModal && (
|
|
||||||
<div className="bm-modal-overlay" onClick={() => setPublishModal(null)}>
|
|
||||||
<div className="bm-modal" onClick={e => e.stopPropagation()}>
|
|
||||||
<h3>네이버 블로그 발행</h3>
|
|
||||||
<p style={{ fontSize: '0.8rem', color: 'rgba(255,255,255,.4)', marginBottom: 12 }}>
|
|
||||||
본문을 네이버 블로그에 붙여넣기한 후, 발행된 URL을 입력하세요.
|
|
||||||
</p>
|
|
||||||
<input
|
|
||||||
className="bm-modal__input"
|
|
||||||
placeholder="https://blog.naver.com/..."
|
|
||||||
value={naverUrl}
|
|
||||||
onChange={e => setNaverUrl(e.target.value)}
|
|
||||||
/>
|
|
||||||
<div className="bm-modal__buttons">
|
|
||||||
<button className="bm-btn bm-btn--secondary" onClick={() => setPublishModal(null)}>취소</button>
|
|
||||||
<button className="bm-btn bm-btn--primary" onClick={handlePublish}>발행 완료</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
/* 사이드바 토글 버튼(top-left) 과 겹치지 않도록 오른쪽 하단 배치 */
|
/* 사이드바 토글 버튼(top-left) 과 겹치지 않도록 오른쪽 하단 배치 */
|
||||||
bottom: 24px;
|
bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px) + 16px);
|
||||||
right: 24px;
|
right: 24px;
|
||||||
top: auto;
|
top: auto;
|
||||||
left: auto;
|
left: auto;
|
||||||
@@ -451,9 +451,8 @@
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 768px) {
|
||||||
.blog-header,
|
.blog-header {
|
||||||
.blog-grid {
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,10 +468,10 @@
|
|||||||
|
|
||||||
.blog-list {
|
.blog-list {
|
||||||
display: none;
|
display: none;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-list.is-visible {
|
.blog-list.is-visible {
|
||||||
display: block;
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -490,6 +489,13 @@
|
|||||||
|
|
||||||
.blog-list.is-visible .blog-category-filter {
|
.blog-list.is-visible .blog-category-filter {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-list.is-visible .blog-category-filter > * {
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-list.is-visible .blog-pagination {
|
.blog-list.is-visible .blog-pagination {
|
||||||
@@ -498,22 +504,18 @@
|
|||||||
|
|
||||||
.blog-article {
|
.blog-article {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
padding: 18px;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.blog-header h1 {
|
.blog-header h1 {
|
||||||
font-size: clamp(24px, 6vw, 32px);
|
font-size: clamp(24px, 6vw, 32px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-grid {
|
.blog-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-list {
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blog-list__item-btn {
|
.blog-list__item-btn {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
@@ -526,10 +528,6 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-article {
|
|
||||||
padding: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blog-article__body h1 {
|
.blog-article__body h1 {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
@@ -766,4 +764,19 @@
|
|||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 태그/카테고리 필터 가로 스크롤 */
|
||||||
|
.blog-categories,
|
||||||
|
.blog-category-list {
|
||||||
|
display: flex;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-categories > *,
|
||||||
|
.blog-category-list > * {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
updateBlogPost,
|
updateBlogPost,
|
||||||
deleteBlogPost,
|
deleteBlogPost,
|
||||||
} from '../../api';
|
} from '../../api';
|
||||||
|
import PullToRefresh from '../../components/PullToRefresh';
|
||||||
|
import FAB from '../../components/FAB';
|
||||||
import './Blog.css';
|
import './Blog.css';
|
||||||
|
|
||||||
// ── 마크다운 렌더러 ──────────────────────────────────────────────────────────
|
// ── 마크다운 렌더러 ──────────────────────────────────────────────────────────
|
||||||
@@ -359,9 +361,8 @@ const Blog = () => {
|
|||||||
const [editorPost, setEditorPost] = useState(null); // null=닫힘, {}=새글, post=수정
|
const [editorPost, setEditorPost] = useState(null); // null=닫힘, {}=새글, post=수정
|
||||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||||
|
|
||||||
// API 글 불러오기
|
const fetchPosts = useCallback(() => {
|
||||||
useEffect(() => {
|
return getBlogPostsApi()
|
||||||
getBlogPostsApi()
|
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
const posts = Array.isArray(data) ? data : (data?.posts ?? []);
|
const posts = Array.isArray(data) ? data : (data?.posts ?? []);
|
||||||
setApiPosts(posts.map((p) => ({ ...p, slug: `api-${p.id}` })));
|
setApiPosts(posts.map((p) => ({ ...p, slug: `api-${p.id}` })));
|
||||||
@@ -369,6 +370,11 @@ const Blog = () => {
|
|||||||
.catch(() => setApiError(true));
|
.catch(() => setApiError(true));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// API 글 불러오기
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPosts();
|
||||||
|
}, [fetchPosts]);
|
||||||
|
|
||||||
// 정적 + API 글 병합 (API 글이 앞에 표시)
|
// 정적 + API 글 병합 (API 글이 앞에 표시)
|
||||||
const allPosts = useMemo(() => {
|
const allPosts = useMemo(() => {
|
||||||
const combined = [...apiPosts, ...staticPosts];
|
const combined = [...apiPosts, ...staticPosts];
|
||||||
@@ -450,6 +456,7 @@ const Blog = () => {
|
|||||||
const closeEditor = useCallback(() => { setIsEditorOpen(false); setEditorPost(null); }, []);
|
const closeEditor = useCallback(() => { setIsEditorOpen(false); setEditorPost(null); }, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<PullToRefresh onRefresh={fetchPosts}>
|
||||||
<div className="blog">
|
<div className="blog">
|
||||||
<header className="blog-header">
|
<header className="blog-header">
|
||||||
<div>
|
<div>
|
||||||
@@ -651,7 +658,10 @@ const Blog = () => {
|
|||||||
onClose={closeEditor}
|
onClose={closeEditor}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<FAB onClick={openNewEditor} label="글 쓰기" />
|
||||||
</div>
|
</div>
|
||||||
|
</PullToRefresh>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,17 @@ const LAB_ITEMS = [
|
|||||||
icon: '📅',
|
icon: '📅',
|
||||||
status: 'live',
|
status: 'live',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'agent-office',
|
||||||
|
path: '/agent-office',
|
||||||
|
title: 'Agent Office',
|
||||||
|
category: 'AI · 자동화',
|
||||||
|
desc: 'AI 에이전트들이 사무실에서 자동으로 작업하는 가상 오피스',
|
||||||
|
tags: ['Canvas 2D', 'WebSocket', 'AI Agent', 'Telegram'],
|
||||||
|
accent: '#8b5cf6',
|
||||||
|
icon: '🏢',
|
||||||
|
status: 'wip',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATUS_LABEL = {
|
const STATUS_LABEL = {
|
||||||
|
|||||||
@@ -80,3 +80,14 @@
|
|||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sword-stream {
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sword-stream__overlay {
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -727,7 +727,7 @@
|
|||||||
|
|
||||||
/* ── Responsive ──────────────────────────────────────────────────────── */
|
/* ── Responsive ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 1024px) {
|
||||||
.home-hero {
|
.home-hero {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -803,15 +803,27 @@
|
|||||||
.home-profile__name {
|
.home-profile__name {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-hero__stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-card {
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-posts {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.home-grid {
|
.home-grid {
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-hero__stats {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { navLinks } from '../../routes.jsx';
|
import { navLinks } from '../../routes.jsx';
|
||||||
import { getBlogPosts } from '../../data/blog';
|
import { getBlogPosts } from '../../data/blog';
|
||||||
import { getTodos } from '../../api';
|
import { getTodos } from '../../api';
|
||||||
import { getCurrentTheme } from '../../data/heroConfig';
|
import { getCurrentTheme } from '../../data/heroConfig';
|
||||||
import myPhoto from '../../assets/myPhoto.jpg';
|
import myPhoto from '../../assets/myPhoto.jpg';
|
||||||
|
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||||
|
import SwipeableView from '../../components/SwipeableView';
|
||||||
|
import PullToRefresh from '../../components/PullToRefresh';
|
||||||
import './Home.css';
|
import './Home.css';
|
||||||
|
|
||||||
const TODO_COLUMNS = [
|
const TODO_COLUMNS = [
|
||||||
@@ -17,22 +20,32 @@ const Home = () => {
|
|||||||
const posts = getBlogPosts().slice(0, 3);
|
const posts = getBlogPosts().slice(0, 3);
|
||||||
const highlights = navLinks.filter((link) => link.id !== 'home');
|
const highlights = navLinks.filter((link) => link.id !== 'home');
|
||||||
const theme = getCurrentTheme();
|
const theme = getCurrentTheme();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const [todosByStatus, setTodosByStatus] = useState({ todo: [], in_progress: [], done: [] });
|
const [todosByStatus, setTodosByStatus] = useState({ todo: [], in_progress: [], done: [] });
|
||||||
|
const [portfolio, setPortfolio] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getTodos()
|
fetch('/api/profile/public')
|
||||||
.then((data) => {
|
.then(r => r.ok ? r.json() : null)
|
||||||
if (!Array.isArray(data)) return;
|
.catch(() => null)
|
||||||
setTodosByStatus({
|
.then(d => setPortfolio(d));
|
||||||
todo: data.filter((t) => t.status === 'todo'),
|
|
||||||
in_progress: data.filter((t) => t.status === 'in_progress'),
|
|
||||||
done: data.filter((t) => t.status === 'done'),
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => { /* 조용히 실패 */ });
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadTodos = useCallback(async () => {
|
||||||
|
const data = await getTodos();
|
||||||
|
if (!Array.isArray(data)) return;
|
||||||
|
setTodosByStatus({
|
||||||
|
todo: data.filter((t) => t.status === 'todo'),
|
||||||
|
in_progress: data.filter((t) => t.status === 'in_progress'),
|
||||||
|
done: data.filter((t) => t.status === 'done'),
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTodos().catch(() => { /* 조용히 실패 */ });
|
||||||
|
}, [loadTodos]);
|
||||||
|
|
||||||
const totalTasks = todosByStatus.todo.length + todosByStatus.in_progress.length + todosByStatus.done.length;
|
const totalTasks = todosByStatus.todo.length + todosByStatus.in_progress.length + todosByStatus.done.length;
|
||||||
const doneTasks = todosByStatus.done.length;
|
const doneTasks = todosByStatus.done.length;
|
||||||
const inProgress = todosByStatus.in_progress.length;
|
const inProgress = todosByStatus.in_progress.length;
|
||||||
@@ -132,7 +145,79 @@ const Home = () => {
|
|||||||
<h2>TODO</h2>
|
<h2>TODO</h2>
|
||||||
<p>계획 · 진행 중 · 완료 태스크를 한눈에 확인합니다.</p>
|
<p>계획 · 진행 중 · 완료 태스크를 한눈에 확인합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<TodoBoard todosByStatus={todosByStatus} />
|
<PullToRefresh onRefresh={loadTodos}>
|
||||||
|
{isMobile ? (
|
||||||
|
<SwipeableView
|
||||||
|
tabs={[
|
||||||
|
{
|
||||||
|
key: 'todo',
|
||||||
|
label: 'TODO',
|
||||||
|
content: (
|
||||||
|
<div className="home-todo-col__body">
|
||||||
|
{(todosByStatus.todo || []).length === 0 ? (
|
||||||
|
<p className="home-todo-col__empty">태스크가 없습니다.</p>
|
||||||
|
) : (todosByStatus.todo || []).map((todo) => (
|
||||||
|
<div key={todo.id} className="home-todo-card">
|
||||||
|
<p className="home-todo-card__title">{todo.title}</p>
|
||||||
|
{todo.description && <p className="home-todo-card__desc">{todo.description}</p>}
|
||||||
|
<p className="home-todo-card__date">
|
||||||
|
{todo.updated_at
|
||||||
|
? new Date(todo.updated_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'in_progress',
|
||||||
|
label: '진행중',
|
||||||
|
content: (
|
||||||
|
<div className="home-todo-col__body">
|
||||||
|
{(todosByStatus.in_progress || []).length === 0 ? (
|
||||||
|
<p className="home-todo-col__empty">태스크가 없습니다.</p>
|
||||||
|
) : (todosByStatus.in_progress || []).map((todo) => (
|
||||||
|
<div key={todo.id} className="home-todo-card">
|
||||||
|
<p className="home-todo-card__title">{todo.title}</p>
|
||||||
|
{todo.description && <p className="home-todo-card__desc">{todo.description}</p>}
|
||||||
|
<p className="home-todo-card__date">
|
||||||
|
{todo.updated_at
|
||||||
|
? new Date(todo.updated_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'done',
|
||||||
|
label: '완료',
|
||||||
|
content: (
|
||||||
|
<div className="home-todo-col__body">
|
||||||
|
{(todosByStatus.done || []).length === 0 ? (
|
||||||
|
<p className="home-todo-col__empty">태스크가 없습니다.</p>
|
||||||
|
) : (todosByStatus.done || []).map((todo) => (
|
||||||
|
<div key={todo.id} className="home-todo-card">
|
||||||
|
<p className="home-todo-card__title">{todo.title}</p>
|
||||||
|
{todo.description && <p className="home-todo-card__desc">{todo.description}</p>}
|
||||||
|
<p className="home-todo-card__date">
|
||||||
|
{todo.updated_at
|
||||||
|
? new Date(todo.updated_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TodoBoard todosByStatus={todosByStatus} />
|
||||||
|
)}
|
||||||
|
</PullToRefresh>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="home-section">
|
<section className="home-section">
|
||||||
@@ -145,47 +230,30 @@ const Home = () => {
|
|||||||
<div className="home-profile__identity">
|
<div className="home-profile__identity">
|
||||||
<img
|
<img
|
||||||
className="home-profile__avatar"
|
className="home-profile__avatar"
|
||||||
src={myPhoto}
|
src={portfolio?.profile?.photo_url || myPhoto}
|
||||||
alt="Profile"
|
alt="Profile"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p className="home-profile__role">Server Developer</p>
|
<p className="home-profile__role">{portfolio?.profile?.role || 'Server Developer'}</p>
|
||||||
<p className="home-profile__name">박 재 오</p>
|
<p className="home-profile__name">{portfolio?.profile?.name || '박 재 오'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="home-profile__bio">
|
<p className="home-profile__bio">
|
||||||
주변 동료와 함께 소통하며 성장하는걸 좋아합니다. <br />
|
{portfolio?.profile?.bio || '주변 동료와 함께 소통하며 성장하는걸 좋아합니다.'}
|
||||||
성능 최적화, 인프라 자동화를 중요하게 생각합니다. <br />
|
|
||||||
여행과 사진, 새로운 기술 탐구를 좋아합니다.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="home-profile__timeline">
|
|
||||||
<p className="home-profile__section-title">연혁</p>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<span className="timeline-period">2023.02 - 현재</span>
|
|
||||||
<strong>Server Developer</strong>
|
|
||||||
<span>내비 TIS 교통 서버 / 현대오토에버</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span className="timeline-period">2020.01 - 2023.02</span>
|
|
||||||
<strong>Embedded Device SW Developer</strong>
|
|
||||||
<span>캐시비 단말기 개발 / 롯데정보통신</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span className="timeline-period">2019.07 - 2019.12</span>
|
|
||||||
<strong>SSAFY - 삼성 SW Academy</strong>
|
|
||||||
<span>SSAFY 1기 수료</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="home-profile__tags">
|
<div className="home-profile__tags">
|
||||||
{['C++', 'Git', 'AWS', 'Jira', 'MySQL', 'Docker', 'Kubernetes', 'Linux'].map((tag) => (
|
{(portfolio?.skills || []).slice(0, 8).map((s) => (
|
||||||
|
<span key={s.id || s.name}>{s.name}</span>
|
||||||
|
))}
|
||||||
|
{!portfolio && ['C++', 'Git', 'AWS', 'Jira', 'MySQL', 'Docker', 'Kubernetes', 'Linux'].map((tag) => (
|
||||||
<span key={tag}>{tag}</span>
|
<span key={tag}>{tag}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="home-profile__actions">
|
<div className="home-profile__actions">
|
||||||
<button className="button ghost">프로필 수정</button>
|
<Link className="button ghost" to="/portfolio">
|
||||||
<a className="button primary" href="mailto:bgg8988@gmail.com">
|
포트폴리오 보기
|
||||||
|
</Link>
|
||||||
|
<a className="button primary" href={`mailto:${portfolio?.profile?.email || 'bgg8988@gmail.com'}`}>
|
||||||
연락하기
|
연락하기
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
169
src/pages/insta/InstaCards.css
Normal file
169
src/pages/insta/InstaCards.css
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/* ── InstaCards ──────────────────────────────────────────────────────────── */
|
||||||
|
.ic { max-width: 1100px; margin: 0 auto; padding: 24px 16px 80px; }
|
||||||
|
|
||||||
|
/* 헤더 */
|
||||||
|
.ic-header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
|
||||||
|
.ic-header h1 { font-size: 1.5rem; font-weight: 700; color: var(--text-primary, #e4e4e7); margin: 0; }
|
||||||
|
.ic-status-badges { display: flex; gap: 8px; margin-left: auto; }
|
||||||
|
.ic-badge { font-size: 0.7rem; padding: 2px 8px; border-radius: 99px; background: rgba(236,72,153,.15); color: #ec4899; }
|
||||||
|
.ic-badge--on { background: rgba(16,185,129,.15); color: #10b981; }
|
||||||
|
.ic-badge--off { background: rgba(239,68,68,.12); color: #ef4444; }
|
||||||
|
|
||||||
|
/* 버튼 공통 */
|
||||||
|
.ic-btn { padding: 8px 18px; border-radius: 8px; border: none; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: all .15s; display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.ic-btn--primary { background: #ec4899; color: #fff; }
|
||||||
|
.ic-btn--primary:hover { background: #db2777; }
|
||||||
|
.ic-btn--primary:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
.ic-btn--secondary { background: rgba(255,255,255,.08); color: rgba(255,255,255,.7); }
|
||||||
|
.ic-btn--secondary:hover { background: rgba(255,255,255,.12); }
|
||||||
|
.ic-btn--secondary:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
.ic-btn--danger { background: rgba(239,68,68,.15); color: #ef4444; }
|
||||||
|
.ic-btn--danger:hover { background: rgba(239,68,68,.25); }
|
||||||
|
.ic-btn--sm { padding: 4px 10px; font-size: 0.75rem; }
|
||||||
|
|
||||||
|
.ic-spinner { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: ic-spin .6s linear infinite; display: inline-block; }
|
||||||
|
@keyframes ic-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* 레이아웃: 모바일 1컬럼, 데스크탑 2컬럼 */
|
||||||
|
.ic-layout { display: grid; grid-template-columns: 1fr; gap: 20px; }
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.ic-layout { grid-template-columns: 320px 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 섹션 카드 */
|
||||||
|
.ic-section { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 16px; }
|
||||||
|
.ic-section__title { font-size: 0.85rem; font-weight: 700; color: rgba(255,255,255,.6); text-transform: uppercase; letter-spacing: .05em; margin: 0 0 14px; }
|
||||||
|
|
||||||
|
/* 트리거 패널 */
|
||||||
|
.ic-trigger-buttons { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.ic-task-status { margin-top: 10px; padding: 10px 12px; background: rgba(255,255,255,.03); border-radius: 8px; font-size: 0.8rem; }
|
||||||
|
.ic-task-status__label { color: rgba(255,255,255,.4); margin-bottom: 4px; }
|
||||||
|
.ic-task-status__msg { color: var(--text-primary, #e4e4e7); }
|
||||||
|
.ic-task-status__progress { margin-top: 6px; height: 3px; background: rgba(255,255,255,.08); border-radius: 2px; }
|
||||||
|
.ic-task-status__fill { height: 100%; background: #ec4899; border-radius: 2px; transition: width .3s; }
|
||||||
|
|
||||||
|
/* 카테고리 필터 */
|
||||||
|
.ic-filter { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||||
|
.ic-filter-btn { padding: 4px 12px; border-radius: 99px; border: 1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.04); color: rgba(255,255,255,.5); font-size: 0.75rem; cursor: pointer; transition: all .15s; }
|
||||||
|
.ic-filter-btn--active { background: rgba(236,72,153,.18); border-color: #ec4899; color: #ec4899; }
|
||||||
|
|
||||||
|
/* 키워드 목록 */
|
||||||
|
.ic-keywords { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.ic-keyword-row { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: rgba(255,255,255,.03); border-radius: 8px; }
|
||||||
|
.ic-keyword-row__kw { font-size: 0.9rem; font-weight: 600; color: var(--text-primary, #e4e4e7); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.ic-keyword-row__meta { font-size: 0.72rem; color: rgba(255,255,255,.35); white-space: nowrap; }
|
||||||
|
.ic-keyword-row__score { font-size: 0.75rem; font-weight: 700; color: #ec4899; min-width: 36px; text-align: right; }
|
||||||
|
|
||||||
|
/* 슬레이트 그리드 */
|
||||||
|
.ic-slates-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
|
||||||
|
.ic-slate-card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 10px; overflow: hidden; cursor: pointer; transition: border-color .15s; }
|
||||||
|
.ic-slate-card:hover { border-color: rgba(236,72,153,.4); }
|
||||||
|
.ic-slate-card--active { border-color: #ec4899; }
|
||||||
|
.ic-slate-thumb { width: 100%; aspect-ratio: 4/5; object-fit: cover; background: rgba(255,255,255,.06); display: block; }
|
||||||
|
.ic-slate-thumb--placeholder { width: 100%; aspect-ratio: 4/5; background: rgba(255,255,255,.04); display: flex; align-items: center; justify-content: center; font-size: 1.8rem; }
|
||||||
|
.ic-slate-card__info { padding: 8px; }
|
||||||
|
.ic-slate-card__kw { font-size: 0.78rem; font-weight: 600; color: var(--text-primary, #e4e4e7); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.ic-slate-card__meta { display: flex; align-items: center; justify-content: space-between; margin-top: 4px; }
|
||||||
|
.ic-slate-card__date { font-size: 0.65rem; color: rgba(255,255,255,.3); }
|
||||||
|
|
||||||
|
/* 상태 뱃지 */
|
||||||
|
.ic-status-badge { font-size: 0.65rem; padding: 1px 6px; border-radius: 99px; font-weight: 600; }
|
||||||
|
.ic-status-badge--draft { background: rgba(161,161,170,.15); color: #a1a1aa; }
|
||||||
|
.ic-status-badge--rendered { background: rgba(96,165,250,.15); color: #60a5fa; }
|
||||||
|
.ic-status-badge--sent { background: rgba(16,185,129,.15); color: #10b981; }
|
||||||
|
.ic-status-badge--failed { background: rgba(239,68,68,.12); color: #ef4444; }
|
||||||
|
|
||||||
|
/* 슬레이트 상세 패널 */
|
||||||
|
.ic-detail { margin-top: 20px; padding: 16px; background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; }
|
||||||
|
.ic-detail__header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
|
||||||
|
.ic-detail__title { font-size: 1rem; font-weight: 700; color: var(--text-primary, #e4e4e7); flex: 1; }
|
||||||
|
.ic-detail__actions { display: flex; gap: 8px; }
|
||||||
|
|
||||||
|
.ic-pages-strip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 8px; margin-bottom: 14px; scroll-snap-type: x mandatory; }
|
||||||
|
.ic-page-img { width: 120px; flex-shrink: 0; aspect-ratio: 4/5; border-radius: 6px; object-fit: cover; scroll-snap-align: start; border: 1px solid rgba(255,255,255,.08); background: rgba(255,255,255,.04); }
|
||||||
|
|
||||||
|
.ic-caption-box { background: rgba(255,255,255,.03); border-radius: 8px; padding: 12px; margin-bottom: 10px; }
|
||||||
|
.ic-caption-box__label { font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,.4); text-transform: uppercase; margin-bottom: 6px; }
|
||||||
|
.ic-caption-text { font-size: 0.85rem; color: var(--text-primary, #e4e4e7); line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.ic-hashtags { font-size: 0.8rem; color: #60a5fa; line-height: 1.8; word-break: break-all; }
|
||||||
|
|
||||||
|
/* 프롬프트 에디터 */
|
||||||
|
.ic-prompt-editor { margin-top: 20px; }
|
||||||
|
.ic-prompt-editor__title { font-size: 0.85rem; font-weight: 700; color: rgba(255,255,255,.5); margin-bottom: 12px; text-transform: uppercase; }
|
||||||
|
.ic-prompt-block { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 10px; padding: 14px; margin-bottom: 12px; }
|
||||||
|
.ic-prompt-block__head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||||
|
.ic-prompt-block__name { font-size: 0.8rem; font-weight: 700; color: rgba(255,255,255,.7); flex: 1; }
|
||||||
|
.ic-prompt-block__date { font-size: 0.68rem; color: rgba(255,255,255,.3); }
|
||||||
|
.ic-prompt-textarea { width: 100%; min-height: 140px; background: rgba(0,0,0,.3); border: 1px solid rgba(255,255,255,.1); border-radius: 6px; color: var(--text-primary, #e4e4e7); font-size: 0.8rem; font-family: monospace; line-height: 1.5; padding: 10px; resize: vertical; box-sizing: border-box; outline: none; }
|
||||||
|
.ic-prompt-textarea:focus { border-color: #ec4899; }
|
||||||
|
.ic-prompt-save-row { display: flex; justify-content: flex-end; margin-top: 8px; }
|
||||||
|
|
||||||
|
/* 빈 상태 */
|
||||||
|
.ic-empty { text-align: center; padding: 40px 20px; color: rgba(255,255,255,.3); font-size: 0.85rem; }
|
||||||
|
|
||||||
|
/* ── tabs ── */
|
||||||
|
.ic-tabbar { display: flex; gap: 8px; border-bottom: 1px solid #e2e8f0; margin-bottom: 16px; }
|
||||||
|
.ic-tab {
|
||||||
|
background: transparent; border: 0; padding: 10px 16px;
|
||||||
|
cursor: pointer; font-size: 14px; font-weight: 600;
|
||||||
|
color: #64748b; border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.ic-tab.is-active { color: #ec4899; border-bottom-color: #ec4899; }
|
||||||
|
|
||||||
|
/* ── trends grid ── */
|
||||||
|
.ic-trends-grid { display: grid; gap: 16px; grid-template-columns: 1fr; }
|
||||||
|
@media (min-width: 1024px) { .ic-trends-grid { grid-template-columns: 320px 1fr; } }
|
||||||
|
|
||||||
|
/* ── ic-panel base ── */
|
||||||
|
.ic-panel { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 16px; }
|
||||||
|
.ic-panel__title { font-size: 0.95rem; font-weight: 700; color: var(--text-primary, #e4e4e7); margin: 0 0 8px; }
|
||||||
|
.ic-panel__hint { font-size: 0.78rem; color: rgba(255,255,255,.4); margin: 0 0 10px; }
|
||||||
|
|
||||||
|
/* ── focus panel ── */
|
||||||
|
.ic-panel--focus .ic-focus__list { display: flex; flex-direction: column; gap: 10px; margin: 12px 0; }
|
||||||
|
.ic-focus__row { display: grid; grid-template-columns: 110px 1fr 50px; align-items: center; gap: 8px; }
|
||||||
|
.ic-focus__label { font-weight: 600; color: #475569; text-transform: capitalize; }
|
||||||
|
.ic-focus__slider { width: 100%; accent-color: #ec4899; }
|
||||||
|
.ic-focus__num { text-align: right; font-variant-numeric: tabular-nums; color: #475569; }
|
||||||
|
.ic-focus__add { display: flex; gap: 8px; margin-top: 12px; }
|
||||||
|
.ic-focus__add input { flex: 1; padding: 8px; border: 1px solid #cbd5e1; border-radius: 6px; background: rgba(255,255,255,.06); color: var(--text-primary, #e4e4e7); }
|
||||||
|
.ic-focus__add button { padding: 8px 14px; background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.12); border-radius: 6px; color: rgba(255,255,255,.7); cursor: pointer; font-size: 0.85rem; }
|
||||||
|
.ic-focus__save {
|
||||||
|
width: 100%; padding: 10px; margin-top: 12px;
|
||||||
|
background: #ec4899; color: #fff; border: 0; border-radius: 6px; cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.ic-focus__save:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
.ic-focus__hint { margin-top: 12px; padding: 10px; background: rgba(245,158,11,.1); border-left: 3px solid #f59e0b; font-size: 12px; color: rgba(255,255,255,.6); line-height: 1.5; }
|
||||||
|
.ic-focus__hint code { background: rgba(0,0,0,.2); padding: 1px 4px; border-radius: 3px; }
|
||||||
|
|
||||||
|
/* ── trends panel ── */
|
||||||
|
.ic-trends__cols { display: grid; grid-template-columns: 1fr; gap: 16px; }
|
||||||
|
@media (min-width: 768px) { .ic-trends__cols { grid-template-columns: 1fr 1fr; } }
|
||||||
|
.ic-trends__col h4 { margin: 0 0 8px; font-size: 14px; color: rgba(255,255,255,.5); }
|
||||||
|
.ic-trend__group { margin-bottom: 14px; }
|
||||||
|
.ic-trend__group-head { font-size: 12px; font-weight: 700; text-transform: uppercase; margin-bottom: 4px; letter-spacing: 0.5px; }
|
||||||
|
.ic-trend__row {
|
||||||
|
display: grid; grid-template-columns: 10px 1fr 50px 36px;
|
||||||
|
align-items: center; gap: 8px; padding: 6px 4px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,.06);
|
||||||
|
}
|
||||||
|
.ic-trend__cat-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.ic-trend__kw { font-weight: 500; color: var(--text-primary, #e4e4e7); font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.ic-trend__score { text-align: right; color: rgba(255,255,255,.4); font-variant-numeric: tabular-nums; font-size: 12px; }
|
||||||
|
.ic-trend__make { background: #ec4899; border: 0; color: #fff; border-radius: 4px; cursor: pointer; padding: 4px; font-size: 14px; }
|
||||||
|
.ic-trend__make:hover { background: #db2777; }
|
||||||
|
|
||||||
|
.ic-panel__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||||
|
.ic-panel__actions { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.ic-panel__actions button { padding: 6px 12px; background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.1); border-radius: 6px; color: rgba(255,255,255,.7); cursor: pointer; font-size: 0.8rem; }
|
||||||
|
.ic-panel__actions button:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ── impact panel ── */
|
||||||
|
.ic-impact__row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
|
||||||
|
.ic-impact__chip {
|
||||||
|
display: flex; align-items: baseline; gap: 6px;
|
||||||
|
padding: 6px 12px; background: rgba(255,255,255,.06); border-radius: 999px;
|
||||||
|
}
|
||||||
|
.ic-impact__cat { font-weight: 600; text-transform: capitalize; color: rgba(255,255,255,.6); font-size: 0.82rem; }
|
||||||
|
.ic-impact__count { color: #ec4899; font-weight: 700; font-size: 0.82rem; }
|
||||||
781
src/pages/insta/InstaCards.jsx
Normal file
781
src/pages/insta/InstaCards.jsx
Normal file
@@ -0,0 +1,781 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import PullToRefresh from '../../components/PullToRefresh';
|
||||||
|
import {
|
||||||
|
getInstaStatus,
|
||||||
|
instaCollectNews,
|
||||||
|
instaExtractKeywords,
|
||||||
|
getInstaKeywords,
|
||||||
|
createInstaSlate,
|
||||||
|
getInstaSlates,
|
||||||
|
getInstaSlate,
|
||||||
|
renderInstaSlate,
|
||||||
|
deleteInstaSlate,
|
||||||
|
getInstaAssetUrl,
|
||||||
|
getInstaTask,
|
||||||
|
getInstaPrompt,
|
||||||
|
putInstaPrompt,
|
||||||
|
getInstaTrends,
|
||||||
|
instaCollectTrends,
|
||||||
|
getInstaPreferences,
|
||||||
|
putInstaPreferences,
|
||||||
|
} from '../../api';
|
||||||
|
import './InstaCards.css';
|
||||||
|
|
||||||
|
/* ────────────────────── 유틸 ────────────────────── */
|
||||||
|
function fmtDate(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
return new Date(iso).toLocaleDateString('ko-KR', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }) {
|
||||||
|
return (
|
||||||
|
<span className={`ic-status-badge ic-status-badge--${status || 'draft'}`}>
|
||||||
|
{status || 'draft'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ────────────────────── 폴링 훅 ────────────────────── */
|
||||||
|
function usePollTask(onDone) {
|
||||||
|
const [taskId, setTaskId] = useState(null);
|
||||||
|
const [task, setTask] = useState(null);
|
||||||
|
const timer = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!taskId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const t = await getInstaTask(taskId);
|
||||||
|
if (cancelled) return;
|
||||||
|
setTask(t);
|
||||||
|
if (t.status === 'succeeded' || t.status === 'failed') {
|
||||||
|
setTaskId(null);
|
||||||
|
onDone?.(t);
|
||||||
|
} else {
|
||||||
|
timer.current = setTimeout(poll, 3000);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) timer.current = setTimeout(poll, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
poll();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearTimeout(timer.current);
|
||||||
|
};
|
||||||
|
}, [taskId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return {
|
||||||
|
taskId,
|
||||||
|
task,
|
||||||
|
start: setTaskId,
|
||||||
|
clear: () => { setTaskId(null); setTask(null); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ────────────────────── TaskStatusBox ────────────────────── */
|
||||||
|
function TaskStatusBox({ task }) {
|
||||||
|
if (!task) return null;
|
||||||
|
const pct = task.progress != null ? task.progress : (task.status === 'succeeded' ? 100 : 0);
|
||||||
|
return (
|
||||||
|
<div className="ic-task-status">
|
||||||
|
<div className="ic-task-status__label">
|
||||||
|
{task.status === 'succeeded' ? '완료' : task.status === 'failed' ? '실패' : '진행 중'}
|
||||||
|
</div>
|
||||||
|
<div className="ic-task-status__msg">{task.message || task.error || ''}</div>
|
||||||
|
<div className="ic-task-status__progress">
|
||||||
|
<div className="ic-task-status__fill" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════ Trends 탭 패널 1: AccountFocusPanel ══════════════ */
|
||||||
|
function AccountFocusPanel() {
|
||||||
|
const [prefs, setPrefs] = useState([]);
|
||||||
|
const [draft, setDraft] = useState({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [newCat, setNewCat] = useState('');
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const data = await getInstaPreferences();
|
||||||
|
setPrefs(data.categories || []);
|
||||||
|
const m = {};
|
||||||
|
(data.categories || []).forEach(p => { m[p.category] = Math.round(p.weight * 100); });
|
||||||
|
setDraft(m);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = {};
|
||||||
|
Object.entries(draft).forEach(([k, v]) => { payload[k] = (Number(v) || 0) / 100; });
|
||||||
|
await putInstaPreferences(payload);
|
||||||
|
await load();
|
||||||
|
} finally { setSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCat = () => {
|
||||||
|
const name = newCat.trim().toLowerCase();
|
||||||
|
if (!name || draft[name] !== undefined) return;
|
||||||
|
setDraft({ ...draft, [name]: 0 });
|
||||||
|
setNewCat('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="ic-panel ic-panel--focus">
|
||||||
|
<h3 className="ic-panel__title">🎯 이 계정의 주제 (카테고리 가중치)</h3>
|
||||||
|
<p className="ic-panel__hint">슬라이더는 각 카테고리에 자동 추출 키워드 비율을 결정합니다. 합계는 자동 정규화됩니다.</p>
|
||||||
|
<div className="ic-focus__list">
|
||||||
|
{Object.entries(draft).map(([cat, val]) => (
|
||||||
|
<div key={cat} className="ic-focus__row">
|
||||||
|
<label className="ic-focus__label">{cat}</label>
|
||||||
|
<input
|
||||||
|
type="range" min="0" max="100" value={val}
|
||||||
|
onChange={e => setDraft({ ...draft, [cat]: Number(e.target.value) })}
|
||||||
|
className="ic-focus__slider"
|
||||||
|
/>
|
||||||
|
<span className="ic-focus__num">{val}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="ic-focus__add">
|
||||||
|
<input
|
||||||
|
type="text" placeholder="신규 카테고리 (영문 소문자)"
|
||||||
|
value={newCat} onChange={e => setNewCat(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button onClick={addCat}>+ 추가</button>
|
||||||
|
</div>
|
||||||
|
<button className="ic-focus__save" onClick={save} disabled={saving}>
|
||||||
|
{saving ? '저장 중...' : '저장'}
|
||||||
|
</button>
|
||||||
|
<div className="ic-focus__hint">
|
||||||
|
💡 신규 카테고리를 추가했다면 Cards 탭의 Prompt Templates Editor에서
|
||||||
|
<code>category_seeds</code>에 시드 키워드도 함께 정의해야 자동 추출에 반영됩니다.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════ Trends 탭 패널 2: ExternalTrendsPanel ══════════ */
|
||||||
|
const CATEGORY_COLORS = {
|
||||||
|
economy: '#0F62FE', psychology: '#A66CFF',
|
||||||
|
celebrity: '#FF5C8A', uncategorized: '#6B7280',
|
||||||
|
};
|
||||||
|
|
||||||
|
function ExternalTrendsPanel({ onCreateSlate }) {
|
||||||
|
const [naver, setNaver] = useState([]);
|
||||||
|
const [google, setGoogle] = useState([]);
|
||||||
|
const [lastFetched, setLastFetched] = useState(null);
|
||||||
|
const [collecting, setCollecting] = useState(false);
|
||||||
|
const [task, setTask] = useState(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const [n, g] = await Promise.all([
|
||||||
|
getInstaTrends({ source: 'naver_popular', days: 2 }),
|
||||||
|
getInstaTrends({ source: 'google_trends', days: 2 }),
|
||||||
|
]);
|
||||||
|
setNaver(n.items || []);
|
||||||
|
setGoogle(g.items || []);
|
||||||
|
const all = [...(n.items || []), ...(g.items || [])];
|
||||||
|
if (all.length) {
|
||||||
|
const latest = all.map(t => t.suggested_at).sort().reverse()[0];
|
||||||
|
setLastFetched(latest);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const trigger = async () => {
|
||||||
|
setCollecting(true);
|
||||||
|
try {
|
||||||
|
const { task_id } = await instaCollectTrends();
|
||||||
|
let st = null;
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
st = await getInstaTask(task_id);
|
||||||
|
setTask(st);
|
||||||
|
if (st.status === 'succeeded' || st.status === 'failed') break;
|
||||||
|
await new Promise(r => setTimeout(r, 3000));
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
} finally { setCollecting(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupByCat = (items) => {
|
||||||
|
const g = {};
|
||||||
|
items.forEach(it => { (g[it.category] = g[it.category] || []).push(it); });
|
||||||
|
return g;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRow = (t) => (
|
||||||
|
<div className="ic-trend__row" key={`${t.source}-${t.id}`}>
|
||||||
|
<span className="ic-trend__cat-dot" style={{ background: CATEGORY_COLORS[t.category] || '#6B7280' }} />
|
||||||
|
<span className="ic-trend__kw">{t.keyword}</span>
|
||||||
|
<span className="ic-trend__score">{(t.score || 0).toFixed(2)}</span>
|
||||||
|
<button
|
||||||
|
className="ic-trend__make"
|
||||||
|
onClick={() => onCreateSlate?.({ keyword: t.keyword, category: t.category })}
|
||||||
|
>🎴</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const naverGrouped = groupByCat(naver);
|
||||||
|
return (
|
||||||
|
<section className="ic-panel ic-panel--trends">
|
||||||
|
<div className="ic-panel__head">
|
||||||
|
<h3 className="ic-panel__title">📈 외부 트렌드</h3>
|
||||||
|
<div className="ic-panel__actions">
|
||||||
|
<span className="ic-panel__hint">
|
||||||
|
{lastFetched ? `마지막 수집: ${fmtDate(lastFetched)}` : '아직 수집 없음'}
|
||||||
|
</span>
|
||||||
|
<button onClick={trigger} disabled={collecting}>
|
||||||
|
{collecting ? '수집 중...' : '🔄 수동 수집'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{task && <TaskStatusBox task={task} />}
|
||||||
|
<div className="ic-trends__cols">
|
||||||
|
<div className="ic-trends__col">
|
||||||
|
<h4>🔥 NAVER 인기</h4>
|
||||||
|
{Object.keys(naverGrouped).length === 0 && <p className="ic-empty">없음</p>}
|
||||||
|
{Object.entries(naverGrouped).map(([cat, items]) => (
|
||||||
|
<div key={cat} className="ic-trend__group">
|
||||||
|
<div className="ic-trend__group-head" style={{ color: CATEGORY_COLORS[cat] || '#6B7280' }}>{cat}</div>
|
||||||
|
{items.map(renderRow)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="ic-trends__col">
|
||||||
|
<h4>🌐 Google Trends</h4>
|
||||||
|
{google.length === 0 && <p className="ic-empty">없음</p>}
|
||||||
|
{google.map(renderRow)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════ Trends 탭 패널 3: PreferenceImpactPanel ══════ */
|
||||||
|
function PreferenceImpactPanel() {
|
||||||
|
const [prefs, setPrefs] = useState([]);
|
||||||
|
const TOTAL = 15;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const data = await getInstaPreferences();
|
||||||
|
setPrefs(data.categories || []);
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const totalWeight = prefs.reduce((s, p) => s + (p.weight || 0), 0) || 1;
|
||||||
|
const breakdown = prefs.map(p => ({
|
||||||
|
category: p.category,
|
||||||
|
count: Math.round(TOTAL * (p.weight || 0) / totalWeight),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="ic-panel ic-panel--impact">
|
||||||
|
<h3 className="ic-panel__title">📊 다음 자동 추출 미리보기</h3>
|
||||||
|
<div className="ic-impact__row">
|
||||||
|
{breakdown.map(b => (
|
||||||
|
<div key={b.category} className="ic-impact__chip">
|
||||||
|
<span className="ic-impact__cat">{b.category}</span>
|
||||||
|
<span className="ic-impact__count">{b.count}개</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
export default function InstaCards() {
|
||||||
|
const [status, setStatus] = useState(null);
|
||||||
|
const [selectedSlateId, setSelectedSlateId] = useState(null);
|
||||||
|
|
||||||
|
/* ── 탭 상태 (URL 동기화) ── */
|
||||||
|
const [activeTab, setActiveTab] = useState(() => {
|
||||||
|
const u = new URL(window.location.href);
|
||||||
|
return u.searchParams.get('tab') === 'trends' ? 'trends' : 'cards';
|
||||||
|
});
|
||||||
|
|
||||||
|
const switchTab = (next) => {
|
||||||
|
setActiveTab(next);
|
||||||
|
const u = new URL(window.location.href);
|
||||||
|
if (next === 'cards') u.searchParams.delete('tab');
|
||||||
|
else u.searchParams.set('tab', next);
|
||||||
|
window.history.replaceState({}, '', u.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadStatus = useCallback(() => {
|
||||||
|
return getInstaStatus().then(setStatus).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadStatus();
|
||||||
|
}, [loadStatus]);
|
||||||
|
|
||||||
|
/* ── handleCreateSlate: 키워드 → 슬레이트 생성 (Trends 탭에서도 공유) ── */
|
||||||
|
const handleCreateSlate = useCallback(async ({ keyword, category, keyword_id } = {}) => {
|
||||||
|
try {
|
||||||
|
await createInstaSlate({ keyword, category, keyword_id });
|
||||||
|
setSelectedSlateId(null);
|
||||||
|
} catch (e) {
|
||||||
|
alert('카드 생성 실패: ' + e.message);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ic">
|
||||||
|
{/* ── 탭 바 ── */}
|
||||||
|
<div className="ic-tabbar">
|
||||||
|
<button
|
||||||
|
className={`ic-tab ${activeTab === 'cards' ? 'is-active' : ''}`}
|
||||||
|
onClick={() => switchTab('cards')}
|
||||||
|
>🎴 Cards</button>
|
||||||
|
<button
|
||||||
|
className={`ic-tab ${activeTab === 'trends' ? 'is-active' : ''}`}
|
||||||
|
onClick={() => switchTab('trends')}
|
||||||
|
>📈 Trends</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Cards 탭 (기존 5-패널) ── */}
|
||||||
|
{activeTab === 'cards' && (
|
||||||
|
<>
|
||||||
|
<PullToRefresh onRefresh={loadStatus}>
|
||||||
|
<div>
|
||||||
|
{/* 헤더 + 상태 배너 */}
|
||||||
|
<header className="ic-header">
|
||||||
|
<h1>Insta Cards</h1>
|
||||||
|
{status && (
|
||||||
|
<div className="ic-status-badges">
|
||||||
|
<span className={`ic-badge ${status.naver_api ? 'ic-badge--on' : 'ic-badge--off'}`}>
|
||||||
|
Naver {status.naver_api ? 'ON' : 'OFF'}
|
||||||
|
</span>
|
||||||
|
<span className={`ic-badge ${status.anthropic_api ? 'ic-badge--on' : 'ic-badge--off'}`}>
|
||||||
|
AI {status.anthropic_api ? 'ON' : 'OFF'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="ic-layout">
|
||||||
|
{/* 왼쪽: 트리거 + 키워드 */}
|
||||||
|
<div>
|
||||||
|
<TriggerPanel />
|
||||||
|
<div style={{ height: 16 }} />
|
||||||
|
<KeywordsPanel onCreateSlate={() => setSelectedSlateId(null)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오른쪽: 슬레이트 목록 + 상세 */}
|
||||||
|
<div>
|
||||||
|
<SlatesPanel
|
||||||
|
selectedId={selectedSlateId}
|
||||||
|
onSelect={setSelectedSlateId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PromptTemplatesEditor />
|
||||||
|
</div>
|
||||||
|
</PullToRefresh>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Trends 탭 (3 new panels) ── */}
|
||||||
|
{activeTab === 'trends' && (
|
||||||
|
<div className="ic-trends-grid">
|
||||||
|
<AccountFocusPanel />
|
||||||
|
<ExternalTrendsPanel onCreateSlate={handleCreateSlate} />
|
||||||
|
<PreferenceImpactPanel />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════ 트리거 패널 ══════════════════════════════════════ */
|
||||||
|
function TriggerPanel() {
|
||||||
|
const collectPoll = usePollTask();
|
||||||
|
const keywordsPoll = usePollTask();
|
||||||
|
|
||||||
|
async function handleCollect() {
|
||||||
|
try {
|
||||||
|
const res = await instaCollectNews();
|
||||||
|
collectPoll.start(res.task_id);
|
||||||
|
} catch (e) {
|
||||||
|
alert('뉴스 수집 실패: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleKeywords() {
|
||||||
|
try {
|
||||||
|
const res = await instaExtractKeywords();
|
||||||
|
keywordsPoll.start(res.task_id);
|
||||||
|
} catch (e) {
|
||||||
|
alert('키워드 추출 실패: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectBusy = !!collectPoll.taskId;
|
||||||
|
const kwBusy = !!keywordsPoll.taskId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ic-section">
|
||||||
|
<p className="ic-section__title">트리거</p>
|
||||||
|
<div className="ic-trigger-buttons">
|
||||||
|
<button
|
||||||
|
className="ic-btn ic-btn--primary"
|
||||||
|
onClick={handleCollect}
|
||||||
|
disabled={collectBusy}
|
||||||
|
>
|
||||||
|
{collectBusy && <span className="ic-spinner" />}
|
||||||
|
뉴스 수집
|
||||||
|
</button>
|
||||||
|
<TaskStatusBox task={collectPoll.task} />
|
||||||
|
<button
|
||||||
|
className="ic-btn ic-btn--secondary"
|
||||||
|
onClick={handleKeywords}
|
||||||
|
disabled={kwBusy}
|
||||||
|
>
|
||||||
|
{kwBusy && <span className="ic-spinner" />}
|
||||||
|
키워드 추출
|
||||||
|
</button>
|
||||||
|
<TaskStatusBox task={keywordsPoll.task} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════ 키워드 목록 ══════════════════════════════════════ */
|
||||||
|
const CATEGORIES = ['전체', 'economy', 'psychology', 'celebrity'];
|
||||||
|
|
||||||
|
function KeywordsPanel({ onCreateSlate }) {
|
||||||
|
const [category, setCategory] = useState('전체');
|
||||||
|
const [keywords, setKeywords] = useState([]);
|
||||||
|
const [creating, setCreating] = useState(null); // keyword_id being created
|
||||||
|
const slatePoll = usePollTask((t) => {
|
||||||
|
if (t.status === 'succeeded') onCreateSlate?.();
|
||||||
|
setCreating(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
const cat = category === '전체' ? undefined : category;
|
||||||
|
getInstaKeywords({ category: cat }).then((r) => setKeywords(r.items || [])).catch(() => {});
|
||||||
|
}, [category]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
async function handleCreate(kw) {
|
||||||
|
if (creating) return;
|
||||||
|
setCreating(kw.id);
|
||||||
|
try {
|
||||||
|
const res = await createInstaSlate({
|
||||||
|
keyword: kw.keyword,
|
||||||
|
category: kw.category,
|
||||||
|
keyword_id: kw.id,
|
||||||
|
});
|
||||||
|
slatePoll.start(res.task_id);
|
||||||
|
} catch (e) {
|
||||||
|
alert('카드 생성 실패: ' + e.message);
|
||||||
|
setCreating(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ic-section">
|
||||||
|
<p className="ic-section__title">트렌딩 키워드</p>
|
||||||
|
|
||||||
|
{/* 카테고리 필터 */}
|
||||||
|
<div className="ic-filter">
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
className={`ic-filter-btn ${category === c ? 'ic-filter-btn--active' : ''}`}
|
||||||
|
onClick={() => setCategory(c)}
|
||||||
|
>
|
||||||
|
{c}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{slatePoll.task && <TaskStatusBox task={slatePoll.task} />}
|
||||||
|
|
||||||
|
{keywords.length === 0 ? (
|
||||||
|
<div className="ic-empty">키워드가 없습니다. 키워드 추출을 실행하세요.</div>
|
||||||
|
) : (
|
||||||
|
<div className="ic-keywords">
|
||||||
|
{keywords.map((kw) => (
|
||||||
|
<div key={kw.id} className="ic-keyword-row">
|
||||||
|
<span className="ic-keyword-row__kw">{kw.keyword}</span>
|
||||||
|
<span className="ic-keyword-row__meta">
|
||||||
|
{kw.category} · {kw.articles_count ?? 0}건
|
||||||
|
</span>
|
||||||
|
<span className="ic-keyword-row__score">{kw.score?.toFixed(1) ?? '-'}</span>
|
||||||
|
<button
|
||||||
|
className="ic-btn ic-btn--primary ic-btn--sm"
|
||||||
|
onClick={() => handleCreate(kw)}
|
||||||
|
disabled={!!creating}
|
||||||
|
>
|
||||||
|
{creating === kw.id ? <span className="ic-spinner" /> : '🎴'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════ 슬레이트 목록 ══════════════════════════════════ */
|
||||||
|
function SlatesPanel({ selectedId, onSelect }) {
|
||||||
|
const [slates, setSlates] = useState([]);
|
||||||
|
const [detail, setDetail] = useState(null);
|
||||||
|
|
||||||
|
const loadSlates = useCallback(() => {
|
||||||
|
getInstaSlates(50).then((r) => setSlates(r.items || [])).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { loadSlates(); }, [loadSlates]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedId) { setDetail(null); return; }
|
||||||
|
getInstaSlate(selectedId).then(setDetail).catch(() => setDetail(null));
|
||||||
|
}, [selectedId]);
|
||||||
|
|
||||||
|
function handleSelect(id) {
|
||||||
|
onSelect(id === selectedId ? null : id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id) {
|
||||||
|
if (!confirm('슬레이트를 삭제하시겠습니까?')) return;
|
||||||
|
try {
|
||||||
|
await deleteInstaSlate(id);
|
||||||
|
if (selectedId === id) onSelect(null);
|
||||||
|
loadSlates();
|
||||||
|
} catch (e) {
|
||||||
|
alert('삭제 실패: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRender(id) {
|
||||||
|
try {
|
||||||
|
const res = await renderInstaSlate(id);
|
||||||
|
// Re-render is fire-and-forget from the panel; user can refresh detail
|
||||||
|
alert('재렌더 요청 완료 (task: ' + res.task_id + ')');
|
||||||
|
setTimeout(loadSlates, 3000);
|
||||||
|
} catch (e) {
|
||||||
|
alert('재렌더 실패: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="ic-section">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 14 }}>
|
||||||
|
<p className="ic-section__title" style={{ margin: 0, flex: 1 }}>슬레이트 목록</p>
|
||||||
|
<button className="ic-btn ic-btn--secondary ic-btn--sm" onClick={loadSlates}>↻ 새로고침</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{slates.length === 0 ? (
|
||||||
|
<div className="ic-empty">슬레이트가 없습니다. 카드를 생성해 보세요.</div>
|
||||||
|
) : (
|
||||||
|
<div className="ic-slates-grid">
|
||||||
|
{slates.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className={`ic-slate-card ${selectedId === s.id ? 'ic-slate-card--active' : ''}`}
|
||||||
|
onClick={() => handleSelect(s.id)}
|
||||||
|
>
|
||||||
|
{s.status === 'rendered' || s.status === 'sent' ? (
|
||||||
|
<img
|
||||||
|
className="ic-slate-thumb"
|
||||||
|
src={getInstaAssetUrl(s.id, 1)}
|
||||||
|
alt={s.keyword}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="ic-slate-thumb--placeholder">🎴</div>
|
||||||
|
)}
|
||||||
|
<div className="ic-slate-card__info">
|
||||||
|
<div className="ic-slate-card__kw">{s.keyword}</div>
|
||||||
|
<div className="ic-slate-card__meta">
|
||||||
|
<span className="ic-slate-card__date">{fmtDate(s.created_at)}</span>
|
||||||
|
<StatusBadge status={s.status} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 슬레이트 상세 */}
|
||||||
|
{detail && (
|
||||||
|
<SlateDetail
|
||||||
|
slate={detail}
|
||||||
|
onDelete={() => handleDelete(detail.id)}
|
||||||
|
onRender={() => handleRender(detail.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════ 슬레이트 상세 ══════════════════════════════════ */
|
||||||
|
function SlateDetail({ slate, onDelete, onRender }) {
|
||||||
|
const pages = slate.assets || [];
|
||||||
|
const pageCount = pages.length > 0 ? pages.length : 10;
|
||||||
|
|
||||||
|
function copyCaption() {
|
||||||
|
const text = [slate.suggested_caption, slate.hashtags?.join(' ')].filter(Boolean).join('\n\n');
|
||||||
|
navigator.clipboard.writeText(text).then(() => alert('클립보드에 복사되었습니다!'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ic-detail">
|
||||||
|
<div className="ic-detail__header">
|
||||||
|
<div className="ic-detail__title">
|
||||||
|
{slate.keyword}
|
||||||
|
<span style={{ marginLeft: 8 }}><StatusBadge status={slate.status} /></span>
|
||||||
|
</div>
|
||||||
|
<div className="ic-detail__actions">
|
||||||
|
<button className="ic-btn ic-btn--secondary ic-btn--sm" onClick={onRender}>재렌더</button>
|
||||||
|
<button className="ic-btn ic-btn--danger ic-btn--sm" onClick={onDelete}>삭제</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지 이미지 스트립 */}
|
||||||
|
{(slate.status === 'rendered' || slate.status === 'sent') ? (
|
||||||
|
<div className="ic-pages-strip">
|
||||||
|
{Array.from({ length: pageCount }, (_, i) => i + 1).map((page) => (
|
||||||
|
<img
|
||||||
|
key={page}
|
||||||
|
className="ic-page-img"
|
||||||
|
src={getInstaAssetUrl(slate.id, page)}
|
||||||
|
alt={`Page ${page}`}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="ic-empty" style={{ padding: '20px 0' }}>
|
||||||
|
{slate.status === 'failed' ? '렌더 실패 — 재렌더를 시도하세요.' : '렌더링 전입니다.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 캡션 */}
|
||||||
|
{slate.suggested_caption && (
|
||||||
|
<div className="ic-caption-box">
|
||||||
|
<div className="ic-caption-box__label">
|
||||||
|
캡션
|
||||||
|
<button
|
||||||
|
className="ic-btn ic-btn--secondary ic-btn--sm"
|
||||||
|
style={{ marginLeft: 8 }}
|
||||||
|
onClick={copyCaption}
|
||||||
|
>
|
||||||
|
복사
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="ic-caption-text">{slate.suggested_caption}</div>
|
||||||
|
{slate.hashtags?.length > 0 && (
|
||||||
|
<div className="ic-hashtags" style={{ marginTop: 8 }}>
|
||||||
|
{slate.hashtags.join(' ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 커버 카피 / 바디 카피 */}
|
||||||
|
{slate.cover_copy && (
|
||||||
|
<div className="ic-caption-box">
|
||||||
|
<div className="ic-caption-box__label">커버 카피</div>
|
||||||
|
<div className="ic-caption-text">{slate.cover_copy}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════ 프롬프트 템플릿 에디터 ══════════════════════════ */
|
||||||
|
const PROMPT_NAMES = ['slate_writer', 'category_seeds'];
|
||||||
|
|
||||||
|
function PromptTemplatesEditor() {
|
||||||
|
const [prompts, setPrompts] = useState({});
|
||||||
|
const [drafts, setDrafts] = useState({});
|
||||||
|
const [saving, setSaving] = useState({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
PROMPT_NAMES.forEach((name) => {
|
||||||
|
getInstaPrompt(name)
|
||||||
|
.then((p) => {
|
||||||
|
setPrompts((prev) => ({ ...prev, [name]: p }));
|
||||||
|
setDrafts((prev) => ({ ...prev, [name]: p.template }));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setPrompts((prev) => ({ ...prev, [name]: null }));
|
||||||
|
setDrafts((prev) => ({ ...prev, [name]: '' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleSave(name) {
|
||||||
|
setSaving((prev) => ({ ...prev, [name]: true }));
|
||||||
|
try {
|
||||||
|
const updated = await putInstaPrompt(name, drafts[name] || '', prompts[name]?.description || '');
|
||||||
|
setPrompts((prev) => ({ ...prev, [name]: updated }));
|
||||||
|
alert(`${name} 저장 완료`);
|
||||||
|
} catch (e) {
|
||||||
|
alert('저장 실패: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving((prev) => ({ ...prev, [name]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ic-prompt-editor" style={{ marginTop: 24 }}>
|
||||||
|
<p className="ic-prompt-editor__title">프롬프트 템플릿</p>
|
||||||
|
{PROMPT_NAMES.map((name) => (
|
||||||
|
<div key={name} className="ic-prompt-block">
|
||||||
|
<div className="ic-prompt-block__head">
|
||||||
|
<span className="ic-prompt-block__name">{name}</span>
|
||||||
|
{prompts[name]?.updated_at && (
|
||||||
|
<span className="ic-prompt-block__date">
|
||||||
|
최종 수정: {fmtDate(prompts[name].updated_at)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{prompts[name]?.description && (
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'rgba(255,255,255,.4)', marginBottom: 6 }}>
|
||||||
|
{prompts[name].description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
className="ic-prompt-textarea"
|
||||||
|
value={drafts[name] ?? ''}
|
||||||
|
onChange={(e) => setDrafts((prev) => ({ ...prev, [name]: e.target.value }))}
|
||||||
|
placeholder={`${name} 템플릿을 입력하세요...`}
|
||||||
|
/>
|
||||||
|
<div className="ic-prompt-save-row">
|
||||||
|
<button
|
||||||
|
className="ic-btn ic-btn--primary ic-btn--sm"
|
||||||
|
onClick={() => handleSave(name)}
|
||||||
|
disabled={saving[name]}
|
||||||
|
>
|
||||||
|
{saving[name] ? <span className="ic-spinner" /> : null}
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,460 +1,56 @@
|
|||||||
import React, { useMemo } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import {
|
import BriefingTab from './tabs/BriefingTab';
|
||||||
fmtKST, Ball, NumberRow, copyNumbers,
|
import AnalysisTab from './tabs/AnalysisTab';
|
||||||
buildMetricsFromFrequency, BEST_PICKS_DEFAULT_SHOW,
|
import PurchaseTab from './tabs/PurchaseTab';
|
||||||
} from './lottoUtils';
|
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||||
|
import SwipeableView from '../../components/SwipeableView';
|
||||||
|
|
||||||
/* ── hooks ──────────────────────────────────────────────────────── */
|
const TABS = [
|
||||||
import useLottoData from './hooks/useLottoData';
|
{ id: 'briefing', label: '🗓 이번 주 브리핑' },
|
||||||
import usePurchases from './hooks/usePurchases';
|
{ id: 'analysis', label: '📚 자료실 / Deep Dive' },
|
||||||
import useManualRecommend from './hooks/useManualRecommend';
|
{ id: 'purchase', label: '💰 구매·성과' },
|
||||||
|
];
|
||||||
|
|
||||||
/* ── components ─────────────────────────────────────────────────── */
|
|
||||||
import MetricBlock from './components/MetricBlock';
|
|
||||||
import FrequencyChart from './components/FrequencyChart';
|
|
||||||
import PerformanceBanner from './components/PerformanceBanner';
|
|
||||||
import CombinedRecommendPanel from './components/CombinedRecommendPanel';
|
|
||||||
import ReportPanel from './components/ReportPanel';
|
|
||||||
import PersonalAnalysisPanel from './components/PersonalAnalysisPanel';
|
|
||||||
import PurchasePanel from './components/PurchasePanel';
|
|
||||||
|
|
||||||
/* ── component ──────────────────────────────────────────────────── */
|
|
||||||
export default function Functions() {
|
export default function Functions() {
|
||||||
const ld = useLottoData();
|
const [tab, setTab] = useState('briefing');
|
||||||
const pur = usePurchases();
|
const isMobile = useIsMobile();
|
||||||
const mr = useManualRecommend();
|
|
||||||
|
|
||||||
/* ── derived ────────────────────────────────────────────────── */
|
const tabIndex = TABS.findIndex(t => t.id === tab);
|
||||||
const overallMetrics = useMemo(() => buildMetricsFromFrequency(ld.stats?.frequency), [ld.stats]);
|
|
||||||
const visibleBestPicks = ld.bestPicksExpanded ? ld.bestPicks : ld.bestPicks.slice(0, BEST_PICKS_DEFAULT_SHOW);
|
|
||||||
|
|
||||||
/* ── merged error ───────────────────────────────────────────── */
|
const handleTabChange = useCallback((index) => {
|
||||||
const error = ld.error || mr.error;
|
setTab(TABS[index].id);
|
||||||
const clearError = () => { ld.setError(''); mr.setError(''); };
|
}, []);
|
||||||
|
|
||||||
/* ── render ──────────────────────────────────────────────────── */
|
|
||||||
return (
|
return (
|
||||||
<div className="lotto-functions">
|
<div className="lotto-functions">
|
||||||
{error ? (
|
{isMobile ? (
|
||||||
<div className="lotto-alert">
|
<SwipeableView
|
||||||
<div>
|
tabs={TABS.map(t => ({
|
||||||
<p className="lotto-alert__title">오류</p>
|
key: t.id,
|
||||||
<p className="lotto-alert__message">{error}</p>
|
label: t.label,
|
||||||
</div>
|
content: t.id === 'briefing' ? <BriefingTab /> : t.id === 'analysis' ? <AnalysisTab /> : <PurchaseTab />,
|
||||||
<button className="button ghost small" onClick={clearError}>닫기</button>
|
}))}
|
||||||
</div>
|
activeIndex={tabIndex}
|
||||||
) : null}
|
onTabChange={handleTabChange}
|
||||||
|
/>
|
||||||
{/* ── 신뢰도 배너 ── */}
|
) : (
|
||||||
<PerformanceBanner perf={ld.perfStats} />
|
<>
|
||||||
|
<nav className="lotto-tabs">
|
||||||
{/* ── 종합 추론 번호 추천 ── */}
|
{TABS.map(t => (
|
||||||
<CombinedRecommendPanel
|
<button
|
||||||
combined={ld.combined}
|
key={t.id}
|
||||||
history={ld.combinedHistory}
|
className={tab === t.id ? 'active' : ''}
|
||||||
loading={ld.combinedLoading}
|
onClick={() => setTab(t.id)}
|
||||||
histLoading={ld.combinedHistLoading}
|
>{t.label}</button>
|
||||||
onRun={ld.runCombinedRecommend}
|
|
||||||
onCopy={copyNumbers}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ── 최신 회차 + 시뮬레이션 추천 ── */}
|
|
||||||
<div className="lotto-grid">
|
|
||||||
{/* Latest Draw */}
|
|
||||||
<section className="lotto-panel">
|
|
||||||
<div className="lotto-panel__head">
|
|
||||||
<div>
|
|
||||||
<p className="lotto-panel__eyebrow">Latest Draw</p>
|
|
||||||
<h3>최신 회차</h3>
|
|
||||||
<p className="lotto-panel__sub">최신 회차와 번호를 빠르게 확인할 수 있습니다.</p>
|
|
||||||
</div>
|
|
||||||
<div className="lotto-panel__actions">
|
|
||||||
{ld.loading.latest ? <span className="lotto-chip">로딩 중</span> : null}
|
|
||||||
<button className="button ghost small" onClick={ld.refreshLatest} disabled={ld.loading.latest}>
|
|
||||||
새로고침
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{ld.latest ? (
|
|
||||||
<>
|
|
||||||
<div className="lotto-meta">
|
|
||||||
<div>
|
|
||||||
<p className="lotto-meta__title">{ld.latest.drawNo}회</p>
|
|
||||||
<p className="lotto-meta__date">{ld.latest.date}</p>
|
|
||||||
</div>
|
|
||||||
<button className="button small" onClick={() => copyNumbers(ld.latest.numbers)}>
|
|
||||||
번호 복사
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<NumberRow nums={ld.latest.numbers} />
|
|
||||||
<p className="lotto-bonus">보너스 <strong>{ld.latest.bonus}</strong></p>
|
|
||||||
{overallMetrics && (
|
|
||||||
<MetricBlock title="당첨 통계 (전체 회차)" metrics={overallMetrics} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="lotto-empty">최신 회차 데이터가 없습니다.</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Simulation Picks */}
|
|
||||||
<section className="lotto-panel">
|
|
||||||
<div className="lotto-panel__head">
|
|
||||||
<div>
|
|
||||||
<p className="lotto-panel__eyebrow">Simulation Picks</p>
|
|
||||||
<h3>시뮬레이션 추천</h3>
|
|
||||||
<p className="lotto-panel__sub">
|
|
||||||
하루 6회 몬테카를로 시뮬레이션으로 선별된 최적 번호입니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="lotto-panel__actions">
|
|
||||||
{ld.loading.bestPicks ? <span className="lotto-chip">로딩 중</span> : null}
|
|
||||||
{ld.simulating ? <span className="lotto-chip lotto-chip--active">분석 중</span> : null}
|
|
||||||
<button className="button ghost small" onClick={ld.refreshBestPicks}
|
|
||||||
disabled={ld.loading.bestPicks || ld.simulating}>
|
|
||||||
새로고침
|
|
||||||
</button>
|
|
||||||
<button className="button small" onClick={ld.onSimulate}
|
|
||||||
disabled={ld.simulating || ld.loading.bestPicks}>
|
|
||||||
{ld.simulating ? '실행 중...' : '지금 실행'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{ld.simResult && (
|
|
||||||
<div className="lotto-sim-result">
|
|
||||||
<p>완료: {ld.simResult.total_generated?.toLocaleString()}개 후보 → 상위 {ld.simResult.best_n_saved}개 저장</p>
|
|
||||||
<p>최고 점수 {((ld.simResult.best_score ?? 0) * 100).toFixed(1)}% / 평균 {((ld.simResult.avg_score ?? 0) * 100).toFixed(1)}%</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ld.bestPicks.length === 0 ? (
|
|
||||||
<p className="lotto-empty">
|
|
||||||
{ld.loading.bestPicks ? '불러오는 중...' : "시뮬레이션 결과가 없습니다. '지금 실행'으로 시작하세요."}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="lotto-picks">
|
|
||||||
{visibleBestPicks.map((pick) => (
|
|
||||||
<div key={pick.id} className="lotto-pick">
|
|
||||||
<span className="lotto-pick__rank">#{pick.rank}</span>
|
|
||||||
<div className="lotto-pick__content">
|
|
||||||
<NumberRow nums={pick.numbers} />
|
|
||||||
<div className="lotto-pick__score">
|
|
||||||
<span className="lotto-pick__score-label">
|
|
||||||
{((pick.score_total ?? 0) * 100).toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
<div className="lotto-pick__bar">
|
|
||||||
<span style={{ width: `${(pick.score_total ?? 0) * 100}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button className="button ghost small" onClick={() => copyNumbers(pick.numbers)}>
|
|
||||||
복사
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{ld.bestPicks.length > BEST_PICKS_DEFAULT_SHOW && (
|
|
||||||
<button
|
|
||||||
className="button ghost small lotto-history-toggle"
|
|
||||||
onClick={() => ld.setBestPicksExpanded((p) => !p)}
|
|
||||||
aria-expanded={ld.bestPicksExpanded}
|
|
||||||
>
|
|
||||||
{ld.bestPicksExpanded ? '접기' : `모두 보기 (${ld.bestPicks.length}개)`}
|
|
||||||
<span className={`lotto-history-toggle__icon ${ld.bestPicksExpanded ? 'is-open' : ''}`} aria-hidden>▼</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<p className="lotto-panel__sub">
|
|
||||||
갱신: {fmtKST(ld.bestPicks[0]?.created_at) || '-'}
|
|
||||||
{ld.bestPicks[0]?.based_on_draw ? ` · ${ld.bestPicks[0].based_on_draw}회 기준` : ''}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── 이번 주 공략 리포트 ── */}
|
|
||||||
<ReportPanel
|
|
||||||
report={ld.report}
|
|
||||||
history={ld.reportHistory}
|
|
||||||
loading={ld.reportLoading}
|
|
||||||
onRefresh={ld.refreshReport}
|
|
||||||
onSelectDrw={ld.loadSpecificReport}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ── 통계 분석 ── */}
|
|
||||||
<section className="lotto-panel lotto-panel--wide">
|
|
||||||
<div className="lotto-panel__head">
|
|
||||||
<div>
|
|
||||||
<p className="lotto-panel__eyebrow">Analysis</p>
|
|
||||||
<h3>통계 분석</h3>
|
|
||||||
<p className="lotto-panel__sub">빈도, Z-score, 갭 분석으로 번호를 분류합니다.</p>
|
|
||||||
</div>
|
|
||||||
<div className="lotto-panel__actions">
|
|
||||||
{ld.loading.analysis ? <span className="lotto-chip">로딩 중</span> : null}
|
|
||||||
<button className="button ghost small" onClick={ld.refreshAnalysis} disabled={ld.loading.analysis}>
|
|
||||||
새로고침
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{ld.analysis ? (
|
|
||||||
<div className="lotto-analysis">
|
|
||||||
<div className="lotto-analysis__row">
|
|
||||||
<div className="lotto-analysis__group">
|
|
||||||
<p className="lotto-analysis__label">🔥 핫 번호 <span>출현 빈도 상위 10</span></p>
|
|
||||||
<div className="lotto-row">
|
|
||||||
{(ld.analysis.hot_numbers ?? []).map((n) => <Ball key={n} n={n} />)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="lotto-analysis__group">
|
|
||||||
<p className="lotto-analysis__label">🧊 콜드 번호 <span>출현 빈도 하위 10</span></p>
|
|
||||||
<div className="lotto-row">
|
|
||||||
{(ld.analysis.cold_numbers ?? []).map((n) => <Ball key={n} n={n} />)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="lotto-analysis__group">
|
|
||||||
<p className="lotto-analysis__label">⏰ 오버듀 번호 <span>오래 안 나온 번호 (회차 수)</span></p>
|
|
||||||
<div className="lotto-row">
|
|
||||||
{(ld.analysis.overdue_numbers ?? []).map((n) => {
|
|
||||||
const stat = (ld.analysis.number_stats ?? []).find((s) => s.number === n);
|
|
||||||
return (
|
|
||||||
<div key={n} className="lotto-overdue">
|
|
||||||
<Ball n={n} />
|
|
||||||
<span className="lotto-overdue__gap">{stat?.gap ?? '-'}회</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="lotto-analysis__stats">
|
|
||||||
<span>역대 합계 평균 <strong>{ld.analysis.mean_sum}</strong></span>
|
|
||||||
<span>표준편차 <strong>±{ld.analysis.std_sum}</strong></span>
|
|
||||||
<span>분석 회차 <strong>{ld.analysis.total_draws?.toLocaleString()}</strong></span>
|
|
||||||
<span>
|
|
||||||
홀수 3:짝수 3 확률{' '}
|
|
||||||
<strong>
|
|
||||||
{ld.analysis.odd_distribution?.['3'] ? `${ld.analysis.odd_distribution['3']}%` : '-'}
|
|
||||||
</strong>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="lotto-empty">
|
|
||||||
{ld.loading.analysis ? '불러오는 중...' : '통계 분석 데이터가 없습니다.'}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ── 전체 번호 분포 ── */}
|
|
||||||
<section className="lotto-panel lotto-panel--wide">
|
|
||||||
<div className="lotto-panel__head">
|
|
||||||
<div>
|
|
||||||
<p className="lotto-panel__eyebrow">Distribution</p>
|
|
||||||
<h3>전체 회차 번호 분포</h3>
|
|
||||||
<p className="lotto-panel__sub">1~45번 번호가 등장한 횟수를 기준으로 분포를 표시합니다.</p>
|
|
||||||
</div>
|
|
||||||
<div className="lotto-panel__actions">
|
|
||||||
{ld.statsLoading ? <span className="lotto-chip">로딩 중</span> : null}
|
|
||||||
{ld.stats?.total_draws ? (
|
|
||||||
<span className="lotto-chip">{ld.stats.total_draws}회차</span>
|
|
||||||
) : null}
|
|
||||||
<button className="button ghost small" onClick={ld.refreshStats} disabled={ld.statsLoading}>
|
|
||||||
새로고침
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{ld.statsError ? <p className="lotto-empty">{ld.statsError}</p> : null}
|
|
||||||
{ld.stats ? (
|
|
||||||
<FrequencyChart stats={ld.stats} />
|
|
||||||
) : (
|
|
||||||
<p className="lotto-empty">통계 데이터를 불러오지 못했습니다.</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ── 내 번호 패턴 ── */}
|
|
||||||
<PersonalAnalysisPanel data={ld.personalAnalysis} loading={ld.personalLoading} />
|
|
||||||
|
|
||||||
{/* ── 구매 기록 ── */}
|
|
||||||
<PurchasePanel
|
|
||||||
records={pur.purchases}
|
|
||||||
stats={pur.purchaseStats}
|
|
||||||
loading={pur.purchaseLoading}
|
|
||||||
formOpen={pur.purchaseFormOpen}
|
|
||||||
form={pur.purchaseForm}
|
|
||||||
formSaving={pur.purchaseFormSaving}
|
|
||||||
formError={pur.purchaseFormError}
|
|
||||||
editId={pur.purchaseEditId}
|
|
||||||
onFormOpen={pur.handlePurchaseFormOpen}
|
|
||||||
onFormClose={pur.handlePurchaseFormClose}
|
|
||||||
onFormChange={pur.handlePurchaseFormChange}
|
|
||||||
onFormSubmit={pur.handlePurchaseFormSubmit}
|
|
||||||
onEditStart={pur.handlePurchaseEditStart}
|
|
||||||
onDelete={pur.handlePurchaseDelete}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ── 수동 추천 ── */}
|
|
||||||
<section className="lotto-panel">
|
|
||||||
<div className="lotto-panel__head">
|
|
||||||
<div>
|
|
||||||
<p className="lotto-panel__eyebrow">Manual Recommendation</p>
|
|
||||||
<h3>수동 추천</h3>
|
|
||||||
<p className="lotto-panel__sub">파라미터를 직접 조정해 번호를 추천받을 수 있습니다.</p>
|
|
||||||
</div>
|
|
||||||
<div className="lotto-panel__actions">
|
|
||||||
{mr.loading.recommend ? <span className="lotto-chip">계산 중</span> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="lotto-presets">
|
|
||||||
{mr.presets.map((preset) => (
|
|
||||||
<button key={preset.name} className="button ghost small"
|
|
||||||
onClick={() => mr.setParams({
|
|
||||||
recent_window: preset.recent_window,
|
|
||||||
recent_weight: preset.recent_weight,
|
|
||||||
avoid_recent_k: preset.avoid_recent_k,
|
|
||||||
})}>
|
|
||||||
{preset.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="lotto-form">
|
|
||||||
<label className="lotto-field">
|
|
||||||
recent_window <span>최근 N회차 가중치 범위</span>
|
|
||||||
<input type="number" min={20} max={1000} value={mr.params.recent_window}
|
|
||||||
onChange={(e) => mr.setParams((s) => ({ ...s, recent_window: Number(e.target.value) }))} />
|
|
||||||
</label>
|
|
||||||
<label className="lotto-field">
|
|
||||||
recent_weight <span>최근 회차 가중치</span>
|
|
||||||
<input type="number" step="0.1" min={0.5} max={10} value={mr.params.recent_weight}
|
|
||||||
onChange={(e) => mr.setParams((s) => ({ ...s, recent_weight: Number(e.target.value) }))} />
|
|
||||||
</label>
|
|
||||||
<label className="lotto-field">
|
|
||||||
avoid_recent_k <span>최근 K회차 중복 회피</span>
|
|
||||||
<input type="number" min={0} max={50} value={mr.params.avoid_recent_k}
|
|
||||||
onChange={(e) => mr.setParams((s) => ({ ...s, avoid_recent_k: Number(e.target.value) }))} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button className="button primary" onClick={mr.onRecommend} disabled={mr.loading.recommend}>
|
|
||||||
추천 받기
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{mr.result ? (
|
|
||||||
<div className="lotto-result">
|
|
||||||
<div className="lotto-result__meta">
|
|
||||||
<div>
|
|
||||||
<p className="lotto-result__id">추천 ID #{mr.result.id}</p>
|
|
||||||
<p className="lotto-result__based">기준 회차 {mr.result.based_on_latest_draw ?? '-'}</p>
|
|
||||||
</div>
|
|
||||||
<button className="button small" onClick={() => copyNumbers(mr.result.numbers)}>
|
|
||||||
번호 복사
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{mr.result.numbers && <NumberRow nums={mr.result.numbers} />}
|
|
||||||
{mr.historyMetrics && (
|
|
||||||
<div className="lotto-compare">
|
|
||||||
<MetricBlock title="추천 통계 (히스토리)" metrics={mr.historyMetrics} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{Array.isArray(mr.result.items) && mr.result.items.length ? (
|
|
||||||
<details className="lotto-details">
|
|
||||||
<summary>추천 후보 보기</summary>
|
|
||||||
<div className="lotto-batch">
|
|
||||||
{mr.result.items.map((item, idx) => (
|
|
||||||
<div key={item.id ?? `candidate-${idx}`} className="lotto-batch__item">
|
|
||||||
<div className="lotto-batch__meta">
|
|
||||||
<div>
|
|
||||||
<p className="lotto-batch__title">후보 #{item.id ?? idx + 1}</p>
|
|
||||||
<p className="lotto-batch__sub">기준 회차 {item.based_on_draw ?? '-'}</p>
|
|
||||||
</div>
|
|
||||||
<button className="button ghost small" onClick={() => copyNumbers(item.numbers)}>
|
|
||||||
복사
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<NumberRow nums={item.numbers} />
|
|
||||||
{item.metrics && <MetricBlock title="후보 통계" metrics={item.metrics} />}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
) : null}
|
|
||||||
{mr.result.explain && (
|
|
||||||
<details className="lotto-details">
|
|
||||||
<summary>설명 보기</summary>
|
|
||||||
<pre>{JSON.stringify(mr.result.explain, null, 2)}</pre>
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="lotto-empty">아직 추천 결과가 없습니다.</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ── 추천 히스토리 ── */}
|
|
||||||
<section className="lotto-panel">
|
|
||||||
<div className="lotto-panel__head">
|
|
||||||
<div>
|
|
||||||
<p className="lotto-panel__eyebrow">History</p>
|
|
||||||
<h3>추천 히스토리</h3>
|
|
||||||
<p className="lotto-panel__sub">수동 추천 결과를 모아서 확인할 수 있습니다.</p>
|
|
||||||
</div>
|
|
||||||
<div className="lotto-panel__actions">
|
|
||||||
<span className="lotto-chip">{mr.history.length}건</span>
|
|
||||||
{mr.history.length > 5 && (
|
|
||||||
<button className="button ghost small lotto-history-toggle"
|
|
||||||
onClick={() => mr.setHistoryExpanded((p) => !p)}
|
|
||||||
aria-expanded={mr.historyExpanded}>
|
|
||||||
{mr.historyExpanded ? '접기' : '더보기'}
|
|
||||||
<span className={`lotto-history-toggle__icon ${mr.historyExpanded ? 'is-open' : ''}`} aria-hidden>▼</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button className="button ghost small" onClick={mr.refreshHistory} disabled={mr.loading.history}>
|
|
||||||
새로고침
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{mr.loading.history ? <p className="lotto-empty">불러오는 중...</p> : null}
|
|
||||||
{mr.history.length === 0 ? (
|
|
||||||
<p className="lotto-empty">저장된 히스토리가 없습니다.</p>
|
|
||||||
) : (
|
|
||||||
<div className="lotto-history">
|
|
||||||
{mr.visibleHistory.map((item) => (
|
|
||||||
<div key={item.id} className="lotto-history__item">
|
|
||||||
<div className="lotto-history__meta">
|
|
||||||
<p>#{item.id}</p>
|
|
||||||
<p>{fmtKST(item.created_at)}</p>
|
|
||||||
<p>기준 회차 {item.based_on_draw ?? '-'}</p>
|
|
||||||
</div>
|
|
||||||
<div className="lotto-history__body">
|
|
||||||
<NumberRow nums={item.numbers} />
|
|
||||||
<p className="lotto-history__params">
|
|
||||||
window={item.params?.recent_window}, weight={item.params?.recent_weight},
|
|
||||||
avoid_k={item.params?.avoid_recent_k}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="lotto-history__actions">
|
|
||||||
<button className="button ghost small" onClick={() => copyNumbers(item.numbers)}>
|
|
||||||
복사
|
|
||||||
</button>
|
|
||||||
<button className="button danger small" onClick={() => mr.onDelete(item.id)}>
|
|
||||||
삭제
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
<span ref={mr.historyEndRef} />
|
</nav>
|
||||||
|
<div className="lotto-tab-body">
|
||||||
|
{tab === 'briefing' && <BriefingTab />}
|
||||||
|
{tab === 'analysis' && <AnalysisTab />}
|
||||||
|
{tab === 'purchase' && <PurchaseTab />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</>
|
||||||
</section>
|
)}
|
||||||
|
|
||||||
<footer className="lotto-foot">
|
|
||||||
backend: FastAPI / nginx proxy / DB: sqlite ·{' '}
|
|
||||||
<a className="lotto-foot__link" href="/lotto-api.md" download>API 스펙 다운로드</a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1020,7 +1020,7 @@
|
|||||||
|
|
||||||
.lotto-purchase-list__head {
|
.lotto-purchase-list__head {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 60px 100px 100px 100px minmax(0, 1fr) 120px;
|
grid-template-columns: 60px 100px 100px 100px minmax(0, 160px) minmax(0, 1fr) 120px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -1033,7 +1033,7 @@
|
|||||||
|
|
||||||
.lotto-purchase-row {
|
.lotto-purchase-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 60px 100px 100px 100px minmax(0, 1fr) 120px;
|
grid-template-columns: 60px 100px 100px 100px minmax(0, 160px) minmax(0, 1fr) 120px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
@@ -1068,47 +1068,28 @@
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lotto-purchase-row__hits {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hit-badge { display: inline-block; min-width: 16px; padding: 1px 4px; margin-right: 2px;
|
||||||
|
font-size: 10px; border-radius: 4px; background: rgba(255,255,255,0.06); text-align: center; }
|
||||||
|
.hit-badge.hit-3 { background: rgba(80, 200, 120, 0.2); color: #76e09a; }
|
||||||
|
.hit-badge.hit-4 { background: rgba(255, 200, 80, 0.25); color: #ffce6e; font-weight: 700; }
|
||||||
|
.hit-badge.hit-5, .hit-badge.hit-6 { background: rgba(255, 100, 130, 0.3); color: #ff8aa0; font-weight: 700; }
|
||||||
|
.prize-flag { font-size: 10px; color: #ff8aa0; margin-left: 6px; }
|
||||||
|
|
||||||
.is-pos { color: #97c9aa; }
|
.is-pos { color: #97c9aa; }
|
||||||
.is-neg { color: #f7a8a5; }
|
.is-neg { color: #f7a8a5; }
|
||||||
.is-prize { color: #fdd4b1; }
|
.is-prize { color: #fdd4b1; }
|
||||||
|
|
||||||
/* ── 반응형 ─────────────────────────────────────────────────────────────── */
|
/* ── 반응형 ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 480px) {
|
||||||
.lotto-header {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lotto-history__item {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lotto-analysis__row {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lotto-pick {
|
|
||||||
grid-template-columns: 24px minmax(0, 1fr) auto;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lotto-report-top {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lotto-purchase-list__head,
|
|
||||||
.lotto-purchase-row {
|
|
||||||
grid-template-columns: 56px 90px 90px minmax(0, 1fr) 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lotto-purchase-list__head span:nth-child(4),
|
|
||||||
.lotto-purchase-row span:nth-child(4) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.lotto-purchase-stats {
|
.lotto-purchase-stats {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -1132,8 +1113,8 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lotto-purchase-list__head span:nth-child(n+3):nth-child(-n+5),
|
.lotto-purchase-list__head span:nth-child(n+3):nth-child(-n+6),
|
||||||
.lotto-purchase-row span:nth-child(n+3):nth-child(-n+5) {
|
.lotto-purchase-row span:nth-child(n+3):nth-child(-n+6) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1157,6 +1138,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.lotto-header {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-analysis__row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-pick {
|
||||||
|
grid-template-columns: 24px minmax(0, 1fr) auto;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-report-top {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-purchase-list__head,
|
||||||
|
.lotto-purchase-row {
|
||||||
|
grid-template-columns: 56px 90px 90px minmax(0, 120px) minmax(0, 1fr) 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-purchase-list__head span:nth-child(4),
|
||||||
|
.lotto-purchase-row span:nth-child(4) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.lotto-header h1 {
|
.lotto-header h1 {
|
||||||
font-size: clamp(24px, 6vw, 32px);
|
font-size: clamp(24px, 6vw, 32px);
|
||||||
}
|
}
|
||||||
@@ -1181,9 +1190,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lotto-ball {
|
.lotto-ball {
|
||||||
width: 36px;
|
width: 32px;
|
||||||
height: 36px;
|
height: 32px;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lotto-meta__title {
|
.lotto-meta__title {
|
||||||
@@ -1191,6 +1200,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lotto-history__item {
|
.lotto-history__item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
@@ -1459,7 +1469,7 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 480px) {
|
||||||
.lotto-combined__method {
|
.lotto-combined__method {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -1475,3 +1485,70 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Briefing UI ──────────────────────────────────────────────────────────── */
|
||||||
|
.briefing-header { padding: 16px; border-radius: 12px; background: rgba(129,140,248,0.08); margin-bottom: 16px; }
|
||||||
|
.briefing-header-row { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.briefing-meta { display: flex; gap: 12px; color: #94a3b8; font-size: 0.85rem; margin-top: 4px; flex-wrap: wrap; }
|
||||||
|
.briefing-confidence strong { color: #e2e8f0; }
|
||||||
|
.briefing-tokens { font-family: monospace; }
|
||||||
|
.briefing-confidence-bar { height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; margin-top: 8px; overflow: hidden; }
|
||||||
|
.briefing-confidence-bar > div { height: 100%; background: linear-gradient(90deg, #818cf8, #34d399); transition: width .3s; }
|
||||||
|
.briefing-summary { padding: 12px 16px; background: rgba(0,0,0,0.2); border-radius: 10px; margin-bottom: 16px; }
|
||||||
|
.briefing-summary h3 { margin: 0 0 8px; }
|
||||||
|
.briefing-3lines { margin: 0; padding-left: 20px; }
|
||||||
|
.briefing-hotcold { color: #fbbf24; margin-top: 8px; }
|
||||||
|
.briefing-warning { color: #f87171; margin-top: 8px; }
|
||||||
|
.pick-card { padding: 12px; border-radius: 10px; background: rgba(255,255,255,0.04); border-left: 3px solid #64748b; margin-bottom: 8px; }
|
||||||
|
.pick-card--안정 { border-left-color: #34d399; }
|
||||||
|
.pick-card--균형 { border-left-color: #fbbf24; }
|
||||||
|
.pick-card--공격 { border-left-color: #f87171; }
|
||||||
|
.pick-card-header { display: flex; justify-content: space-between; font-size: 0.85rem; color: #94a3b8; margin-bottom: 6px; }
|
||||||
|
.pick-card-balls { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 6px; }
|
||||||
|
.ball { width: 32px; height: 32px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: bold; color: #fff; }
|
||||||
|
.ball--1 { background: #fbbf24; } .ball--2 { background: #60a5fa; } .ball--3 { background: #f87171; }
|
||||||
|
.ball--4 { background: #94a3b8; } .ball--5 { background: #34d399; }
|
||||||
|
.pick-card-reason { margin: 0; font-size: 0.85rem; color: #cbd5e1; }
|
||||||
|
.briefing-empty { text-align: center; padding: 40px 20px; color: #94a3b8; }
|
||||||
|
.briefing-empty button { margin-top: 12px; padding: 8px 20px; }
|
||||||
|
.briefing-empty-hint { font-size: 0.85rem; }
|
||||||
|
.briefing-error { color: #f87171; margin-top: 8px; }
|
||||||
|
.curator-usage-footer { display: flex; gap: 12px; padding: 10px 14px; background: rgba(0,0,0,0.25); border-radius: 8px; font-size: 0.8rem; color: #94a3b8; margin-top: 24px; flex-wrap: wrap; font-family: monospace; }
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.briefing-meta { font-size: 0.75rem; }
|
||||||
|
.briefing-tokens { width: 100%; }
|
||||||
|
.pick-card-balls { justify-content: center; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tab navigation ───────────────────────────────────────────────────────── */
|
||||||
|
.lotto-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid rgba(255,255,255,0.1); }
|
||||||
|
.lotto-tabs button { padding: 8px 16px; background: transparent; border: none; color: #94a3b8; cursor: pointer; border-bottom: 2px solid transparent; }
|
||||||
|
.lotto-tabs button.active { color: #e2e8f0; border-bottom-color: #818cf8; }
|
||||||
|
.lotto-tab-body { padding-top: 8px; display: grid; gap: 24px; }
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.lotto-tabs { overflow-x: auto; }
|
||||||
|
.lotto-tabs button { white-space: nowrap; }
|
||||||
|
|
||||||
|
/* 구매 이력 테이블 가로 스크롤 */
|
||||||
|
.purchase-list {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-ball {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotto-section-fold { margin-bottom: 14px; }
|
||||||
|
.lotto-section-fold > summary { cursor: pointer; padding: 12px 16px; background: rgba(255,255,255,0.03);
|
||||||
|
border-radius: 10px; font-weight: 600; font-size: 14px; opacity: 0.85; }
|
||||||
|
.lotto-section-fold[open] > summary { margin-bottom: 12px; opacity: 1; }
|
||||||
|
|
||||||
|
.trend-chart { display: block; margin: 0 auto; }
|
||||||
|
.trend-legend { display: flex; gap: 16px; justify-content: center; font-size: 11px; opacity: 0.7; margin-top: 8px; }
|
||||||
|
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; vertical-align: middle; }
|
||||||
|
.dot--curator { background: #b8a8ff; }
|
||||||
|
.dot--user { background: #76e09a; }
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ const PurchasePanel = ({
|
|||||||
<span>투자금</span>
|
<span>투자금</span>
|
||||||
<span>당첨금</span>
|
<span>당첨금</span>
|
||||||
<span>손익</span>
|
<span>손익</span>
|
||||||
|
<span>채점</span>
|
||||||
<span>메모</span>
|
<span>메모</span>
|
||||||
<span />
|
<span />
|
||||||
</div>
|
</div>
|
||||||
@@ -152,6 +153,14 @@ const PurchasePanel = ({
|
|||||||
<span className={net >= 0 ? 'is-pos' : 'is-neg'}>
|
<span className={net >= 0 ? 'is-pos' : 'is-neg'}>
|
||||||
{net >= 0 ? '+' : ''}{fmtWon(net)}
|
{net >= 0 ? '+' : ''}{fmtWon(net)}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="lotto-purchase-row__hits">
|
||||||
|
{(rec.results || []).map((r, i) => (
|
||||||
|
<span key={i} className={`hit-badge hit-${r.correct}`}>{r.correct}</span>
|
||||||
|
))}
|
||||||
|
{(rec.results || []).some((r) => r.correct >= 4) && (
|
||||||
|
<span className="prize-flag">🚨 4등↑ 확인 필요</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<span className="lotto-purchase-row__note">{rec.note || '-'}</span>
|
<span className="lotto-purchase-row__note">{rec.note || '-'}</span>
|
||||||
<div className="lotto-purchase-row__actions">
|
<div className="lotto-purchase-row__actions">
|
||||||
<button className="button ghost small" onClick={() => onEditStart(rec)}>
|
<button className="button ghost small" onClick={() => onEditStart(rec)}>
|
||||||
|
|||||||
44
src/pages/lotto/components/PurchaseTrendChart.jsx
Normal file
44
src/pages/lotto/components/PurchaseTrendChart.jsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getReviewHistory } from '../../../api';
|
||||||
|
|
||||||
|
export default function PurchaseTrendChart() {
|
||||||
|
const [reviews, setReviews] = useState([]);
|
||||||
|
useEffect(() => {
|
||||||
|
getReviewHistory(4).then(rs => setReviews(rs.reverse())); // asc
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (reviews.length === 0) return null;
|
||||||
|
|
||||||
|
const maxAvg = Math.max(
|
||||||
|
...reviews.flatMap(r => [r.curator_avg_match || 0, r.user_avg_match || 0]),
|
||||||
|
2.5
|
||||||
|
);
|
||||||
|
const w = 320, h = 80, pad = 16;
|
||||||
|
const xs = (i) => pad + (i / Math.max(reviews.length - 1, 1)) * (w - 2 * pad);
|
||||||
|
const ys = (v) => v == null ? null : h - pad - (v / maxAvg) * (h - 2 * pad);
|
||||||
|
|
||||||
|
const line = (key) => reviews
|
||||||
|
.map((r, i) => ({ x: xs(i), y: ys(r[key]) }))
|
||||||
|
.filter(p => p.y != null)
|
||||||
|
.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="lotto-panel">
|
||||||
|
<div className="lotto-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="lotto-panel__eyebrow">Trend (last 4 weeks)</p>
|
||||||
|
<h3>너 vs 큐레이터 평균 일치 수</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg width={w} height={h} className="trend-chart">
|
||||||
|
<path d={line('curator_avg_match')} stroke="#b8a8ff" strokeWidth="2" fill="none" />
|
||||||
|
<path d={line('user_avg_match')} stroke="#76e09a" strokeWidth="2" fill="none" />
|
||||||
|
</svg>
|
||||||
|
<div className="trend-legend">
|
||||||
|
<span><span className="dot dot--curator" /> 큐레이터</span>
|
||||||
|
<span><span className="dot dot--user" /> 너</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/pages/lotto/components/briefing/BriefingEmpty.jsx
Normal file
12
src/pages/lotto/components/briefing/BriefingEmpty.jsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export default function BriefingEmpty({ regenerating, onRegenerate, error }) {
|
||||||
|
return (
|
||||||
|
<div className="briefing-empty">
|
||||||
|
<p>아직 이번 주 브리핑이 없습니다.</p>
|
||||||
|
<p className="briefing-empty-hint">매주 월요일 07:00에 자동 생성됩니다.</p>
|
||||||
|
<button onClick={onRegenerate} disabled={regenerating}>
|
||||||
|
{regenerating ? '⏳ 생성 중...' : '지금 생성'}
|
||||||
|
</button>
|
||||||
|
{error && <p className="briefing-error">⚠️ {error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/pages/lotto/components/briefing/BriefingHeader.jsx
Normal file
28
src/pages/lotto/components/briefing/BriefingHeader.jsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { estimateCost, fmtUsd, fmtTokens } from './pricing';
|
||||||
|
|
||||||
|
export default function BriefingHeader({ briefing, regenerating, onRegenerate }) {
|
||||||
|
const cost = estimateCost(briefing);
|
||||||
|
const genDate = new Date(briefing.generated_at).toLocaleString('ko-KR');
|
||||||
|
return (
|
||||||
|
<div className="briefing-header">
|
||||||
|
<div className="briefing-header-row">
|
||||||
|
<h2>🗓 #{briefing.draw_no}회 브리핑</h2>
|
||||||
|
<button onClick={onRegenerate} disabled={regenerating}>
|
||||||
|
{regenerating ? '⏳ 생성 중...' : '🔄 다시 생성'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="briefing-meta">
|
||||||
|
<span>{genDate}</span>
|
||||||
|
<span className="briefing-confidence">
|
||||||
|
신뢰도 <strong>{briefing.confidence}</strong>/100
|
||||||
|
</span>
|
||||||
|
<span className="briefing-tokens">
|
||||||
|
{fmtTokens(briefing.tokens_input)} in · {fmtTokens(briefing.tokens_output)} out · {fmtUsd(cost)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="briefing-confidence-bar">
|
||||||
|
<div style={{ width: `${briefing.confidence}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/pages/lotto/components/briefing/BriefingSummary.jsx
Normal file
16
src/pages/lotto/components/briefing/BriefingSummary.jsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export default function BriefingSummary({ narrative }) {
|
||||||
|
return (
|
||||||
|
<div className="briefing-summary">
|
||||||
|
<h3>{narrative.headline}</h3>
|
||||||
|
<ul className="briefing-3lines">
|
||||||
|
{narrative.summary_3lines.map((line, i) => <li key={i}>{line}</li>)}
|
||||||
|
</ul>
|
||||||
|
{narrative.hot_cold_comment && (
|
||||||
|
<p className="briefing-hotcold">🔥❄️ {narrative.hot_cold_comment}</p>
|
||||||
|
)}
|
||||||
|
{narrative.warnings && (
|
||||||
|
<p className="briefing-warning">⚠️ {narrative.warnings}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/pages/lotto/components/briefing/CuratorUsageFooter.jsx
Normal file
17
src/pages/lotto/components/briefing/CuratorUsageFooter.jsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import useCuratorUsage from '../../hooks/useCuratorUsage';
|
||||||
|
import { estimateCost, fmtUsd, fmtTokens } from './pricing';
|
||||||
|
|
||||||
|
export default function CuratorUsageFooter() {
|
||||||
|
const { usage } = useCuratorUsage(30);
|
||||||
|
if (!usage) return null;
|
||||||
|
const cost = estimateCost(usage);
|
||||||
|
return (
|
||||||
|
<div className="curator-usage-footer">
|
||||||
|
<span>최근 30일 큐레이터:</span>
|
||||||
|
<span>{usage.calls}회 호출</span>
|
||||||
|
<span>{fmtTokens(usage.tokens_input + usage.tokens_output)} tokens</span>
|
||||||
|
<span>{fmtUsd(cost)}</span>
|
||||||
|
<span>캐시 {(usage.cache_hit_rate * 100).toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/pages/lotto/components/briefing/PickSetCard.jsx
Normal file
18
src/pages/lotto/components/briefing/PickSetCard.jsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
const RISK_BADGE = { '안정': '🟢', '균형': '🟡', '공격': '🔴' };
|
||||||
|
|
||||||
|
export default function PickSetCard({ pick, index }) {
|
||||||
|
return (
|
||||||
|
<div className={`pick-card pick-card--${pick.risk_tag}`}>
|
||||||
|
<div className="pick-card-header">
|
||||||
|
<span className="pick-card-index">Set {index + 1}</span>
|
||||||
|
<span className="pick-card-risk">{RISK_BADGE[pick.risk_tag] || '⚪'} {pick.risk_tag}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pick-card-balls">
|
||||||
|
{pick.numbers.map(n => (
|
||||||
|
<span key={n} className={`ball ball--${Math.ceil(n / 10)}`}>{n}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="pick-card-reason">{pick.reason}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/pages/lotto/components/briefing/pricing.js
Normal file
23
src/pages/lotto/components/briefing/pricing.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
const IN_PER_M = 3.00;
|
||||||
|
const OUT_PER_M = 15.00;
|
||||||
|
const CACHE_READ_PER_M = 0.30;
|
||||||
|
const CACHE_WRITE_PER_M = 3.75;
|
||||||
|
|
||||||
|
export function estimateCost({ tokens_input = 0, tokens_output = 0, cache_read = 0, cache_write = 0 }) {
|
||||||
|
const usd =
|
||||||
|
(tokens_input / 1_000_000) * IN_PER_M +
|
||||||
|
(tokens_output / 1_000_000) * OUT_PER_M +
|
||||||
|
(cache_read / 1_000_000) * CACHE_READ_PER_M +
|
||||||
|
(cache_write / 1_000_000) * CACHE_WRITE_PER_M;
|
||||||
|
return usd;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtUsd(usd) {
|
||||||
|
if (usd < 0.01) return `$${usd.toFixed(4)}`;
|
||||||
|
return `$${usd.toFixed(3)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtTokens(n) {
|
||||||
|
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
33
src/pages/lotto/components/decision/BulkPurchaseButton.jsx
Normal file
33
src/pages/lotto/components/decision/BulkPurchaseButton.jsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { bulkPurchase } from '../../../../api';
|
||||||
|
import { MODES } from './TierModeToggle';
|
||||||
|
|
||||||
|
export default function BulkPurchaseButton({ drawNo, tierMode, onSuccess }) {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const mode = MODES.find(m => m.key === tierMode) || MODES[0];
|
||||||
|
|
||||||
|
const onClick = async () => {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await bulkPurchase({
|
||||||
|
draw_no: drawNo,
|
||||||
|
tier_mode: tierMode,
|
||||||
|
sets: mode.sets,
|
||||||
|
amount: mode.amount,
|
||||||
|
});
|
||||||
|
onSuccess?.();
|
||||||
|
alert(`${mode.sets}세트 구매 기록 완료!`);
|
||||||
|
} catch (e) {
|
||||||
|
alert(`구매 기록 실패: ${e?.message || e}`);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className="lc-btn lc-btn--prim" onClick={onClick} disabled={busy || !drawNo}>
|
||||||
|
{busy ? '저장 중...' : `이대로 ${mode.sets}세트 구매했음`}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
src/pages/lotto/components/decision/DecisionCard.jsx
Normal file
102
src/pages/lotto/components/decision/DecisionCard.jsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import RetrospectiveBox from './RetrospectiveBox';
|
||||||
|
import TierModeToggle, { MODES } from './TierModeToggle';
|
||||||
|
import TierSection from './TierSection';
|
||||||
|
import BulkPurchaseButton from './BulkPurchaseButton';
|
||||||
|
import './decision.css';
|
||||||
|
|
||||||
|
const TIER_CHAIN = {
|
||||||
|
core: ['core'],
|
||||||
|
core_bonus: ['core', 'bonus'],
|
||||||
|
core_bonus_extended: ['core', 'bonus', 'extended'],
|
||||||
|
full: ['core', 'bonus', 'extended', 'pool'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'lotto.tier_mode';
|
||||||
|
|
||||||
|
export default function DecisionCard({ briefing, review, onPurchaseSuccess }) {
|
||||||
|
const [tierMode, setTierMode] = useState(() =>
|
||||||
|
localStorage.getItem(STORAGE_KEY) || 'core'
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, tierMode);
|
||||||
|
}, [tierMode]);
|
||||||
|
|
||||||
|
const visibleTiers = TIER_CHAIN[tierMode];
|
||||||
|
|
||||||
|
const totalSets = useMemo(
|
||||||
|
() => visibleTiers.reduce((sum, t) => sum + (briefing?.picks?.[t]?.length || 0), 0),
|
||||||
|
[briefing, visibleTiers]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 분배 칩 — 보이는 계층의 risk_tag 합산
|
||||||
|
const balance = useMemo(() => {
|
||||||
|
const acc = { '안정': 0, '균형': 0, '공격': 0 };
|
||||||
|
for (const t of visibleTiers) {
|
||||||
|
for (const p of (briefing?.picks?.[t] || [])) {
|
||||||
|
if (acc[p.risk_tag] !== undefined) acc[p.risk_tag]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, [briefing, visibleTiers]);
|
||||||
|
|
||||||
|
if (!briefing) return null;
|
||||||
|
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lc-card">
|
||||||
|
<header className="lc-head">
|
||||||
|
<div>
|
||||||
|
<p className="lc-eyebrow">Curator Briefing · {briefing.draw_no}회</p>
|
||||||
|
<h3 className="lc-title">{briefing.narrative.headline}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="lc-conf">
|
||||||
|
<div className="lc-conf__num">{briefing.confidence}</div>
|
||||||
|
<div className="lc-conf__lbl">CONFIDENCE</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<RetrospectiveBox briefing={briefing} review={review} />
|
||||||
|
|
||||||
|
<p className="lc-headline-3">
|
||||||
|
{(briefing.narrative.summary_3lines || []).join(' · ')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="lc-balance">
|
||||||
|
<div className="lc-balance__chips">
|
||||||
|
{balance['안정'] > 0 && <span className="lc-chip lc-chip--stable">안정 ×{balance['안정']}</span>}
|
||||||
|
{balance['균형'] > 0 && <span className="lc-chip lc-chip--balance">균형 ×{balance['균형']}</span>}
|
||||||
|
{balance['공격'] > 0 && <span className="lc-chip lc-chip--aggro">공격 ×{balance['공격']}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TierModeToggle value={tierMode} onChange={setTierMode} />
|
||||||
|
|
||||||
|
{visibleTiers.map(tier => {
|
||||||
|
const picks = briefing.picks?.[tier] || [];
|
||||||
|
const idxBase = cursor;
|
||||||
|
cursor += picks.length;
|
||||||
|
return (
|
||||||
|
<TierSection
|
||||||
|
key={tier}
|
||||||
|
tier={tier}
|
||||||
|
picks={picks}
|
||||||
|
rationale={briefing.tier_rationale?.[tier]}
|
||||||
|
indexBase={idxBase}
|
||||||
|
totalSets={totalSets}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="lc-actions">
|
||||||
|
<BulkPurchaseButton
|
||||||
|
drawNo={briefing.draw_no}
|
||||||
|
tierMode={tierMode}
|
||||||
|
onSuccess={onPurchaseSuccess}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/pages/lotto/components/decision/PickCard.jsx
Normal file
19
src/pages/lotto/components/decision/PickCard.jsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
const ROLE_COLOR = { '안정': 'stable', '균형': 'balance', '공격': 'aggro' };
|
||||||
|
|
||||||
|
export default function PickCard({ pick, index, total }) {
|
||||||
|
const role = pick.risk_tag;
|
||||||
|
return (
|
||||||
|
<div className="lc-set">
|
||||||
|
<div className="lc-set__head">
|
||||||
|
<span className={`lc-set__role lc-set__role--${ROLE_COLOR[role]}`}>● {role}</span>
|
||||||
|
<span className="lc-set__idx">Set {index + 1} / {total}</span>
|
||||||
|
</div>
|
||||||
|
<div className="lc-balls">
|
||||||
|
{pick.numbers.map(n => (
|
||||||
|
<span key={n} className={`ball ball--${Math.ceil(n / 10)}`}>{n}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="lc-set__reason">{pick.reason}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/pages/lotto/components/decision/RetrospectiveBox.jsx
Normal file
11
src/pages/lotto/components/decision/RetrospectiveBox.jsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export default function RetrospectiveBox({ briefing, review }) {
|
||||||
|
const retro = briefing?.narrative?.retrospective;
|
||||||
|
if (!retro) return null;
|
||||||
|
const drawNo = review?.draw_no ?? (briefing?.draw_no ? briefing.draw_no - 1 : null);
|
||||||
|
return (
|
||||||
|
<aside className="lc-retro">
|
||||||
|
<p className="lc-retro__time">▸ 지난 주 {drawNo ? `${drawNo}회` : ''} 회고</p>
|
||||||
|
<p className="lc-retro__body">{retro}</p>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/pages/lotto/components/decision/TierModeToggle.jsx
Normal file
28
src/pages/lotto/components/decision/TierModeToggle.jsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
const MODES = [
|
||||||
|
{ key: 'core', label: '코어', sets: 5, amount: 5000 },
|
||||||
|
{ key: 'core_bonus', label: '+ 보너스', sets: 10, amount: 10000 },
|
||||||
|
{ key: 'core_bonus_extended', label: '+ 확장', sets: 15, amount: 15000 },
|
||||||
|
{ key: 'full', label: '+ 풀', sets: 20, amount: 20000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TierModeToggle({ value, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className="lc-toggle" role="tablist">
|
||||||
|
{MODES.map((m, i) => (
|
||||||
|
<button
|
||||||
|
key={m.key}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={value === m.key}
|
||||||
|
className={`lc-toggle__chip ${value === m.key ? 'is-active' : ''}`}
|
||||||
|
onClick={() => onChange(m.key)}
|
||||||
|
>
|
||||||
|
<span className="lc-toggle__dots">{'●'.repeat(i + 1) + '○'.repeat(3 - i)}</span>
|
||||||
|
<span className="lc-toggle__lbl">{m.label}</span>
|
||||||
|
<span className="lc-toggle__sub">{m.sets}세트 · {m.amount.toLocaleString()}원</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { MODES };
|
||||||
25
src/pages/lotto/components/decision/TierSection.jsx
Normal file
25
src/pages/lotto/components/decision/TierSection.jsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import PickCard from './PickCard';
|
||||||
|
|
||||||
|
const TIER_TITLE = {
|
||||||
|
core: '코어 (필수, 5세트)',
|
||||||
|
bonus: '보너스 (+5)',
|
||||||
|
extended: '확장 (+5)',
|
||||||
|
pool: '풀 (+5)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TierSection({ tier, picks, rationale, indexBase = 0, totalSets }) {
|
||||||
|
if (!picks?.length) return null;
|
||||||
|
return (
|
||||||
|
<section className={`lc-tier lc-tier--${tier}`}>
|
||||||
|
<header className="lc-tier__head">
|
||||||
|
<h4>{TIER_TITLE[tier]}</h4>
|
||||||
|
{rationale && tier !== 'core' && (
|
||||||
|
<p className="lc-tier__rationale">{rationale}</p>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
{picks.map((p, i) => (
|
||||||
|
<PickCard key={i} pick={p} index={indexBase + i} total={totalSets} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/pages/lotto/components/decision/decision.css
Normal file
52
src/pages/lotto/components/decision/decision.css
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
.lc-card { max-width: 720px; margin: 0 auto; background: linear-gradient(180deg, #161220 0%, #1a1426 100%);
|
||||||
|
border: 1px solid rgba(255,255,255,0.08); border-radius: 16px; padding: 24px; color: #ece6f7; }
|
||||||
|
.lc-head { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 14px; }
|
||||||
|
.lc-eyebrow { font-size: 10px; letter-spacing: 2px; opacity: 0.5; text-transform: uppercase; margin: 0 0 4px; }
|
||||||
|
.lc-title { font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
|
||||||
|
.lc-conf { display: flex; flex-direction: column; align-items: flex-end; }
|
||||||
|
.lc-conf__num { font-family: 'Courier New', monospace; font-size: 28px; font-weight: 700; color: #b8a8ff; letter-spacing: -0.04em; }
|
||||||
|
.lc-conf__lbl { font-size: 9px; letter-spacing: 1.5px; opacity: 0.55; }
|
||||||
|
.lc-retro { background: rgba(184, 168, 255, 0.06); border-left: 2px solid rgba(184, 168, 255, 0.4);
|
||||||
|
padding: 10px 14px; margin: 14px 0; border-radius: 4px; }
|
||||||
|
.lc-retro__time { font-size: 9px; letter-spacing: 1.5px; color: #b8a8ff; opacity: 0.7; margin: 0 0 4px; }
|
||||||
|
.lc-retro__body { font-size: 13px; line-height: 1.55; opacity: 0.85; margin: 0; }
|
||||||
|
.lc-headline { font-size: 16px; font-weight: 600; line-height: 1.5; margin: 18px 0 4px; }
|
||||||
|
.lc-headline-3 { font-size: 12px; opacity: 0.65; line-height: 1.55; margin: 0 0 18px; }
|
||||||
|
.lc-balance { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px;
|
||||||
|
background: rgba(255,255,255,0.03); border-radius: 8px; margin-bottom: 16px; font-size: 11px; }
|
||||||
|
.lc-balance__chips { display: flex; gap: 8px; }
|
||||||
|
.lc-chip { padding: 3px 8px; border-radius: 100px; font-weight: 600; font-size: 11px; }
|
||||||
|
.lc-chip--stable { background: rgba(80, 200, 120, 0.15); color: #76e09a; }
|
||||||
|
.lc-chip--balance { background: rgba(255, 200, 80, 0.15); color: #ffce6e; }
|
||||||
|
.lc-chip--aggro { background: rgba(255, 100, 130, 0.15); color: #ff8aa0; }
|
||||||
|
.lc-toggle { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 16px 0; }
|
||||||
|
.lc-toggle__chip { padding: 10px 8px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
border-radius: 10px; color: #ece6f7; cursor: pointer; display: flex; flex-direction: column; gap: 4px; align-items: center; }
|
||||||
|
.lc-toggle__chip.is-active { background: rgba(184, 168, 255, 0.15); border-color: rgba(184, 168, 255, 0.5); }
|
||||||
|
.lc-toggle__dots { letter-spacing: 2px; font-size: 10px; opacity: 0.7; }
|
||||||
|
.lc-toggle__lbl { font-size: 12px; font-weight: 600; }
|
||||||
|
.lc-toggle__sub { font-size: 10px; opacity: 0.55; }
|
||||||
|
.lc-tier { margin-bottom: 14px; }
|
||||||
|
.lc-tier__head { padding: 8px 0; border-top: 1px dashed rgba(255,255,255,0.1); margin-bottom: 8px; }
|
||||||
|
.lc-tier:first-of-type .lc-tier__head { border-top: none; }
|
||||||
|
.lc-tier__head h4 { font-size: 12px; font-weight: 600; margin: 0 0 4px; opacity: 0.75; letter-spacing: 0.5px; }
|
||||||
|
.lc-tier__rationale { font-size: 11px; opacity: 0.55; margin: 0; }
|
||||||
|
.lc-set { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 12px;
|
||||||
|
padding: 14px; margin-bottom: 10px; }
|
||||||
|
.lc-set__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||||
|
.lc-set__role { font-size: 11px; font-weight: 600; letter-spacing: 0.5px; }
|
||||||
|
.lc-set__role--stable { color: #76e09a; }
|
||||||
|
.lc-set__role--balance { color: #ffce6e; }
|
||||||
|
.lc-set__role--aggro { color: #ff8aa0; }
|
||||||
|
.lc-set__idx { font-size: 10px; opacity: 0.4; }
|
||||||
|
.lc-balls { display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
|
||||||
|
.lc-set__reason { font-size: 12px; opacity: 0.7; line-height: 1.45; margin: 0; }
|
||||||
|
.lc-actions { display: flex; gap: 10px; margin-top: 18px; }
|
||||||
|
.lc-btn { padding: 12px 16px; border-radius: 10px; border: none; font-weight: 600; cursor: pointer;
|
||||||
|
font-size: 14px; min-width: 160px; }
|
||||||
|
.lc-btn--prim { background: linear-gradient(135deg, #b8a8ff, #8a78db); color: #14101e; }
|
||||||
|
.lc-btn--prim:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.lc-btn--ghost { background: transparent; border: 1px solid rgba(255,255,255,0.15); color: #ece6f7; }
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.lc-toggle { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
68
src/pages/lotto/hooks/useBriefing.js
Normal file
68
src/pages/lotto/hooks/useBriefing.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { getLatestBriefing, triggerLottoCurate } from '../../../api';
|
||||||
|
|
||||||
|
const normalizePicks = (picks) => {
|
||||||
|
if (Array.isArray(picks)) {
|
||||||
|
return { core: picks, bonus: [], extended: [], pool: [] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
core: picks?.core || [],
|
||||||
|
bonus: picks?.bonus || [],
|
||||||
|
extended: picks?.extended || [],
|
||||||
|
pool: picks?.pool || [],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function useBriefing() {
|
||||||
|
const [briefing, setBriefing] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [regenerating, setRegenerating] = useState(false);
|
||||||
|
const pollingRef = useRef(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true); setError('');
|
||||||
|
try {
|
||||||
|
const data = await getLatestBriefing();
|
||||||
|
setBriefing(data ? { ...data, picks: normalizePicks(data.picks) } : data);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const regenerate = useCallback(async () => {
|
||||||
|
setRegenerating(true); setError('');
|
||||||
|
try {
|
||||||
|
const prevGen = briefing?.generated_at;
|
||||||
|
await triggerLottoCurate();
|
||||||
|
let attempts = 0;
|
||||||
|
pollingRef.current = setInterval(async () => {
|
||||||
|
attempts += 1;
|
||||||
|
try {
|
||||||
|
const data = await getLatestBriefing();
|
||||||
|
if (data && data.generated_at !== prevGen) {
|
||||||
|
setBriefing({ ...data, picks: normalizePicks(data.picks) });
|
||||||
|
setRegenerating(false);
|
||||||
|
clearInterval(pollingRef.current);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
if (attempts >= 40) {
|
||||||
|
clearInterval(pollingRef.current);
|
||||||
|
setRegenerating(false);
|
||||||
|
setError('재생성 타임아웃 (2분)');
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
setRegenerating(false);
|
||||||
|
}
|
||||||
|
}, [briefing?.generated_at]);
|
||||||
|
|
||||||
|
useEffect(() => () => { if (pollingRef.current) clearInterval(pollingRef.current); }, []);
|
||||||
|
|
||||||
|
return { briefing, loading, error, regenerating, reload: load, regenerate };
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user